diff --git a/src/Jiaowu.Api/Controllers/GradesController.cs b/src/Jiaowu.Api/Controllers/GradesController.cs
index 2553fc9..6a962df 100644
--- a/src/Jiaowu.Api/Controllers/GradesController.cs
+++ b/src/Jiaowu.Api/Controllers/GradesController.cs
@@ -994,6 +994,100 @@ public sealed class GradesController(
return Ok(new { Student = student, Records = records });
}
+ ///
+ /// Returns the student's all-term academic metrics and the corresponding
+ /// rankings within their current administrative class, major and college.
+ /// Only formally published course grades participate in this calculation.
+ ///
+ [HttpGet("student/academic-overview")]
+ [Authorize(Roles = SystemRoles.Student)]
+ public async Task GetStudentAcademicOverview(
+ CancellationToken cancellationToken)
+ {
+ var userId = currentUserDataScope.Current.UserId;
+ var student = await db.Students.AsNoTracking()
+ .Where(x => x.UserId == userId)
+ .Select(x => new StudentAcademicScope(
+ x.Id,
+ x.AdministrativeClassId,
+ x.AdministrativeClass!.MajorId,
+ x.AdministrativeClass.Major!.CollegeId))
+ .FirstOrDefaultAsync(cancellationToken);
+ if (student is null)
+ return ConflictProblem("当前账号未关联有效学生档案。");
+
+ var overview = await (cache ?? NoOpAppCache.Instance).GetOrCreateAsync(
+ AppCacheKeys.StudentAcademicOverview(student.Id),
+ token => BuildStudentAcademicOverviewAsync(student, token),
+ AppCacheProfile.Analytics,
+ [AppCacheTags.StudentAcademicStatistics],
+ cancellationToken);
+ return Ok(overview);
+ }
+
+ private async Task BuildStudentAcademicOverviewAsync(
+ StudentAcademicScope student,
+ CancellationToken cancellationToken)
+ {
+ var summary = await db.StudentAcademicStatistics.AsNoTracking()
+ .FirstOrDefaultAsync(x => x.StudentId == student.Id, cancellationToken);
+ var students = await db.StudentAcademicStatistics.AsNoTracking()
+ .Select(x => new StudentAcademicAggregate(
+ x.StudentId,
+ x.Student!.AdministrativeClassId,
+ x.Student.AdministrativeClass!.MajorId,
+ x.Student.AdministrativeClass.Major!.CollegeId,
+ x.CourseCount,
+ x.Gpa,
+ x.WeightedAverageScore,
+ x.AverageScore))
+ .ToListAsync(cancellationToken);
+ var current = summary is null
+ ? StudentAcademicAggregate.Empty(
+ student.Id,
+ student.AdministrativeClassId,
+ student.MajorId,
+ student.CollegeId)
+ : new StudentAcademicAggregate(
+ student.Id,
+ student.AdministrativeClassId,
+ student.MajorId,
+ student.CollegeId,
+ summary.CourseCount,
+ summary.Gpa,
+ summary.WeightedAverageScore,
+ summary.AverageScore);
+ var termTrend = await db.StudentAcademicTermStatistics.AsNoTracking()
+ .Where(x => x.StudentId == student.Id)
+ .OrderBy(x => x.AcademicTerm!.StartDate)
+ .Select(x => new StudentTermAcademicTrend(
+ x.AcademicTermId,
+ x.AcademicTerm!.Name,
+ x.CourseCount,
+ x.Gpa,
+ x.WeightedAverageScore,
+ x.AverageScore))
+ .ToListAsync(cancellationToken);
+
+ return new StudentAcademicOverviewResponse(
+ summary is null,
+ current.CourseCount,
+ summary?.CalculatedAt,
+ termTrend,
+ new StudentAcademicMetrics(
+ AcademicMetric.Create(current.Gpa, students, x => x.Gpa, current),
+ AcademicMetric.Create(
+ current.WeightedAverageScore,
+ students,
+ x => x.WeightedAverageScore,
+ current),
+ AcademicMetric.Create(
+ current.AverageScore,
+ students,
+ x => x.AverageScore,
+ current)));
+ }
+
[HttpGet("sheets/{id:guid}/statistics")]
[Authorize(Roles = StatisticsUsers)]
public async Task GetCourseStatistics(
@@ -1280,3 +1374,75 @@ public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment);
+
+internal sealed record StudentAcademicScope(
+ Guid Id,
+ Guid AdministrativeClassId,
+ Guid MajorId,
+ Guid CollegeId);
+
+internal sealed record StudentAcademicOverviewResponse(
+ bool IsRefreshing,
+ int CourseCount,
+ DateTime? CalculatedAt,
+ IReadOnlyCollection TermTrend,
+ StudentAcademicMetrics Metrics);
+
+internal sealed record StudentAcademicMetrics(
+ AcademicMetric Gpa,
+ AcademicMetric WeightedAverageScore,
+ AcademicMetric AverageScore);
+
+internal sealed record StudentAcademicAggregate(
+ Guid StudentId,
+ Guid ClassId,
+ Guid MajorId,
+ Guid CollegeId,
+ int CourseCount,
+ decimal? Gpa,
+ decimal? WeightedAverageScore,
+ decimal? AverageScore)
+{
+ public static StudentAcademicAggregate Empty(
+ Guid studentId,
+ Guid classId,
+ Guid majorId,
+ Guid collegeId) => new(studentId, classId, majorId, collegeId, 0, null, null, null);
+}
+
+internal sealed record AcademicMetric(
+ decimal? Value,
+ AcademicRank ClassRank,
+ AcademicRank MajorRank,
+ AcademicRank CollegeRank)
+{
+ public static AcademicMetric Create(
+ decimal? value,
+ IReadOnlyCollection students,
+ Func selector,
+ StudentAcademicAggregate current) => new(
+ value.HasValue ? Math.Round(value.Value, 2, MidpointRounding.AwayFromZero) : null,
+ Rank(value, students.Where(x => x.ClassId == current.ClassId), selector),
+ Rank(value, students.Where(x => x.MajorId == current.MajorId), selector),
+ Rank(value, students.Where(x => x.CollegeId == current.CollegeId), selector));
+
+ private static AcademicRank Rank(
+ decimal? value,
+ IEnumerable peers,
+ Func selector)
+ {
+ var values = peers.Select(selector).Where(x => x.HasValue).Select(x => x!.Value).ToList();
+ if (!value.HasValue) return new AcademicRank(null, values.Count);
+ return new AcademicRank(values.Count(x => x > value.Value) + 1, values.Count);
+ }
+}
+
+internal sealed record AcademicRank(int? Rank, int PeerCount);
+
+internal sealed record StudentTermAcademicTrend(
+ Guid AcademicTermId,
+ string TermName,
+ int CourseCount,
+ decimal? Gpa,
+ decimal? WeightedAverageScore,
+ decimal? AverageScore);
diff --git a/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs b/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs
index 952c965..c2b6f56 100644
--- a/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs
+++ b/src/Jiaowu.Api/Domain/Academic/GradeEntities.cs
@@ -139,6 +139,37 @@ public sealed class CourseGradeStatisticsRefreshJob : EntityBase
public string? ErrorMessage { get; set; }
}
+///
+/// Materialized all-term academic metrics for one student. Rankings are derived
+/// from this compact table, never by scanning raw grade records on a request.
+///
+public sealed class StudentAcademicStatistic : EntityBase
+{
+ public Guid StudentId { get; set; }
+ public Student? Student { get; set; }
+ public int CourseCount { get; set; }
+ public decimal? Gpa { get; set; }
+ public decimal? WeightedAverageScore { get; set; }
+ public decimal? AverageScore { get; set; }
+ public DateTime CalculatedAt { get; set; }
+}
+
+///
+/// Materialized academic metrics for one student in one academic term.
+///
+public sealed class StudentAcademicTermStatistic : EntityBase
+{
+ public Guid StudentId { get; set; }
+ public Student? Student { get; set; }
+ public Guid AcademicTermId { get; set; }
+ public AcademicTerm? AcademicTerm { get; set; }
+ public int CourseCount { get; set; }
+ public decimal? Gpa { get; set; }
+ public decimal? WeightedAverageScore { get; set; }
+ public decimal? AverageScore { get; set; }
+ public DateTime CalculatedAt { get; set; }
+}
+
public enum CourseGradeStatisticsRefreshJobStatus
{
Queued = 1,
diff --git a/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs b/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs
index df562ae..c9c1def 100644
--- a/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs
+++ b/src/Jiaowu.Api/Infrastructure/Caching/AppCache.cs
@@ -188,6 +188,9 @@ public static class AppCacheKeys
public static string TeachingTaskGradeAnalytics(Guid gradeSheetId) =>
$"grade-analytics:sheet:{gradeSheetId:N}:v1";
+
+ public static string StudentAcademicOverview(Guid studentId) =>
+ $"student-academic-overview:{studentId:N}:v1";
}
public static class AppCacheTags
@@ -195,6 +198,7 @@ public static class AppCacheTags
public const string BaseData = "base-data";
public const string Analytics = "analytics";
public const string CourseGradeStatistics = "grade-statistics";
+ public const string StudentAcademicStatistics = "student-academic-statistics";
public const string Timetables = "timetables";
public const string TimetableOptions = "timetable:options";
diff --git a/src/Jiaowu.Api/Infrastructure/Grades/CourseGradeStatisticsRefreshWorker.cs b/src/Jiaowu.Api/Infrastructure/Grades/CourseGradeStatisticsRefreshWorker.cs
index 9bc12c0..718210b 100644
--- a/src/Jiaowu.Api/Infrastructure/Grades/CourseGradeStatisticsRefreshWorker.cs
+++ b/src/Jiaowu.Api/Infrastructure/Grades/CourseGradeStatisticsRefreshWorker.cs
@@ -7,9 +7,8 @@ using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Infrastructure.Grades;
///
-/// Repairs missing or stale materialized grade statistics on a database-
-/// configured fixed interval. Grade writes do not enqueue refresh jobs; this
-/// worker batches changes made during bulk imports.
+/// Refreshes materialized grade statistics only on the database-configured
+/// interval. It also backfills historical and previously missed data safely.
///
public sealed class CourseGradeStatisticsRefreshWorker(
IServiceScopeFactory scopeFactory,
@@ -152,14 +151,18 @@ public sealed class CourseGradeStatisticsRefreshScheduler(
db.TeachingTaskGradeStatistics
.Where(statistic => statistic.GradeSheetId == sheet.Id)
.Select(statistic => (DateTime?)statistic.CalculatedAt)
- .FirstOrDefault()))
+ .FirstOrDefault(),
+ sheet.Records.Any(record => !db.StudentAcademicStatistics.Any(
+ statistic => statistic.StudentId == record.StudentId &&
+ statistic.CalculatedAt >= record.UpdatedAt))))
.ToListAsync(cancellationToken);
var stale = rows
.Where(row =>
row.CalculatedAt is null ||
row.SheetUpdatedAt > row.CalculatedAt ||
- row.RecordsUpdatedAt > row.CalculatedAt)
+ row.RecordsUpdatedAt > row.CalculatedAt ||
+ row.StudentAcademicStatisticsStale)
.GroupBy(row => new CourseTermTarget(row.CourseId, row.AcademicTermId))
.Where(group => !active.Contains(group.Key))
.Select(group => group
@@ -222,7 +225,8 @@ public sealed class CourseGradeStatisticsRefreshScheduler(
Guid AcademicTermId,
DateTime SheetUpdatedAt,
DateTime RecordsUpdatedAt,
- DateTime? CalculatedAt);
+ DateTime? CalculatedAt,
+ bool StudentAcademicStatisticsStale);
private sealed record CourseTermTarget(Guid CourseId, Guid AcademicTermId);
}
diff --git a/src/Jiaowu.Api/Infrastructure/Grades/CourseGradeStatisticsService.cs b/src/Jiaowu.Api/Infrastructure/Grades/CourseGradeStatisticsService.cs
index 7813700..9547562 100644
--- a/src/Jiaowu.Api/Infrastructure/Grades/CourseGradeStatisticsService.cs
+++ b/src/Jiaowu.Api/Infrastructure/Grades/CourseGradeStatisticsService.cs
@@ -12,6 +12,7 @@ namespace Jiaowu.Api.Infrastructure.Grades;
public sealed class CourseGradeStatisticsRefreshJobProcessor(
AppDbContext db,
IAppCache cache,
+ StudentAcademicStatisticsService studentAcademicStatistics,
ILogger logger)
{
public async Task ProcessAsync(Guid jobId, CancellationToken cancellationToken)
@@ -107,6 +108,9 @@ public sealed class CourseGradeStatisticsRefreshJobProcessor(
await db.SaveChangesAsync(cancellationToken);
await cache.RemoveByTagAsync(AppCacheTags.CourseGradeStatistics,
cancellationToken);
+ await studentAcademicStatistics.RefreshForGradeSheetAsync(
+ job.GradeSheetId,
+ cancellationToken);
}
catch (Exception exception)
{
diff --git a/src/Jiaowu.Api/Infrastructure/Grades/StudentAcademicStatisticsService.cs b/src/Jiaowu.Api/Infrastructure/Grades/StudentAcademicStatisticsService.cs
new file mode 100644
index 0000000..8b5aafc
--- /dev/null
+++ b/src/Jiaowu.Api/Infrastructure/Grades/StudentAcademicStatisticsService.cs
@@ -0,0 +1,133 @@
+using Jiaowu.Api.Domain.Academic;
+using Jiaowu.Api.Infrastructure.Caching;
+using Jiaowu.Api.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jiaowu.Api.Infrastructure.Grades;
+
+///
+/// Rebuilds the materialized all-term and per-term academic statistics for the
+/// students affected by a published course grade sheet.
+///
+public sealed class StudentAcademicStatisticsService(
+ AppDbContext db,
+ IAppCache cache)
+{
+ public async Task RefreshForGradeSheetAsync(
+ Guid gradeSheetId,
+ CancellationToken cancellationToken)
+ {
+ var studentIds = await db.GradeRecords.AsNoTracking()
+ .Where(x => x.GradeSheetId == gradeSheetId)
+ .Select(x => x.StudentId)
+ .Distinct()
+ .ToArrayAsync(cancellationToken);
+ await RefreshAsync(studentIds, cancellationToken);
+ }
+
+ public async Task RefreshAsync(
+ IReadOnlyCollection requestedStudentIds,
+ CancellationToken cancellationToken)
+ {
+ var studentIds = requestedStudentIds.Distinct().ToArray();
+ if (studentIds.Length == 0) return;
+
+ var rows = await db.GradeRecords.AsNoTracking()
+ .Where(x => x.GradeSheet!.Status == GradeSheetStatus.Published)
+ .WhereIn(studentIds, x => x.StudentId)
+ .Select(x => new GradeRow(
+ x.StudentId,
+ x.GradeSheet!.TeachingTask!.AcademicTermId,
+ x.GradeSheet.TeachingTask.Course!.Credits,
+ x.GradePoint,
+ x.TotalScore))
+ .ToListAsync(cancellationToken);
+ var now = DateTime.UtcNow;
+ var allStatistics = rows
+ .GroupBy(x => x.StudentId)
+ .Select(group => CreateAllTerm(group.Key, group, now))
+ .ToList();
+ var termStatistics = rows
+ .GroupBy(x => new { x.StudentId, x.AcademicTermId })
+ .Select(group => CreateTerm(group.Key.StudentId, group.Key.AcademicTermId, group, now))
+ .ToList();
+
+ await db.StudentAcademicTermStatistics
+ .WhereIn(studentIds, x => x.StudentId)
+ .ExecuteDeleteAsync(cancellationToken);
+ await db.StudentAcademicStatistics
+ .WhereIn(studentIds, x => x.StudentId)
+ .ExecuteDeleteAsync(cancellationToken);
+ if (allStatistics.Count > 0) db.StudentAcademicStatistics.AddRange(allStatistics);
+ if (termStatistics.Count > 0) db.StudentAcademicTermStatistics.AddRange(termStatistics);
+ await db.SaveChangesAsync(cancellationToken);
+ await cache.RemoveByTagAsync(AppCacheTags.StudentAcademicStatistics, cancellationToken);
+ }
+
+ private static StudentAcademicStatistic CreateAllTerm(
+ Guid studentId,
+ IEnumerable source,
+ DateTime now)
+ {
+ var metrics = Calculate(source);
+ return new StudentAcademicStatistic
+ {
+ StudentId = studentId,
+ CourseCount = metrics.CourseCount,
+ Gpa = metrics.Gpa,
+ WeightedAverageScore = metrics.WeightedAverageScore,
+ AverageScore = metrics.AverageScore,
+ CalculatedAt = now
+ };
+ }
+
+ private static StudentAcademicTermStatistic CreateTerm(
+ Guid studentId,
+ Guid academicTermId,
+ IEnumerable source,
+ DateTime now)
+ {
+ var metrics = Calculate(source);
+ return new StudentAcademicTermStatistic
+ {
+ StudentId = studentId,
+ AcademicTermId = academicTermId,
+ CourseCount = metrics.CourseCount,
+ Gpa = metrics.Gpa,
+ WeightedAverageScore = metrics.WeightedAverageScore,
+ AverageScore = metrics.AverageScore,
+ CalculatedAt = now
+ };
+ }
+
+ private static AcademicMetrics Calculate(IEnumerable source)
+ {
+ var rows = source.ToArray();
+ var gpaRows = rows.Where(x => x.GradePoint.HasValue).ToArray();
+ var gpaCredits = gpaRows.Sum(x => x.Credits);
+ var scoreRows = rows.Where(x => x.TotalScore.HasValue).ToArray();
+ var scoreCredits = scoreRows.Sum(x => x.Credits);
+ return new AcademicMetrics(
+ rows.Length,
+ Round(gpaCredits == 0 ? null : gpaRows.Sum(x => x.GradePoint!.Value * x.Credits) / gpaCredits),
+ Round(scoreCredits == 0 ? null : scoreRows.Sum(x => x.TotalScore!.Value * x.Credits) / scoreCredits),
+ Round(scoreRows.Length == 0 ? null : scoreRows.Average(x => x.TotalScore!.Value)));
+ }
+
+ private static decimal? Round(decimal? value) => value.HasValue
+ ? Math.Round(value.Value, 2, MidpointRounding.AwayFromZero)
+ : null;
+
+ private sealed record GradeRow(
+ Guid StudentId,
+ Guid AcademicTermId,
+ decimal Credits,
+ decimal? GradePoint,
+ decimal? TotalScore);
+
+ private sealed record AcademicMetrics(
+ int CourseCount,
+ decimal? Gpa,
+ decimal? WeightedAverageScore,
+ decimal? AverageScore);
+}
diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs
index 88ace57..5579735 100644
--- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs
+++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs
@@ -83,6 +83,10 @@ public sealed class AppDbContext(DbContextOptions options)
Set();
public DbSet CourseGradeStatisticsRefreshJobs =>
Set();
+ public DbSet StudentAcademicStatistics =>
+ Set();
+ public DbSet StudentAcademicTermStatistics =>
+ Set();
public DbSet OtherExamBatches => Set();
public DbSet OtherExamResults => Set();
public DbSet AttendanceSheets => Set();
@@ -1045,6 +1049,30 @@ public sealed class AppDbContext(DbContextOptions options)
.OnDelete(DeleteBehavior.Cascade);
});
+ builder.Entity(entity =>
+ {
+ entity.Property(x => x.Gpa).HasPrecision(4, 2);
+ entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 2);
+ entity.Property(x => x.AverageScore).HasPrecision(5, 2);
+ entity.HasIndex(x => x.StudentId).IsUnique();
+ entity.HasIndex(x => x.CalculatedAt);
+ entity.HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId)
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ builder.Entity(entity =>
+ {
+ entity.Property(x => x.Gpa).HasPrecision(4, 2);
+ entity.Property(x => x.WeightedAverageScore).HasPrecision(5, 2);
+ entity.Property(x => x.AverageScore).HasPrecision(5, 2);
+ entity.HasIndex(x => new { x.StudentId, x.AcademicTermId }).IsUnique();
+ entity.HasIndex(x => x.AcademicTermId);
+ entity.HasOne(x => x.Student).WithMany().HasForeignKey(x => x.StudentId)
+ .OnDelete(DeleteBehavior.Cascade);
+ entity.HasOne(x => x.AcademicTerm).WithMany().HasForeignKey(x => x.AcademicTermId)
+ .OnDelete(DeleteBehavior.Restrict);
+ });
+
builder.Entity(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs
index 879ca1f..6df4abf 100644
--- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs
+++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs
@@ -108,6 +108,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260811_56_self_scheduled_experiment_selection";
private const string RetakeOnlyCourseSelectionScopeMigration =
"20260815_57_retake_only_course_selection_scope";
+ private const string StudentAcademicStatisticsMigration =
+ "20260815_58_student_academic_statistics";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -770,6 +772,14 @@ public sealed class DevelopmentSqliteMigrator(
? []
: RetakeOnlyCourseSelectionScopeStatements,
cancellationToken);
+ var studentAcademicStatisticsExist = await db.Database
+ .SqlQueryRaw(
+ "SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'StudentAcademicStatistics'")
+ .AnyAsync(value => value > 0, cancellationToken);
+ await ApplyMigrationAsync(
+ StudentAcademicStatisticsMigration,
+ studentAcademicStatisticsExist ? [] : StudentAcademicStatisticsStatements,
+ cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -3188,6 +3198,36 @@ public sealed class DevelopmentSqliteMigrator(
"ALTER TABLE \"CourseSelectionRounds\" ADD COLUMN \"Scope\" INTEGER NOT NULL DEFAULT 1;"
];
+ private static readonly string[] StudentAcademicStatisticsStatements =
+ [
+ """
+ CREATE TABLE "StudentAcademicStatistics" (
+ "Id" TEXT NOT NULL CONSTRAINT "PK_StudentAcademicStatistics" PRIMARY KEY,
+ "StudentId" TEXT NOT NULL, "CourseCount" INTEGER NOT NULL,
+ "Gpa" TEXT NULL, "WeightedAverageScore" TEXT NULL, "AverageScore" TEXT NULL,
+ "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
+ CONSTRAINT "FK_StudentAcademicStatistics_Students_StudentId"
+ FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE CASCADE
+ );
+ """,
+ "CREATE UNIQUE INDEX \"IX_StudentAcademicStatistics_StudentId\" ON \"StudentAcademicStatistics\" (\"StudentId\");",
+ "CREATE INDEX \"IX_StudentAcademicStatistics_CalculatedAt\" ON \"StudentAcademicStatistics\" (\"CalculatedAt\");",
+ """
+ CREATE TABLE "StudentAcademicTermStatistics" (
+ "Id" TEXT NOT NULL CONSTRAINT "PK_StudentAcademicTermStatistics" PRIMARY KEY,
+ "StudentId" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "CourseCount" INTEGER NOT NULL,
+ "Gpa" TEXT NULL, "WeightedAverageScore" TEXT NULL, "AverageScore" TEXT NULL,
+ "CalculatedAt" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL,
+ CONSTRAINT "FK_StudentAcademicTermStatistics_Students_StudentId"
+ FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE CASCADE,
+ CONSTRAINT "FK_StudentAcademicTermStatistics_AcademicTerms_AcademicTermId"
+ FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT
+ );
+ """,
+ "CREATE UNIQUE INDEX \"IX_StudentAcademicTermStatistics_StudentId_AcademicTermId\" ON \"StudentAcademicTermStatistics\" (\"StudentId\", \"AcademicTermId\");",
+ "CREATE INDEX \"IX_StudentAcademicTermStatistics_AcademicTermId\" ON \"StudentAcademicTermStatistics\" (\"AcademicTermId\");"
+ ];
+
private static readonly string[] ReusableCourseGroupsStatements =
[
"""
diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260815101105_PersistStudentAcademicStatistics.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260815101105_PersistStudentAcademicStatistics.Designer.cs
new file mode 100644
index 0000000..095fb6f
--- /dev/null
+++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260815101105_PersistStudentAcademicStatistics.Designer.cs
@@ -0,0 +1,7115 @@
+//
+using System;
+using Jiaowu.Api.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260815101105_PersistStudentAcademicStatistics")]
+ partial class PersistStudentAcademicStatistics
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AcademicYear")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ArchivedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EndDate")
+ .HasColumnType("date");
+
+ b.Property("IsArchived")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("IsCurrent")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Season")
+ .HasColumnType("int");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("StartDate")
+ .HasColumnType("date");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("IsArchived");
+
+ b.HasIndex("IsCurrent");
+
+ b.HasIndex("IsEnabled", "SortOrder");
+
+ b.ToTable("AcademicTerms");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CounselorName")
+ .HasColumnType("longtext");
+
+ b.Property("CounselorUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Grade")
+ .HasColumnType("int");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("MajorId")
+ .HasColumnType("char(36)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("CounselorUserId");
+
+ b.HasIndex("MajorId");
+
+ b.HasIndex("IsEnabled", "SortOrder");
+
+ b.ToTable("AdministrativeClasses");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceCheckInAttempt", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AccuracyMeters")
+ .HasColumnType("double");
+
+ b.Property("AttendanceSheetId")
+ .HasColumnType("char(36)");
+
+ b.Property("CheckInMethod")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DeviceIdentifierHash")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("DevicePlatform")
+ .HasMaxLength(32)
+ .HasColumnType("varchar(32)");
+
+ b.Property("DistanceMeters")
+ .HasColumnType("double");
+
+ b.Property("FailureCode")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("IpAddress")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("IsSuccessful")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Latitude")
+ .HasPrecision(10, 7)
+ .HasColumnType("decimal(10,7)");
+
+ b.Property("Longitude")
+ .HasPrecision(10, 7)
+ .HasColumnType("decimal(10,7)");
+
+ b.Property("RiskFlags")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("StudentId")
+ .HasColumnType("char(36)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UserAgent")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StudentId");
+
+ b.HasIndex("DeviceIdentifierHash", "CreatedAt");
+
+ b.HasIndex("IpAddress", "CreatedAt");
+
+ b.HasIndex("AttendanceSheetId", "StudentId", "CreatedAt");
+
+ b.ToTable("AttendanceCheckInAttempts");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b =>
+ {
+ b.Property("AttendanceSheetId")
+ .HasColumnType("char(36)");
+
+ b.Property("StudentId")
+ .HasColumnType("char(36)");
+
+ b.Property("AppealReason")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("AppealReviewComment")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("AppealReviewedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("AppealStatus")
+ .HasColumnType("int");
+
+ b.Property("AppealSubmittedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CheckInAccuracyMeters")
+ .HasColumnType("double");
+
+ b.Property("CheckInAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CheckInDistanceMeters")
+ .HasColumnType("double");
+
+ b.Property("CheckInLatitude")
+ .HasPrecision(10, 7)
+ .HasColumnType("decimal(10,7)");
+
+ b.Property("CheckInLongitude")
+ .HasPrecision(10, 7)
+ .HasColumnType("decimal(10,7)");
+
+ b.Property("CheckedInMethod")
+ .HasColumnType("int");
+
+ b.Property("Notes")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.HasKey("AttendanceSheetId", "StudentId");
+
+ b.HasIndex("AppealStatus");
+
+ b.HasIndex("StudentId");
+
+ b.ToTable("AttendanceRecords");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AttendanceDate")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CheckInEndsAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CheckInMethod")
+ .HasColumnType("int");
+
+ b.Property("CheckInStartsAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CheckInToken")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("LocationRadiusMeters")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.Property("Notes")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("SubmittedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("TargetLatitude")
+ .HasPrecision(10, 7)
+ .HasColumnType("decimal(10,7)");
+
+ b.Property("TargetLongitude")
+ .HasPrecision(10, 7)
+ .HasColumnType("decimal(10,7)");
+
+ b.Property("TeachingTaskId")
+ .HasColumnType("char(36)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CheckInToken")
+ .IsUnique();
+
+ b.HasIndex("TeachingTaskId", "AttendanceDate");
+
+ b.ToTable("AttendanceSheets");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("ActiveSchedulePlanId")
+ .HasColumnType("char(36)");
+
+ b.Property("CompletedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CompletedTasks")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedEntries")
+ .HasColumnType("int");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2000)
+ .HasColumnType("varchar(2000)");
+
+ b.Property("MessagesJson")
+ .HasColumnType("longtext");
+
+ b.Property("ProcessedTasks")
+ .HasColumnType("int");
+
+ b.Property("RequestedByUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("SchedulePlanId")
+ .HasColumnType("char(36)");
+
+ b.Property("StartedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("TotalTasks")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActiveSchedulePlanId")
+ .IsUnique();
+
+ b.HasIndex("RequestedByUserId");
+
+ b.HasIndex("SchedulePlanId", "CreatedAt");
+
+ b.HasIndex("Status", "CreatedAt");
+
+ b.ToTable("AutomaticScheduleJobs");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("CampusId")
+ .HasColumnType("char(36)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CampusId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("IsEnabled", "SortOrder");
+
+ b.ToTable("Buildings");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Address")
+ .HasColumnType("longtext");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("IsEnabled", "SortOrder");
+
+ b.ToTable("Campuses");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("BuildingId")
+ .HasColumnType("char(36)");
+
+ b.Property("Capacity")
+ .HasColumnType("int");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Equipment")
+ .HasColumnType("longtext");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("RoomType")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("TeachingVenueNature")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("BuildingId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("IsEnabled", "SortOrder");
+
+ b.ToTable("Classrooms");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AcademicTermId")
+ .HasColumnType("char(36)");
+
+ b.Property("ApplicantCollegeId")
+ .HasColumnType("char(36)");
+
+ b.Property("ApplicantName")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("ApplicantUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("AttendeeCount")
+ .HasColumnType("int");
+
+ b.Property("CancelledAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ClassroomId")
+ .HasColumnType("char(36)");
+
+ b.Property("ContactPhone")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Notes")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("PeriodCount")
+ .HasColumnType("int");
+
+ b.Property("Purpose")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("varchar(200)");
+
+ b.Property("ReservationDate")
+ .HasColumnType("date");
+
+ b.Property("ReviewComment")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("ReviewedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ReviewedByUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("StartPeriod")
+ .HasColumnType("int");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AcademicTermId");
+
+ b.HasIndex("ReviewedByUserId");
+
+ b.HasIndex("ApplicantCollegeId", "Status", "ReservationDate");
+
+ b.HasIndex("ApplicantUserId", "Status", "CreatedAt");
+
+ b.HasIndex("ClassroomId", "ReservationDate", "Status", "StartPeriod");
+
+ b.ToTable("ClassroomReservations");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("CampusId")
+ .HasColumnType("char(36)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("ShortName")
+ .HasColumnType("longtext");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CampusId");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("IsEnabled", "SortOrder");
+
+ b.ToTable("Colleges");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AssessmentMethod")
+ .HasColumnType("int");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CollegeId")
+ .HasColumnType("char(36)");
+
+ b.Property("CourseCategoryId")
+ .HasColumnType("char(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Credits")
+ .HasPrecision(5, 2)
+ .HasColumnType("decimal(5,2)");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("EnglishName")
+ .HasMaxLength(150)
+ .HasColumnType("varchar(150)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("LectureHours")
+ .HasColumnType("int");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Nature")
+ .HasColumnType("int");
+
+ b.Property("PracticeHours")
+ .HasColumnType("int");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("TotalHours")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("CourseCategoryId");
+
+ b.HasIndex("CollegeId", "Nature");
+
+ b.HasIndex("IsEnabled", "SortOrder");
+
+ b.ToTable("Courses");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseAdjustment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("ApplicantUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("CancelDate")
+ .HasColumnType("date");
+
+ b.Property("CancelWeek")
+ .HasColumnType("int");
+
+ b.Property("ClassroomId")
+ .HasColumnType("char(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DayOfWeek")
+ .HasColumnType("int");
+
+ b.Property("PeriodCount")
+ .HasColumnType("int");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("ReviewComment")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("ReviewedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ReviewedByUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("SourceClassroomId")
+ .HasColumnType("char(36)");
+
+ b.Property("SourceDate")
+ .HasColumnType("date");
+
+ b.Property("SourcePeriodCount")
+ .HasColumnType("int");
+
+ b.Property("SourceScheduleEntryId")
+ .HasColumnType("char(36)");
+
+ b.Property("SourceStartPeriod")
+ .HasColumnType("int");
+
+ b.Property("SourceWeek")
+ .HasColumnType("int");
+
+ b.Property("StartPeriod")
+ .HasColumnType("int");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("SubmittedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("SubstituteTeacherId")
+ .HasColumnType("char(36)");
+
+ b.Property("TargetDate")
+ .HasColumnType("date");
+
+ b.Property("TeachingTaskId")
+ .HasColumnType("char(36)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicantUserId");
+
+ b.HasIndex("ClassroomId");
+
+ b.HasIndex("SubstituteTeacherId");
+
+ b.HasIndex("SourceScheduleEntryId", "SourceWeek");
+
+ b.HasIndex("Status", "CreatedAt");
+
+ b.HasIndex("TeachingTaskId", "Status");
+
+ b.ToTable("CourseAdjustments");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseCategory", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("SortOrder")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("IsEnabled", "SortOrder");
+
+ b.ToTable("CourseCategories");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("CourseSelectionOfferingId")
+ .HasColumnType("char(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EnrolledAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EnrollmentType")
+ .HasColumnType("int");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("StudentId")
+ .HasColumnType("char(36)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("WaitlistedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("WithdrawnAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CourseSelectionOfferingId", "StudentId")
+ .IsUnique();
+
+ b.HasIndex("StudentId", "Status");
+
+ b.HasIndex("CourseSelectionOfferingId", "Status", "WaitlistedAt")
+ .HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt");
+
+ b.ToTable("CourseEnrollments");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseExemption", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("ReviewComment")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("ReviewedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ReviewedByUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("StudentId")
+ .HasColumnType("char(36)");
+
+ b.Property("SubmittedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("TeachingTaskId")
+ .HasColumnType("char(36)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TeachingTaskId");
+
+ b.HasIndex("Status", "CreatedAt");
+
+ b.HasIndex("StudentId", "TeachingTaskId")
+ .IsUnique();
+
+ b.ToTable("CourseExemptions");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseGradeStatistic", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AcademicTermId")
+ .HasColumnType("char(36)");
+
+ b.Property("AverageScore")
+ .HasPrecision(5, 1)
+ .HasColumnType("decimal(5,1)");
+
+ b.Property("Below60Count")
+ .HasColumnType("int");
+
+ b.Property("CalculatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CourseId")
+ .HasColumnType("char(36)");
+
+ b.Property