升级
This commit is contained in:
1 parent
cec56564b5
commit
ae075bda6d
32 files changed
+10050
-88
No files matched your search
@@ -692,3 +692,19 @@ Docker 镜像不包含 `.env`、数据库密码或 JWT 密钥。应用容器以
|
||||
dotnet test Jiaowu.slnx
|
||||
npm --prefix web run build
|
||||
```
|
||||
|
||||
|
||||
## 人员、课程、学期与成绩归档
|
||||
|
||||
人员和课程列表默认显示未归档记录,可切换“已归档”或清空筛选查看全部;成绩管理提供同样的归档筛选。学期管理保留当前和历史学期展示,并可按归档状态筛选。各页面均提供单条、批量归档及恢复,提交前展示阻断事项和处理入口,归档或恢复原因必填,每批最多 100 条。批量请求逐项重新校验和提交,显示每条记录的处理结果。
|
||||
|
||||
- 人员:学生毕业/退学、教师退休/离职后可归档;归档前检查相关未结课任务、未发布成绩、学生待审批事项和必办离校事项。恢复不会变更学籍、任职状态或登录账号状态。
|
||||
- 课程:未结束教学任务,以及在读学生适用的已发布培养方案会阻止归档。已归档课程退出新开课和课程申报选项;已有培养方案、教学任务和成绩引用保留。课程启停状态独立于归档状态。
|
||||
- 学期:当前学期不可归档;开放选课批次、未结束教学任务、未建册或未发布成绩需要先处理。恢复后仍为历史学期,不会自动设为当前。归档不会连带归档人员、课程,也不冻结补考和成绩更正。
|
||||
- 成绩:以教学班成绩单为单位,仅允许已发布且没有待审批成绩更正的成绩单归档。归档标记独立于发布状态,成绩单、绩点、毕业审核和历史统计继续使用原正式成绩;更正仍走既有审批流程。
|
||||
|
||||
已归档人员、课程和学期不能直接编辑或删除;人员、课程和学期 Excel 导入不能覆盖已归档记录,导出遵循列表归档筛选。教学任务发布和撤销结课会检查已归档的学期、课程与教师。归档操作按原有角色及学院范围授权,课程继续执行公共课校级维护规则,学期由校级管理员管理。每次状态变化保留操作者、时间和原因,恢复不删除历史日志。
|
||||
|
||||
统一接口为 `POST /api/archives/{kind}/preview`、`POST /api/archives/{kind}/apply` 和 `GET /api/archives/{kind}/{id}/history?page=1`,其中 `kind` 为 `teachers`、`students`、`courses`、`terms`、`grades`。原学期归档接口继续可用并复用同一校验流程。历史日志按每页 20 条读取,旧归档记录不会伪造操作日志。
|
||||
|
||||
数据库新增 `20260905093721_UnifiedArchiving` MySQL 迁移;SQLite 开发库由 `DevelopmentSqliteMigrator` 补齐字段和归档日志表,已有记录默认未归档,已有学期归档状态保留。部署时应按现有数据库升级流程应用迁移。
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Loaded 3 of 32 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user