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

服务端根据登录账号绑定本人档案,不接受学生 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
+1
View File
@@ -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
+8
View File
@@ -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
+1
View File
@@ -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 "" \
+2
View File
@@ -2,6 +2,8 @@
面向普通高校的教务管理系统。后端使用 ASP.NET Core 10、EF Core 10,前端使用 Vue 3、TypeScript 和 Element Plus。
官方电子凭证支持学生自助申请和教务代签,由服务端生成成绩单与学籍状态证明 PDF,并提供唯一凭证编号、二维码公开验真、下载记录、失效和重签;生产部署应通过 `OfficialDocuments__PublicBaseUrl` 配置二维码使用的最终 HTTPS 根地址。
当前已实现系统登录与角色权限、基础数据、用户管理、教师档案、学生档案、课程库、培养方案、教学任务、排课课表、学生选课、成绩管理、考试考场、学籍异动、毕业审核、学位授予、毕业离校和首页统计。人员及课程列表支持组合筛选、服务端分页和完整增删改查;培养方案支持课程模块、专业年级版本、复制新版本、发布与旧版本归档,已发布版本可继续维护名称、学分说明和课程结构,适用专业、入学年级及版本号保持锁定;教学任务支持学期课程开设、多教师、合班、容量校验、发布与结课,公共课由校级教务负责、专业必修/专业选修/实践课下放课程所属学院管理,并支持教师按学期申报授课科目、学院审核授课资格、公共课按若干行政班合并教学班,以及在审核通过的教师池中随机均衡分配后批量生成草稿;排课支持学期作息维护、单双周与周次节次、课程可用时间、校区/教学楼/指定教室约束、不占用教室课程、教室容量、教师/行政班/教室冲突校验、自动生成、手工微调和版本化发布;教师和学生可启用个人教学日历订阅,将固定课程、考试/监考、补考及灵活课程提醒同步到支持 iCalendar 的客户端,并可重置或停用订阅地址;选课支持批次时间窗、投放范围、容量与学分上限、重复课程与课表冲突校验、退课截止时间、满员候补、顺位查询、退课后资格复核与自动递补,以及正式名单和候补队列管理;成绩管理支持分项比例、批量录入、特殊考试状态、自动总评与绩点、教师提交、学院审核、校级发布和学生成绩单;考试管理支持考试计划、场次、考场容量、监考教师、考生名单以及考场/监考/学生时间冲突校验;学籍异动支持休学、复学、退学申请,辅导员、学院、学校三级顺序审核,学生撤回,以及最终审批后自动同步学籍状态;毕业审核按入学年级匹配已发布培养方案,以正式成绩计算总学分、必修通过和未解决不及格课程,支持学院范围查看、人工复核、校级锁定发布和学生结果查询;学位授予以已发布毕业资格为来源,按正式成绩加权平均绩点生成规则结论,支持学院人工复核、校级发布锁定和学生结果查询;毕业离校支持自定义事项与责任部门,按校级、学院、辅导员角色分工办理,强制数据范围校验,学生进度查询,以及必办事项全部完成后的批次锁定。
课程库支持下载标准模板后批量导入 `.xlsx`,按课程编码新增或更新,并在整批校验失败时不写入任何课程;授课资格既支持教师申报后审核,也支持学院在本院教师范围内直接分配;学生可在“我的培养方案”中查看本人适用的已发布方案,并按已完成、在读、重修中、未通过、待完成和未修读状态核对课程与学分进度。
+1
View File
@@ -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:-}"
@@ -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",
@@ -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<FileContentResult>(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<ConflictObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<ConflictObjectResult>(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<CreatedAtActionResult>(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<OkObjectResult>(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<ConflictObjectResult>(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<Fixture> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var db = new AppDbContext(new DbContextOptionsBuilder<AppDbContext>()
.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<OfficialDocument> 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<string>([SystemRoles.AcademicAdmin]));
}
private sealed class TestStudentDataScope(Guid studentUserId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
studentUserId,
"张明",
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Student]));
}
}
+4
View File
@@ -191,6 +191,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
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 ? '电子成绩单与证明' : '官方凭证签发' },
),
],
},
{
+14
View File
@@ -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',
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { CircleCheckFilled, CircleCloseFilled, Search } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
const route = useRoute()
const router = useRouter()
const code = ref('')
const loading = ref(false)
const checked = ref(false)
const error = ref('')
const result = ref<any>()
const typeLabels: Record<string, string> = {
Transcript: '官方电子成绩单',
StudentStatusCertificate: '学籍状态证明',
}
const statusLabels: Record<string, string> = {
Valid: '有效',
Invalidated: '已失效',
Superseded: '已被重签凭证替代',
}
async function verify(value = code.value) {
const normalized = value.trim()
if (!normalized) return
code.value = normalized
loading.value = true
checked.value = false
error.value = ''
try {
const { data } = await http.get(`/official-documents/verify/${encodeURIComponent(normalized)}`)
result.value = data
checked.value = true
if (route.params.verificationCode !== normalized) {
await router.replace({ name: 'official-document-verification', params: { verificationCode: normalized } })
}
} catch (reason) {
error.value = apiErrorMessage(reason)
} finally {
loading.value = false
}
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
}
watch(
() => route.params.verificationCode,
(value) => {
if (typeof value === 'string' && value) {
code.value = value
verify(value)
}
},
{ immediate: true },
)
</script>
<template>
<main class="verify-page">
<section class="verify-shell">
<header>
<span>明序大学 · 教务处</span>
<h1>官方电子凭证验真</h1>
<p>扫描凭证二维码会自动验真也可以输入 PDF 上印制的唯一凭证编号</p>
</header>
<div class="verify-search">
<el-input v-model="code" size="large" placeholder="请输入凭证编号或验真码" @keyup.enter="verify()" />
<el-button type="primary" size="large" :icon="Search" :loading="loading" @click="verify()">立即验真</el-button>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<section v-if="checked" class="verify-result" :class="result?.valid ? 'valid' : 'invalid'">
<div class="result-state">
<el-icon><CircleCheckFilled v-if="result?.valid" /><CircleCloseFilled v-else /></el-icon>
<div v-if="!result?.found">
<h2>未查到该凭证</h2>
<p>验真码不存在或输入不完整请核对原始二维码</p>
</div>
<div v-else-if="result.valid">
<h2>验真通过 · 凭证有效</h2>
<p>该编号由本系统签发当前未失效未被替代</p>
</div>
<div v-else>
<h2>{{ statusLabels[result.status] || '凭证无效' }}</h2>
<p>请勿继续将此文件作为有效官方凭证使用</p>
</div>
</div>
<dl v-if="result?.found">
<div><dt>凭证编号</dt><dd>{{ result.documentNumber }}</dd></div>
<div><dt>凭证类型</dt><dd>{{ typeLabels[result.type] }}</dd></div>
<div><dt>签发单位</dt><dd>{{ result.institutionName }}</dd></div>
<div><dt>学生</dt><dd>{{ result.studentName }} · {{ result.studentNumber }}</dd></div>
<div><dt>学院专业</dt><dd>{{ result.collegeName }} · {{ result.majorName }}</dd></div>
<div><dt>签发时间</dt><dd>{{ formatTime(result.issuedAt) }}</dd></div>
<div v-if="result.invalidatedAt"><dt>失效时间</dt><dd>{{ formatTime(result.invalidatedAt) }}</dd></div>
<div v-if="result.replacementDocumentNumber"><dt>替代凭证</dt><dd>{{ result.replacementDocumentNumber }}</dd></div>
<div class="hash"><dt>PDF SHA-256</dt><dd>{{ result.pdfSha256 }}</dd></div>
</dl>
</section>
<footer>
<p>验真结果以本页面实时状态为准请核对 PDF 显示的凭证编号与本页一致</p>
<router-link to="/login">返回教务系统</router-link>
</footer>
</section>
</main>
</template>
<style scoped>
.verify-page { min-height: 100vh; display: grid; place-items: center; padding: 28px 16px; background: radial-gradient(circle at 15% 0, #dbece7, transparent 35%), linear-gradient(145deg, #edf3f1, #f8faf9 55%, #e6eeec); }
.verify-shell { width: min(720px, 100%); overflow: hidden; border: 1px solid rgba(41, 89, 82, .18); border-radius: 16px; background: rgba(255, 255, 255, .94); box-shadow: 0 24px 70px rgba(31, 70, 64, .14); }
header { padding: 34px 38px 28px; color: #f5fbf9; background: #214f49; }
header span { font: 650 11px Consolas, monospace; letter-spacing: .14em; opacity: .72; }
header h1 { margin: 9px 0 7px; font: 700 29px Georgia, "Noto Serif SC", serif; }
header p { margin: 0; max-width: 540px; color: rgba(245, 251, 249, .72); font-size: 13px; line-height: 1.65; }
.verify-search { display: grid; grid-template-columns: 1fr auto; gap: 10px; padding: 26px 38px 18px; }
.verify-shell > .el-alert { width: auto; margin: 0 38px; }
.verify-result { margin: 18px 38px 30px; overflow: hidden; border: 1px solid #cce2db; border-radius: 11px; }
.verify-result.invalid { border-color: #ecd2cf; }
.result-state { display: flex; gap: 14px; align-items: center; padding: 20px; color: #236b5b; background: #eff8f5; }
.invalid .result-state { color: #a44840; background: #fff3f1; }
.result-state .el-icon { flex: 0 0 auto; font-size: 35px; }
.result-state h2 { margin: 0 0 4px; font-size: 18px; }
.result-state p { margin: 0; color: #697a76; font-size: 12px; }
dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; padding: 10px 20px 18px; }
dl div { padding: 12px 4px; border-bottom: 1px solid #edf1f0; }
dt { margin-bottom: 4px; color: #83908d; font-size: 10px; }
dd { margin: 0; color: #263d39; font-size: 13px; font-weight: 650; }
.hash { grid-column: 1 / -1; }
.hash dd { overflow-wrap: anywhere; font: 500 11px Consolas, monospace; }
footer { display: flex; justify-content: space-between; gap: 18px; padding: 18px 38px; color: #7b8986; background: #f6f8f7; font-size: 11px; }
footer p { margin: 0; }
footer a { flex: 0 0 auto; color: #28675d; text-decoration: none; }
@media (max-width: 560px) {
header, .verify-search { padding-left: 22px; padding-right: 22px; }
.verify-search { grid-template-columns: 1fr; }
.verify-result, .verify-shell > .el-alert { margin-left: 22px; margin-right: 22px; }
dl { grid-template-columns: 1fr; }
.hash { grid-column: auto; }
footer { align-items: start; flex-direction: column; padding: 16px 22px; }
}
</style>
+385
View File
@@ -0,0 +1,385 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Download, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
type DocumentType = 'Transcript' | 'StudentStatusCertificate'
type DocumentStatus = 'Valid' | 'Invalidated' | 'Superseded'
interface OfficialDocumentRow {
id: string
documentNumber: string
type: DocumentType
status: DocumentStatus
studentId: string
studentNumber: string
studentName: string
collegeName: string
purpose?: string
issuedAt: string
issuedByName: string
invalidatedAt?: string
invalidationReason?: string
reissuedFromDocumentId?: string
downloadCount: number
lastDownloadedAt?: string
}
interface StudentOption {
id: string
studentNumber: string
name: string
status: string
className: string
majorName: string
collegeName: string
}
const auth = useAuthStore()
const isManager = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)),
)
const loading = ref(false)
const issuing = ref(false)
const documents = ref<OfficialDocumentRow[]>([])
const students = ref<StudentOption[]>([])
const issueDialog = ref(false)
const historyDrawer = ref(false)
const historyLoading = ref(false)
const downloadHistory = ref<any[]>([])
const selectedDocument = ref<OfficialDocumentRow>()
const filters = reactive<{ type?: DocumentType; status?: DocumentStatus }>({})
const issueForm = reactive<{ studentId: string; type: DocumentType; purpose: string }>({
studentId: '',
type: 'Transcript',
purpose: '',
})
const typeLabels: Record<DocumentType, string> = {
Transcript: '官方电子成绩单',
StudentStatusCertificate: '学籍状态证明',
}
const statusLabels: Record<DocumentStatus, string> = {
Valid: '有效',
Invalidated: '已失效',
Superseded: '已重签',
}
async function load() {
loading.value = true
try {
const { data } = await http.get('/official-documents', { params: filters })
documents.value = data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadStudents(keyword = '') {
if (!isManager.value) return
try {
const { data } = await http.get('/official-documents/students/options', {
params: { keyword: keyword || undefined },
})
students.value = data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function openIssue() {
issueForm.studentId = ''
issueForm.type = 'Transcript'
issueForm.purpose = ''
issueDialog.value = true
if (isManager.value) await loadStudents()
}
async function issue() {
if (isManager.value && !issueForm.studentId) {
ElMessage.warning('请选择学生。')
return
}
issuing.value = true
try {
const response = isManager.value
? await http.post('/official-documents', issueForm)
: await http.post('/official-documents/mine', {
type: issueForm.type,
purpose: issueForm.purpose,
})
issueDialog.value = false
ElMessage.success(
response.data?.reused
? '已为你保留刚申请的同类有效凭证。'
: isManager.value
? '官方凭证已签发。'
: '电子凭证已生成,可在列表中下载。',
)
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
issuing.value = false
}
}
async function download(row: OfficialDocumentRow) {
try {
const response = await http.get(`/official-documents/${row.id}/download`, {
responseType: 'blob',
timeout: 30000,
})
const url = URL.createObjectURL(response.data)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${row.documentNumber}.pdf`
anchor.click()
URL.revokeObjectURL(url)
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function invalidate(row: OfficialDocumentRow) {
try {
const { value } = await ElMessageBox.prompt(
'失效后学生不能再下载,但已下载文件仍可扫码看到“已失效”。请输入原因。',
'确认失效',
{ inputPattern: /^.{2,500}$/, inputErrorMessage: '原因需为 2-500 个字符', confirmButtonText: '确认失效' },
)
await http.post(`/official-documents/${row.id}/invalidate`, { reason: value })
ElMessage.success('凭证已失效。')
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function reissue(row: OfficialDocumentRow) {
try {
const { value } = await ElMessageBox.prompt(
'系统会冻结原凭证并按当前正式数据生成新编号和新二维码。请输入重签原因。',
'重签凭证',
{ inputPattern: /^.{2,500}$/, inputErrorMessage: '原因需为 2-500 个字符', confirmButtonText: '确认重签' },
)
await http.post(`/official-documents/${row.id}/reissue`, { reason: value })
ElMessage.success('新凭证已签发,原凭证已标记为被替代。')
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function showHistory(row: OfficialDocumentRow) {
selectedDocument.value = row
historyDrawer.value = true
historyLoading.value = true
try {
const { data } = await http.get(`/official-documents/${row.id}/downloads`)
downloadHistory.value = data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
historyLoading.value = false
}
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
}
function statusTagType(status: DocumentStatus) {
return status === 'Valid' ? 'success' : status === 'Superseded' ? 'warning' : 'danger'
}
onMounted(load)
</script>
<template>
<div class="document-page">
<header class="document-hero">
<div>
<span>OFFICIAL DOCUMENT SERVICE</span>
<h2>{{ isManager ? '官方凭证签发中心' : '电子成绩单与证明' }}</h2>
<p>{{ isManager ? '服务端固化签发快照和 PDF,凭证编号唯一,二维码可公开验真。' : '在线申请后即时生成,可下载 PDF,并通过二维码公开验真。' }}</p>
</div>
<el-button type="primary" :icon="Plus" @click="openIssue">
{{ isManager ? '签发新凭证' : '申请电子凭证' }}
</el-button>
</header>
<section class="trust-strip">
<div><b>唯一编号</b><span>每次签发独立编号</span></div>
<div><b>二维码验真</b><span>实时显示有效状态</span></div>
<div><b>下载留痕</b><span>时间账号与来源可追溯</span></div>
<div><b>失效与重签</b><span>旧凭证保留完整链路</span></div>
</section>
<section class="document-toolbar">
<el-select v-model="filters.type" clearable placeholder="全部凭证类型" @change="load">
<el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button>
<span> {{ documents.length }} 份凭证</span>
</section>
<section class="document-list" v-loading="loading">
<article v-for="row in documents" :key="row.id" class="document-card">
<div class="document-mark" :class="row.type === 'Transcript' ? 'transcript' : 'certificate'">
<span>{{ row.type === 'Transcript' ? 'TR' : 'SC' }}</span>
<small>PDF</small>
</div>
<div class="document-main">
<div class="document-title">
<div>
<span>{{ typeLabels[row.type] }}</span>
<h3>{{ row.documentNumber }}</h3>
</div>
<el-tag :type="statusTagType(row.status)" effect="plain">{{ statusLabels[row.status] }}</el-tag>
</div>
<p class="student-line">
<b>{{ row.studentName }}</b>
<span>{{ row.studentNumber }} · {{ row.collegeName }}</span>
</p>
<p class="document-meta">
签发于 {{ formatTime(row.issuedAt) }} · {{ row.issuedByName }}
<template v-if="row.purpose"> · 用途{{ row.purpose }}</template>
</p>
<el-alert
v-if="row.status !== 'Valid'"
:title="`${statusLabels[row.status]}${row.invalidationReason || '未填写原因'}`"
type="warning"
:closable="false"
/>
</div>
<div class="document-actions">
<el-button v-if="row.status === 'Valid'" type="primary" :icon="Download" @click="download(row)">下载 PDF</el-button>
<el-button v-if="isManager" :icon="Search" @click="showHistory(row)">下载记录{{ row.downloadCount }}</el-button>
<el-button v-if="isManager && row.status === 'Valid'" type="danger" plain @click="invalidate(row)">设为失效</el-button>
<el-button v-if="isManager && row.status !== 'Superseded'" type="warning" plain @click="reissue(row)">重签</el-button>
</div>
</article>
<el-empty
v-if="!documents.length"
:description="isManager ? '暂无符合条件的官方凭证' : '暂无电子凭证,可点击上方按钮在线申请'"
/>
</section>
<el-dialog v-model="issueDialog" :title="isManager ? '签发官方凭证' : '申请电子凭证'" width="620px">
<el-alert
:title="isManager
? '签发后将固化当前正式数据;后续数据修改不会悄悄改变已签发文件。'
: '系统将按你的学籍档案即时生成;成绩单仅包含已正式发布的成绩,5 分钟内重复申请同类型、同用途凭证将复用已有文件。'"
type="info"
:closable="false"
/>
<el-form label-position="top" class="issue-form">
<el-form-item v-if="isManager" label="学生" required>
<el-select
v-model="issueForm.studentId"
filterable
remote
:remote-method="loadStudents"
placeholder="按姓名或学号搜索"
style="width: 100%"
>
<el-option
v-for="student in students"
:key="student.id"
:value="student.id"
:label="`${student.studentNumber} · ${student.name}`"
>
<div class="student-option">
<b>{{ student.studentNumber }} · {{ student.name }}</b>
<span>{{ student.collegeName }} · {{ student.majorName }} · {{ student.className }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="凭证类型" required>
<el-radio-group v-model="issueForm.type">
<el-radio-button value="Transcript">官方电子成绩单</el-radio-button>
<el-radio-button value="StudentStatusCertificate">学籍状态证明</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="用途(可选)">
<el-input v-model="issueForm.purpose" maxlength="200" show-word-limit placeholder="例如:升学申请、实习材料" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="issueDialog = false">取消</el-button>
<el-button type="primary" :loading="issuing" @click="issue">
{{ isManager ? '确认签发' : '提交申请' }}
</el-button>
</template>
</el-dialog>
<el-drawer v-model="historyDrawer" title="凭证下载记录" size="560px">
<p class="drawer-number">{{ selectedDocument?.documentNumber }}</p>
<el-table :data="downloadHistory" v-loading="historyLoading">
<el-table-column label="下载人" prop="downloadedByName" min-width="110" />
<el-table-column label="下载时间" min-width="165">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="来源 IP" prop="ipAddress" min-width="130" />
</el-table>
<el-empty v-if="!historyLoading && !downloadHistory.length" description="尚无下载记录" />
</el-drawer>
</div>
</template>
<style scoped>
.document-page { display: grid; gap: 18px; }
.document-hero { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 26px 28px; border-radius: 12px; color: #f4fbf8; background: linear-gradient(120deg, #173b3a, #245e55 68%, #33746a); box-shadow: 0 14px 34px rgba(25, 65, 60, .16); }
.document-hero span { font: 650 10px/1.2 Consolas, monospace; letter-spacing: .18em; opacity: .72; }
.document-hero h2 { margin: 8px 0 5px; font: 700 27px/1.25 Georgia, "Noto Serif SC", serif; }
.document-hero p { margin: 0; color: rgba(244, 251, 248, .74); font-size: 13px; }
.trust-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; overflow: hidden; border: 1px solid #dce7e4; border-radius: 10px; background: #dce7e4; }
.trust-strip div { display: grid; gap: 3px; padding: 14px 18px; background: #f8fbfa; }
.trust-strip b { color: #244d48; font-size: 13px; }
.trust-strip span { color: #71807e; font-size: 11px; }
.document-toolbar { display: flex; align-items: center; gap: 10px; }
.document-toolbar .el-select { width: 180px; }
.document-toolbar > span { margin-left: auto; color: #7b8785; font-size: 12px; }
.document-list { display: grid; gap: 12px; min-height: 180px; }
.document-card { display: grid; grid-template-columns: 74px minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 18px; border: 1px solid #e0e7e5; border-radius: 10px; background: white; }
.document-mark { display: grid; place-content: center; width: 62px; height: 76px; border-radius: 5px 5px 12px 5px; color: white; background: #245e55; box-shadow: 0 8px 18px rgba(36, 94, 85, .18); }
.document-mark.certificate { background: #6b5541; }
.document-mark span { font: 800 18px/1 Consolas, monospace; letter-spacing: .1em; }
.document-mark small { margin-top: 7px; text-align: center; font: 700 8px/1 Consolas, monospace; opacity: .68; }
.document-main { min-width: 0; }
.document-title { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.document-title span { color: #687774; font-size: 11px; }
.document-title h3 { margin: 4px 0 0; overflow: hidden; color: #263d3a; font: 700 15px/1.3 Consolas, monospace; text-overflow: ellipsis; }
.student-line { display: flex; gap: 9px; align-items: baseline; margin: 11px 0 4px; }
.student-line b { color: #263d3a; }
.student-line span, .document-meta { color: #77827f; font-size: 12px; }
.document-meta { margin: 0 0 8px; }
.document-actions { display: flex; flex-direction: column; gap: 7px; min-width: 150px; }
.issue-form { margin-top: 18px; }
.student-option { display: grid; line-height: 1.3; }
.student-option span { color: #8a9693; font-size: 11px; }
.drawer-number { margin: -8px 0 18px; color: #667572; font: 600 12px Consolas, monospace; }
@media (max-width: 760px) {
.document-hero { align-items: stretch; flex-direction: column; padding: 22px; }
.trust-strip { grid-template-columns: 1fr 1fr; }
.document-toolbar { align-items: stretch; flex-wrap: wrap; }
.document-toolbar .el-select { width: calc(50% - 5px); }
.document-toolbar > span { width: 100%; margin-left: 0; }
.document-card { grid-template-columns: 54px minmax(0, 1fr); align-items: start; padding: 14px; }
.document-mark { width: 50px; height: 62px; }
.document-actions { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; }
.student-line { align-items: start; flex-direction: column; gap: 2px; }
}
</style>