From ecc6546f3abfaaf0e5eb6371cc12bcb29bc6d33c Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 26 Jul 2026 18:24:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=A6=E7=94=9F=E5=8F=AF=E5=9C=A8=E2=80=9C?= =?UTF-8?q?=E5=AD=A6=E7=B1=8D=E7=AE=A1=E7=90=86=20=E2=86=92=20=E7=94=B5?= =?UTF-8?q?=E5=AD=90=E6=88=90=E7=BB=A9=E5=8D=95=E4=B8=8E=E8=AF=81=E6=98=8E?= =?UTF-8?q?=E2=80=9D=E7=94=B3=E8=AF=B7=E6=88=90=E7=BB=A9=E5=8D=95=E6=88=96?= =?UTF-8?q?=E5=AD=A6=E7=B1=8D=E8=AF=81=E6=98=8E=E3=80=82=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E6=A0=B9=E6=8D=AE=E7=99=BB=E5=BD=95=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BB=91=E5=AE=9A=E6=9C=AC=E4=BA=BA=E6=A1=A3=E6=A1=88?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E6=8E=A5=E5=8F=97=E5=AD=A6=E7=94=9F=20ID?= =?UTF-8?q?=EF=BC=8C=E6=97=A0=E6=B3=95=E4=BB=A3=E4=BB=96=E4=BA=BA=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E3=80=82=20=E6=88=90=E7=BB=A9=E5=8D=95=E4=BB=85?= =?UTF-8?q?=E5=8C=85=E5=90=AB=E6=AD=A3=E5=BC=8F=E5=8F=91=E5=B8=83=E6=88=90?= =?UTF-8?q?=E7=BB=A9=EF=BC=9B=E6=97=A0=E5=B7=B2=E5=8F=91=E5=B8=83=E6=88=90?= =?UTF-8?q?=E7=BB=A9=E6=97=B6=E6=98=8E=E7=A1=AE=E6=8F=90=E7=A4=BA=E3=80=82?= =?UTF-8?q?=20=E5=90=8C=E7=B1=BB=E5=9E=8B=E3=80=81=E5=90=8C=E7=94=A8?= =?UTF-8?q?=E9=80=94=205=20=E5=88=86=E9=92=9F=E5=86=85=E9=87=8D=E5=A4=8D?= =?UTF-8?q?=E7=94=B3=E8=AF=B7=E5=A4=8D=E7=94=A8=E5=B7=B2=E6=9C=89=E6=9C=89?= =?UTF-8?q?=E6=95=88=E5=87=AD=E8=AF=81=E3=80=82=20=E7=94=B3=E8=AF=B7?= =?UTF-8?q?=E5=90=8E=E5=8D=B3=E6=97=B6=E7=94=9F=E6=88=90=20PDF=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E5=9C=A8=E6=9C=AC=E4=BA=BA=E5=87=AD=E8=AF=81=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=B8=8B=E8=BD=BD=E3=80=81=E4=BA=8C=E7=BB=B4=E7=A0=81?= =?UTF-8?q?=E9=AA=8C=E7=9C=9F=E3=80=82=20=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E7=9A=84=E4=B8=8B=E8=BD=BD=E8=AE=B0=E5=BD=95=E3=80=81=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E3=80=81=E9=87=8D=E7=AD=BE=E8=83=BD=E5=8A=9B=E4=BF=9D?= =?UTF-8?q?=E6=8C=81=E4=B8=8D=E5=8F=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.docker.example | 1 + .env.example | 8 + Dockerfile | 1 + README.md | 2 + compose.example.yml | 1 + .../OfficialDocumentsController.cs | 413 ++ .../Academic/OfficialDocumentEntities.cs | 51 + .../OfficialDocumentOptions.cs | 12 + .../OfficialDocumentPdfGenerator.cs | 409 ++ .../OfficialDocumentService.cs | 165 + .../OfficialDocumentSnapshots.cs | 41 + .../Persistence/AppDbContext.cs | 51 + .../Persistence/DevelopmentSqliteMigrator.cs | 73 + ...260726094419_OfficialDocuments.Designer.cs | 4579 +++++++++++++++++ .../MySql/20260726094419_OfficialDocuments.cs | 156 + .../MySql/AppDbContextModelSnapshot.cs | 181 + src/Jiaowu.Api/Jiaowu.Api.csproj | 3 + src/Jiaowu.Api/Program.cs | 27 + src/Jiaowu.Api/appsettings.json | 7 + .../Jiaowu.Api.Tests/OfficialDocumentTests.cs | 470 ++ web/src/layouts/AdminLayout.vue | 4 + web/src/router/index.ts | 14 + web/src/views/OfficialDocumentVerifyView.vue | 149 + web/src/views/OfficialDocumentsView.vue | 385 ++ 24 files changed, 7203 insertions(+) create mode 100644 src/Jiaowu.Api/Controllers/OfficialDocumentsController.cs create mode 100644 src/Jiaowu.Api/Domain/Academic/OfficialDocumentEntities.cs create mode 100644 src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentOptions.cs create mode 100644 src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentPdfGenerator.cs create mode 100644 src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentService.cs create mode 100644 src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentSnapshots.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726094419_OfficialDocuments.Designer.cs create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726094419_OfficialDocuments.cs create mode 100644 tests/Jiaowu.Api.Tests/OfficialDocumentTests.cs create mode 100644 web/src/views/OfficialDocumentVerifyView.vue create mode 100644 web/src/views/OfficialDocumentsView.vue diff --git a/.env.docker.example b/.env.docker.example index dca4dbc..f825408 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -11,6 +11,7 @@ MYSQL_ROOT_PASSWORD= JWT_KEY= ALLOWED_HOSTS=localhost CORS_ORIGIN=http://localhost:8080 +OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL=http://localhost:8080 # 仅首次创建演示管理员时使用;演示数据成功写入后删除下面三行。 SEED_ADMIN_USERNAME=demo-admin diff --git a/.env.example b/.env.example index 741095f..2006d96 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,14 @@ Jwt__ExpireMinutes=60 AllowedHosts=jiaowu.example.edu.cn Cors__Origins__0=https://jiaowu.example.edu.cn +# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。 +OfficialDocuments__InstitutionName=明序大学 +OfficialDocuments__IssuingOffice=教务处 +OfficialDocuments__DocumentNumberPrefix=MXU +OfficialDocuments__PublicBaseUrl=https://jiaowu.example.edu.cn +# 默认使用系统中文字体;仅在自定义字体部署时填写绝对路径。 +# OfficialDocuments__FontPath=/usr/share/fonts/noto/NotoSansCJK-Regular.ttc + # 仅首次创建管理员时临时取消注释,创建成功后立即删除。 # SeedAdmin__UserName=admin # SeedAdmin__Password=REPLACE_WITH_A_STRONG_ADMIN_PASSWORD diff --git a/Dockerfile b/Dockerfile index 788fb9a..e00893c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ ENV ASPNETCORE_ENVIRONMENT=Production \ EXPOSE 8080 COPY --from=build /app/publish/ ./ ARG UID=10001 +RUN apk add --no-cache font-noto-cjk RUN adduser \ --disabled-password \ --gecos "" \ diff --git a/README.md b/README.md index a9b478d..bf09a3b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。 +官方电子凭证支持学生自助申请和教务代签,由服务端生成成绩单与学籍状态证明 PDF,并提供唯一凭证编号、二维码公开验真、下载记录、失效和重签;生产部署应通过 `OfficialDocuments__PublicBaseUrl` 配置二维码使用的最终 HTTPS 根地址。 + 当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;教师和学生可启用个人教学日历订阅,将固定课程、考试/监考、补考及灵活课程提醒同步到支持 iCalendar 的客户端,并可重置或停用订阅地址;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。 课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。 diff --git a/compose.example.yml b/compose.example.yml index 368c511..5e8d725 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -22,6 +22,7 @@ x-jiaowu-environment: &jiaowu-environment Jwt__ExpireMinutes: "60" AllowedHosts: "${ALLOWED_HOSTS:-localhost}" Cors__Origins__0: "${CORS_ORIGIN:-http://localhost:8080}" + OfficialDocuments__PublicBaseUrl: "${OFFICIAL_DOCUMENTS_PUBLIC_BASE_URL:-http://localhost:8080}" SeedAdmin__UserName: "${SEED_ADMIN_USERNAME:-}" SeedAdmin__Password: "${SEED_ADMIN_PASSWORD:-}" SeedAdmin__DisplayName: "${SEED_ADMIN_DISPLAY_NAME:-}" diff --git a/src/Jiaowu.Api/Controllers/OfficialDocumentsController.cs b/src/Jiaowu.Api/Controllers/OfficialDocumentsController.cs new file mode 100644 index 0000000..784fdb1 --- /dev/null +++ b/src/Jiaowu.Api/Controllers/OfficialDocumentsController.cs @@ -0,0 +1,413 @@ +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.OfficialDocuments; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Jiaowu.Api.Controllers; + +[ApiController] +[Route("api/official-documents")] +public sealed class OfficialDocumentsController( + AppDbContext db, + ICurrentUserDataScope currentUserDataScope, + OfficialDocumentService service, + IOptions options) : ControllerBase +{ + private const string Managers = + SystemRoles.SuperAdmin + "," + + SystemRoles.AcademicAdmin + "," + + SystemRoles.CollegeAdmin; + private const string Readers = Managers + "," + SystemRoles.Student; + + [HttpGet] + [Authorize(Roles = Readers)] + public async Task List( + OfficialDocumentType? type, + OfficialDocumentStatus? status, + Guid? studentId, + CancellationToken cancellationToken) + { + var source = AccessibleDocuments().AsNoTracking(); + if (type.HasValue) source = source.Where(x => x.Type == type); + if (status.HasValue) source = source.Where(x => x.Status == status); + if (studentId.HasValue) source = source.Where(x => x.StudentId == studentId); + + return Ok(await source + .OrderByDescending(x => x.IssuedAt) + .Select(x => new + { + x.Id, + x.DocumentNumber, + x.Type, + x.Status, + x.StudentId, + x.Student!.StudentNumber, + StudentName = x.Student.Name, + CollegeName = x.Student.AdministrativeClass!.Major!.College!.Name, + x.Purpose, + x.IssuedAt, + IssuedByName = x.Student.UserId == x.IssuedByUserId + ? "学生自助服务" + : x.IssuedByUser!.DisplayName, + x.InvalidatedAt, + x.InvalidationReason, + x.ReissuedFromDocumentId, + DownloadCount = x.Downloads.Count, + LastDownloadedAt = x.Downloads.Max(download => (DateTime?)download.CreatedAt) + }) + .ToListAsync(cancellationToken)); + } + + [HttpGet("students/options")] + [Authorize(Roles = Managers)] + public async Task StudentOptions( + string? keyword, + CancellationToken cancellationToken) + { + var source = AccessibleStudents().AsNoTracking(); + if (!string.IsNullOrWhiteSpace(keyword)) + { + var value = keyword.Trim(); + source = source.Where(x => + x.StudentNumber.Contains(value) || x.Name.Contains(value)); + } + + return Ok(await source.OrderBy(x => x.StudentNumber) + .Select(x => new + { + x.Id, + x.StudentNumber, + x.Name, + x.Status, + ClassName = x.AdministrativeClass!.Name, + MajorName = x.AdministrativeClass.Major!.Name, + CollegeName = x.AdministrativeClass.Major.College!.Name + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost] + [Authorize(Roles = Managers)] + public async Task Issue( + IssueOfficialDocumentRequest request, + CancellationToken cancellationToken) + { + if (!await AccessibleStudents().AnyAsync(x => x.Id == request.StudentId, cancellationToken)) + return NotFound(); + + try + { + var document = await service.CreateAsync( + request.StudentId, + request.Type, + request.Purpose, + currentUserDataScope.Current.UserId, + currentUserDataScope.Current.DisplayName ?? "教务管理员", + VerificationBaseUrl(), + null, + cancellationToken); + db.OfficialDocuments.Add(document); + await db.SaveChangesAsync(cancellationToken); + return CreatedAtAction(nameof(Download), new { id = document.Id }, + new { document.Id, document.DocumentNumber }); + } + catch (OfficialDocumentValidationException exception) + { + return ConflictProblem(exception.Message); + } + } + + [HttpPost("mine")] + [Authorize(Roles = SystemRoles.Student)] + public async Task IssueMine( + SelfIssueOfficialDocumentRequest request, + CancellationToken cancellationToken) + { + var userId = currentUserDataScope.Current.UserId; + var studentId = await db.Students.AsNoTracking() + .Where(x => x.UserId == userId) + .Select(x => (Guid?)x.Id) + .SingleOrDefaultAsync(cancellationToken); + if (!studentId.HasValue) + return ConflictProblem("当前账号未关联有效学生档案,不能申请电子凭证。"); + + var purpose = string.IsNullOrWhiteSpace(request.Purpose) + ? null + : request.Purpose.Trim(); + var duplicateThreshold = DateTime.UtcNow.AddMinutes(-5); + var recentDocument = await db.OfficialDocuments.AsNoTracking() + .Where(x => + x.StudentId == studentId.Value && + x.Type == request.Type && + x.Status == OfficialDocumentStatus.Valid && + x.Purpose == purpose && + x.IssuedAt >= duplicateThreshold) + .OrderByDescending(x => x.IssuedAt) + .Select(x => new { x.Id, x.DocumentNumber }) + .FirstOrDefaultAsync(cancellationToken); + if (recentDocument is not null) + return Ok(new + { + recentDocument.Id, + recentDocument.DocumentNumber, + Reused = true + }); + + try + { + var document = await service.CreateAsync( + studentId.Value, + request.Type, + purpose, + userId, + $"{options.Value.IssuingOffice}自助签发", + VerificationBaseUrl(), + null, + cancellationToken); + db.OfficialDocuments.Add(document); + await db.SaveChangesAsync(cancellationToken); + return CreatedAtAction(nameof(Download), new { id = document.Id }, + new { document.Id, document.DocumentNumber, Reused = false }); + } + catch (OfficialDocumentValidationException exception) + { + return ConflictProblem(exception.Message); + } + } + + [HttpGet("{id:guid}/download")] + [Authorize(Roles = Readers)] + public async Task Download(Guid id, CancellationToken cancellationToken) + { + var document = await AccessibleDocuments() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (document is null) return NotFound(); + if (document.Status != OfficialDocumentStatus.Valid) + return ConflictProblem("该凭证已经失效或被新凭证替代,不能继续下载。"); + + db.OfficialDocumentDownloads.Add(new OfficialDocumentDownload + { + OfficialDocumentId = document.Id, + DownloadedByUserId = currentUserDataScope.Current.UserId, + IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), + UserAgent = Request.Headers.UserAgent.ToString() + }); + await db.SaveChangesAsync(cancellationToken); + return File(document.PdfContent, "application/pdf", $"{document.DocumentNumber}.pdf"); + } + + [HttpGet("{id:guid}/downloads")] + [Authorize(Roles = Managers)] + public async Task DownloadHistory( + Guid id, + CancellationToken cancellationToken) + { + if (!await AccessibleDocuments().AnyAsync(x => x.Id == id, cancellationToken)) + return NotFound(); + + return Ok(await db.OfficialDocumentDownloads.AsNoTracking() + .Where(x => x.OfficialDocumentId == id) + .OrderByDescending(x => x.CreatedAt) + .Select(x => new + { + x.Id, + x.CreatedAt, + x.DownloadedByUserId, + DownloadedByName = x.DownloadedByUser!.DisplayName, + x.IpAddress, + x.UserAgent + }) + .ToListAsync(cancellationToken)); + } + + [HttpPost("{id:guid}/invalidate")] + [Authorize(Roles = Managers)] + public async Task Invalidate( + Guid id, + InvalidateOfficialDocumentRequest request, + CancellationToken cancellationToken) + { + var document = await AccessibleDocuments() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken); + if (document is null) return NotFound(); + if (document.Status != OfficialDocumentStatus.Valid) + return ConflictProblem("该凭证当前已不是有效状态。"); + + document.Status = OfficialDocumentStatus.Invalidated; + document.InvalidatedAt = DateTime.UtcNow; + document.InvalidatedByUserId = currentUserDataScope.Current.UserId; + document.InvalidationReason = request.Reason.Trim(); + await db.SaveChangesAsync(cancellationToken); + return Ok(new { document.Id, document.Status, document.InvalidatedAt }); + } + + [HttpPost("{id:guid}/reissue")] + [Authorize(Roles = Managers)] + public async Task Reissue( + Guid id, + ReissueOfficialDocumentRequest request, + CancellationToken cancellationToken) + { + try + { + var newDocument = await db.ExecuteInRetriableTransactionAsync(async transaction => + { + var original = await AccessibleDocuments() + .FirstOrDefaultAsync(x => x.Id == id, cancellationToken) + ?? throw new OfficialDocumentNotFoundException(); + if (await db.OfficialDocuments.AnyAsync( + x => x.ReissuedFromDocumentId == original.Id, + cancellationToken)) + throw new OfficialDocumentValidationException("该凭证已经重签过,不能重复重签。"); + + var replacement = await service.CreateAsync( + original.StudentId, + original.Type, + request.Purpose ?? original.Purpose, + currentUserDataScope.Current.UserId, + currentUserDataScope.Current.DisplayName ?? "教务管理员", + VerificationBaseUrl(), + original.Id, + cancellationToken); + original.Status = OfficialDocumentStatus.Superseded; + original.InvalidatedAt = DateTime.UtcNow; + original.InvalidatedByUserId = currentUserDataScope.Current.UserId; + original.InvalidationReason = request.Reason.Trim(); + db.OfficialDocuments.Add(replacement); + await db.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + return replacement; + }, cancellationToken); + + return Ok(new { newDocument.Id, newDocument.DocumentNumber }); + } + catch (OfficialDocumentNotFoundException) + { + return NotFound(); + } + catch (OfficialDocumentValidationException exception) + { + return ConflictProblem(exception.Message); + } + } + + [HttpGet("verify/{verificationCode}")] + [AllowAnonymous] + [EnableRateLimiting("official-verification")] + public async Task Verify( + string verificationCode, + CancellationToken cancellationToken) + { + var normalized = verificationCode.Trim(); + if (normalized.Length is < 12 or > 128) + return Ok(new { Found = false, Valid = false }); + + var hash = OfficialDocumentService.HashVerificationCode(normalized); + var documentNumber = normalized.ToUpperInvariant(); + var document = await db.OfficialDocuments.AsNoTracking() + .FirstOrDefaultAsync(x => + x.VerificationCodeHash == hash || + x.DocumentNumber == documentNumber, + cancellationToken); + if (document is null) return Ok(new { Found = false, Valid = false }); + + var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson); + var replacementNumber = await db.OfficialDocuments.AsNoTracking() + .Where(x => x.ReissuedFromDocumentId == document.Id) + .Select(x => x.DocumentNumber) + .FirstOrDefaultAsync(cancellationToken); + return Ok(new + { + Found = true, + Valid = document.Status == OfficialDocumentStatus.Valid, + document.Status, + document.DocumentNumber, + document.Type, + snapshot.InstitutionName, + StudentName = MaskName(snapshot.Student.Name), + StudentNumber = MaskStudentNumber(snapshot.Student.StudentNumber), + snapshot.Student.CollegeName, + snapshot.Student.MajorName, + document.IssuedAt, + document.InvalidatedAt, + ReplacementDocumentNumber = replacementNumber, + PdfSha256 = document.PdfSha256 + }); + } + + private IQueryable AccessibleDocuments() + { + var scope = currentUserDataScope.Current; + var source = db.OfficialDocuments.AsQueryable(); + var isManager = scope.IsInRole(SystemRoles.SuperAdmin) || + scope.IsInRole(SystemRoles.AcademicAdmin) || + scope.IsInRole(SystemRoles.CollegeAdmin); + if (scope.IsInRole(SystemRoles.Student) && !isManager) + return source.Where(x => x.Student!.UserId == scope.UserId); + if (scope.RestrictedCollegeId.HasValue) + return source.Where(x => + x.Student!.AdministrativeClass!.Major!.CollegeId == + scope.RestrictedCollegeId.Value); + return source; + } + + private IQueryable AccessibleStudents() + { + var source = db.Students.AsQueryable(); + var collegeId = currentUserDataScope.Current.RestrictedCollegeId; + return collegeId.HasValue + ? source.Where(x => x.AdministrativeClass!.Major!.CollegeId == collegeId.Value) + : source; + } + + private string VerificationBaseUrl() + { + var configured = options.Value.PublicBaseUrl; + if (!string.IsNullOrWhiteSpace(configured)) return configured.TrimEnd('/'); + return $"{Request.Scheme}://{Request.Host}{Request.PathBase}"; + } + + private static string MaskName(string name) => name.Length switch + { + 0 => "*", + 1 => name, + 2 => name[0] + "*", + _ => name[0] + new string('*', name.Length - 2) + name[^1] + }; + + private static string MaskStudentNumber(string number) => number.Length <= 4 + ? new string('*', number.Length) + : number[..2] + new string('*', number.Length - 4) + number[^2..]; + + private ConflictObjectResult ConflictProblem(string detail) => Conflict(new ProblemDetails + { + Title = "当前状态不允许此操作", + Detail = detail, + Status = StatusCodes.Status409Conflict + }); +} + +public sealed record IssueOfficialDocumentRequest( + Guid StudentId, + OfficialDocumentType Type, + [param: StringLength(200)] string? Purpose); + +public sealed record SelfIssueOfficialDocumentRequest( + OfficialDocumentType Type, + [param: StringLength(200)] string? Purpose); + +public sealed record InvalidateOfficialDocumentRequest( + [param: Required, StringLength(500, MinimumLength = 2)] string Reason); + +public sealed record ReissueOfficialDocumentRequest( + [param: Required, StringLength(500, MinimumLength = 2)] string Reason, + [param: StringLength(200)] string? Purpose); + +file sealed class OfficialDocumentNotFoundException : Exception; diff --git a/src/Jiaowu.Api/Domain/Academic/OfficialDocumentEntities.cs b/src/Jiaowu.Api/Domain/Academic/OfficialDocumentEntities.cs new file mode 100644 index 0000000..5d82379 --- /dev/null +++ b/src/Jiaowu.Api/Domain/Academic/OfficialDocumentEntities.cs @@ -0,0 +1,51 @@ +using Jiaowu.Api.Domain.Common; +using Jiaowu.Api.Domain.Identity; + +namespace Jiaowu.Api.Domain.Academic; + +public sealed class OfficialDocument : EntityBase +{ + public required string DocumentNumber { get; set; } + public required string VerificationCodeHash { get; set; } + public OfficialDocumentType Type { get; set; } + public OfficialDocumentStatus Status { get; set; } = OfficialDocumentStatus.Valid; + public Guid StudentId { get; set; } + public Student? Student { get; set; } + public Guid IssuedByUserId { get; set; } + public ApplicationUser? IssuedByUser { get; set; } + public DateTime IssuedAt { get; set; } = DateTime.UtcNow; + public string? Purpose { get; set; } + public required string SnapshotJson { get; set; } + public required byte[] PdfContent { get; set; } + public required string PdfSha256 { get; set; } + public DateTime? InvalidatedAt { get; set; } + public Guid? InvalidatedByUserId { get; set; } + public ApplicationUser? InvalidatedByUser { get; set; } + public string? InvalidationReason { get; set; } + public Guid? ReissuedFromDocumentId { get; set; } + public OfficialDocument? ReissuedFromDocument { get; set; } + public ICollection Downloads { get; set; } = []; +} + +public sealed class OfficialDocumentDownload : EntityBase +{ + public Guid OfficialDocumentId { get; set; } + public OfficialDocument? OfficialDocument { get; set; } + public Guid DownloadedByUserId { get; set; } + public ApplicationUser? DownloadedByUser { get; set; } + public string? IpAddress { get; set; } + public string? UserAgent { get; set; } +} + +public enum OfficialDocumentType +{ + Transcript = 1, + StudentStatusCertificate = 2 +} + +public enum OfficialDocumentStatus +{ + Valid = 1, + Invalidated = 2, + Superseded = 3 +} diff --git a/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentOptions.cs b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentOptions.cs new file mode 100644 index 0000000..6d8432c --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentOptions.cs @@ -0,0 +1,12 @@ +namespace Jiaowu.Api.Infrastructure.OfficialDocuments; + +public sealed class OfficialDocumentOptions +{ + public const string SectionName = "OfficialDocuments"; + + public string InstitutionName { get; set; } = "明序大学"; + public string IssuingOffice { get; set; } = "教务处"; + public string DocumentNumberPrefix { get; set; } = "MXU"; + public string? PublicBaseUrl { get; set; } + public string? FontPath { get; set; } +} diff --git a/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentPdfGenerator.cs b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentPdfGenerator.cs new file mode 100644 index 0000000..030e9b7 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentPdfGenerator.cs @@ -0,0 +1,409 @@ +using System.Security.Cryptography; +using QRCoder; +using SkiaSharp; + +namespace Jiaowu.Api.Infrastructure.OfficialDocuments; + +public interface IOfficialDocumentPdfGenerator +{ + GeneratedOfficialDocument Generate( + OfficialDocumentSnapshot snapshot, + string verificationUrl); +} + +public sealed class OfficialDocumentPdfGenerator( + OfficialDocumentOptions options) : IOfficialDocumentPdfGenerator +{ + private const float PageWidth = 595; + private const float PageHeight = 842; + private const float Margin = 44; + private const float ContentWidth = PageWidth - Margin * 2; + private static readonly SKColor Ink = new(31, 48, 65); + private static readonly SKColor Muted = new(91, 107, 120); + private static readonly SKColor Accent = new(26, 91, 82); + private static readonly SKColor Pale = new(236, 244, 242); + private static readonly SKColor Rule = new(208, 219, 220); + + public GeneratedOfficialDocument Generate( + OfficialDocumentSnapshot snapshot, + string verificationUrl) + { + using var typeface = ResolveTypeface(); + using var stream = new MemoryStream(); + using (var document = SKDocument.CreatePdf(stream)) + { + if (snapshot.Type == Domain.Academic.OfficialDocumentType.Transcript) + DrawTranscript(document, typeface, snapshot, verificationUrl); + else + DrawStatusCertificate(document, typeface, snapshot, verificationUrl); + document.Close(); + } + + var content = stream.ToArray(); + var hash = Convert.ToHexString(SHA256.HashData(content)).ToLowerInvariant(); + return new GeneratedOfficialDocument(content, hash); + } + + private void DrawTranscript( + SKDocument document, + SKTypeface typeface, + OfficialDocumentSnapshot snapshot, + string verificationUrl) + { + const int rowsPerPage = 19; + var pageCount = Math.Max(1, (int)Math.Ceiling(snapshot.Grades.Count / (double)rowsPerPage)); + for (var pageIndex = 0; pageIndex < pageCount; pageIndex++) + { + using var canvas = document.BeginPage(PageWidth, PageHeight); + DrawPageFrame(canvas); + DrawHeader(canvas, typeface, snapshot, "官方电子成绩单", "OFFICIAL ACADEMIC TRANSCRIPT"); + + var y = 145f; + if (pageIndex == 0) + { + y = DrawStudentSummary(canvas, typeface, snapshot.Student, y); + y = DrawTranscriptSummary(canvas, typeface, snapshot, y + 12); + } + else + { + DrawText(canvas, typeface, $"{snapshot.Student.Name} · {snapshot.Student.StudentNumber}", + Margin, y, 10, Muted); + y += 20; + } + + var rows = snapshot.Grades.Skip(pageIndex * rowsPerPage).Take(rowsPerPage).ToArray(); + y = DrawGradeTable(canvas, typeface, rows, y + 14); + + if (pageIndex == pageCount - 1) + DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, Math.Min(y + 18, 685)); + + DrawFooter(canvas, typeface, snapshot, pageIndex + 1, pageCount); + document.EndPage(); + } + } + + private void DrawStatusCertificate( + SKDocument document, + SKTypeface typeface, + OfficialDocumentSnapshot snapshot, + string verificationUrl) + { + using var canvas = document.BeginPage(PageWidth, PageHeight); + DrawPageFrame(canvas); + DrawHeader(canvas, typeface, snapshot, "学籍状态证明", "CERTIFICATE OF STUDENT STATUS"); + + var student = snapshot.Student; + var statusText = student.Status switch + { + "在读" => "现为我校在读学生", + "休学" => "现为我校注册学生,当前处于休学状态", + "已毕业" => "曾在我校就读,现已毕业", + "退学" => "曾在我校就读,现已办理退学", + _ => $"在我校的学籍状态为“{student.Status}”" + }; + + DrawText(canvas, typeface, "证 明", PageWidth / 2, 215, 25, Ink, SKTextAlign.Center, true); + var body = $"兹证明,{student.Name}(学号:{student.StudentNumber})," + + $"于 {student.EnrollmentDate:yyyy年M月d日} 入学," + + $"就读于{student.CollegeName}{student.MajorName}专业,行政班级为{student.ClassName}," + + $"{statusText}。"; + var y = 285f; + foreach (var line in WrapText(typeface, body, 16, 448)) + { + DrawText(canvas, typeface, line, 74, y, 16, Ink); + y += 32; + } + + if (!string.IsNullOrWhiteSpace(snapshot.Purpose)) + { + DrawText(canvas, typeface, $"用途:{snapshot.Purpose}", 74, y + 14, 11, Muted); + y += 30; + } + + DrawText(canvas, typeface, "特此证明。", 74, y + 28, 16, Ink); + DrawText(canvas, typeface, snapshot.InstitutionName, 435, y + 100, 14, Ink, + SKTextAlign.Center, true); + DrawText(canvas, typeface, snapshot.IssuingOffice, 435, y + 126, 12, Ink, + SKTextAlign.Center); + DrawText(canvas, typeface, snapshot.IssuedAt.ToLocalTime().ToString("yyyy年M月d日"), + 435, y + 150, 11, Muted, SKTextAlign.Center); + + DrawVerificationBlock(canvas, typeface, snapshot, verificationUrl, 625); + DrawFooter(canvas, typeface, snapshot, 1, 1); + document.EndPage(); + } + + private static void DrawPageFrame(SKCanvas canvas) + { + canvas.Clear(SKColors.White); + using var border = new SKPaint { Color = Accent, Style = SKPaintStyle.Stroke, StrokeWidth = 1.4f }; + using var inner = new SKPaint { Color = Rule, Style = SKPaintStyle.Stroke, StrokeWidth = .55f }; + canvas.DrawRect(22, 22, PageWidth - 44, PageHeight - 44, border); + canvas.DrawRect(27, 27, PageWidth - 54, PageHeight - 54, inner); + } + + private static void DrawHeader( + SKCanvas canvas, + SKTypeface typeface, + OfficialDocumentSnapshot snapshot, + string title, + string englishTitle) + { + DrawText(canvas, typeface, snapshot.InstitutionName, Margin, 66, 16, Accent, bold: true); + DrawText(canvas, typeface, snapshot.IssuingOffice, PageWidth - Margin, 66, 10, Muted, + SKTextAlign.Right); + DrawText(canvas, typeface, title, PageWidth / 2, 99, 23, Ink, SKTextAlign.Center, true); + DrawText(canvas, typeface, englishTitle, PageWidth / 2, 119, 8.5f, Muted, + SKTextAlign.Center); + using var rule = new SKPaint { Color = Accent, StrokeWidth = 1.2f }; + canvas.DrawLine(Margin, 132, PageWidth - Margin, 132, rule); + } + + private static float DrawStudentSummary( + SKCanvas canvas, + SKTypeface typeface, + OfficialStudentSnapshot student, + float y) + { + using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill }; + canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 86), 5, 5, fill); + DrawLabelValue(canvas, typeface, "姓名", student.Name, Margin + 15, y + 24); + DrawLabelValue(canvas, typeface, "学号", student.StudentNumber, 210, y + 24); + DrawLabelValue(canvas, typeface, "学籍状态", student.Status, 404, y + 24); + DrawLabelValue(canvas, typeface, "学院", student.CollegeName, Margin + 15, y + 57); + DrawLabelValue(canvas, typeface, "专业", student.MajorName, 210, y + 57); + DrawLabelValue(canvas, typeface, "班级", student.ClassName, 404, y + 57); + return y + 86; + } + + private static float DrawTranscriptSummary( + SKCanvas canvas, + SKTypeface typeface, + OfficialDocumentSnapshot snapshot, + float y) + { + var values = new[] + { + ("课程门数", snapshot.Grades.Count.ToString()), + ("修读学分", FormatDecimal(snapshot.TotalCredits)), + ("获得学分", FormatDecimal(snapshot.EarnedCredits)), + ("平均绩点", snapshot.GradePointAverage?.ToString("0.00") ?? "-") + }; + var width = ContentWidth / values.Length; + for (var i = 0; i < values.Length; i++) + { + var center = Margin + width * i + width / 2; + DrawText(canvas, typeface, values[i].Item1, center, y + 12, 8.5f, Muted, + SKTextAlign.Center); + DrawText(canvas, typeface, values[i].Item2, center, y + 33, 15, Accent, + SKTextAlign.Center, true); + } + return y + 42; + } + + private static float DrawGradeTable( + SKCanvas canvas, + SKTypeface typeface, + IReadOnlyList rows, + float y) + { + var widths = new[] { 96f, 64f, 160f, 48f, 55f, 45f, 39f }; + var headers = new[] { "学期", "课程代码", "课程名称", "学分", "成绩", "绩点", "状态" }; + const float headerHeight = 27; + const float rowHeight = 24; + using var headerPaint = new SKPaint { Color = Accent, Style = SKPaintStyle.Fill }; + using var stripe = new SKPaint { Color = new SKColor(247, 249, 249), Style = SKPaintStyle.Fill }; + using var rule = new SKPaint { Color = Rule, StrokeWidth = .5f }; + canvas.DrawRect(Margin, y, ContentWidth, headerHeight, headerPaint); + + var x = Margin; + for (var i = 0; i < headers.Length; i++) + { + DrawText(canvas, typeface, headers[i], x + widths[i] / 2, y + 18, 8.5f, + SKColors.White, SKTextAlign.Center, true); + x += widths[i]; + } + + for (var rowIndex = 0; rowIndex < rows.Count; rowIndex++) + { + var row = rows[rowIndex]; + var rowY = y + headerHeight + rowIndex * rowHeight; + if (rowIndex % 2 == 1) + canvas.DrawRect(Margin, rowY, ContentWidth, rowHeight, stripe); + canvas.DrawLine(Margin, rowY + rowHeight, PageWidth - Margin, rowY + rowHeight, rule); + var values = new[] + { + row.AcademicTerm, + row.CourseCode, + Ellipsize(typeface, row.CourseName, 8.5f, widths[2] - 8), + FormatDecimal(row.Credits), + row.Score?.ToString("0.#") ?? "-", + row.GradePoint?.ToString("0.0#") ?? "-", + row.ExamStatus + }; + x = Margin; + for (var i = 0; i < values.Length; i++) + { + DrawText(canvas, typeface, values[i], x + widths[i] / 2, rowY + 16, 8.5f, + Ink, SKTextAlign.Center); + x += widths[i]; + } + } + return y + headerHeight + rows.Count * rowHeight; + } + + private static void DrawVerificationBlock( + SKCanvas canvas, + SKTypeface typeface, + OfficialDocumentSnapshot snapshot, + string verificationUrl, + float y) + { + using var fill = new SKPaint { Color = Pale, Style = SKPaintStyle.Fill }; + canvas.DrawRoundRect(new SKRect(Margin, y, PageWidth - Margin, y + 84), 5, 5, fill); + DrawQrCode(canvas, verificationUrl, Margin + 10, y + 7, 70); + DrawText(canvas, typeface, "扫码验真", Margin + 94, y + 22, 11, Accent, bold: true); + DrawText(canvas, typeface, $"凭证编号:{snapshot.DocumentNumber}", Margin + 94, y + 43, 9.5f, Ink); + DrawText(canvas, typeface, "二维码指向本系统公开验真页;失效或重签后状态会实时更新。", + Margin + 94, y + 62, 8.5f, Muted); + } + + private static void DrawFooter( + SKCanvas canvas, + SKTypeface typeface, + OfficialDocumentSnapshot snapshot, + int page, + int pageCount) + { + using var rule = new SKPaint { Color = Rule, StrokeWidth = .6f }; + canvas.DrawLine(Margin, 790, PageWidth - Margin, 790, rule); + DrawText(canvas, typeface, + $"签发:{snapshot.IssuedByName} · {snapshot.IssuedAt.ToLocalTime():yyyy-MM-dd HH:mm}", + Margin, 807, 7.5f, Muted); + DrawText(canvas, typeface, $"第 {page} / {pageCount} 页", PageWidth - Margin, 807, 7.5f, + Muted, SKTextAlign.Right); + } + + private static void DrawLabelValue( + SKCanvas canvas, + SKTypeface typeface, + string label, + string value, + float x, + float y) + { + DrawText(canvas, typeface, label, x, y, 8, Muted); + DrawText(canvas, typeface, value, x + 42, y, 10, Ink, bold: true); + } + + private static void DrawQrCode(SKCanvas canvas, string content, float x, float y, float size) + { + using var generator = new QRCodeGenerator(); + using var data = generator.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q); + var modules = data.ModuleMatrix; + const int quiet = 4; + var moduleSize = size / (modules.Count + quiet * 2); + using var paint = new SKPaint { Color = Ink, Style = SKPaintStyle.Fill, IsAntialias = false }; + for (var row = 0; row < modules.Count; row++) + for (var column = 0; column < modules[row].Count; column++) + { + if (!modules[row][column]) continue; + canvas.DrawRect( + x + (column + quiet) * moduleSize, + y + (row + quiet) * moduleSize, + moduleSize + .08f, + moduleSize + .08f, + paint); + } + } + + private static void DrawText( + SKCanvas canvas, + SKTypeface typeface, + string text, + float x, + float y, + float size, + SKColor color, + SKTextAlign align = SKTextAlign.Left, + bool bold = false) + { + using var font = new SKFont(typeface, size) { Embolden = bold }; + using var paint = new SKPaint { Color = color, IsAntialias = true }; + canvas.DrawText(text, x, y, align, font, paint); + } + + private static IReadOnlyList WrapText( + SKTypeface typeface, + string text, + float size, + float maxWidth) + { + using var font = new SKFont(typeface, size); + var lines = new List(); + var current = ""; + foreach (var character in text) + { + var candidate = current + character; + if (current.Length > 0 && font.MeasureText(candidate) > maxWidth) + { + var breakIndex = current.LastIndexOfAny([' ', ',', '。', ';', ':', '、']); + if (breakIndex >= current.Length / 2) + { + lines.Add(current[..(breakIndex + 1)].TrimEnd()); + current = current[(breakIndex + 1)..].TrimStart() + character; + } + else + { + lines.Add(current); + current = character.ToString(); + } + } + else + { + current = candidate; + } + } + if (current.Length > 0) lines.Add(current); + return lines; + } + + private static string Ellipsize( + SKTypeface typeface, + string text, + float size, + float maxWidth) + { + using var font = new SKFont(typeface, size); + if (font.MeasureText(text) <= maxWidth) return text; + var value = text; + while (value.Length > 1 && font.MeasureText(value + "…") > maxWidth) + value = value[..^1]; + return value + "…"; + } + + private SKTypeface ResolveTypeface() + { + var candidates = new[] + { + options.FontPath, + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "simhei.ttf"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Fonts), "msyh.ttc"), + "/usr/share/fonts/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc" + }; + foreach (var path in candidates.Where(x => !string.IsNullOrWhiteSpace(x))) + { + if (!File.Exists(path)) continue; + var typeface = SKTypeface.FromFile(path); + if (typeface is not null) return typeface; + } + + var fallback = SKTypeface.FromFamilyName("Microsoft YaHei") ?? + SKTypeface.FromFamilyName("Noto Sans CJK SC"); + return fallback ?? throw new InvalidOperationException( + "未找到可用于官方 PDF 的中文字体。请配置 OfficialDocuments:FontPath。"); + } + + private static string FormatDecimal(decimal value) => value.ToString("0.##"); +} diff --git a/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentService.cs b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentService.cs new file mode 100644 index 0000000..7a4a426 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentService.cs @@ -0,0 +1,165 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.OfficialDocuments; + +public sealed class OfficialDocumentService( + AppDbContext db, + OfficialDocumentOptions options, + IOfficialDocumentPdfGenerator pdfGenerator) +{ + private static readonly JsonSerializerOptions SnapshotJsonOptions = new(JsonSerializerDefaults.Web); + + public async Task CreateAsync( + Guid studentId, + OfficialDocumentType type, + string? purpose, + Guid issuedByUserId, + string issuedByName, + string verificationBaseUrl, + Guid? reissuedFromDocumentId, + CancellationToken cancellationToken) + { + var student = await db.Students.AsNoTracking() + .Where(x => x.Id == studentId) + .Select(x => new + { + x.StudentNumber, + x.Name, + x.Gender, + x.EnrollmentYear, + x.EnrollmentDate, + x.Status, + CollegeName = x.AdministrativeClass!.Major!.College!.Name, + MajorName = x.AdministrativeClass.Major.Name, + ClassName = x.AdministrativeClass.Name + }) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new OfficialDocumentValidationException("学生档案不存在。"); + + var grades = type == OfficialDocumentType.Transcript + ? await db.GradeRecords.AsNoTracking() + .Where(x => + x.StudentId == studentId && + x.GradeSheet!.Status == GradeSheetStatus.Published) + .OrderBy(x => x.GradeSheet!.TeachingTask!.AcademicTerm!.StartDate) + .ThenBy(x => x.GradeSheet!.TeachingTask!.Course!.Code) + .Select(x => new OfficialTranscriptRow( + x.GradeSheet!.TeachingTask!.AcademicTerm!.Name, + x.GradeSheet.TeachingTask.Course!.Code, + x.GradeSheet.TeachingTask.Course.Name, + x.GradeSheet.TeachingTask.Course.Credits, + x.TotalScore, + x.GradePoint, + GradeExamStatusLabel(x.ExamStatus))) + .ToListAsync(cancellationToken) + : []; + + if (type == OfficialDocumentType.Transcript && grades.Count == 0) + throw new OfficialDocumentValidationException("该学生暂无已发布成绩,不能签发官方成绩单。"); + + var issuedAt = DateTime.UtcNow; + var documentNumber = CreateDocumentNumber(type, issuedAt); + var verificationCode = CreateVerificationCode(); + var studentSnapshot = new OfficialStudentSnapshot( + student.StudentNumber, + student.Name, + GenderLabel(student.Gender), + student.CollegeName, + student.MajorName, + student.ClassName, + student.EnrollmentYear, + student.EnrollmentDate, + StudentStatusLabel(student.Status)); + var totalCredits = grades.Sum(x => x.Credits); + var passedGrades = grades + .Where(x => x.Score >= 60 || x.ExamStatus == "免修") + .ToArray(); + var gpaGrades = grades.Where(x => x.GradePoint.HasValue).ToArray(); + var gpaCredits = gpaGrades.Sum(x => x.Credits); + var gpa = gpaCredits > 0 + ? gpaGrades.Sum(x => x.GradePoint!.Value * x.Credits) / gpaCredits + : (decimal?)null; + var snapshot = new OfficialDocumentSnapshot( + options.InstitutionName, + options.IssuingOffice, + documentNumber, + type, + issuedAt, + issuedByName, + string.IsNullOrWhiteSpace(purpose) ? null : purpose.Trim(), + studentSnapshot, + grades, + totalCredits, + passedGrades.Sum(x => x.Credits), + gpa); + var verificationUrl = $"{verificationBaseUrl.TrimEnd('/')}/verify/{verificationCode}"; + var generated = pdfGenerator.Generate(snapshot, verificationUrl); + + return new OfficialDocument + { + DocumentNumber = documentNumber, + VerificationCodeHash = HashVerificationCode(verificationCode), + Type = type, + Status = OfficialDocumentStatus.Valid, + StudentId = studentId, + IssuedByUserId = issuedByUserId, + IssuedAt = issuedAt, + Purpose = snapshot.Purpose, + SnapshotJson = JsonSerializer.Serialize(snapshot, SnapshotJsonOptions), + PdfContent = generated.Content, + PdfSha256 = generated.Sha256, + ReissuedFromDocumentId = reissuedFromDocumentId + }; + } + + public static string HashVerificationCode(string verificationCode) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(verificationCode))) + .ToLowerInvariant(); + + public static OfficialDocumentSnapshot DeserializeSnapshot(string json) => + JsonSerializer.Deserialize(json, SnapshotJsonOptions) + ?? throw new InvalidOperationException("官方凭证快照无法读取。"); + + private string CreateDocumentNumber(OfficialDocumentType type, DateTime issuedAt) + { + var typeCode = type == OfficialDocumentType.Transcript ? "TR" : "SC"; + var random = Convert.ToHexString(RandomNumberGenerator.GetBytes(6)); + return $"{options.DocumentNumberPrefix}-{typeCode}-{issuedAt:yyyyMMdd}-{random}"; + } + + private static string CreateVerificationCode() => + Convert.ToHexString(RandomNumberGenerator.GetBytes(24)).ToLowerInvariant(); + + private static string GenderLabel(Gender value) => value switch + { + Gender.Male => "男", + Gender.Female => "女", + _ => "未登记" + }; + + private static string StudentStatusLabel(StudentStatus value) => value switch + { + StudentStatus.Active => "在读", + StudentStatus.Suspended => "休学", + StudentStatus.Graduated => "已毕业", + StudentStatus.Withdrawn => "退学", + _ => "未知" + }; + + private static string GradeExamStatusLabel(GradeExamStatus value) => value switch + { + GradeExamStatus.Normal => "正常", + GradeExamStatus.Absent => "缺考", + GradeExamStatus.Deferred => "缓考", + GradeExamStatus.Exempt => "免修", + GradeExamStatus.Makeup => "补考", + _ => "未知" + }; +} + +public sealed class OfficialDocumentValidationException(string message) : Exception(message); diff --git a/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentSnapshots.cs b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentSnapshots.cs new file mode 100644 index 0000000..325cb9b --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/OfficialDocuments/OfficialDocumentSnapshots.cs @@ -0,0 +1,41 @@ +using Jiaowu.Api.Domain.Academic; + +namespace Jiaowu.Api.Infrastructure.OfficialDocuments; + +public sealed record OfficialDocumentSnapshot( + string InstitutionName, + string IssuingOffice, + string DocumentNumber, + OfficialDocumentType Type, + DateTime IssuedAt, + string IssuedByName, + string? Purpose, + OfficialStudentSnapshot Student, + IReadOnlyList Grades, + decimal TotalCredits, + decimal EarnedCredits, + decimal? GradePointAverage); + +public sealed record OfficialStudentSnapshot( + string StudentNumber, + string Name, + string Gender, + string CollegeName, + string MajorName, + string ClassName, + int EnrollmentYear, + DateOnly EnrollmentDate, + string Status); + +public sealed record OfficialTranscriptRow( + string AcademicTerm, + string CourseCode, + string CourseName, + decimal Credits, + decimal? Score, + decimal? GradePoint, + string ExamStatus); + +public sealed record GeneratedOfficialDocument( + byte[] Content, + string Sha256); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index 78493c7..1f1bc07 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -91,6 +91,9 @@ public sealed class AppDbContext(DbContextOptions options) Set(); public DbSet GraduationClearanceRecords => Set(); + public DbSet OfficialDocuments => Set(); + public DbSet OfficialDocumentDownloads => + Set(); public DbSet AuditLogs => Set(); protected override void ConfigureConventions( @@ -927,6 +930,54 @@ public sealed class AppDbContext(DbContextOptions options) entity.Property(x => x.IpAddress).HasMaxLength(64); entity.HasIndex(x => x.CreatedAt); }); + + builder.Entity(entity => + { + entity.Property(x => x.DocumentNumber).HasMaxLength(50); + entity.Property(x => x.VerificationCodeHash).HasMaxLength(64); + entity.Property(x => x.Purpose).HasMaxLength(200); + entity.Property(x => x.SnapshotJson).HasColumnType("longtext"); + entity.Property(x => x.PdfContent).HasColumnType("longblob"); + entity.Property(x => x.PdfSha256).HasMaxLength(64); + entity.Property(x => x.InvalidationReason).HasMaxLength(500); + entity.HasIndex(x => x.DocumentNumber).IsUnique(); + entity.HasIndex(x => x.VerificationCodeHash).IsUnique(); + entity.HasIndex(x => new { x.StudentId, x.IssuedAt }); + entity.HasIndex(x => new { x.Status, x.IssuedAt }); + entity.HasOne(x => x.Student) + .WithMany() + .HasForeignKey(x => x.StudentId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.IssuedByUser) + .WithMany() + .HasForeignKey(x => x.IssuedByUserId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne(x => x.InvalidatedByUser) + .WithMany() + .HasForeignKey(x => x.InvalidatedByUserId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasIndex(x => x.ReissuedFromDocumentId).IsUnique(); + entity.HasOne(x => x.ReissuedFromDocument) + .WithMany() + .HasForeignKey(x => x.ReissuedFromDocumentId) + .OnDelete(DeleteBehavior.Restrict); + }); + + builder.Entity(entity => + { + entity.Property(x => x.IpAddress).HasMaxLength(64); + entity.Property(x => x.UserAgent).HasMaxLength(500); + entity.HasIndex(x => new { x.OfficialDocumentId, x.CreatedAt }); + entity.HasIndex(x => new { x.DownloadedByUserId, x.CreatedAt }); + entity.HasOne(x => x.OfficialDocument) + .WithMany(x => x.Downloads) + .HasForeignKey(x => x.OfficialDocumentId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(x => x.DownloadedByUser) + .WithMany() + .HasForeignKey(x => x.DownloadedByUserId) + .OnDelete(DeleteBehavior.Restrict); + }); } public override Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index eb049a3..e0fe6f2 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -52,6 +52,8 @@ public sealed class DevelopmentSqliteMigrator( "20260726_28_course_selection_waitlist"; private const string PersonalCalendarSubscriptionMigration = "20260726_29_personal_calendar_subscription"; + private const string OfficialDocumentsMigration = + "20260726_30_official_documents"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -376,6 +378,19 @@ public sealed class DevelopmentSqliteMigrator( ? [] : PersonalCalendarSubscriptionStatements, cancellationToken); + + var officialDocumentsExist = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM sqlite_master + WHERE type = 'table' AND name = 'OfficialDocuments' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + OfficialDocumentsMigration, + officialDocumentsExist ? [] : OfficialDocumentStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -1645,6 +1660,64 @@ public sealed class DevelopmentSqliteMigrator( """ ]; + private static readonly string[] OfficialDocumentStatements = + [ + """ + CREATE TABLE "OfficialDocuments" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_OfficialDocuments" PRIMARY KEY, + "DocumentNumber" TEXT NOT NULL, + "VerificationCodeHash" TEXT NOT NULL, + "Type" INTEGER NOT NULL, + "Status" INTEGER NOT NULL, + "StudentId" TEXT NOT NULL, + "IssuedByUserId" TEXT NOT NULL, + "IssuedAt" TEXT NOT NULL, + "Purpose" TEXT NULL, + "SnapshotJson" TEXT NOT NULL, + "PdfContent" BLOB NOT NULL, + "PdfSha256" TEXT NOT NULL, + "InvalidatedAt" TEXT NULL, + "InvalidatedByUserId" TEXT NULL, + "InvalidationReason" TEXT NULL, + "ReissuedFromDocumentId" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_OfficialDocuments_Students_StudentId" + FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_OfficialDocuments_AspNetUsers_IssuedByUserId" + FOREIGN KEY ("IssuedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_OfficialDocuments_AspNetUsers_InvalidatedByUserId" + FOREIGN KEY ("InvalidatedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT, + CONSTRAINT "FK_OfficialDocuments_ReissuedFromDocumentId" + FOREIGN KEY ("ReissuedFromDocumentId") REFERENCES "OfficialDocuments" ("Id") ON DELETE RESTRICT + ); + """, + """CREATE UNIQUE INDEX "IX_OfficialDocuments_DocumentNumber" ON "OfficialDocuments" ("DocumentNumber");""", + """CREATE UNIQUE INDEX "IX_OfficialDocuments_VerificationCodeHash" ON "OfficialDocuments" ("VerificationCodeHash");""", + """CREATE INDEX "IX_OfficialDocuments_StudentId_IssuedAt" ON "OfficialDocuments" ("StudentId", "IssuedAt");""", + """CREATE INDEX "IX_OfficialDocuments_Status_IssuedAt" ON "OfficialDocuments" ("Status", "IssuedAt");""", + """CREATE INDEX "IX_OfficialDocuments_IssuedByUserId" ON "OfficialDocuments" ("IssuedByUserId");""", + """CREATE INDEX "IX_OfficialDocuments_InvalidatedByUserId" ON "OfficialDocuments" ("InvalidatedByUserId");""", + """CREATE UNIQUE INDEX "IX_OfficialDocuments_ReissuedFromDocumentId" ON "OfficialDocuments" ("ReissuedFromDocumentId");""", + """ + CREATE TABLE "OfficialDocumentDownloads" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_OfficialDocumentDownloads" PRIMARY KEY, + "OfficialDocumentId" TEXT NOT NULL, + "DownloadedByUserId" TEXT NOT NULL, + "IpAddress" TEXT NULL, + "UserAgent" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL, + CONSTRAINT "FK_OfficialDocumentDownloads_OfficialDocuments" + FOREIGN KEY ("OfficialDocumentId") REFERENCES "OfficialDocuments" ("Id") ON DELETE CASCADE, + CONSTRAINT "FK_OfficialDocumentDownloads_AspNetUsers" + FOREIGN KEY ("DownloadedByUserId") REFERENCES "AspNetUsers" ("Id") ON DELETE RESTRICT + ); + """, + """CREATE INDEX "IX_OfficialDocumentDownloads_OfficialDocumentId_CreatedAt" ON "OfficialDocumentDownloads" ("OfficialDocumentId", "CreatedAt");""", + """CREATE INDEX "IX_OfficialDocumentDownloads_DownloadedByUserId_CreatedAt" ON "OfficialDocumentDownloads" ("DownloadedByUserId", "CreatedAt");""" + ]; + private static readonly string[] CourseAdjustmentsStatements = [ """ diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726094419_OfficialDocuments.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726094419_OfficialDocuments.Designer.cs new file mode 100644 index 0000000..fe0b379 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726094419_OfficialDocuments.Designer.cs @@ -0,0 +1,4579 @@ +// +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("20260726094419_OfficialDocuments")] + partial class OfficialDocuments + { + /// + 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.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("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.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("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("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.CourseSelectionOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsOpenToAll") + .HasColumnType("tinyint(1)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("CourseSelectionRoundId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseSelectionOfferings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("MaxCourseCount") + .HasColumnType("int"); + + b.Property("MaxCredits") + .HasPrecision(6, 1) + .HasColumnType("decimal(6,1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawalEndsAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("CourseSelectionRounds"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Grade"); + + b.HasIndex("CourseSelectionRoundId", "Grade") + .IsUnique(); + + b.ToTable("CourseSelectionRoundGrades"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalCourseId") + .HasColumnType("char(36)"); + + 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("SubstituteCourseId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OriginalCourseId"); + + b.HasIndex("SubstituteCourseId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "OriginalCourseId") + .IsUnique(); + + b.ToTable("CourseSubstitutions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumModuleId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RecommendedSemester") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("CurriculumModuleId", "CourseId") + .IsUnique(); + + b.ToTable("CurriculumCourses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId", "Code") + .IsUnique(); + + b.ToTable("CurriculumModules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EffectiveGrade") + .HasColumnType("int"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "EffectiveGrade"); + + b.HasIndex("MajorId", "EffectiveGrade", "Version") + .IsUnique(); + + b.ToTable("CurriculumPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DeferredExam", 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("DeferredExams"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("MinimumGradePoint") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("DegreeAwardBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AverageGradePoint") + .HasPrecision(4, 2) + .HasColumnType("decimal(4,2)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeAwardBatchId") + .HasColumnType("char(36)"); + + b.Property("ExceptionReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("GraduationAuditResultId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationAuditResultId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("DegreeAwardBatchId", "StudentId") + .IsUnique(); + + b.ToTable("DegreeAwardResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationDimension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EvaluationSetupId") + .HasColumnType("char(36)"); + + b.Property("MaxScore") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("EvaluationSetupId", "SortOrder"); + + b.ToTable("EvaluationDimensions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EvaluationSetupId") + .HasColumnType("char(36)"); + + 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("StudentId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("EvaluationSetupId", "StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("EvaluationRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationScore", b => + { + b.Property("EvaluationRecordId") + .HasColumnType("char(36)"); + + b.Property("EvaluationDimensionId") + .HasColumnType("char(36)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("EvaluationRecordId", "EvaluationDimensionId"); + + b.HasIndex("EvaluationDimensionId"); + + b.ToTable("EvaluationScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("EvaluationSetups"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("ExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamDate") + .HasColumnType("date"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("ExamPlanId", "ExamDate"); + + b.HasIndex("ExamPlanId", "StartsAt"); + + b.ToTable("ExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("ExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Weight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.HasKey("Id"); + + b.HasIndex("GradeSheetId", "SortOrder"); + + b.ToTable("GradeItems"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItemScore", b => + { + b.Property("GradeRecordId") + .HasColumnType("char(36)"); + + b.Property("GradeItemId") + .HasColumnType("char(36)"); + + b.Property("Score") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.HasKey("GradeRecordId", "GradeItemId"); + + b.HasIndex("GradeItemId"); + + b.ToTable("GradeItemScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeModification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApplicantUserId") + .HasColumnType("char(36)"); + + b.Property("CollegeReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("CollegeReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("FinalReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("GradeRecordId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RequestedScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GradeRecordId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("GradeModifications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamStatus") + .HasColumnType("int"); + + b.Property("FinalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("GradePoint") + .HasPrecision(3, 1) + .HasColumnType("decimal(3,1)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RegularScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TotalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("RegularWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("GradeSheets"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("GraduationYear", "EnrollmentYear"); + + b.ToTable("GraduationAuditBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("EarnedCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("FailedCourseCount") + .HasColumnType("int"); + + b.Property("GraduationAuditBatchId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("MissingCourseNames") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("PassedRequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("StudentStatusSnapshot") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("GraduationAuditBatchId", "StudentId") + .IsUnique(); + + b.ToTable("GraduationAuditResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationYear") + .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("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("GraduationClearanceBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceBatchId") + .HasColumnType("char(36)"); + + b.Property("IsRequired") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ResponsibleRole") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("ResponsibleUnit") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceBatchId", "Code") + .IsUnique(); + + b.ToTable("GraduationClearanceItems"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedByUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceItemId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceItemId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.ToTable("GraduationClearanceRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Majors"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamAutoJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedSessions") + .HasColumnType("int"); + + b.Property("EnrolledStudents") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MakeupExamPlanId") + .HasColumnType("char(36)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedCourses") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCourses") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MakeupExamPlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("MakeupExamAutoJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamEnrollment", b => + { + b.Property("MakeupExamSessionId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("MakeupScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("SourceDeferredExamId") + .HasColumnType("char(36)"); + + b.Property("SourceGradeRecordId") + .HasColumnType("char(36)"); + + b.HasKey("MakeupExamSessionId", "StudentId"); + + b.HasIndex("SourceDeferredExamId"); + + b.HasIndex("SourceGradeRecordId"); + + b.HasIndex("StudentId", "MakeupExamSessionId"); + + b.ToTable("MakeupExamEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("MakeupExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamDate") + .HasColumnType("date"); + + b.Property("MakeupExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("MakeupExamPlanId", "ExamDate"); + + b.HasIndex("MakeupExamPlanId", "StartsAt"); + + b.ToTable("MakeupExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSessionInvigilator", b => + { + b.Property("MakeupExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("MakeupExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("MakeupExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("LinkUrl") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UserId", "IsRead"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocumentNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvalidatedAt") + .HasColumnType("datetime(6)"); + + b.Property("InvalidatedByUserId") + .HasColumnType("char(36)"); + + b.Property("InvalidationReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IssuedAt") + .HasColumnType("datetime(6)"); + + b.Property("IssuedByUserId") + .HasColumnType("char(36)"); + + b.Property("PdfContent") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("PdfSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ReissuedFromDocumentId") + .HasColumnType("char(36)"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VerificationCodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentNumber") + .IsUnique(); + + b.HasIndex("InvalidatedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("ReissuedFromDocumentId") + .IsUnique(); + + b.HasIndex("VerificationCodeHash") + .IsUnique(); + + b.HasIndex("Status", "IssuedAt"); + + b.HasIndex("StudentId", "IssuedAt"); + + b.ToTable("OfficialDocuments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DownloadedByUserId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OfficialDocumentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("DownloadedByUserId", "CreatedAt"); + + b.HasIndex("OfficialDocumentId", "CreatedAt"); + + b.ToTable("OfficialDocumentDownloads"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("ActiveAcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedSteps") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentStep") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + 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("TotalSteps") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAcademicTermId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("SchedulePublishJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("time"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("PeriodNumber") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "PeriodNumber") + .IsUnique(); + + b.ToTable("ScheduleTimeSlots"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalStatus") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("State") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetStatus") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId", "State"); + + b.ToTable("StudentStatusChanges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Statement") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("TeacherId"); + + b.HasIndex("Status", "AcademicTermId"); + + b.HasIndex("AcademicTermId", "TeacherId", "CourseId") + .IsUnique(); + + b.ToTable("TeacherCourseApplications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("GenerationBatchCode") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("SchedulingMode") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.Property("TeachingTaskScheduleConstraintId") + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("TeachingTaskAllowedClassrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowedDayOfWeeks") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EarliestPeriod") + .HasColumnType("int"); + + b.Property("LatestPeriod") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredCampusId") + .HasColumnType("char(36)"); + + b.Property("RequiresClassroom") + .HasColumnType("tinyint(1)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("RequiredCampusId"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("TeachingTaskScheduleConstraints"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("AcknowledgeComment") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TriggerValue") + .HasPrecision(7, 2) + .HasColumnType("decimal(7,2)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("StudentId", "AcademicTermId", "Type") + .IsUnique(); + + b.ToTable("WarningRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("AutoCheckEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("CheckDayOfWeek") + .HasColumnType("int"); + + b.Property("CheckHour") + .HasColumnType("int"); + + b.Property("CheckMinute") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastCheckAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NotifyCounselor") + .HasColumnType("tinyint(1)"); + + b.Property("NotifyStudent") + .HasColumnType("tinyint(1)"); + + b.Property("Threshold") + .HasPrecision(7, 2) + .HasColumnType("decimal(7,2)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Type") + .IsUnique(); + + b.ToTable("WarningRules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CalendarSubscriptionCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CalendarSubscriptionStamp") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("StaffNumber"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser") + .WithMany() + .HasForeignKey("CounselorUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CounselorUser"); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet") + .WithMany("Records") + .HasForeignKey("AttendanceSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AttendanceSheet"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", 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.AutomaticScheduleJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CourseCategory", "CourseCategory") + .WithMany() + .HasForeignKey("CourseCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("College"); + + b.Navigation("CourseCategory"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseAdjustment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "SubstituteTeacher") + .WithMany() + .HasForeignKey("SubstituteTeacherId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SubstituteTeacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering") + .WithMany("Enrollments") + .HasForeignKey("CourseSelectionOfferingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionOffering"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseExemption", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("Offerings") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("EligibleGrades") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "OriginalCourse") + .WithMany() + .HasForeignKey("OriginalCourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "SubstituteCourse") + .WithMany() + .HasForeignKey("SubstituteCourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OriginalCourse"); + + b.Navigation("Student"); + + b.Navigation("SubstituteCourse"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumModule", "CurriculumModule") + .WithMany("Courses") + .HasForeignKey("CurriculumModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("CurriculumModule"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany("Modules") + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DeferredExam", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", "DegreeAwardBatch") + .WithMany("Results") + .HasForeignKey("DegreeAwardBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditResult", "GraduationAuditResult") + .WithMany() + .HasForeignKey("GraduationAuditResultId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DegreeAwardBatch"); + + b.Navigation("GraduationAuditResult"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationDimension", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationSetup", "EvaluationSetup") + .WithMany("Dimensions") + .HasForeignKey("EvaluationSetupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EvaluationSetup"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationSetup", "EvaluationSetup") + .WithMany("Records") + .HasForeignKey("EvaluationSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EvaluationSetup"); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationScore", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationDimension", "EvaluationDimension") + .WithMany("Scores") + .HasForeignKey("EvaluationDimensionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationRecord", "EvaluationRecord") + .WithMany("Scores") + .HasForeignKey("EvaluationRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EvaluationDimension"); + + b.Navigation("EvaluationRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Sessions") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("ExamPlan"); + + b.Navigation("RequiredBuilding"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("Invigilators") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamSession"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") + .WithMany("Items") + .HasForeignKey("GradeSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeSheet"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItemScore", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeItem", "GradeItem") + .WithMany("Scores") + .HasForeignKey("GradeItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "GradeRecord") + .WithMany("ItemScores") + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeItem"); + + b.Navigation("GradeRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeModification", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "GradeRecord") + .WithMany() + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GradeRecord"); + }); + + 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.GraduationAuditResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany() + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", "GraduationAuditBatch") + .WithMany("Results") + .HasForeignKey("GraduationAuditBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + + b.Navigation("GraduationAuditBatch"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", "GraduationClearanceBatch") + .WithMany("Items") + .HasForeignKey("GraduationClearanceBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GraduationClearanceBatch"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", "GraduationClearanceItem") + .WithMany("Records") + .HasForeignKey("GraduationClearanceItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GraduationClearanceItem"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamAutoJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamPlan", "MakeupExamPlan") + .WithMany() + .HasForeignKey("MakeupExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MakeupExamPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamSession", "MakeupExamSession") + .WithMany("Enrollments") + .HasForeignKey("MakeupExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.DeferredExam", "SourceDeferredExam") + .WithMany() + .HasForeignKey("SourceDeferredExamId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "SourceGradeRecord") + .WithMany() + .HasForeignKey("SourceGradeRecordId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MakeupExamSession"); + + b.Navigation("SourceDeferredExam"); + + b.Navigation("SourceGradeRecord"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamPlan", "MakeupExamPlan") + .WithMany("Sessions") + .HasForeignKey("MakeupExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("MakeupExamPlan"); + + b.Navigation("RequiredBuilding"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamSession", "MakeupExamSession") + .WithMany("Invigilators") + .HasForeignKey("MakeupExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MakeupExamSession"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "InvalidatedByUser") + .WithMany() + .HasForeignKey("InvalidatedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "ReissuedFromDocument") + .WithMany() + .HasForeignKey("ReissuedFromDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("InvalidatedByUser"); + + b.Navigation("IssuedByUser"); + + b.Navigation("ReissuedFromDocument"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "DownloadedByUser") + .WithMany() + .HasForeignKey("DownloadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "OfficialDocument") + .WithMany("Downloads") + .HasForeignKey("OfficialDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DownloadedByUser"); + + b.Navigation("OfficialDocument"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany("Students") + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint") + .WithMany("AllowedClassrooms") + .HasForeignKey("TeachingTaskScheduleConstraintId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("TeachingTaskScheduleConstraint"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Classes") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdministrativeClass"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "RequiredCampus") + .WithMany() + .HasForeignKey("RequiredCampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RequiredBuilding"); + + b.Navigation("RequiredCampus"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Teachers") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Teacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Navigation("EligibleGrades"); + + b.Navigation("Offerings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Navigation("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationDimension", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.Navigation("Dimensions"); + + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Navigation("Invigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Navigation("ItemScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Navigation("Items"); + + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.Navigation("Enrollments"); + + b.Navigation("Invigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.Navigation("Downloads"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Navigation("Classes"); + + b.Navigation("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Navigation("AllowedClassrooms"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726094419_OfficialDocuments.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726094419_OfficialDocuments.cs new file mode 100644 index 0000000..c4f7d07 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726094419_OfficialDocuments.cs @@ -0,0 +1,156 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class OfficialDocuments : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "OfficialDocuments", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + DocumentNumber = table.Column(type: "varchar(50)", maxLength: 50, nullable: false), + VerificationCodeHash = table.Column(type: "varchar(64)", maxLength: 64, nullable: false), + Type = table.Column(type: "int", nullable: false), + Status = table.Column(type: "int", nullable: false), + StudentId = table.Column(type: "char(36)", nullable: false), + IssuedByUserId = table.Column(type: "char(36)", nullable: false), + IssuedAt = table.Column(type: "datetime(6)", nullable: false), + Purpose = table.Column(type: "varchar(200)", maxLength: 200, nullable: true), + SnapshotJson = table.Column(type: "longtext", nullable: false), + PdfContent = table.Column(type: "longblob", nullable: false), + PdfSha256 = table.Column(type: "varchar(64)", maxLength: 64, nullable: false), + InvalidatedAt = table.Column(type: "datetime(6)", nullable: true), + InvalidatedByUserId = table.Column(type: "char(36)", nullable: true), + InvalidationReason = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + ReissuedFromDocumentId = table.Column(type: "char(36)", nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OfficialDocuments", x => x.Id); + table.ForeignKey( + name: "FK_OfficialDocuments_AspNetUsers_InvalidatedByUserId", + column: x => x.InvalidatedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_OfficialDocuments_AspNetUsers_IssuedByUserId", + column: x => x.IssuedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_OfficialDocuments_OfficialDocuments_ReissuedFromDocumentId", + column: x => x.ReissuedFromDocumentId, + principalTable: "OfficialDocuments", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_OfficialDocuments_Students_StudentId", + column: x => x.StudentId, + principalTable: "Students", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateTable( + name: "OfficialDocumentDownloads", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + OfficialDocumentId = table.Column(type: "char(36)", nullable: false), + DownloadedByUserId = table.Column(type: "char(36)", nullable: false), + IpAddress = table.Column(type: "varchar(64)", maxLength: 64, nullable: true), + UserAgent = table.Column(type: "varchar(500)", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_OfficialDocumentDownloads", x => x.Id); + table.ForeignKey( + name: "FK_OfficialDocumentDownloads_AspNetUsers_DownloadedByUserId", + column: x => x.DownloadedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_OfficialDocumentDownloads_OfficialDocuments_OfficialDocument~", + column: x => x.OfficialDocumentId, + principalTable: "OfficialDocuments", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_OfficialDocumentDownloads_DownloadedByUserId_CreatedAt", + table: "OfficialDocumentDownloads", + columns: new[] { "DownloadedByUserId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_OfficialDocumentDownloads_OfficialDocumentId_CreatedAt", + table: "OfficialDocumentDownloads", + columns: new[] { "OfficialDocumentId", "CreatedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_OfficialDocuments_DocumentNumber", + table: "OfficialDocuments", + column: "DocumentNumber", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OfficialDocuments_InvalidatedByUserId", + table: "OfficialDocuments", + column: "InvalidatedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_OfficialDocuments_IssuedByUserId", + table: "OfficialDocuments", + column: "IssuedByUserId"); + + migrationBuilder.CreateIndex( + name: "IX_OfficialDocuments_ReissuedFromDocumentId", + table: "OfficialDocuments", + column: "ReissuedFromDocumentId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OfficialDocuments_Status_IssuedAt", + table: "OfficialDocuments", + columns: new[] { "Status", "IssuedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_OfficialDocuments_StudentId_IssuedAt", + table: "OfficialDocuments", + columns: new[] { "StudentId", "IssuedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_OfficialDocuments_VerificationCodeHash", + table: "OfficialDocuments", + column: "VerificationCodeHash", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OfficialDocumentDownloads"); + + migrationBuilder.DropTable( + name: "OfficialDocuments"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 439df79..d41640b 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -2251,6 +2251,130 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("Notifications"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocumentNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvalidatedAt") + .HasColumnType("datetime(6)"); + + b.Property("InvalidatedByUserId") + .HasColumnType("char(36)"); + + b.Property("InvalidationReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IssuedAt") + .HasColumnType("datetime(6)"); + + b.Property("IssuedByUserId") + .HasColumnType("char(36)"); + + b.Property("PdfContent") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("PdfSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ReissuedFromDocumentId") + .HasColumnType("char(36)"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VerificationCodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentNumber") + .IsUnique(); + + b.HasIndex("InvalidatedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("ReissuedFromDocumentId") + .IsUnique(); + + b.HasIndex("VerificationCodeHash") + .IsUnique(); + + b.HasIndex("Status", "IssuedAt"); + + b.HasIndex("StudentId", "IssuedAt"); + + b.ToTable("OfficialDocuments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DownloadedByUserId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OfficialDocumentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("DownloadedByUserId", "CreatedAt"); + + b.HasIndex("OfficialDocumentId", "CreatedAt"); + + b.ToTable("OfficialDocumentDownloads"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => { b.Property("Id") @@ -3952,6 +4076,58 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Teacher"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "InvalidatedByUser") + .WithMany() + .HasForeignKey("InvalidatedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "ReissuedFromDocument") + .WithMany() + .HasForeignKey("ReissuedFromDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("InvalidatedByUser"); + + b.Navigation("IssuedByUser"); + + b.Navigation("ReissuedFromDocument"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "DownloadedByUser") + .WithMany() + .HasForeignKey("DownloadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "OfficialDocument") + .WithMany("Downloads") + .HasForeignKey("OfficialDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DownloadedByUser"); + + b.Navigation("OfficialDocument"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => { b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") @@ -4373,6 +4549,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.Navigation("Invigilators"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.Navigation("Downloads"); + }); + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => { b.Navigation("Entries"); diff --git a/src/Jiaowu.Api/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj index f982593..c7bb74b 100644 --- a/src/Jiaowu.Api/Jiaowu.Api.csproj +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -31,6 +31,9 @@ + + + diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index 1a3d599..31a2b47 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -6,6 +6,7 @@ using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Middleware; +using Jiaowu.Api.Infrastructure.OfficialDocuments; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Jiaowu.Api.Infrastructure.Timetables; @@ -66,6 +67,17 @@ var databaseOptions = builder.Configuration var cacheOptions = builder.Configuration .GetSection(AppCacheOptions.SectionName) .Get() ?? new AppCacheOptions(); +var officialDocumentOptions = builder.Configuration + .GetSection(OfficialDocumentOptions.SectionName) + .Get() ?? new OfficialDocumentOptions(); + +if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) || + string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) || + string.IsNullOrWhiteSpace(officialDocumentOptions.DocumentNumberPrefix)) +{ + throw new InvalidOperationException( + "OfficialDocuments 的 InstitutionName、IssuingOffice 和 DocumentNumberPrefix 不能为空。"); +} if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase) && !builder.Environment.IsDevelopment()) @@ -109,6 +121,9 @@ if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 || builder.Services.AddSingleton(databaseOptions); builder.Services.AddSingleton(cacheOptions); +builder.Services.AddSingleton(officialDocumentOptions); +builder.Services.Configure( + builder.Configuration.GetSection(OfficialDocumentOptions.SectionName)); builder.Services.AddDbContextPool(options => { if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase)) @@ -215,6 +230,8 @@ builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) @@ -247,6 +264,16 @@ builder.Services.AddRateLimiter(options => QueueLimit = 0, AutoReplenishment = true })); + options.AddPolicy("official-verification", context => + RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 30, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + })); }); builder.Services.AddCors(options => diff --git a/src/Jiaowu.Api/appsettings.json b/src/Jiaowu.Api/appsettings.json index 69c53eb..1863974 100644 --- a/src/Jiaowu.Api/appsettings.json +++ b/src/Jiaowu.Api/appsettings.json @@ -28,6 +28,13 @@ "Cors": { "Origins": [] }, + "OfficialDocuments": { + "InstitutionName": "明序大学", + "IssuingOffice": "教务处", + "DocumentNumberPrefix": "MXU", + "PublicBaseUrl": "", + "FontPath": "" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/tests/Jiaowu.Api.Tests/OfficialDocumentTests.cs b/tests/Jiaowu.Api.Tests/OfficialDocumentTests.cs new file mode 100644 index 0000000..09c2277 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/OfficialDocumentTests.cs @@ -0,0 +1,470 @@ +using System.Security.Cryptography; +using System.Text.Json; +using System.ComponentModel.DataAnnotations; +using Jiaowu.Api.Controllers; +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.Auth; +using Jiaowu.Api.Infrastructure.OfficialDocuments; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Jiaowu.Api.Tests; + +public sealed class OfficialDocumentTests +{ + [Theory] + [InlineData(typeof(IssueOfficialDocumentRequest), "Purpose")] + [InlineData(typeof(SelfIssueOfficialDocumentRequest), "Purpose")] + [InlineData(typeof(InvalidateOfficialDocumentRequest), "Reason")] + [InlineData(typeof(ReissueOfficialDocumentRequest), "Reason")] + [InlineData(typeof(ReissueOfficialDocumentRequest), "Purpose")] + public void RequestRecords_DefineValidationOnPrimaryConstructorParameters( + Type requestType, + string parameterName) + { + var constructor = Assert.Single(requestType.GetConstructors()); + var parameter = Assert.Single(constructor.GetParameters() + .Where(x => x.Name == parameterName)); + Assert.NotEmpty(parameter.GetCustomAttributes(typeof(ValidationAttribute), false)); + + var property = requestType.GetProperty(parameterName); + Assert.NotNull(property); + Assert.Empty(property.GetCustomAttributes(typeof(ValidationAttribute), false)); + } + + [Theory] + [InlineData(OfficialDocumentType.Transcript)] + [InlineData(OfficialDocumentType.StudentStatusCertificate)] + public void PdfGenerator_CreatesPdfWithStableHash(OfficialDocumentType type) + { + var options = CreateOptions(); + var generator = new OfficialDocumentPdfGenerator(options); + var snapshot = CreateSnapshot(type); + + var result = generator.Generate(snapshot, "https://jw.example.edu/verify/test-code"); + + Assert.True(result.Content.Length > 5_000); + Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(result.Content, 0, 4)); + Assert.Equal( + Convert.ToHexString(SHA256.HashData(result.Content)).ToLowerInvariant(), + result.Sha256); + } + + [Fact] + public async Task Service_IssuesTranscriptFromPublishedGradesOnly() + { + await using var fixture = await Fixture.CreateAsync(); + + var document = await fixture.Service.CreateAsync( + fixture.Student.Id, + OfficialDocumentType.Transcript, + "升学申请", + fixture.Manager.Id, + fixture.Manager.DisplayName, + "https://jw.example.edu", + null, + CancellationToken.None); + + var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson); + Assert.StartsWith("MXU-TR-", document.DocumentNumber); + Assert.Equal(64, document.VerificationCodeHash.Length); + Assert.Equal("升学申请", document.Purpose); + Assert.Single(snapshot.Grades); + Assert.Equal("程序设计基础", snapshot.Grades[0].CourseName); + Assert.Equal(4m, snapshot.EarnedCredits); + Assert.Equal(3.7m, snapshot.GradePointAverage); + Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(document.PdfContent, 0, 4)); + } + + [Fact] + public async Task Download_IsLogged_AndInvalidDocumentCannotBeDownloaded() + { + await using var fixture = await Fixture.CreateAsync(); + var document = await fixture.IssueAndSaveAsync(); + var controller = fixture.CreateController(); + + var result = await controller.Download(document.Id, CancellationToken.None); + + Assert.IsType(result); + var log = await fixture.Db.OfficialDocumentDownloads.SingleAsync(); + Assert.Equal(document.Id, log.OfficialDocumentId); + Assert.Equal(fixture.Manager.Id, log.DownloadedByUserId); + + document.Status = OfficialDocumentStatus.Invalidated; + await fixture.Db.SaveChangesAsync(); + var invalidResult = await controller.Download(document.Id, CancellationToken.None); + Assert.IsType(invalidResult); + Assert.Single(await fixture.Db.OfficialDocumentDownloads.ToListAsync()); + } + + [Fact] + public async Task Verify_ReturnsMaskedIdentityAndCurrentRevocationState() + { + await using var fixture = await Fixture.CreateAsync(); + const string verificationCode = "1234567890abcdef1234567890abcdef1234567890abcdef"; + var snapshot = CreateSnapshot(OfficialDocumentType.StudentStatusCertificate); + var document = new OfficialDocument + { + DocumentNumber = snapshot.DocumentNumber, + VerificationCodeHash = OfficialDocumentService.HashVerificationCode(verificationCode), + Type = snapshot.Type, + Status = OfficialDocumentStatus.Valid, + StudentId = fixture.Student.Id, + IssuedByUserId = fixture.Manager.Id, + IssuedAt = snapshot.IssuedAt, + SnapshotJson = JsonSerializer.Serialize(snapshot, new JsonSerializerOptions(JsonSerializerDefaults.Web)), + PdfContent = [1, 2, 3], + PdfSha256 = new string('a', 64) + }; + fixture.Db.OfficialDocuments.Add(document); + await fixture.Db.SaveChangesAsync(); + var controller = fixture.CreateController(); + + var action = await controller.Verify(verificationCode, CancellationToken.None); + var ok = Assert.IsType(action); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + Assert.Equal("张*", json.RootElement.GetProperty("StudentName").GetString()); + Assert.Equal("20*****01", json.RootElement.GetProperty("StudentNumber").GetString()); + Assert.True(json.RootElement.GetProperty("Valid").GetBoolean()); + + var numberAction = await controller.Verify(document.DocumentNumber, CancellationToken.None); + var numberOk = Assert.IsType(numberAction); + using var numberJson = JsonDocument.Parse(JsonSerializer.Serialize(numberOk.Value)); + Assert.True(numberJson.RootElement.GetProperty("Valid").GetBoolean()); + + document.Status = OfficialDocumentStatus.Invalidated; + document.InvalidatedAt = DateTime.UtcNow; + await fixture.Db.SaveChangesAsync(); + action = await controller.Verify(verificationCode, CancellationToken.None); + ok = Assert.IsType(action); + using var invalidJson = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)); + Assert.False(invalidJson.RootElement.GetProperty("Valid").GetBoolean()); + } + + [Fact] + public async Task Reissue_SupersedesOriginalAndCreatesSingleLinkedReplacement() + { + await using var fixture = await Fixture.CreateAsync(); + var original = await fixture.IssueAndSaveAsync(); + var controller = fixture.CreateController(); + + var result = await controller.Reissue( + original.Id, + new ReissueOfficialDocumentRequest("成绩更正后重新签发", null), + CancellationToken.None); + + Assert.IsType(result); + fixture.Db.ChangeTracker.Clear(); + var savedOriginal = await fixture.Db.OfficialDocuments.SingleAsync(x => x.Id == original.Id); + var replacement = await fixture.Db.OfficialDocuments.SingleAsync( + x => x.ReissuedFromDocumentId == original.Id); + Assert.Equal(OfficialDocumentStatus.Superseded, savedOriginal.Status); + Assert.Equal(OfficialDocumentStatus.Valid, replacement.Status); + Assert.NotEqual(savedOriginal.DocumentNumber, replacement.DocumentNumber); + Assert.NotEqual(savedOriginal.VerificationCodeHash, replacement.VerificationCodeHash); + + var duplicate = await controller.Reissue( + original.Id, + new ReissueOfficialDocumentRequest("再次重签", null), + CancellationToken.None); + Assert.IsType(duplicate); + Assert.Equal(2, await fixture.Db.OfficialDocuments.CountAsync()); + } + + [Fact] + public async Task StudentCanIssueOwnDocument_AndRecentDuplicateIsReused() + { + await using var fixture = await Fixture.CreateAsync(); + var controller = fixture.CreateStudentController(); + var request = new SelfIssueOfficialDocumentRequest( + OfficialDocumentType.Transcript, + " 实习申请 "); + + var first = await controller.IssueMine(request, CancellationToken.None); + + Assert.IsType(first); + var document = await fixture.Db.OfficialDocuments.SingleAsync(); + Assert.Equal(fixture.Student.Id, document.StudentId); + Assert.Equal(fixture.StudentUser.Id, document.IssuedByUserId); + Assert.Equal("实习申请", document.Purpose); + var snapshot = OfficialDocumentService.DeserializeSnapshot(document.SnapshotJson); + Assert.Equal("教务处自助签发", snapshot.IssuedByName); + Assert.Single(snapshot.Grades); + + var duplicate = await controller.IssueMine(request, CancellationToken.None); + + Assert.IsType(duplicate); + Assert.Single(await fixture.Db.OfficialDocuments.ToListAsync()); + } + + [Fact] + public async Task StudentWithoutLinkedRecord_CannotIssueDocument() + { + await using var fixture = await Fixture.CreateAsync(); + var controller = fixture.CreateStudentController(Guid.NewGuid()); + + var result = await controller.IssueMine( + new SelfIssueOfficialDocumentRequest( + OfficialDocumentType.StudentStatusCertificate, + null), + CancellationToken.None); + + Assert.IsType(result); + Assert.Empty(await fixture.Db.OfficialDocuments.ToListAsync()); + } + + private static OfficialDocumentOptions CreateOptions() => new() + { + InstitutionName = "明序大学", + IssuingOffice = "教务处", + DocumentNumberPrefix = "MXU" + }; + + private static OfficialDocumentSnapshot CreateSnapshot(OfficialDocumentType type) => new( + "明序大学", + "教务处", + type == OfficialDocumentType.Transcript + ? "MXU-TR-20260726-ABCDEF123456" + : "MXU-SC-20260726-ABCDEF123456", + type, + new DateTime(2026, 7, 26, 8, 30, 0, DateTimeKind.Utc), + "教务管理员", + "升学申请", + new OfficialStudentSnapshot( + "202600001", + "张明", + "男", + "计算机学院", + "计算机科学与技术", + "计科 2026-1 班", + 2026, + new DateOnly(2026, 9, 1), + "在读"), + type == OfficialDocumentType.Transcript + ? [new OfficialTranscriptRow("2026-2027-1", "CS101", "程序设计基础", 4, 88, 3.7m, "正常")] + : [], + type == OfficialDocumentType.Transcript ? 4 : 0, + type == OfficialDocumentType.Transcript ? 4 : 0, + type == OfficialDocumentType.Transcript ? 3.7m : null); + + private sealed class Fixture : IAsyncDisposable + { + private readonly SqliteConnection connection; + private readonly OfficialDocumentOptions options; + + private Fixture( + SqliteConnection connection, + AppDbContext db, + ApplicationUser manager, + ApplicationUser studentUser, + Student student, + OfficialDocumentOptions options) + { + this.connection = connection; + this.options = options; + Db = db; + Manager = manager; + StudentUser = studentUser; + Student = student; + Service = new OfficialDocumentService( + db, + options, + new OfficialDocumentPdfGenerator(options)); + } + + public AppDbContext Db { get; } + public ApplicationUser Manager { get; } + public ApplicationUser StudentUser { get; } + public Student Student { get; } + public OfficialDocumentService Service { get; } + + public static async Task CreateAsync() + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var db = new AppDbContext(new DbContextOptionsBuilder() + .UseSqlite(connection) + .Options); + await db.Database.EnsureCreatedAsync(); + + var manager = new ApplicationUser + { + Id = Guid.NewGuid(), + UserName = "academic-admin", + NormalizedUserName = "ACADEMIC-ADMIN", + DisplayName = "教务管理员" + }; + var studentUser = new ApplicationUser + { + Id = Guid.NewGuid(), + UserName = "202600001", + NormalizedUserName = "202600001", + DisplayName = "张明" + }; + var college = new College { Code = "CS", Name = "计算机学院" }; + var major = new Major + { + Code = "080901", + Name = "计算机科学与技术", + DegreeType = "工学学士", + CollegeId = college.Id + }; + var administrativeClass = new AdministrativeClass + { + Code = "CS2026-1", + Name = "计科 2026-1 班", + MajorId = major.Id, + Grade = 2026 + }; + var student = new Student + { + StudentNumber = "202600001", + Name = "张明", + Gender = Gender.Male, + AdministrativeClassId = administrativeClass.Id, + EnrollmentYear = 2026, + EnrollmentDate = new DateOnly(2026, 9, 1), + Status = StudentStatus.Active, + UserId = studentUser.Id + }; + var term = new AcademicTerm + { + Code = "2026-1", + Name = "2026-2027 学年第一学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 20) + }; + var course = new Course + { + Code = "CS101", + Name = "程序设计基础", + CollegeId = college.Id, + Credits = 4, + TotalHours = 64, + LectureHours = 48, + PracticeHours = 16 + }; + var task = new TeachingTask + { + TaskNumber = "2026-CS101-01", + Name = "程序设计基础 01 班", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 40, + Status = TeachingTaskStatus.Closed + }; + var published = new GradeSheet + { + TeachingTaskId = task.Id, + Status = GradeSheetStatus.Published, + Records = + [ + new GradeRecord + { + StudentId = student.Id, + TotalScore = 88, + GradePoint = 3.7m + } + ] + }; + var draftTask = new TeachingTask + { + TaskNumber = "2026-CS102-01", + Name = "未发布课程", + AcademicTermId = term.Id, + CourseId = course.Id, + Capacity = 40 + }; + var draft = new GradeSheet + { + TeachingTaskId = draftTask.Id, + Status = GradeSheetStatus.Approved, + Records = + [ + new GradeRecord + { + StudentId = student.Id, + TotalScore = 99, + GradePoint = 4m + } + ] + }; + db.AddRange(manager, studentUser, college, major, administrativeClass, student, term, course, task, + published, draftTask, draft); + await db.SaveChangesAsync(); + return new Fixture(connection, db, manager, studentUser, student, CreateOptions()); + } + + public async Task IssueAndSaveAsync() + { + var document = await Service.CreateAsync( + Student.Id, + OfficialDocumentType.Transcript, + null, + Manager.Id, + Manager.DisplayName, + "https://jw.example.edu", + null, + CancellationToken.None); + Db.OfficialDocuments.Add(document); + await Db.SaveChangesAsync(); + return document; + } + + public OfficialDocumentsController CreateController() + => CreateController(new TestDataScope(Manager.Id)); + + public OfficialDocumentsController CreateStudentController(Guid? userId = null) + => CreateController(new TestStudentDataScope(userId ?? StudentUser.Id)); + + private OfficialDocumentsController CreateController(ICurrentUserDataScope dataScope) + { + var controller = new OfficialDocumentsController( + Db, + dataScope, + Service, + Options.Create(options)); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + controller.Request.Scheme = "https"; + controller.Request.Host = new HostString("jw.example.edu"); + controller.HttpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback; + return controller; + } + + public async ValueTask DisposeAsync() + { + await Db.DisposeAsync(); + await connection.DisposeAsync(); + } + } + + private sealed class TestDataScope(Guid managerId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + managerId, + "教务管理员", + null, + DataScope.All, + new HashSet([SystemRoles.AcademicAdmin])); + } + + private sealed class TestStudentDataScope(Guid studentUserId) : ICurrentUserDataScope + { + public CurrentUserScope Current { get; } = new( + studentUserId, + "张明", + null, + DataScope.Self, + new HashSet([SystemRoles.Student])); + } +} diff --git a/web/src/layouts/AdminLayout.vue b/web/src/layouts/AdminLayout.vue index 3eabf8b..7f6c122 100644 --- a/web/src/layouts/AdminLayout.vue +++ b/web/src/layouts/AdminLayout.vue @@ -191,6 +191,10 @@ const navigationGroups = computed(() => [ hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student']), { path: '/student-status-changes', label: isStudent.value ? '学籍异动' : '异动审核' }, ), + ...whenVisible( + hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']), + { path: '/official-documents', label: isStudent.value ? '电子成绩单与证明' : '官方凭证签发' }, + ), ], }, { diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 2293c3a..3646b7f 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -23,6 +23,12 @@ const router = createRouter({ component: () => import('../views/AccountActivationView.vue'), meta: { public: true }, }, + { + path: '/verify/:verificationCode?', + name: 'official-document-verification', + component: () => import('../views/OfficialDocumentVerifyView.vue'), + meta: { public: true }, + }, { path: '/', component: AdminLayout, @@ -236,6 +242,14 @@ const router = createRouter({ roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'], }, }, + { + path: 'official-documents', + name: 'official-documents', + component: () => import('../views/OfficialDocumentsView.vue'), + meta: { + roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'], + }, + }, { path: 'graduation-audits', name: 'graduation-audits', diff --git a/web/src/views/OfficialDocumentVerifyView.vue b/web/src/views/OfficialDocumentVerifyView.vue new file mode 100644 index 0000000..7cd6b2f --- /dev/null +++ b/web/src/views/OfficialDocumentVerifyView.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/web/src/views/OfficialDocumentsView.vue b/web/src/views/OfficialDocumentsView.vue new file mode 100644 index 0000000..6be86de --- /dev/null +++ b/web/src/views/OfficialDocumentsView.vue @@ -0,0 +1,385 @@ + + + + +