学生可在“学籍管理 → 电子成绩单与证明”申请成绩单或学籍证明。
服务端根据登录账号绑定本人档案,不接受学生 ID,无法代他人申请。 成绩单仅包含正式发布成绩;无已发布成绩时明确提示。 同类型、同用途 5 分钟内重复申请复用已有有效凭证。 申请后即时生成 PDF,可在本人凭证列表下载、二维码验真。 管理员的下载记录、失效、重签能力保持不变。
This commit is contained in:
@@ -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]));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user