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