已完成“成绩管理与学业档案”模块:

教师批量录入平时、期中、期末成绩。
自动计算总评、绩点和及格状态。
支持缺考、缓考、免修。
教师提交 → 学院审核/退回 → 校级发布。
学生只能查看正式发布成绩。
自动统计平均分、完成度、及格率和加权 GPA。
SQLite 本地增量升级和 MySQL 正式迁移均已完成。
分级权限在前后端同时校验。
This commit is contained in:
2026-07-24 15:33:47 +08:00 Unverified
parent 151a22554f
commit af52e27a3a
18 changed files with 3686 additions and 22 deletions
@@ -44,6 +44,11 @@ public sealed class DashboardController(AppDbContext db) : ControllerBase
.CountAsync(
x => x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken),
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken),
PublishedGradeSheets = await db.GradeSheets.CountAsync(
x => x.Status == GradeSheetStatus.Published,
cancellationToken),
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
}
};
@@ -0,0 +1,515 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Grades;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/grades")]
public sealed class GradesController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
private const string SheetUsers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin + "," +
SystemRoles.Teacher;
private const string Reviewers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
SystemRoles.CollegeAdmin;
private const string Publishers =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin;
[HttpGet("sheets")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheets(
Guid? academicTermId,
GradeSheetStatus? status,
CancellationToken cancellationToken)
{
var source = AccessibleTasks().AsNoTracking()
.Where(x =>
x.Status == TeachingTaskStatus.Published ||
x.Status == TeachingTaskStatus.Closed);
if (academicTermId.HasValue)
source = source.Where(x => x.AcademicTermId == academicTermId);
var items = await source
.OrderByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
TaskName = x.Name,
x.AcademicTermId,
TermName = x.AcademicTerm!.Name,
CourseCode = x.Course!.Code,
CourseName = x.Course.Name,
CollegeName = x.Course.College!.Name,
x.Course.Credits,
TeacherNames = x.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.Classes.Select(item => item.AdministrativeClass!.Name),
Sheet = db.GradeSheets
.Where(sheet => sheet.TeachingTaskId == x.Id)
.Select(sheet => new
{
sheet.Id,
sheet.Status,
sheet.RegularWeight,
sheet.MidtermWeight,
sheet.FinalWeight,
StudentCount = sheet.Records.Count,
CompletedCount = sheet.Records.Count(record =>
record.TotalScore != null ||
record.ExamStatus != GradeExamStatus.Normal),
PassedCount = sheet.Records.Count(record =>
record.TotalScore >= 60),
sheet.SubmittedAt,
sheet.ReviewedAt,
sheet.PublishedAt,
sheet.UpdatedAt
})
.FirstOrDefault()
})
.ToListAsync(cancellationToken);
if (status.HasValue)
items = items.Where(x => x.Sheet?.Status == status.Value).ToList();
return Ok(items);
}
[HttpPost("sheets")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> CreateSheet(
GradeSheetRequest request,
CancellationToken cancellationToken)
{
if (!GradeCalculator.AreWeightsValid(
request.RegularWeight,
request.MidtermWeight,
request.FinalWeight))
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。");
var task = await AccessibleTasks()
.Include(x => x.Teachers)
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
if (task is null) return NotFound();
if (task.Status is not (TeachingTaskStatus.Published or TeachingTaskStatus.Closed))
return ConflictProblem("只有已发布或已结课的教学班可以建立成绩单。");
if (!CanInitialize(task)) return Forbid();
if (await db.GradeSheets.AnyAsync(
x => x.TeachingTaskId == task.Id,
cancellationToken))
return ConflictProblem("该教学班已经建立成绩单。");
var studentIds = await db.CourseEnrollments.AsNoTracking()
.Where(x =>
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.TeachingTaskId == task.Id)
.Select(x => x.StudentId)
.Distinct()
.ToListAsync(cancellationToken);
if (studentIds.Count == 0)
return ConflictProblem("教学班当前没有有效选课学生,无法建立成绩单。");
var sheet = new GradeSheet
{
TeachingTaskId = task.Id,
RegularWeight = request.RegularWeight,
MidtermWeight = request.MidtermWeight,
FinalWeight = request.FinalWeight,
Records = studentIds.Select(studentId => new GradeRecord
{
StudentId = studentId
}).ToList()
};
db.GradeSheets.Add(sheet);
return await SaveAsync(sheet.Id, true, cancellationToken);
}
[HttpGet("sheets/{id:guid}")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> GetSheet(Guid id, CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets().AsNoTracking()
.Where(x => x.Id == id)
.Select(x => new
{
x.Id,
x.TeachingTaskId,
x.TeachingTask!.TaskNumber,
TaskName = x.TeachingTask.Name,
x.TeachingTask.AcademicTermId,
TermName = x.TeachingTask.AcademicTerm!.Name,
CourseCode = x.TeachingTask.Course!.Code,
CourseName = x.TeachingTask.Course.Name,
CollegeName = x.TeachingTask.Course.College!.Name,
x.TeachingTask.Course.Credits,
TeacherNames = x.TeachingTask.Teachers
.OrderByDescending(item => item.IsPrimary)
.Select(item => item.Teacher!.Name),
ClassNames = x.TeachingTask.Classes
.Select(item => item.AdministrativeClass!.Name),
x.RegularWeight,
x.MidtermWeight,
x.FinalWeight,
x.Status,
x.ReviewComment,
x.SubmittedAt,
x.ReviewedAt,
x.PublishedAt,
Records = x.Records
.OrderBy(record => record.Student!.StudentNumber)
.Select(record => new
{
record.Id,
record.StudentId,
record.Student!.StudentNumber,
record.Student.Name,
ClassName = record.Student.AdministrativeClass!.Name,
record.RegularScore,
record.MidtermScore,
record.FinalScore,
record.TotalScore,
record.GradePoint,
record.ExamStatus,
record.Notes,
record.UpdatedAt
}),
x.CreatedAt,
x.UpdatedAt
})
.FirstOrDefaultAsync(cancellationToken);
if (sheet is null) return NotFound();
var task = await db.TeachingTasks.AsNoTracking()
.Include(x => x.Teachers)
.ThenInclude(x => x.Teacher)
.SingleAsync(x => x.Id == sheet.TeachingTaskId, cancellationToken);
return Ok(new
{
Sheet = sheet,
CanEdit = CanEditScores(task) &&
sheet.Status is GradeSheetStatus.Draft or GradeSheetStatus.Returned,
CanReview = IsReviewer() && sheet.Status == GradeSheetStatus.Submitted,
CanPublish = IsPublisher() && sheet.Status == GradeSheetStatus.Approved
});
}
[HttpPut("sheets/{id:guid}/weights")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> UpdateWeights(
Guid id,
GradeWeightsRequest request,
CancellationToken cancellationToken)
{
if (!GradeCalculator.AreWeightsValid(
request.RegularWeight,
request.MidtermWeight,
request.FinalWeight))
return ValidationProblem("平时、期中和期末成绩比例必须合计 100%。");
var sheet = await EditableSheetAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("当前状态不能修改成绩构成。");
sheet.RegularWeight = request.RegularWeight;
sheet.MidtermWeight = request.MidtermWeight;
sheet.FinalWeight = request.FinalWeight;
foreach (var record in sheet.Records) Recalculate(sheet, record);
return await SaveAsync(id, false, cancellationToken);
}
[HttpPut("sheets/{id:guid}/records")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> UpdateRecords(
Guid id,
GradeRecordsRequest request,
CancellationToken cancellationToken)
{
var sheet = await EditableSheetAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("成绩单提交后不能继续修改。");
var records = sheet.Records.ToDictionary(x => x.Id);
if (request.Records.Select(x => x.Id).Distinct().Count() != request.Records.Count ||
request.Records.Any(x => !records.ContainsKey(x.Id)))
return ValidationProblem("包含无效或重复的成绩记录。");
foreach (var item in request.Records)
{
if (!ValidScore(item.RegularScore) ||
!ValidScore(item.MidtermScore) ||
!ValidScore(item.FinalScore))
return ValidationProblem("成绩必须在 0—100 分之间。");
var record = records[item.Id];
record.RegularScore = item.RegularScore;
record.MidtermScore = item.MidtermScore;
record.FinalScore = item.FinalScore;
record.ExamStatus = item.ExamStatus;
record.Notes = Normalize(item.Notes);
Recalculate(sheet, record);
}
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("sheets/{id:guid}/submit")]
[Authorize(Roles = SheetUsers)]
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
{
var sheet = await EditableSheetAsync(id, cancellationToken);
if (sheet is null) return NotFound();
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
if (sheet.Status is not (GradeSheetStatus.Draft or GradeSheetStatus.Returned))
return ConflictProblem("只有草稿或已退回成绩单可以提交。");
if (sheet.Records.Count == 0)
return ConflictProblem("成绩单没有学生记录。");
if (sheet.Records.Any(x =>
x.ExamStatus == GradeExamStatus.Normal &&
!x.TotalScore.HasValue))
return ConflictProblem("仍有学生缺少必填成绩分项,请补充后再提交。");
sheet.Status = GradeSheetStatus.Submitted;
sheet.SubmittedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("sheets/{id:guid}/approve")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Approve(Guid id, CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
x => x.Id == id,
cancellationToken);
if (sheet is null) return NotFound();
if (!IsReviewer()) return Forbid();
if (sheet.Status != GradeSheetStatus.Submitted)
return ConflictProblem("只有待审核成绩单可以通过审核。");
sheet.Status = GradeSheetStatus.Approved;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = null;
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("sheets/{id:guid}/return")]
[Authorize(Roles = Reviewers)]
public async Task<ActionResult> Return(
Guid id,
GradeReviewRequest request,
CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
x => x.Id == id,
cancellationToken);
if (sheet is null) return NotFound();
if (!IsReviewer()) return Forbid();
if (sheet.Status != GradeSheetStatus.Submitted)
return ConflictProblem("只有待审核成绩单可以退回。");
if (string.IsNullOrWhiteSpace(request.Comment))
return ValidationProblem("退回时必须填写修改意见。");
sheet.Status = GradeSheetStatus.Returned;
sheet.ReviewedAt = DateTime.UtcNow;
sheet.ReviewComment = request.Comment.Trim();
return await SaveAsync(id, false, cancellationToken);
}
[HttpPost("sheets/{id:guid}/publish")]
[Authorize(Roles = Publishers)]
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
{
var sheet = await AccessibleSheets().FirstOrDefaultAsync(
x => x.Id == id,
cancellationToken);
if (sheet is null) return NotFound();
if (!IsPublisher()) return Forbid();
if (sheet.Status != GradeSheetStatus.Approved)
return ConflictProblem("只有审核通过的成绩单可以发布。");
sheet.Status = GradeSheetStatus.Published;
sheet.PublishedAt = DateTime.UtcNow;
return await SaveAsync(id, false, cancellationToken);
}
[HttpGet("student/transcript")]
[Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> GetStudentTranscript(
Guid? academicTermId,
CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.AsNoTracking()
.Where(x => x.UserId == userId)
.Select(x => new
{
x.Id,
x.StudentNumber,
x.Name,
ClassName = x.AdministrativeClass!.Name,
MajorName = x.AdministrativeClass.Major!.Name
})
.FirstOrDefaultAsync(cancellationToken);
if (student is null)
return ConflictProblem("当前账号未关联有效学生档案。");
var source = db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.GradeSheet!.Status == GradeSheetStatus.Published);
if (academicTermId.HasValue)
source = source.Where(x =>
x.GradeSheet!.TeachingTask!.AcademicTermId == academicTermId);
var records = await source
.OrderByDescending(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate)
.ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code)
.Select(x => new
{
x.Id,
AcademicTermId = x.GradeSheet!.TeachingTask!.AcademicTermId,
TermName = x.GradeSheet.TeachingTask.AcademicTerm!.Name,
x.GradeSheet.TeachingTaskId,
x.GradeSheet.TeachingTask.TaskNumber,
CourseCode = x.GradeSheet.TeachingTask.Course!.Code,
CourseName = x.GradeSheet.TeachingTask.Course.Name,
x.GradeSheet.TeachingTask.Course.Credits,
x.TotalScore,
x.GradePoint,
x.ExamStatus,
x.GradeSheet.PublishedAt
})
.ToListAsync(cancellationToken);
return Ok(new { Student = student, Records = records });
}
private IQueryable<TeachingTask> AccessibleTasks()
{
var source = db.TeachingTasks.AsQueryable();
var scope = currentUserDataScope.Current;
if (scope.Scope == DataScope.All) return source;
if (scope.Scope == DataScope.College)
return source.Where(x => x.Course!.CollegeId == scope.RestrictedCollegeId);
if (scope.IsInRole(SystemRoles.Teacher))
return source.Where(x =>
x.Teachers.Any(item => item.Teacher!.UserId == scope.UserId));
return source.Where(_ => false);
}
private IQueryable<GradeSheet> AccessibleSheets() =>
db.GradeSheets.Where(x => AccessibleTasks().Any(task =>
task.Id == x.TeachingTaskId));
private async Task<GradeSheet?> EditableSheetAsync(
Guid id,
CancellationToken cancellationToken) =>
await AccessibleSheets()
.Include(x => x.Records)
.Include(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private bool CanInitialize(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
IsAssignedTeacher(task);
private bool CanEditScores(TeachingTask task) =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
IsAssignedTeacher(task);
private bool IsAssignedTeacher(TeachingTask task) =>
task.Teachers.Any(x =>
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
private bool IsReviewer() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin);
private bool IsPublisher() =>
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
private static void Recalculate(GradeSheet sheet, GradeRecord record)
{
record.TotalScore = GradeCalculator.CalculateTotal(
record.RegularScore,
record.MidtermScore,
record.FinalScore,
sheet.RegularWeight,
sheet.MidtermWeight,
sheet.FinalWeight,
record.ExamStatus);
record.GradePoint = GradeCalculator.CalculateGradePoint(record.TotalScore);
}
private static bool ValidScore(decimal? score) =>
!score.HasValue || score.Value is >= 0 and <= 100;
private async Task<ActionResult> SaveAsync(
Guid id,
bool created,
CancellationToken cancellationToken)
{
try
{
await db.SaveChangesAsync(cancellationToken);
return created ? Created(string.Empty, new { id }) : NoContent();
}
catch (DbUpdateException)
{
return ConflictProblem("成绩单已存在,或关联学生与教学班已发生变化。");
}
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法完成成绩操作",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public sealed record GradeSheetRequest(
Guid TeachingTaskId,
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight);
public sealed record GradeWeightsRequest(
[Range(typeof(decimal), "0", "100")] decimal RegularWeight,
[Range(typeof(decimal), "0", "100")] decimal MidtermWeight,
[Range(typeof(decimal), "0", "100")] decimal FinalWeight);
public sealed record GradeRecordsRequest(
IReadOnlyCollection<GradeRecordRequest> Records);
public sealed record GradeRecordRequest(
Guid Id,
decimal? RegularScore,
decimal? MidtermScore,
decimal? FinalScore,
GradeExamStatus ExamStatus,
[MaxLength(300)] string? Notes);
public sealed record GradeReviewRequest(
[MaxLength(500)] string? Comment);
@@ -0,0 +1,50 @@
using Jiaowu.Api.Domain.Common;
namespace Jiaowu.Api.Domain.Academic;
public sealed class GradeSheet : EntityBase
{
public Guid TeachingTaskId { get; set; }
public TeachingTask? TeachingTask { get; set; }
public decimal RegularWeight { get; set; } = 30;
public decimal MidtermWeight { get; set; }
public decimal FinalWeight { get; set; } = 70;
public GradeSheetStatus Status { get; set; } = GradeSheetStatus.Draft;
public string? ReviewComment { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime? ReviewedAt { get; set; }
public DateTime? PublishedAt { get; set; }
public ICollection<GradeRecord> Records { get; set; } = [];
}
public sealed class GradeRecord : EntityBase
{
public Guid GradeSheetId { get; set; }
public GradeSheet? GradeSheet { get; set; }
public Guid StudentId { get; set; }
public Student? Student { get; set; }
public decimal? RegularScore { get; set; }
public decimal? MidtermScore { get; set; }
public decimal? FinalScore { get; set; }
public decimal? TotalScore { get; set; }
public decimal? GradePoint { get; set; }
public GradeExamStatus ExamStatus { get; set; } = GradeExamStatus.Normal;
public string? Notes { get; set; }
}
public enum GradeSheetStatus
{
Draft = 1,
Submitted = 2,
Approved = 3,
Published = 4,
Returned = 5
}
public enum GradeExamStatus
{
Normal = 1,
Absent = 2,
Deferred = 3,
Exempt = 4
}
@@ -0,0 +1,57 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Grades;
public static class GradeCalculator
{
public static bool AreWeightsValid(
decimal regularWeight,
decimal midtermWeight,
decimal finalWeight) =>
regularWeight is >= 0 and <= 100 &&
midtermWeight is >= 0 and <= 100 &&
finalWeight is >= 0 and <= 100 &&
regularWeight + midtermWeight + finalWeight == 100;
public static decimal? CalculateTotal(
decimal? regularScore,
decimal? midtermScore,
decimal? finalScore,
decimal regularWeight,
decimal midtermWeight,
decimal finalWeight,
GradeExamStatus examStatus)
{
if (examStatus != GradeExamStatus.Normal ||
regularWeight > 0 && !regularScore.HasValue ||
midtermWeight > 0 && !midtermScore.HasValue ||
finalWeight > 0 && !finalScore.HasValue)
{
return null;
}
var total =
(regularScore ?? 0) * regularWeight / 100 +
(midtermScore ?? 0) * midtermWeight / 100 +
(finalScore ?? 0) * finalWeight / 100;
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
}
public static decimal? CalculateGradePoint(decimal? totalScore)
{
if (!totalScore.HasValue) return null;
return totalScore.Value switch
{
>= 90 => 4.0m,
>= 85 => 3.7m,
>= 82 => 3.3m,
>= 78 => 3.0m,
>= 75 => 2.7m,
>= 72 => 2.3m,
>= 68 => 2.0m,
>= 64 => 1.5m,
>= 60 => 1.0m,
_ => 0m
};
}
}
@@ -33,6 +33,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<CourseSelectionOffering> CourseSelectionOfferings =>
Set<CourseSelectionOffering>();
public DbSet<CourseEnrollment> CourseEnrollments => Set<CourseEnrollment>();
public DbSet<GradeSheet> GradeSheets => Set<GradeSheet>();
public DbSet<GradeRecord> GradeRecords => Set<GradeRecord>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
@@ -317,6 +319,40 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GradeSheet>(entity =>
{
entity.Property(x => x.RegularWeight).HasPrecision(5, 1);
entity.Property(x => x.MidtermWeight).HasPrecision(5, 1);
entity.Property(x => x.FinalWeight).HasPrecision(5, 1);
entity.Property(x => x.ReviewComment).HasMaxLength(500);
entity.HasIndex(x => x.TeachingTaskId).IsUnique();
entity.HasIndex(x => x.Status);
entity.HasOne(x => x.TeachingTask)
.WithMany()
.HasForeignKey(x => x.TeachingTaskId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<GradeRecord>(entity =>
{
entity.Property(x => x.RegularScore).HasPrecision(5, 1);
entity.Property(x => x.MidtermScore).HasPrecision(5, 1);
entity.Property(x => x.FinalScore).HasPrecision(5, 1);
entity.Property(x => x.TotalScore).HasPrecision(5, 1);
entity.Property(x => x.GradePoint).HasPrecision(3, 1);
entity.Property(x => x.Notes).HasMaxLength(300);
entity.HasIndex(x => new { x.GradeSheetId, x.StudentId }).IsUnique();
entity.HasIndex(x => new { x.StudentId, x.TotalScore });
entity.HasOne(x => x.GradeSheet)
.WithMany(x => x.Records)
.HasForeignKey(x => x.GradeSheetId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.Student)
.WithMany()
.HasForeignKey(x => x.StudentId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<AuditLog>(entity =>
{
entity.Property(x => x.Method).HasMaxLength(10);
@@ -422,37 +422,84 @@ public sealed class DatabaseInitializer(
await SeedDevelopmentUsersAsync(computerCollege.Id);
await SeedDevelopmentCourseSelectionAsync();
await SeedDevelopmentGradesAsync();
}
private async Task SeedDevelopmentCourseSelectionAsync()
{
if (await db.CourseSelectionRounds.AnyAsync())
if (!await db.CourseSelectionRounds.AnyAsync())
{
return;
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var task = await db.TeachingTasks.SingleAsync(
x => x.TaskNumber == "2026-1-CS101-01");
var now = DateTime.UtcNow;
db.CourseSelectionRounds.Add(new CourseSelectionRound
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期第一轮选课",
StartsAt = now.AddDays(-2),
EndsAt = now.AddDays(14),
WithdrawalEndsAt = now.AddDays(21),
MaxCredits = 30,
Status = CourseSelectionRoundStatus.Open,
Notes = "本地开发演示轮次,可用于验证选课、退课和教学班名单。",
Offerings =
[
new CourseSelectionOffering
{
TeachingTaskId = task.Id,
Capacity = 60,
IsOpenToAll = false,
Notes = "面向计科 2026-1 班开放。"
}
]
});
await db.SaveChangesAsync();
}
var term = await db.AcademicTerms.SingleAsync(x => x.IsCurrent);
var seededTask = await db.TeachingTasks.SingleAsync(
x => x.TaskNumber == "2026-1-CS101-01");
var offering = await db.CourseSelectionOfferings.FirstOrDefaultAsync(
x => x.TeachingTaskId == seededTask.Id);
var student = await db.Students.SingleAsync(x =>
x.StudentNumber == "202601001");
if (offering is not null &&
!await db.CourseEnrollments.AnyAsync(x =>
x.CourseSelectionOfferingId == offering.Id &&
x.StudentId == student.Id))
{
db.CourseEnrollments.Add(new CourseEnrollment
{
CourseSelectionOfferingId = offering.Id,
StudentId = student.Id
});
await db.SaveChangesAsync();
}
}
private async Task SeedDevelopmentGradesAsync()
{
if (await db.GradeSheets.AnyAsync()) return;
var task = await db.TeachingTasks.SingleAsync(
x => x.TaskNumber == "2026-1-CS101-01");
var now = DateTime.UtcNow;
db.CourseSelectionRounds.Add(new CourseSelectionRound
var student = await db.Students.SingleAsync(
x => x.StudentNumber == "202601001");
db.GradeSheets.Add(new GradeSheet
{
AcademicTermId = term.Id,
Name = "2026—2027 学年第一学期第一轮选课",
StartsAt = now.AddDays(-2),
EndsAt = now.AddDays(14),
WithdrawalEndsAt = now.AddDays(21),
MaxCredits = 30,
Status = CourseSelectionRoundStatus.Open,
Notes = "本地开发演示轮次,可用于验证选课、退课和教学班名单。",
Offerings =
TeachingTaskId = task.Id,
RegularWeight = 30,
MidtermWeight = 0,
FinalWeight = 70,
Status = GradeSheetStatus.Draft,
Records =
[
new CourseSelectionOffering
new GradeRecord
{
TeachingTaskId = task.Id,
Capacity = 60,
IsOpenToAll = false,
Notes = "面向计科 2026-1 班开放。"
StudentId = student.Id,
RegularScore = 88,
FinalScore = 92,
TotalScore = 90.8m,
GradePoint = 4.0m
}
]
});
@@ -12,6 +12,7 @@ public sealed class DevelopmentSqliteMigrator(
private const string SchedulesMigration = "20260724_04_schedules";
private const string ClassCounselorMigration = "20260724_05_class_counselor";
private const string CourseSelectionMigration = "20260724_06_course_selection";
private const string GradesMigration = "20260724_07_grades";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -63,6 +64,10 @@ public sealed class DevelopmentSqliteMigrator(
CourseSelectionMigration,
CourseSelectionStatements,
cancellationToken);
await ApplyMigrationAsync(
GradesMigration,
GradesStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -506,4 +511,62 @@ public sealed class DevelopmentSqliteMigrator(
ON "CourseEnrollments" ("StudentId", "Status");
"""
];
private static readonly string[] GradesStatements =
[
"""
CREATE TABLE IF NOT EXISTS "GradeSheets" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeSheets" PRIMARY KEY,
"TeachingTaskId" TEXT NOT NULL,
"RegularWeight" TEXT NOT NULL,
"MidtermWeight" TEXT NOT NULL,
"FinalWeight" TEXT NOT NULL,
"Status" INTEGER NOT NULL,
"ReviewComment" TEXT NULL,
"SubmittedAt" TEXT NULL,
"ReviewedAt" TEXT NULL,
"PublishedAt" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeSheets_TeachingTasks_TeachingTaskId"
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeSheets_TeachingTaskId"
ON "GradeSheets" ("TeachingTaskId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeSheets_Status"
ON "GradeSheets" ("Status");
""",
"""
CREATE TABLE IF NOT EXISTS "GradeRecords" (
"Id" TEXT NOT NULL CONSTRAINT "PK_GradeRecords" PRIMARY KEY,
"GradeSheetId" TEXT NOT NULL,
"StudentId" TEXT NOT NULL,
"RegularScore" TEXT NULL,
"MidtermScore" TEXT NULL,
"FinalScore" TEXT NULL,
"TotalScore" TEXT NULL,
"GradePoint" TEXT NULL,
"ExamStatus" INTEGER NOT NULL,
"Notes" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_GradeRecords_GradeSheets_GradeSheetId"
FOREIGN KEY ("GradeSheetId") REFERENCES "GradeSheets" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_GradeRecords_Students_StudentId"
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX IF NOT EXISTS "IX_GradeRecords_GradeSheetId_StudentId"
ON "GradeRecords" ("GradeSheetId", "StudentId");
""",
"""
CREATE INDEX IF NOT EXISTS "IX_GradeRecords_StudentId_TotalScore"
ON "GradeRecords" ("StudentId", "TotalScore");
"""
];
}
@@ -0,0 +1,111 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class GradeManagement : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "GradeSheets",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
RegularWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
MidtermWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
FinalWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GradeSheets", x => x.Id);
table.ForeignKey(
name: "FK_GradeSheets_TeachingTasks_TeachingTaskId",
column: x => x.TeachingTaskId,
principalTable: "TeachingTasks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "GradeRecords",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
GradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
RegularScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
MidtermScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
FinalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
TotalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
GradePoint = table.Column<decimal>(type: "decimal(3,1)", precision: 3, scale: 1, nullable: true),
ExamStatus = table.Column<int>(type: "int", nullable: false),
Notes = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_GradeRecords", x => x.Id);
table.ForeignKey(
name: "FK_GradeRecords_GradeSheets_GradeSheetId",
column: x => x.GradeSheetId,
principalTable: "GradeSheets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_GradeRecords_Students_StudentId",
column: x => x.StudentId,
principalTable: "Students",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_GradeRecords_GradeSheetId_StudentId",
table: "GradeRecords",
columns: new[] { "GradeSheetId", "StudentId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_GradeRecords_StudentId_TotalScore",
table: "GradeRecords",
columns: new[] { "StudentId", "TotalScore" });
migrationBuilder.CreateIndex(
name: "IX_GradeSheets_Status",
table: "GradeSheets",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_GradeSheets_TeachingTaskId",
table: "GradeSheets",
column: "TeachingTaskId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "GradeRecords");
migrationBuilder.DropTable(
name: "GradeSheets");
}
}
}
@@ -634,6 +634,114 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CurriculumPlans");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("ExamStatus")
.HasColumnType("int");
b.Property<decimal?>("FinalScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal?>("GradePoint")
.HasPrecision(3, 1)
.HasColumnType("decimal(3,1)");
b.Property<Guid>("GradeSheetId")
.HasColumnType("char(36)");
b.Property<decimal?>("MidtermScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<string>("Notes")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<decimal?>("RegularScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<decimal?>("TotalScore")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("GradeSheetId", "StudentId")
.IsUnique();
b.HasIndex("StudentId", "TotalScore");
b.ToTable("GradeRecords");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("FinalWeight")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<decimal>("MidtermWeight")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<DateTime?>("PublishedAt")
.HasColumnType("datetime(6)");
b.Property<decimal>("RegularWeight")
.HasPrecision(5, 1)
.HasColumnType("decimal(5,1)");
b.Property<string>("ReviewComment")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime?>("ReviewedAt")
.HasColumnType("datetime(6)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("SubmittedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("TeachingTaskId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("Status");
b.HasIndex("TeachingTaskId")
.IsUnique();
b.ToTable("GradeSheets");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{
b.Property<Guid>("Id")
@@ -1430,6 +1538,36 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Major");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet")
.WithMany("Records")
.HasForeignKey("GradeSheetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student")
.WithMany()
.HasForeignKey("StudentId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("GradeSheet");
b.Navigation("Student");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask")
.WithMany()
.HasForeignKey("TeachingTaskId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.College", "College")
@@ -1644,6 +1782,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Modules");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b =>
{
b.Navigation("Records");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b =>
{
b.Navigation("Entries");