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

服务端根据登录账号绑定本人档案,不接受学生 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,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]));
}
}