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

服务端根据登录账号绑定本人档案,不接受学生 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,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<OfficialDocumentOptions> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<IActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<ActionResult> 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<OfficialDocument> 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<Student> 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;
@@ -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<OfficialDocumentDownload> 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
}
@@ -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");
+3
View File
@@ -31,6 +31,9 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageReference Include="MySql.EntityFrameworkCore" Version="10.0.7" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="SkiaSharp" Version="3.119.2" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="3.119.2" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
+27
View File
@@ -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<AppCacheOptions>() ?? new AppCacheOptions();
var officialDocumentOptions = builder.Configuration
.GetSection(OfficialDocumentOptions.SectionName)
.Get<OfficialDocumentOptions>() ?? 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<OfficialDocumentOptions>(
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
builder.Services.AddDbContextPool<AppDbContext>(options =>
{
if (databaseOptions.Provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase))
@@ -215,6 +230,8 @@ builder.Services.AddScoped<MakeupExamArrangementService>();
builder.Services.AddSingleton<MakeupExamAutoJobQueue>();
builder.Services.AddHostedService<MakeupExamAutoJobWorker>();
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
builder.Services.AddSingleton<IOfficialDocumentPdfGenerator, OfficialDocumentPdfGenerator>();
builder.Services.AddScoped<OfficialDocumentService>();
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 =>
+7
View File
@@ -28,6 +28,13 @@
"Cors": {
"Origins": []
},
"OfficialDocuments": {
"InstitutionName": "明序大学",
"IssuingOffice": "教务处",
"DocumentNumberPrefix": "MXU",
"PublicBaseUrl": "",
"FontPath": ""
},
"Logging": {
"LogLevel": {
"Default": "Information",