学生可在“学籍管理 → 电子成绩单与证明”申请成绩单或学籍证明。

服务端根据登录账号绑定本人档案,不接受学生 ID,无法代他人申请。
成绩单仅包含正式发布成绩;无已发布成绩时明确提示。
同类型、同用途 5 分钟内重复申请复用已有有效凭证。
申请后即时生成 PDF,可在本人凭证列表下载、二维码验真。
管理员的下载记录、失效、重签能力保持不变。
This commit is contained in:
2026-07-26 18:24:53 +08:00 Unverified
parent 40b8cb6e3c
commit ecc6546f3a
24 changed files with 7203 additions and 0 deletions
@@ -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; }
}
@@ -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<OfficialTranscriptRow> 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<string> WrapText(
SKTypeface typeface,
string text,
float size,
float maxWidth)
{
using var font = new SKFont(typeface, size);
var lines = new List<string>();
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.##");
}
@@ -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<OfficialDocument> 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<OfficialDocumentSnapshot>(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);
@@ -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<OfficialTranscriptRow> 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);
@@ -91,6 +91,9 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
Set<GraduationClearanceItem>();
public DbSet<GraduationClearanceRecord> GraduationClearanceRecords =>
Set<GraduationClearanceRecord>();
public DbSet<OfficialDocument> OfficialDocuments => Set<OfficialDocument>();
public DbSet<OfficialDocumentDownload> OfficialDocumentDownloads =>
Set<OfficialDocumentDownload>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void ConfigureConventions(
@@ -927,6 +930,54 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.IpAddress).HasMaxLength(64);
entity.HasIndex(x => x.CreatedAt);
});
builder.Entity<OfficialDocument>(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<OfficialDocumentDownload>(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<int> SaveChangesAsync(CancellationToken cancellationToken = default)
@@ -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<int>(
"""
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 =
[
"""
@@ -0,0 +1,156 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class OfficialDocuments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "OfficialDocuments",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
DocumentNumber = table.Column<string>(type: "varchar(50)", maxLength: 50, nullable: false),
VerificationCodeHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
IssuedByUserId = table.Column<Guid>(type: "char(36)", nullable: false),
IssuedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
Purpose = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
SnapshotJson = table.Column<string>(type: "longtext", nullable: false),
PdfContent = table.Column<byte[]>(type: "longblob", nullable: false),
PdfSha256 = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
InvalidatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
InvalidatedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
InvalidationReason = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
ReissuedFromDocumentId = table.Column<Guid>(type: "char(36)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_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<Guid>(type: "char(36)", nullable: false),
OfficialDocumentId = table.Column<Guid>(type: "char(36)", nullable: false),
DownloadedByUserId = table.Column<Guid>(type: "char(36)", nullable: false),
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
UserAgent = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OfficialDocumentDownloads");
migrationBuilder.DropTable(
name: "OfficialDocuments");
}
}
}
@@ -2251,6 +2251,130 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("Notifications");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("DocumentNumber")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("varchar(50)");
b.Property<DateTime?>("InvalidatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("InvalidatedByUserId")
.HasColumnType("char(36)");
b.Property<string>("InvalidationReason")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<DateTime>("IssuedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("IssuedByUserId")
.HasColumnType("char(36)");
b.Property<byte[]>("PdfContent")
.IsRequired()
.HasColumnType("longblob");
b.Property<string>("PdfSha256")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Purpose")
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<Guid?>("ReissuedFromDocumentId")
.HasColumnType("char(36)");
b.Property<string>("SnapshotJson")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<Guid>("StudentId")
.HasColumnType("char(36)");
b.Property<int>("Type")
.HasColumnType("int");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("DownloadedByUserId")
.HasColumnType("char(36)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid>("OfficialDocumentId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("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<Guid>("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");