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