服务端根据登录账号绑定本人档案,不接受学生 ID,无法代他人申请。 成绩单仅包含正式发布成绩;无已发布成绩时明确提示。 同类型、同用途 5 分钟内重复申请复用已有有效凭证。 申请后即时生成 PDF,可在本人凭证列表下载、二维码验真。 管理员的下载记录、失效、重签能力保持不变。
414 lines
16 KiB
C#
414 lines
16 KiB
C#
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;
|