This commit is contained in:
biss committed 2026-09-07 19:34:03 +08:00
1 parent cec56564b5
commit ae075bda6d
32 files changed
+10052 -90

No files matched your search

@@ -0,0 +1,69 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Academic;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin + "," + SystemRoles.CollegeAdmin)]
[Route("api/archives/{kind}")]
public sealed class ArchivesController(AppDbContext db, ICurrentUserDataScope scope, IAppCache cache) : ControllerBase
{
[HttpPost("preview")]
public async Task<ActionResult> Preview(string kind, ArchiveBatchRequest request, CancellationToken ct)
{
var service = new ArchiveService(db, scope);
var results = new List<object>();
foreach (var id in request.Ids.Distinct())
{
var item = await service.InspectAsync(kind, id, !request.Archived, ct);
results.Add(item is null ? new { Id = id, Error = "记录不存在或无权操作。" } : (object)item.Value.Preview);
}
return Ok(results);
}
[HttpGet("{id:guid}/history")]
public async Task<ActionResult> History(string kind, Guid id, CancellationToken ct, int page = 1)
{
if (await new ArchiveService(db, scope).InspectAsync(kind, id, true, ct) is null) return NotFound();
return Ok(await db.ArchiveEvents.AsNoTracking().Where(x => x.Kind == kind && x.EntityId == id)
.OrderByDescending(x => x.CreatedAt).ThenByDescending(x => x.Id)
.Skip((Math.Clamp(page, 1, 100000) - 1) * 20).Take(20).ToListAsync(ct));
}
[HttpPost("apply")]
public async Task<ActionResult> Apply(string kind, ArchiveBatchRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Reason)) return BadRequest("请填写归档或恢复原因。");
var results = new List<object>();
foreach (var id in request.Ids.Distinct())
{
try
{
var result = await new ArchiveService(db, scope).ChangeAsync(kind, id, request.Archived, request.Reason, ct);
results.Add(result);
}
catch (Exception ex) when (ex is DbUpdateException or System.Data.Common.DbException)
{
results.Add(new ArchiveOperationResult(id, false, "记录已发生变化或保存失败,请刷新后重试。"));
}
finally { db.ChangeTracker.Clear(); }
}
await cache.RemoveByTagAsync(AppCacheTags.Timetables, ct);
await cache.RemoveByTagAsync(AppCacheTags.Analytics, ct);
await cache.RemoveByTagAsync(AppCacheTags.BaseData, ct);
return Ok(results);
}
}
public sealed record ArchiveBatchRequest(
[property: Required, MinLength(1), MaxLength(100)] Guid[] Ids,
bool Archived,
[property: MaxLength(500)] string? Reason = null);
@@ -1,3 +1,5 @@
using Jiaowu.Api.Infrastructure.Academic;
using Jiaowu.Api.Infrastructure.Auth;
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
@@ -14,7 +16,8 @@ namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/base-data")]
public sealed class BaseDataController(AppDbContext db, IAppCache cache) : ControllerBase
public sealed class BaseDataController(AppDbContext db, IAppCache cache,
ICurrentUserDataScope? currentUserDataScope = null) : ControllerBase
{
private const string Administrators =
$"{SystemRoles.SuperAdmin},{SystemRoles.AcademicAdmin}";
@@ -435,8 +438,8 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
return ValidationProblem("学期结束日期必须晚于开始日期。");
if (entity.IsCurrent && !request.IsCurrent)
return ConflictProblem("不能直接取消当前学期,请将另一个学期设为当前。");
if (request.IsCurrent && entity.IsArchived)
return ConflictProblem("已归档学期不能直接设为当前,请先撤销归档。");
if (entity.IsArchived)
return ConflictProblem("已归档学期请先恢复后再编辑。");
if (request.IsCurrent && !request.IsEnabled)
return ValidationProblem("当前学期必须保持启用。");
if (request.IsCurrent)
@@ -493,26 +496,28 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
return ConflictProblem("当前学期不能归档,请先切换到新的当前学期。");
if (entity.IsArchived) return entity;
entity.IsArchived = true;
entity.ArchivedAt = DateTime.UtcNow;
await SaveAndInvalidateAsync(cancellationToken);
return entity;
return await ChangeTermArchiveAsync(id, true, cancellationToken);
}
[HttpPost("terms/{id:guid}/unarchive")]
[Authorize(Roles = Administrators)]
public async Task<ActionResult<AcademicTerm>> UnarchiveTerm(
Guid id,
CancellationToken cancellationToken)
{
var entity = await db.AcademicTerms.FindAsync([id], cancellationToken);
if (entity is null) return NotFound();
if (!entity.IsArchived) return entity;
public Task<ActionResult<AcademicTerm>> UnarchiveTerm(Guid id, CancellationToken cancellationToken) =>
ChangeTermArchiveAsync(id, false, cancellationToken);
entity.IsArchived = false;
entity.ArchivedAt = null;
await SaveAndInvalidateAsync(cancellationToken);
return entity;
private async Task<ActionResult<AcademicTerm>> ChangeTermArchiveAsync(Guid id, bool archived, CancellationToken ct)
{
if (!await db.AcademicTerms.AnyAsync(x => x.Id == id, ct)) return NotFound();
var scope = currentUserDataScope ?? new CurrentUserDataScope(new HttpContextAccessor { HttpContext = HttpContext });
var service = new ArchiveService(db, scope);
try
{
var result = await service.ChangeAsync("terms", id, archived, archived ? "通过学期管理归档" : "通过学期管理恢复", ct);
if (!result.Success) return ConflictProblem(result.Message);
await cache.RemoveByTagAsync(AppCacheTags.BaseData, ct);
await cache.RemoveByTagAsync(AppCacheTags.Timetables, ct);
return (await db.AcademicTerms.FindAsync([id], ct))!;
}
catch (DbUpdateException) { return ConflictProblem("学期数据已变化,请刷新后重试。"); }
}
[HttpGet("classrooms")]
@@ -704,6 +709,8 @@ public sealed class BaseDataController(AppDbContext db, IAppCache cache) : Contr
if (entity is null) return NotFound();
if (entity is AcademicTerm { IsCurrent: true })
return ConflictProblem("当前学期不能删除,请先切换到其他学期。");
if (entity is AcademicTerm { IsArchived: true })
return ConflictProblem("已归档学期不能删除,请先恢复。");
db.Remove(entity);
try
{
@@ -51,10 +51,10 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
}
[HttpGet("{kind}/export")]
public async Task<IActionResult> Export(string kind, CancellationToken cancellationToken)
public async Task<IActionResult> Export(string kind, CancellationToken cancellationToken, bool? isArchived = null, string? keyword = null)
{
if (!TryGetHeaders(kind, out var headers)) return NotFound();
var rows = await GetExportRowsAsync(kind, cancellationToken);
var rows = await GetExportRowsAsync(kind, cancellationToken, isArchived, keyword?.Trim());
var bytes = ExcelWorkbookHelper.Create(KindName(kind), headers, rows);
return File(bytes, ExcelWorkbookHelper.ContentType,
DownloadFileName.Export(KindName(kind), ".xlsx"));
@@ -140,7 +140,7 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
private async Task<IReadOnlyList<IReadOnlyList<object?>>> GetExportRowsAsync(
string kind,
CancellationToken cancellationToken) =>
CancellationToken cancellationToken, bool? isArchived, string? keyword) =>
kind.ToLowerInvariant() switch
{
"campuses" => (await db.Campuses.AsNoTracking().OrderBy(x => x.Code)
@@ -158,7 +158,10 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
.Include(x => x.CounselorUser).OrderBy(x => x.Code).ToListAsync(cancellationToken))
.Select(x => Row(x.Code, x.Name, x.Major!.Code, x.Grade,
x.CounselorUser?.StaffNumber, x.SortOrder, Status(x.IsEnabled))).ToList(),
"terms" => (await db.AcademicTerms.AsNoTracking().OrderByDescending(x => x.StartDate)
"terms" => (await db.AcademicTerms.AsNoTracking()
.Where(x => !isArchived.HasValue || x.IsArchived == isArchived.Value)
.Where(x => keyword == null || keyword == "" || x.Code.Contains(keyword) || x.Name.Contains(keyword))
.OrderByDescending(x => x.StartDate)
.ToListAsync(cancellationToken))
.Select(x => Row(x.Code, x.Name, x.AcademicYear, SeasonName(x.Season),
x.StartDate, x.EndDate, YesNo(x.IsCurrent), Status(x.IsEnabled))).ToList(),
@@ -384,6 +387,11 @@ public sealed class BaseDataExcelController(AppDbContext db, IAppCache cache) :
continue;
}
var entity = existing.GetValueOrDefault(code);
if (entity?.IsArchived == true)
{
errors.Add($"第 {row.RowNumber} 行:学期已归档,请先恢复后再导入更新。");
continue;
}
if (entity is null)
{
entity = new AcademicTerm
Loaded 3 of 32 files, more files were not shown because too many files have changed in this diff. Show more