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 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(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);