7 Commits
  • “教务总览”改成可实际工作的分级工作台:
    校级管理员查看全校数据,学院管理员仅查看本学院数据。
    待办按当前审批阶段统计,涵盖授课资格、成绩、调停课、学籍异动、教室借用等。
    增加学期进度、教学任务发布、课表覆盖、成绩发布状态。
    快捷入口根据管理员角色自动调整。
    完善未配置学期、无待办、加载失败等状态。
    适配桌面和 390px 移动端,无横向溢出。
  • “运维与审计控制台”。
    主要能力:
    SuperAdmin 专用入口:组织与权限 → 运维与审计。
    操作日志分页查询,支持时间、账号、路径、方法和状态码筛选。
    汇总自动排课、课表发布、补考安排三类失败后台任务。
    实时检查数据库、缓存、任务通道及积压状态。
    聚合 5xx、失败/重试任务、健康探针和备份时效告警。
    SQLite 在线备份;MySQL 调用原生客户端备份。
    SHA-256 校验及隔离数据库恢复演练,不覆盖业务库。
    MySQL 强制使用独立运维连接,容器增加持久化备份卷与数据库客户端。
  • 发信改为事务内每 300 人分批写入,避免全校群发一次插入过大;数据库异常现在返回明确的 503,不再只显示模糊 500。
    校级、学院级管理员支持按学院、身份、行政班、教学班、姓名/学号/工号筛选,也可指定最多 500 名收件人。所有结果均由服务端重新校验权限。
    接入 CKEditor 5,支持标题、列表、引用和链接;正文扩展为 MySQL longtext。
    富文本在收件箱、详情弹窗和已发送记录中统一经 DOMPurify 净化后展示。
    页面改用系统现有的冷白、靛蓝、青绿色令牌,新增“选择收件人 → 编辑内容”工作台和移动端适配。
46 changed files with 20974 additions and 461 deletions
+13
View File
@@ -6,6 +6,9 @@ Database__Provider=MySql
Database__ApplyMigrationsOnStartup=false
Database__CommandTimeoutSeconds=30
ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=APP_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
# 仅供备份与隔离恢复演练使用。该账号需要读取业务库,并仅能创建/删除
# jiaowu_restore_drill_* 临时库;不要在此复用日常业务账号。
# ConnectionStrings__OperationsMySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;User=OPS_USER;Password=REPLACE_WITH_A_STRONG_PASSWORD;SslMode=VerifyFull;SslCa=/etc/jiaowu/mysql-ca.pem;"
# Redis 是可选加速器;留空时应用仅使用进程内缓存。
# ConnectionStrings__Redis="redis.example.edu.cn:6380,user=jiaowu,password=REPLACE_WITH_A_STRONG_PASSWORD,ssl=true,abortConnect=false"
@@ -32,6 +35,16 @@ Cache__AnalyticsExpirationMinutes=3
Cache__AnalyticsLocalExpirationSeconds=30
Cache__MaximumPayloadKilobytes=2048
# 运维控制台备份目录必须位于持久化、仅服务账号可写的位置。
Operations__BackupDirectory=/var/lib/jiaowu/backups
Operations__BackupWarningHours=24
Operations__ToolTimeoutMinutes=30
Operations__MySqlDumpPath=mysqldump
Operations__MySqlClientPath=mysql
# 按实际客户端补充 TLS 参数;Oracle MySQL 客户端示例:
# Operations__MySqlAdditionalArguments__0=--ssl-mode=VERIFY_IDENTITY
# Operations__MySqlAdditionalArguments__1=--ssl-ca=/etc/jiaowu/mysql-ca.pem
Jwt__Issuer=Jiaowu.Api
Jwt__Audience=Jiaowu.Web
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
+9 -2
View File
@@ -30,11 +30,14 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
WORKDIR /app
ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_EnableDiagnostics=0
DOTNET_EnableDiagnostics=0 \
Operations__BackupDirectory=/var/lib/jiaowu/backups \
Operations__MySqlDumpPath=mariadb-dump \
Operations__MySqlClientPath=mariadb
EXPOSE 8080
COPY --from=build /app/publish/ ./
ARG UID=10001
RUN apk add --no-cache font-noto-cjk
RUN apk add --no-cache font-noto-cjk mariadb-client
RUN adduser \
--disabled-password \
--gecos "" \
@@ -43,5 +46,9 @@ RUN adduser \
--no-create-home \
--uid "${UID}" \
appuser
RUN mkdir -p /var/lib/jiaowu/backups \
&& chown appuser:appuser /var/lib/jiaowu/backups \
&& chmod 700 /var/lib/jiaowu/backups
VOLUME ["/var/lib/jiaowu/backups"]
USER appuser
ENTRYPOINT ["dotnet", "Jiaowu.Api.dll"]
+25
View File
@@ -101,6 +101,12 @@ sudo chown -R root:jiaowu /opt/jiaowu
sudo chmod 0750 /opt/jiaowu
sudo chmod 0750 /opt/jiaowu/Jiaowu.Api
sudo chmod 0640 /opt/jiaowu/.env
sudo install -d \
--owner=jiaowu \
--group=jiaowu \
--mode=0700 \
/var/lib/jiaowu/backups
sudo apt-get install default-mysql-client
```
如果账号已存在,`useradd` 会报错,可以跳过该命令。RHEL 系发行版的 `nologin` 通常
@@ -297,6 +303,25 @@ Outbox 租约恢复改为按维护周期执行,避免积压发布时每条消
消息会根据 Outbox 状态和租约继续补投。迁移服务应先应用
`BackgroundJobOutbox` 数据库迁移,再启动应用实例。
### 运维与审计控制台
超级管理员可从“组织与权限 → 运维与审计”查询写操作日志、三类失败后台任务、数据库、
缓存与任务通道健康状态,并查看由 5xx、失败/重试任务、健康探针和备份时效汇总出的异常
告警。查询接口和备份操作均在后端强制要求 `SuperAdmin`,不能只依赖前端菜单隐藏。
SQLite 开发环境直接使用在线备份 API。MySQL 环境需要在服务器安装 `mysqldump`
`mysql`(容器镜像已包含对应的 `mariadb-dump``mariadb` 客户端),并配置独立的
`ConnectionStrings__OperationsMySql`。该账号不得复用日常业务账号:它需要读取业务库,
并只应被授权创建和删除名称为 `jiaowu_restore_drill_*` 的临时演练库。恢复演练不会覆盖
当前业务库,流程是“校验 SHA-256 → 恢复到随机临时库 → 检查表结构 → 删除临时库”。
备份目录必须是仅服务账号可写的持久化目录。示例配置使用
`/var/lib/jiaowu/backups`;Compose 已挂载独立命名卷。启用 MySQL TLS 时,还要通过
`Operations__MySqlAdditionalArguments__N` 传入与所选命令行客户端匹配的 CA 与主机名
校验参数。例如 Oracle MySQL 客户端使用 `--ssl-mode=VERIFY_IDENTITY`
`--ssl-ca=/etc/jiaowu/mysql-ca.pem`,容器内 MariaDB 客户端使用 `--ssl`
`--ssl-ca=...``--ssl-verify-server-cert`
## 跨平台发布与 Docker
`.gitea/workflows/publish.yml` 只在推送 `v*` 标签或手动运行时执行,普通分支 push
+6 -2
View File
@@ -17,11 +17,15 @@ services:
options:
max-size: "10m"
max-file: "3"
volumes:
- jiaowu-backups:/var/lib/jiaowu/backups
# 如果 .env 中的 SslCa=/etc/jiaowu/mysql-ca.pem,请把 CA 放到
# ./certs/mysql-ca.pem,并取消下面三行注释
# volumes:
# ./certs/mysql-ca.pem,并在上面的 volumes 中追加以下四行
# - type: bind
# source: ./certs/mysql-ca.pem
# target: /etc/jiaowu/mysql-ca.pem
# read_only: true
volumes:
jiaowu-backups:
+3
View File
@@ -138,6 +138,8 @@ services:
- "${JIAOWU_PORT:-8080}:8080"
restart: unless-stopped
init: true
volumes:
- backup-data:/var/lib/jiaowu/backups
logging: *json-logging
# 工具型一次性服务:普通 docker compose up 不会执行它。
@@ -161,3 +163,4 @@ services:
volumes:
mysql-data:
rabbitmq-data:
backup-data:
@@ -0,0 +1,621 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Graduation;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Controllers;
[ApiController]
[Authorize(Roles = SystemRoles.Student)]
[Route("api/student/academic-planning")]
public sealed class AcademicPlanningController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
[HttpGet]
public async Task<ActionResult> Get(CancellationToken cancellationToken)
{
var loaded = await LoadAsync(cancellationToken);
if (loaded.Error is not null) return loaded.Error;
var context = loaded.Context!;
var completion = CurriculumCompletionRules.Evaluate(
context.Plan.Modules,
context.PassedCourseIds);
var planCompletedCredits = context.Courses
.Where(x => context.PassedCourseIds.Contains(x.CourseId))
.Sum(x => x.Credits);
var suggestionIds = AcademicPlanningRules.SuggestNextSemester(
context.CourseSnapshots,
context.PassedCourseIds,
context.InProgressCourseIds,
context.NextSemester);
var suggestionIdSet = suggestionIds.ToHashSet();
var latestAudit = await db.GraduationAuditResults.AsNoTracking()
.Where(x =>
x.StudentId == context.Student.Id &&
x.GraduationAuditBatch!.Status ==
GraduationAuditBatchStatus.Published)
.OrderByDescending(x => x.GraduationAuditBatch!.GraduationYear)
.ThenByDescending(x => x.GraduationAuditBatch!.PublishedAt)
.Select(x => new
{
BatchName = x.GraduationAuditBatch!.Name,
x.GraduationAuditBatch.GraduationYear,
x.RequiredCredits,
x.EarnedCredits,
x.RequiredCourseCount,
x.PassedRequiredCourseCount,
x.FailedCourseCount,
x.MissingCourseNames,
x.Conclusion,
x.IsOverridden,
x.ReviewComment,
x.GraduationAuditBatch.PublishedAt
})
.FirstOrDefaultAsync(cancellationToken);
var endSemester = Math.Max(
context.Plan.Major!.SchoolingYears * 2,
context.NextSemester + 3);
return Ok(new
{
Student = new
{
context.Student.Id,
context.Student.StudentNumber,
context.Student.Name,
context.Student.EnrollmentYear,
context.Student.Status,
ClassName = context.Student.AdministrativeClass!.Name,
MajorName = context.Student.AdministrativeClass.Major!.Name,
CollegeName =
context.Student.AdministrativeClass.Major.College!.Name
},
Plan = new
{
context.Plan.Id,
context.Plan.Name,
context.Plan.Version,
context.Plan.TotalCredits,
SchoolingYears = context.Plan.Major.SchoolingYears,
CurrentSemester = context.CurrentSemester,
NextSemester = context.NextSemester,
StandardGraduationSemester =
context.Plan.Major.SchoolingYears * 2
},
Baseline = new
{
EarnedCredits = context.EarnedCredits,
PlanCompletedCredits = planCompletedCredits,
CreditGap = Math.Max(
context.Plan.TotalCredits - context.EarnedCredits,
0),
CompletionRate = context.Plan.TotalCredits <= 0
? 0
: Math.Min(100, Math.Round(
planCompletedCredits / context.Plan.TotalCredits * 100,
1)),
completion.RequirementCount,
completion.PassedRequirementCount,
completion.MissingRequirements,
InProgressCredits = context.Courses
.Where(x => context.InProgressCourseIds.Contains(x.CourseId))
.Sum(x => x.Credits)
},
Terms = Enumerable.Range(
context.NextSemester,
endSemester - context.NextSemester + 1)
.Select(semester => new
{
Semester = semester,
Label = FormatSemester(
context.Student.EnrollmentYear,
semester),
IsBeyondStandard =
semester > context.Plan.Major.SchoolingYears * 2
}),
Courses = context.Courses
.OrderBy(x => x.RecommendedSemester)
.ThenBy(x => x.CourseCode)
.Select(course => new
{
course.CourseId,
course.CourseCode,
course.CourseName,
course.Credits,
course.RecommendedSemester,
course.Type,
course.ModuleCode,
course.ModuleName,
Status = context.StatusByCourse[course.CourseId],
IsSuggested = suggestionIdSet.Contains(course.CourseId),
Prerequisites = course.Prerequisites.Select(item => new
{
item.CourseId,
item.CourseCode,
item.CourseName,
IsCompleted =
context.PassedCourseIds.Contains(item.CourseId),
IsInProgress =
context.InProgressCourseIds.Contains(item.CourseId)
})
}),
NextSemesterSuggestion = suggestionIds.Select(courseId =>
{
var course = context.CourseById[courseId];
return new
{
course.CourseId,
course.CourseCode,
course.CourseName,
course.Credits,
course.Type,
Reason = course.Type == CurriculumCourseType.Required &&
course.RecommendedSemester <= context.NextSemester
? "计划学期已到,且先修条件已满足"
: course.Type == CurriculumCourseType.Required
? "必修课程,按培养方案顺序推进"
: "用于补足培养模块与总学分"
};
}),
LatestGraduationAudit = latestAudit,
Assumptions = new[]
{
"模拟课程按顺利通过计算,不会写入成绩或正式选课。",
$"预计毕业学期按每学期最多 {AcademicPlanningRules.RecommendedSemesterCreditLimit:0} 学分估算。",
"当前在读课程按本学期顺利完成计入预测。"
}
});
}
[HttpPost("simulate")]
public async Task<ActionResult> Simulate(
AcademicPlanningRequest request,
CancellationToken cancellationToken)
{
var loaded = await LoadAsync(cancellationToken);
if (loaded.Error is not null) return loaded.Error;
var context = loaded.Context!;
var selections = request.Terms
.SelectMany(term => term.CourseIds.Select(courseId => new
{
term.Semester,
CourseId = courseId
}))
.ToList();
var duplicate = selections
.GroupBy(x => x.CourseId)
.FirstOrDefault(group => group.Count() > 1);
if (duplicate is not null)
return ValidationProblem("同一门课程不能安排在多个学期。");
if (request.Terms.Any(x =>
x.Semester < context.NextSemester ||
x.Semester > context.NextSemester + 12))
return ValidationProblem("模拟学期超出了可规划范围。");
var invalidCourse = selections.FirstOrDefault(x =>
!context.CourseById.ContainsKey(x.CourseId));
if (invalidCourse is not null)
return ValidationProblem("模拟计划包含不属于当前培养方案的课程。");
var alreadyHandled = selections.FirstOrDefault(x =>
context.PassedCourseIds.Contains(x.CourseId) ||
context.InProgressCourseIds.Contains(x.CourseId));
if (alreadyHandled is not null)
return ValidationProblem("已完成或当前在读课程无需重复安排。");
var plannedSemesters = selections.ToDictionary(
x => x.CourseId,
x => x.Semester);
var projectedCourseIds = context.PassedCourseIds
.Concat(context.InProgressCourseIds)
.Concat(plannedSemesters.Keys)
.ToHashSet();
var completion = CurriculumCompletionRules.Evaluate(
context.Plan.Modules,
projectedCourseIds);
var addedCredits = context.Courses
.Where(x =>
!context.PassedCourseIds.Contains(x.CourseId) &&
(context.InProgressCourseIds.Contains(x.CourseId) ||
plannedSemesters.ContainsKey(x.CourseId)))
.Sum(x => x.Credits);
var projectedEarnedCredits = context.EarnedCredits + addedCredits;
var creditGap = Math.Max(
context.Plan.TotalCredits - projectedEarnedCredits,
0);
var prerequisiteIssues = AcademicPlanningRules.FindPrerequisiteIssues(
context.CourseSnapshots,
context.PassedCourseIds,
context.InProgressCourseIds,
plannedSemesters);
var conflicts = prerequisiteIssues.Select(issue =>
{
var course = context.CourseById[issue.CourseId];
var prerequisite = context.AllCourseLabels.GetValueOrDefault(
issue.PrerequisiteCourseId,
new CourseLabel(
issue.PrerequisiteCourseId,
"未知课程",
"未找到的先修课程"));
return new
{
Type = "Prerequisite",
issue.CourseId,
course.CourseName,
PrerequisiteCourseId = prerequisite.CourseId,
PrerequisiteCourseName = prerequisite.CourseName,
issue.PlannedSemester,
issue.PrerequisitePlannedSemester,
Message = issue.PrerequisitePlannedSemester.HasValue
? $"《{prerequisite.CourseName}》必须安排在《{course.CourseName}》之前。"
: $"《{course.CourseName}》的先修课程《{prerequisite.CourseName}》尚未完成或安排。"
};
}).ToList();
var workloadWarnings = request.Terms
.Select(term => new
{
term.Semester,
Credits = term.CourseIds.Sum(courseId =>
context.CourseById.GetValueOrDefault(courseId)?.Credits ?? 0)
})
.Where(x =>
x.Credits > AcademicPlanningRules.HeavySemesterCreditLimit)
.Select(x => new
{
Type = "Workload",
x.Semester,
x.Credits,
Message =
$"第 {x.Semester} 学期安排了 {x.Credits:0.#} 学分,超过建议上限 {AcademicPlanningRules.HeavySemesterCreditLimit:0.#} 学分。"
})
.ToList();
var timingWarnings = selections
.Select(item => new
{
item.Semester,
Course = context.CourseById[item.CourseId]
})
.Where(x => x.Semester < x.Course.RecommendedSemester)
.Select(x => new
{
Type = "EarlyCourse",
x.Course.CourseId,
x.Course.CourseName,
x.Semester,
x.Course.RecommendedSemester,
Message =
$"《{x.Course.CourseName}》早于培养方案建议学期修读,请确认课程开设条件。"
})
.ToList();
var unresolvedFailedCourseCount = context.FailedCourseIds.Count(
courseId => !projectedCourseIds.Contains(courseId));
var conclusion = GraduationAuditRules.Evaluate(
true,
context.Student.Status,
context.Plan.TotalCredits,
projectedEarnedCredits,
completion.RequirementCount,
completion.PassedRequirementCount,
unresolvedFailedCourseCount);
var latestPlannedSemester = plannedSemesters.Count == 0
? context.NextSemester - 1
: plannedSemesters.Values.Max();
var estimatedSemester =
AcademicPlanningRules.EstimateCompletionSemester(
context.NextSemester,
latestPlannedSemester,
creditGap,
completion.RequirementCount -
completion.PassedRequirementCount);
var remainingRequiredSemesterFloor = context.Courses
.Where(course =>
course.Type == CurriculumCourseType.Required &&
!projectedCourseIds.Contains(course.CourseId))
.Select(course => course.RecommendedSemester)
.DefaultIfEmpty(estimatedSemester)
.Max();
estimatedSemester = Math.Max(
estimatedSemester,
remainingRequiredSemesterFloor);
var planCompletedCredits = context.Courses
.Where(x => projectedCourseIds.Contains(x.CourseId))
.Sum(x => x.Credits);
return Ok(new
{
Projected = new
{
EarnedCredits = projectedEarnedCredits,
PlanCompletedCredits = planCompletedCredits,
CreditGap = creditGap,
CompletionRate = context.Plan.TotalCredits <= 0
? 0
: Math.Min(100, Math.Round(
planCompletedCredits / context.Plan.TotalCredits * 100,
1)),
completion.RequirementCount,
completion.PassedRequirementCount,
completion.MissingRequirements,
UnresolvedFailedCourseCount = unresolvedFailedCourseCount,
GraduationConclusion = conclusion,
EstimatedGraduationSemester = estimatedSemester,
EstimatedGraduationTerm = FormatSemester(
context.Student.EnrollmentYear,
estimatedSemester),
IsBeyondStandard =
estimatedSemester >
context.Plan.Major!.SchoolingYears * 2
},
Conflicts = conflicts,
Warnings = workloadWarnings.Cast<object>()
.Concat(timingWarnings)
.ToList(),
TermSummaries = request.Terms
.OrderBy(x => x.Semester)
.Select(term => new
{
term.Semester,
Label = FormatSemester(
context.Student.EnrollmentYear,
term.Semester),
CourseCount = term.CourseIds.Count,
Credits = term.CourseIds.Sum(courseId =>
context.CourseById.GetValueOrDefault(courseId)?.Credits ??
0)
})
});
}
private async Task<LoadResult> LoadAsync(CancellationToken cancellationToken)
{
var userId = currentUserDataScope.Current.UserId;
var student = await db.Students.AsNoTracking()
.Include(x => x.AdministrativeClass)
.ThenInclude(x => x!.Major)
.ThenInclude(x => x!.College)
.FirstOrDefaultAsync(x => x.UserId == userId, cancellationToken);
if (student is null)
return new LoadResult(
null,
ConflictProblem("当前账号尚未关联学生档案。"));
var plan = await db.CurriculumPlans.AsNoTracking()
.AsSplitQuery()
.Include(x => x.Major)
.Include(x => x.Modules)
.ThenInclude(x => x.Courses)
.ThenInclude(x => x.Course)
.ThenInclude(x => x!.Prerequisites)
.ThenInclude(x => x.PrerequisiteCourse)
.Where(x =>
x.MajorId == student.AdministrativeClass!.MajorId &&
x.EffectiveGrade == student.EnrollmentYear &&
x.Status == CurriculumPlanStatus.Published)
.OrderByDescending(x => x.PublishedAt)
.FirstOrDefaultAsync(cancellationToken);
if (plan is null)
return new LoadResult(
null,
ConflictProblem(
$"{student.EnrollmentYear} 级{student.AdministrativeClass!.Major!.Name}尚未发布培养方案。"));
var courses = plan.Modules
.SelectMany(module => module.Courses.Select(item =>
new PlanningCourse(
item.CourseId,
item.Course!.Code,
item.Course.Name,
item.Course.Credits,
item.RecommendedSemester,
item.Type,
module.Code,
module.Name,
item.Course.Prerequisites
.Select(prerequisite => new CourseLabel(
prerequisite.PrerequisiteCourseId,
prerequisite.PrerequisiteCourse!.Code,
prerequisite.PrerequisiteCourse.Name))
.OrderBy(x => x.CourseCode)
.ToList())))
.GroupBy(x => x.CourseId)
.Select(group => group.First())
.ToList();
var planCourseIds = courses.Select(x => x.CourseId).ToArray();
var gradeAttempts = await db.GradeRecords.AsNoTracking()
.Where(x =>
x.StudentId == student.Id &&
x.GradeSheet!.Status == GradeSheetStatus.Published)
.Select(x => new GradeAttempt(
x.GradeSheet!.TeachingTask!.CourseId,
x.GradeSheet.TeachingTask.Course!.Credits,
x.TotalScore,
x.ExamStatus))
.ToListAsync(cancellationToken);
var passedCourseIds = gradeAttempts
.Where(x => StudentCourseProgressRules.IsPassed(
new StudentCourseAttemptSnapshot(x.TotalScore, x.ExamStatus)))
.Select(x => x.CourseId)
.Distinct()
.ToHashSet();
var failedCourseIds = gradeAttempts
.GroupBy(x => x.CourseId)
.Where(group => !group.Any(x =>
StudentCourseProgressRules.IsPassed(
new StudentCourseAttemptSnapshot(
x.TotalScore,
x.ExamStatus))))
.Select(group => group.Key)
.ToHashSet();
var earnedCredits = gradeAttempts
.Where(x => passedCourseIds.Contains(x.CourseId))
.GroupBy(x => x.CourseId)
.Sum(group => group.Max(x => x.Credits));
var inProgressCourseIds = await db.TeachingTasks.AsNoTracking()
.Where(task =>
task.Status == TeachingTaskStatus.Published &&
task.AcademicTerm!.IsCurrent &&
(task.Classes.Any(item =>
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
task.Id)))
.Select(x => x.CourseId)
.Distinct()
.ToListAsync(cancellationToken);
var inProgressSet = inProgressCourseIds
.Where(planCourseIds.Contains)
.Where(courseId => !passedCourseIds.Contains(courseId))
.ToHashSet();
var attemptsByCourse = gradeAttempts
.Where(x => planCourseIds.Contains(x.CourseId))
.GroupBy(x => x.CourseId)
.ToDictionary(x => x.Key, x => x.ToList());
var statuses = courses.ToDictionary(
x => x.CourseId,
x => StudentCourseProgressRules.Evaluate(
attemptsByCourse.GetValueOrDefault(x.CourseId, [])
.Select(attempt => new StudentCourseAttemptSnapshot(
attempt.TotalScore,
attempt.ExamStatus)),
inProgressSet.Contains(x.CourseId)).Status);
var currentTerm = await db.AcademicTerms.AsNoTracking()
.Where(x => x.IsCurrent)
.OrderByDescending(x => x.StartDate)
.FirstOrDefaultAsync(cancellationToken);
var currentSemester = CalculateCurrentSemester(
student.EnrollmentYear,
currentTerm);
var snapshots = courses.Select(x =>
new AcademicPlanningCourseSnapshot(
x.CourseId,
x.CourseName,
x.Credits,
x.RecommendedSemester,
x.Type,
x.Prerequisites.Select(p => p.CourseId).ToArray()))
.ToList();
var allLabels = courses
.Select(x => new CourseLabel(
x.CourseId,
x.CourseCode,
x.CourseName))
.Concat(courses.SelectMany(x => x.Prerequisites))
.GroupBy(x => x.CourseId)
.ToDictionary(x => x.Key, x => x.First());
return new LoadResult(
new PlanningContext(
student,
plan,
courses,
courses.ToDictionary(x => x.CourseId),
snapshots,
allLabels,
passedCourseIds,
inProgressSet,
failedCourseIds,
statuses,
earnedCredits,
currentSemester,
currentSemester + 1),
null);
}
private static int CalculateCurrentSemester(
int enrollmentYear,
AcademicTerm? currentTerm)
{
if (currentTerm is not null)
{
return Math.Max(
1,
currentTerm.Season == TermSeason.Autumn
? (currentTerm.StartDate.Year - enrollmentYear) * 2 + 1
: (currentTerm.StartDate.Year - enrollmentYear - 1) * 2 + 2);
}
var today = DateOnly.FromDateTime(DateTime.Today);
return Math.Max(
1,
today.Month >= 8
? (today.Year - enrollmentYear) * 2 + 1
: (today.Year - enrollmentYear - 1) * 2 + 2);
}
private static string FormatSemester(int enrollmentYear, int semester)
{
var startYear = enrollmentYear + (semester - 1) / 2;
var season = semester % 2 == 1 ? "秋季学期" : "春季学期";
return $"{startYear}—{startYear + 1} 学年{season}";
}
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
Title = "无法进行学业规划",
Detail = detail,
Status = StatusCodes.Status409Conflict
});
private sealed record GradeAttempt(
Guid CourseId,
decimal Credits,
decimal? TotalScore,
GradeExamStatus ExamStatus);
private sealed record CourseLabel(
Guid CourseId,
string CourseCode,
string CourseName);
private sealed record PlanningCourse(
Guid CourseId,
string CourseCode,
string CourseName,
decimal Credits,
int RecommendedSemester,
CurriculumCourseType Type,
string ModuleCode,
string ModuleName,
IReadOnlyList<CourseLabel> Prerequisites);
private sealed record PlanningContext(
Student Student,
CurriculumPlan Plan,
IReadOnlyList<PlanningCourse> Courses,
IReadOnlyDictionary<Guid, PlanningCourse> CourseById,
IReadOnlyList<AcademicPlanningCourseSnapshot> CourseSnapshots,
IReadOnlyDictionary<Guid, CourseLabel> AllCourseLabels,
IReadOnlySet<Guid> PassedCourseIds,
IReadOnlySet<Guid> InProgressCourseIds,
IReadOnlySet<Guid> FailedCourseIds,
IReadOnlyDictionary<Guid, StudentCourseProgressStatus> StatusByCourse,
decimal EarnedCredits,
int CurrentSemester,
int NextSemester);
private sealed record LoadResult(
PlanningContext? Context,
ActionResult? Error);
}
public sealed record AcademicPlanningRequest(
IReadOnlyCollection<AcademicPlanningTermRequest> Terms);
public sealed record AcademicPlanningTermRequest(
[Range(1, 30)] int Semester,
IReadOnlyCollection<Guid> CourseIds);
@@ -84,6 +84,17 @@ public sealed class CoursesController(
x.Nature,
x.AssessmentMethod,
x.Description,
PrerequisiteCourseIds = x.Prerequisites
.OrderBy(item => item.PrerequisiteCourse!.Code)
.Select(item => item.PrerequisiteCourseId),
Prerequisites = x.Prerequisites
.OrderBy(item => item.PrerequisiteCourse!.Code)
.Select(item => new
{
item.PrerequisiteCourseId,
item.PrerequisiteCourse!.Code,
item.PrerequisiteCourse.Name
}),
x.IsEnabled,
x.SortOrder,
x.CreatedAt,
@@ -146,9 +157,6 @@ public sealed class CoursesController(
CourseRequest request,
CancellationToken cancellationToken)
{
var validation = await ValidateAsync(request, cancellationToken);
if (validation is not null) return validation;
var entity = new Course
{
Code = request.Code.Trim(),
@@ -166,6 +174,16 @@ public sealed class CoursesController(
IsEnabled = request.IsEnabled,
SortOrder = request.SortOrder
};
var validation = await ValidateAsync(entity.Id, request, cancellationToken);
if (validation is not null) return validation;
entity.Prerequisites = NormalizePrerequisiteIds(request)
.Select(prerequisiteId => new CoursePrerequisite
{
CourseId = entity.Id,
PrerequisiteCourseId = prerequisiteId
})
.ToList();
db.Courses.Add(entity);
return await SaveAsync(entity.Id, true, cancellationToken);
}
@@ -177,10 +195,12 @@ public sealed class CoursesController(
CourseRequest request,
CancellationToken cancellationToken)
{
var entity = await db.Courses.FindAsync([id], cancellationToken);
var entity = await db.Courses
.Include(x => x.Prerequisites)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
if (entity is null) return NotFound();
if (!CanManage(entity.CollegeId, entity.Nature)) return Forbid();
var validation = await ValidateAsync(request, cancellationToken);
var validation = await ValidateAsync(id, request, cancellationToken);
if (validation is not null) return validation;
entity.Code = request.Code.Trim();
@@ -197,6 +217,19 @@ public sealed class CoursesController(
entity.Description = Normalize(request.Description);
entity.IsEnabled = request.IsEnabled;
entity.SortOrder = request.SortOrder;
var prerequisiteIds = NormalizePrerequisiteIds(request).ToHashSet();
db.CoursePrerequisites.RemoveRange(
entity.Prerequisites.Where(x =>
!prerequisiteIds.Contains(x.PrerequisiteCourseId)));
foreach (var prerequisiteId in prerequisiteIds.Except(
entity.Prerequisites.Select(x => x.PrerequisiteCourseId)))
{
entity.Prerequisites.Add(new CoursePrerequisite
{
CourseId = entity.Id,
PrerequisiteCourseId = prerequisiteId
});
}
return await SaveAsync(entity.Id, false, cancellationToken);
}
@@ -212,6 +245,7 @@ public sealed class CoursesController(
}
private async Task<ActionResult?> ValidateAsync(
Guid courseId,
CourseRequest request,
CancellationToken cancellationToken)
{
@@ -224,9 +258,62 @@ public sealed class CoursesController(
return ValidationProblem("所选课程分类不存在或已停用。");
if (request.LectureHours + request.PracticeHours > request.TotalHours)
return ValidationProblem("讲授学时与实践学时之和不能超过总学时。");
var prerequisiteIds = NormalizePrerequisiteIds(request).ToArray();
if (prerequisiteIds.Contains(courseId))
return ValidationProblem("课程不能把自身设置为先修课程。");
var accessiblePrerequisiteCount = await ScopedCourses()
.CountAsync(x =>
prerequisiteIds.Contains(x.Id) &&
x.IsEnabled,
cancellationToken);
if (accessiblePrerequisiteCount != prerequisiteIds.Length)
return ValidationProblem("包含不存在、已停用或不在当前数据范围内的先修课程。");
if (await CreatesPrerequisiteCycleAsync(
courseId,
prerequisiteIds,
cancellationToken))
return ValidationProblem("先修关系不能形成循环依赖。");
return null;
}
private async Task<bool> CreatesPrerequisiteCycleAsync(
Guid courseId,
IReadOnlyCollection<Guid> prerequisiteIds,
CancellationToken cancellationToken)
{
if (prerequisiteIds.Count == 0) return false;
var edges = await db.CoursePrerequisites.AsNoTracking()
.Where(x => x.CourseId != courseId)
.Select(x => new { x.CourseId, x.PrerequisiteCourseId })
.ToListAsync(cancellationToken);
var prerequisitesByCourse = edges
.GroupBy(x => x.CourseId)
.ToDictionary(
group => group.Key,
group => group.Select(x => x.PrerequisiteCourseId).ToArray());
foreach (var prerequisiteId in prerequisiteIds)
{
var pending = new Stack<Guid>();
var visited = new HashSet<Guid>();
pending.Push(prerequisiteId);
while (pending.TryPop(out var candidate))
{
if (candidate == courseId) return true;
if (!visited.Add(candidate) ||
!prerequisitesByCourse.TryGetValue(candidate, out var next))
continue;
foreach (var item in next) pending.Push(item);
}
}
return false;
}
private static IEnumerable<Guid> NormalizePrerequisiteIds(CourseRequest request) =>
(request.PrerequisiteCourseIds ?? []).Distinct();
private IQueryable<Course> ScopedCourses()
{
var scope = currentUserDataScope.Current;
@@ -307,4 +394,5 @@ public sealed record CourseRequest(
AssessmentMethod AssessmentMethod,
[MaxLength(1000)] string? Description,
bool IsEnabled,
int SortOrder);
int SortOrder,
IReadOnlyCollection<Guid>? PrerequisiteCourseIds = null);
+283 -72
View File
@@ -1,11 +1,10 @@
using System.Text.Json;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Jiaowu.Api.Controllers;
@@ -14,89 +13,301 @@ namespace Jiaowu.Api.Controllers;
[Route("api/dashboard")]
public sealed class DashboardController(
AppDbContext db,
IAppCache appCache,
IOptions<JsonOptions> jsonOptions) : ControllerBase
ICurrentUserDataScope currentUserDataScope) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<object>> Get(CancellationToken cancellationToken)
public async Task<ActionResult<DashboardResponse>> Get(
CancellationToken cancellationToken)
{
var response = await appCache.GetOrCreateAsync(
AppCacheKeys.Dashboard,
LoadAsync,
AppCacheProfile.Analytics,
[AppCacheTags.Analytics],
cancellationToken);
return response;
}
var scope = currentUserDataScope.Current;
Guid? restrictedCollegeId = scope.Scope == DataScope.All
? null
: scope.CollegeId ?? Guid.Empty;
var collegeName = restrictedCollegeId.HasValue &&
restrictedCollegeId.Value != Guid.Empty
? await db.Colleges.AsNoTracking()
.Where(x => x.Id == restrictedCollegeId.Value)
.Select(x => x.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
private async Task<JsonElement> LoadAsync(CancellationToken cancellationToken)
{
var currentTerm = await db.AcademicTerms
.AsNoTracking()
.Where(x => x.IsCurrent)
.Select(x => new { x.Id, x.Name, x.StartDate, x.EndDate })
.Select(x => new DashboardTerm(
x.Id,
x.Name,
x.StartDate,
x.EndDate))
.FirstOrDefaultAsync(cancellationToken);
var currentTermId = currentTerm?.Id;
return JsonSerializer.SerializeToElement(
new
{
CurrentTerm = currentTerm,
Counts = new
{
Campuses = await db.Campuses.CountAsync(cancellationToken),
Colleges = await db.Colleges.CountAsync(cancellationToken),
Majors = await db.Majors.CountAsync(cancellationToken),
Classes = await db.AdministrativeClasses.CountAsync(cancellationToken),
Classrooms = await db.Classrooms.CountAsync(cancellationToken),
Teachers = await db.Teachers.CountAsync(cancellationToken),
Students = await db.Students.CountAsync(cancellationToken),
Courses = await db.Courses.CountAsync(cancellationToken),
CurriculumPlans = await db.CurriculumPlans.CountAsync(cancellationToken),
TeachingTasks = await db.TeachingTasks.CountAsync(cancellationToken),
SchedulePlans = await db.SchedulePlans.CountAsync(cancellationToken),
CourseSelectionRounds = await db.CourseSelectionRounds
.CountAsync(cancellationToken),
CourseSelectionOfferings = await db.CourseSelectionOfferings
.CountAsync(cancellationToken),
CourseEnrollments = await db.CourseEnrollments
.CountAsync(
x => x.Status == CourseEnrollmentStatus.Enrolled,
cancellationToken),
GradeSheets = await db.GradeSheets.CountAsync(cancellationToken),
PublishedGradeSheets = await db.GradeSheets.CountAsync(
var students = db.Students.AsNoTracking()
.Where(x =>
!restrictedCollegeId.HasValue ||
x.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value);
var teachers = db.Teachers.AsNoTracking()
.Where(x =>
!restrictedCollegeId.HasValue ||
x.CollegeId == restrictedCollegeId.Value);
var courses = db.Courses.AsNoTracking()
.Where(x =>
!restrictedCollegeId.HasValue ||
x.CollegeId == restrictedCollegeId.Value);
var teachingTasks = db.TeachingTasks.AsNoTracking()
.Where(x =>
currentTermId.HasValue &&
x.AcademicTermId == currentTermId.Value &&
(!restrictedCollegeId.HasValue ||
x.Course!.CollegeId == restrictedCollegeId.Value));
var gradeSheets = db.GradeSheets.AsNoTracking()
.Where(x =>
currentTermId.HasValue &&
x.TeachingTask!.AcademicTermId == currentTermId.Value &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask.Course!.CollegeId ==
restrictedCollegeId.Value));
var enrollments = db.CourseEnrollments.AsNoTracking()
.Where(x =>
currentTermId.HasValue &&
x.Status == CourseEnrollmentStatus.Enrolled &&
x.CourseSelectionOffering!.CourseSelectionRound!
.AcademicTermId == currentTermId.Value &&
(!restrictedCollegeId.HasValue ||
x.CourseSelectionOffering.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var taskCount = await teachingTasks.CountAsync(cancellationToken);
var publishedTaskCount = await teachingTasks.CountAsync(
x => x.Status == TeachingTaskStatus.Published,
cancellationToken);
var scheduledTaskCount = currentTermId.HasValue
? await db.ScheduleEntries.AsNoTracking()
.Where(x =>
x.SchedulePlan!.AcademicTermId == currentTermId.Value &&
x.SchedulePlan.Status == SchedulePlanStatus.Published &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value))
.Select(x => x.TeachingTaskId)
.Distinct()
.CountAsync(cancellationToken)
: 0;
var gradeSheetCount = await gradeSheets.CountAsync(cancellationToken);
var publishedGradeSheetCount = await gradeSheets.CountAsync(
x => x.Status == GradeSheetStatus.Published,
cancellationToken);
var counts = new DashboardCounts(
await students.CountAsync(
x => x.Status == StudentStatus.Active,
cancellationToken),
GradeRecords = await db.GradeRecords.CountAsync(cancellationToken),
ExamPlans = await db.ExamPlans.CountAsync(cancellationToken),
ExamSessions = await db.ExamSessions.CountAsync(cancellationToken),
StudentStatusChanges = await db.StudentStatusChanges
.CountAsync(cancellationToken),
PendingStudentStatusChanges = await db.StudentStatusChanges.CountAsync(
x => x.State == StudentStatusChangeState.Submitted ||
x.State == StudentStatusChangeState.CounselorApproved ||
x.State == StudentStatusChangeState.CollegeApproved,
await teachers.CountAsync(
x => x.Status == TeacherStatus.Active,
cancellationToken),
GraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(cancellationToken),
PublishedGraduationAuditBatches = await db.GraduationAuditBatches
.CountAsync(
x => x.Status == GraduationAuditBatchStatus.Published,
await courses.CountAsync(
x => x.IsEnabled,
cancellationToken),
DegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(cancellationToken),
PublishedDegreeAwardBatches = await db.DegreeAwardBatches
.CountAsync(
x => x.Status == DegreeAwardBatchStatus.Published,
taskCount,
publishedTaskCount,
scheduledTaskCount,
await enrollments.CountAsync(cancellationToken),
gradeSheetCount,
publishedGradeSheetCount,
await gradeSheets.CountAsync(
x => x.Status == GradeSheetStatus.Submitted,
cancellationToken),
GraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(cancellationToken),
OpenGraduationClearanceBatches = await db.GraduationClearanceBatches
.CountAsync(
x => x.Status == GraduationClearanceBatchStatus.Open,
cancellationToken),
Users = await db.Users.CountAsync(cancellationToken)
currentTermId.HasValue
? await db.CourseSelectionRounds.AsNoTracking().CountAsync(
x => x.AcademicTermId == currentTermId.Value &&
x.Status == CourseSelectionRoundStatus.Open,
cancellationToken)
: 0);
var pending = await LoadPendingAsync(
scope,
restrictedCollegeId,
cancellationToken);
return Ok(new DashboardResponse(
BuildAudience(scope, collegeName),
currentTerm,
counts,
pending,
DateTime.UtcNow));
}
},
jsonOptions.Value.JsonSerializerOptions);
private async Task<DashboardPending> LoadPendingAsync(
CurrentUserScope scope,
Guid? restrictedCollegeId,
CancellationToken cancellationToken)
{
var isSchoolManager =
scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin);
var isCollegeManager =
!isSchoolManager && scope.IsInRole(SystemRoles.CollegeAdmin);
if (!isSchoolManager && !isCollegeManager)
return new DashboardPending(0, 0, 0, 0, 0, 0, 0);
var teacherApplications = db.TeacherCourseApplications.AsNoTracking()
.Where(x =>
x.Status == TeacherCourseApplicationStatus.Pending &&
(!restrictedCollegeId.HasValue ||
x.Teacher!.CollegeId == restrictedCollegeId.Value));
var gradeSheets = db.GradeSheets.AsNoTracking()
.Where(x =>
x.Status == GradeSheetStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var courseAdjustments = db.CourseAdjustments.AsNoTracking()
.Where(x =>
x.Status == CourseAdjustmentStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var studentStatusChanges = db.StudentStatusChanges.AsNoTracking()
.Where(x =>
x.State == (isCollegeManager
? StudentStatusChangeState.CounselorApproved
: StudentStatusChangeState.CollegeApproved) &&
(!restrictedCollegeId.HasValue ||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value));
var gradeModifications = db.GradeModifications.AsNoTracking()
.Where(x =>
x.Status == (isCollegeManager
? GradeModificationStatus.TeacherSubmitted
: GradeModificationStatus.CollegeApproved) &&
(!restrictedCollegeId.HasValue ||
x.GradeRecord!.GradeSheet!.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value));
var generalApprovals =
await db.CourseExemptions.AsNoTracking().CountAsync(
x => x.Status == ApprovalStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken) +
await db.DeferredExams.AsNoTracking().CountAsync(
x => x.Status == ApprovalStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.TeachingTask!.Course!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken) +
await db.CourseSubstitutions.AsNoTracking().CountAsync(
x => x.Status == ApprovalStatus.Submitted &&
(!restrictedCollegeId.HasValue ||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken) +
await db.AttendanceRecords.AsNoTracking().CountAsync(
x => x.AppealStatus == AttendanceAppealStatus.Pending &&
(!restrictedCollegeId.HasValue ||
x.Student!.AdministrativeClass!.Major!.CollegeId ==
restrictedCollegeId.Value),
cancellationToken);
var classroomReservations = isCollegeManager &&
restrictedCollegeId.HasValue
? await db.ClassroomReservations.AsNoTracking().CountAsync(
x => x.Status == ClassroomReservationStatus.Submitted &&
x.ApplicantCollegeId == restrictedCollegeId.Value,
cancellationToken)
: 0;
return new DashboardPending(
await teacherApplications.CountAsync(cancellationToken),
await gradeSheets.CountAsync(cancellationToken),
await courseAdjustments.CountAsync(cancellationToken),
await studentStatusChanges.CountAsync(cancellationToken),
await gradeModifications.CountAsync(cancellationToken),
classroomReservations,
generalApprovals);
}
private static DashboardAudience BuildAudience(
CurrentUserScope scope,
string? collegeName)
{
if (scope.IsInRole(SystemRoles.SuperAdmin))
return new DashboardAudience(
"System",
"全域教务工作台",
"全校",
"统筹基础数据、教学运行与系统治理");
if (scope.IsInRole(SystemRoles.AcademicAdmin))
return new DashboardAudience(
"School",
"校级教务工作台",
"全校",
"聚焦跨学院教学运行与校级审核");
if (scope.IsInRole(SystemRoles.CollegeAdmin))
return new DashboardAudience(
"College",
"学院教务工作台",
collegeName ?? "本学院",
"聚焦本学院教学准备、过程审核与成绩归档");
if (scope.IsInRole(SystemRoles.Leader))
return new DashboardAudience(
"Leadership",
"教学运行观察台",
"全校",
"查看全校教学运行与质量数据");
if (scope.IsInRole(SystemRoles.Counselor))
return new DashboardAudience(
"Counselor",
"班级工作台",
collegeName ?? "所辖班级",
"处理学生过程管理与学业支持");
return new DashboardAudience(
"Teaching",
"教学工作台",
collegeName ?? "个人教学",
"查看课程运行并进入日常教学工作");
}
}
public sealed record DashboardResponse(
DashboardAudience Audience,
DashboardTerm? CurrentTerm,
DashboardCounts Counts,
DashboardPending Pending,
DateTime GeneratedAt);
public sealed record DashboardAudience(
string Level,
string Title,
string ScopeName,
string Description);
public sealed record DashboardTerm(
Guid Id,
string Name,
DateOnly StartDate,
DateOnly EndDate);
public sealed record DashboardCounts(
int Students,
int Teachers,
int Courses,
int TeachingTasks,
int PublishedTeachingTasks,
int ScheduledTeachingTasks,
int CourseEnrollments,
int GradeSheets,
int PublishedGradeSheets,
int SubmittedGradeSheets,
int OpenCourseSelectionRounds);
public sealed record DashboardPending(
int TeacherApplications,
int GradeSheets,
int CourseAdjustments,
int StudentStatusChanges,
int GradeModifications,
int ClassroomReservations,
int GeneralApprovals);
@@ -1,4 +1,7 @@
using System.ComponentModel.DataAnnotations;
using System.Data;
using System.Net;
using System.Text.RegularExpressions;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
@@ -14,8 +17,11 @@ namespace Jiaowu.Api.Controllers;
[Route("api/notifications")]
public sealed class NotificationsController(
AppDbContext db,
ICurrentUserDataScope currentUserDataScope) : ControllerBase
ICurrentUserDataScope currentUserDataScope,
ILogger<NotificationsController>? logger = null) : ControllerBase
{
private const int MaximumSelectedRecipients = 500;
private const int NotificationBatchSize = 300;
private const string Senders =
SystemRoles.SuperAdmin + "," +
SystemRoles.AcademicAdmin + "," +
@@ -106,17 +112,12 @@ public sealed class NotificationsController(
var scope = currentUserDataScope.Current;
if (IsSchoolAdministrator(scope))
{
var recipientCount = await db.Users.AsNoTracking()
.CountAsync(
x => x.IsEnabled && x.Id != scope.UserId,
cancellationToken);
return Ok(new
{
AudienceType = MessageAudienceType.School,
AudienceName = "全校已启用账号",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
return Ok(await BuildAdministratorComposerAsync(
scope,
null,
MessageAudienceType.School,
"全校已启用账号",
cancellationToken));
}
if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
@@ -138,19 +139,12 @@ public sealed class NotificationsController(
if (college is null)
return ConflictProblem("当前账号关联的学院不存在。");
var recipientCount = await db.Users.AsNoTracking()
.CountAsync(
x => x.IsEnabled &&
x.Id != scope.UserId &&
x.CollegeId == collegeId.Value,
cancellationToken);
return Ok(new
{
AudienceType = MessageAudienceType.College,
AudienceName = $"{college}全院成员",
RecipientCount = recipientCount,
TeachingTasks = Array.Empty<object>()
});
return Ok(await BuildAdministratorComposerAsync(
scope,
collegeId.Value,
MessageAudienceType.College,
$"{college}全院成员",
cancellationToken));
}
if (scope.IsInRole(SystemRoles.Teacher))
@@ -195,6 +189,114 @@ public sealed class NotificationsController(
return Forbid();
}
[HttpGet("recipients")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> GetRecipients(
Guid? collegeId,
string? role,
Guid? administrativeClassId,
Guid? teachingTaskId,
string? keyword,
int page = 1,
int pageSize = 30,
CancellationToken cancellationToken = default)
{
var scope = currentUserDataScope.Current;
if (scope.IsInRole(SystemRoles.Teacher) &&
!IsSchoolAdministrator(scope) &&
!scope.IsInRole(SystemRoles.CollegeAdmin) &&
!scope.IsInRole(SystemRoles.Counselor))
{
return Forbid();
}
var resolvedCollegeId = IsSchoolAdministrator(scope)
? null
: await ResolveSenderCollegeIdAsync(scope, cancellationToken);
if (!IsSchoolAdministrator(scope) && !resolvedCollegeId.HasValue)
return ConflictProblem("当前账号未关联学院,暂时无法确定收件人范围。");
var filter = new MessageRecipientFilter(
collegeId,
Normalize(role),
administrativeClassId,
teachingTaskId,
Normalize(keyword));
if (filter.Role is not null && !SystemRoles.All.Contains(filter.Role))
return ValidationProblem("所选身份类型无效。");
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 10, 100);
var source = ApplyRecipientFilter(
ScopedRecipientQuery(scope, resolvedCollegeId),
filter);
var total = await source.CountAsync(cancellationToken);
var users = await source
.OrderBy(x => x.DisplayName)
.ThenBy(x => x.UserName)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(user => new
{
user.Id,
user.DisplayName,
user.UserName,
user.StaffNumber,
CollegeName = db.Colleges
.Where(college => college.Id == user.CollegeId)
.Select(college => college.Name)
.FirstOrDefault(),
StudentNumber = db.Students
.Where(student => student.UserId == user.Id)
.Select(student => student.StudentNumber)
.FirstOrDefault(),
ClassName = db.Students
.Where(student => student.UserId == user.Id)
.Select(student => student.AdministrativeClass!.Name)
.FirstOrDefault(),
TeacherNumber = db.Teachers
.Where(teacher => teacher.UserId == user.Id)
.Select(teacher => teacher.TeacherNumber)
.FirstOrDefault()
})
.ToListAsync(cancellationToken);
var userIds = users.Select(x => x.Id).ToArray();
var roleRows = await db.UserRoles.AsNoTracking()
.Where(x => userIds.Contains(x.UserId))
.Join(
db.Roles.AsNoTracking(),
userRole => userRole.RoleId,
roleEntity => roleEntity.Id,
(userRole, roleEntity) => new
{
userRole.UserId,
RoleName = roleEntity.Name!
})
.ToListAsync(cancellationToken);
var rolesByUser = roleRows
.GroupBy(x => x.UserId)
.ToDictionary(
group => group.Key,
group => group.Select(x => x.RoleName).Distinct().ToArray());
return Ok(new
{
Items = users.Select(user => new
{
user.Id,
user.DisplayName,
user.UserName,
Number = user.StudentNumber ?? user.TeacherNumber ?? user.StaffNumber,
user.CollegeName,
user.ClassName,
Roles = rolesByUser.GetValueOrDefault(user.Id, [])
}),
Total = total,
Page = page,
PageSize = pageSize
});
}
[HttpPost("send")]
[Authorize(Roles = Senders)]
public async Task<ActionResult> Send(
@@ -205,8 +307,10 @@ public sealed class NotificationsController(
var content = request.Content.Trim();
if (title.Length == 0)
return ValidationProblem("请填写消息标题。");
if (content.Length == 0)
if (PlainText(content).Length == 0)
return ValidationProblem("请填写消息正文。");
if (content.Length > 20000)
return ValidationProblem("消息正文过长,请精简至 20000 个字符以内。");
var scope = currentUserDataScope.Current;
MessageAudienceType audienceType;
Guid? audienceId = null;
@@ -215,12 +319,16 @@ public sealed class NotificationsController(
if (IsSchoolAdministrator(scope))
{
audienceType = MessageAudienceType.School;
audienceName = "全校已启用账号";
recipientIds = await db.Users.AsNoTracking()
.Where(x => x.IsEnabled && x.Id != scope.UserId)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var result = await ResolveAdministratorRecipientsAsync(
scope,
null,
request,
"全校已启用账号",
cancellationToken);
if (result.Error is not null) return result.Error;
audienceType = result.AudienceType;
audienceName = result.AudienceName!;
recipientIds = result.RecipientIds!;
}
else if (scope.IsInRole(SystemRoles.CollegeAdmin) ||
scope.IsInRole(SystemRoles.Counselor))
@@ -241,16 +349,19 @@ public sealed class NotificationsController(
if (collegeName is null)
return ConflictProblem("当前账号关联的学院不存在。");
audienceType = MessageAudienceType.College;
audienceId = collegeId.Value;
audienceName = $"{collegeName}全院成员";
recipientIds = await db.Users.AsNoTracking()
.Where(x =>
x.IsEnabled &&
x.Id != scope.UserId &&
x.CollegeId == collegeId.Value)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
var result = await ResolveAdministratorRecipientsAsync(
scope,
collegeId.Value,
request,
$"{collegeName}全院成员",
cancellationToken);
if (result.Error is not null) return result.Error;
audienceType = result.AudienceType;
audienceId = audienceType == MessageAudienceType.College
? collegeId.Value
: null;
audienceName = result.AudienceName!;
recipientIds = result.RecipientIds!;
}
else if (scope.IsInRole(SystemRoles.Teacher))
{
@@ -305,17 +416,31 @@ public sealed class NotificationsController(
RecipientCount = recipientIds.Count,
LinkUrl = null
};
dispatch.Notifications = recipientIds.Select(userId => new Notification
try
{
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.MessageDispatches.Add(dispatch);
await db.SaveChangesAsync(cancellationToken);
db.ChangeTracker.Clear();
foreach (var batch in recipientIds.Chunk(NotificationBatchSize))
{
db.Notifications.AddRange(batch.Select(userId => new Notification
{
UserId = userId,
Title = title,
Content = content,
Category = NotificationCategory.General,
LinkUrl = null
}).ToList();
db.MessageDispatches.Add(dispatch);
LinkUrl = null,
MessageDispatchId = dispatch.Id
}));
await db.SaveChangesAsync(cancellationToken);
db.ChangeTracker.Clear();
}
await transaction.CommitAsync(cancellationToken);
return Ok(new
{
dispatch.Id,
@@ -323,6 +448,22 @@ public sealed class NotificationsController(
dispatch.RecipientCount,
dispatch.CreatedAt
});
},
cancellationToken,
IsolationLevel.ReadCommitted);
}
catch (DbUpdateException exception)
{
logger?.LogError(
exception,
"Failed to send notification dispatch {DispatchId} to {RecipientCount} recipients.",
dispatch.Id,
recipientIds.Count);
return Problem(
title: "消息未能发送",
detail: "消息数据保存失败。请确认数据库已完成最新升级后重试;本次消息没有发送。",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
[HttpGet("sent")]
@@ -433,6 +574,286 @@ public sealed class NotificationsController(
scope.IsInRole(SystemRoles.SuperAdmin) ||
scope.IsInRole(SystemRoles.AcademicAdmin);
private async Task<object> BuildAdministratorComposerAsync(
CurrentUserScope scope,
Guid? fixedCollegeId,
MessageAudienceType audienceType,
string audienceName,
CancellationToken cancellationToken)
{
var colleges = await db.Colleges.AsNoTracking()
.Where(x => !fixedCollegeId.HasValue || x.Id == fixedCollegeId.Value)
.OrderBy(x => x.Code)
.Select(x => new { x.Id, x.Code, x.Name })
.ToListAsync(cancellationToken);
var administrativeClasses = await db.AdministrativeClasses.AsNoTracking()
.Where(x =>
x.IsEnabled &&
(!fixedCollegeId.HasValue ||
x.Major!.CollegeId == fixedCollegeId.Value))
.OrderByDescending(x => x.Grade)
.ThenBy(x => x.Code)
.Select(x => new
{
x.Id,
x.Code,
x.Name,
x.Grade,
CollegeId = x.Major!.CollegeId
})
.ToListAsync(cancellationToken);
var teachingTasks = await db.TeachingTasks.AsNoTracking()
.Where(x =>
x.Status != TeachingTaskStatus.Draft &&
(!fixedCollegeId.HasValue ||
x.Course!.CollegeId == fixedCollegeId.Value))
.OrderByDescending(x => x.AcademicTerm!.IsCurrent)
.ThenByDescending(x => x.AcademicTerm!.StartDate)
.ThenBy(x => x.TaskNumber)
.Select(x => new
{
x.Id,
x.TaskNumber,
CourseName = x.Course!.Name,
TermName = x.AcademicTerm!.Name,
CollegeId = x.Course.CollegeId
})
.ToListAsync(cancellationToken);
var recipientCount = await ScopedRecipientQuery(scope, fixedCollegeId)
.CountAsync(cancellationToken);
return new
{
AudienceType = audienceType,
AudienceName = audienceName,
RecipientCount = recipientCount,
CanFilterRecipients = true,
MaximumSelectedRecipients,
Colleges = colleges,
Roles = RoleOptions,
AdministrativeClasses = administrativeClasses,
TeachingTasks = teachingTasks
};
}
private IQueryable<ApplicationUser> ScopedRecipientQuery(
CurrentUserScope scope,
Guid? fixedCollegeId)
{
var source = db.Users.AsNoTracking()
.Where(x => x.IsEnabled && x.Id != scope.UserId);
if (IsSchoolAdministrator(scope)) return source;
if (!fixedCollegeId.HasValue) return source.Where(_ => false);
var collegeId = fixedCollegeId.Value;
return source.Where(user =>
user.CollegeId == collegeId ||
db.Students.Any(student =>
student.UserId == user.Id &&
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
db.Teachers.Any(teacher =>
teacher.UserId == user.Id &&
teacher.CollegeId == collegeId));
}
private IQueryable<ApplicationUser> ApplyRecipientFilter(
IQueryable<ApplicationUser> source,
MessageRecipientFilter? filter)
{
if (filter is null) return source;
if (filter.CollegeId.HasValue)
{
var collegeId = filter.CollegeId.Value;
source = source.Where(user =>
user.CollegeId == collegeId ||
db.Students.Any(student =>
student.UserId == user.Id &&
student.AdministrativeClass!.Major!.CollegeId == collegeId) ||
db.Teachers.Any(teacher =>
teacher.UserId == user.Id &&
teacher.CollegeId == collegeId));
}
if (!string.IsNullOrWhiteSpace(filter.Role))
{
var role = filter.Role;
source = source.Where(user =>
db.UserRoles.Any(userRole =>
userRole.UserId == user.Id &&
db.Roles.Any(roleEntity =>
roleEntity.Id == userRole.RoleId &&
roleEntity.Name == role)));
}
if (filter.AdministrativeClassId.HasValue)
{
var classId = filter.AdministrativeClassId.Value;
source = source.Where(user =>
db.Students.Any(student =>
student.UserId == user.Id &&
student.AdministrativeClassId == classId));
}
if (filter.TeachingTaskId.HasValue)
{
var taskId = filter.TeachingTaskId.Value;
source = source.Where(user =>
db.Students.Any(student =>
student.UserId == user.Id &&
(db.TeachingTaskClasses.Any(item =>
item.TeachingTaskId == taskId &&
item.AdministrativeClassId ==
student.AdministrativeClassId) ||
db.CourseEnrollments.Any(enrollment =>
enrollment.StudentId == student.Id &&
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
enrollment.CourseSelectionOffering!.TeachingTaskId ==
taskId))));
}
if (!string.IsNullOrWhiteSpace(filter.Keyword))
{
var keyword = filter.Keyword;
source = source.Where(user =>
user.DisplayName.Contains(keyword) ||
(user.UserName != null && user.UserName.Contains(keyword)) ||
(user.StaffNumber != null && user.StaffNumber.Contains(keyword)) ||
db.Students.Any(student =>
student.UserId == user.Id &&
(student.Name.Contains(keyword) ||
student.StudentNumber.Contains(keyword))) ||
db.Teachers.Any(teacher =>
teacher.UserId == user.Id &&
(teacher.Name.Contains(keyword) ||
teacher.TeacherNumber.Contains(keyword))));
}
return source;
}
private async Task<RecipientResolution> ResolveAdministratorRecipientsAsync(
CurrentUserScope scope,
Guid? fixedCollegeId,
SendMessageRequest request,
string defaultAudienceName,
CancellationToken cancellationToken)
{
var source = ScopedRecipientQuery(scope, fixedCollegeId);
if (request.RecipientMode == MessageRecipientMode.Scope)
{
return new RecipientResolution(
fixedCollegeId.HasValue
? MessageAudienceType.College
: MessageAudienceType.School,
defaultAudienceName,
await source.Select(x => x.Id).ToListAsync(cancellationToken),
null);
}
if (request.RecipientMode == MessageRecipientMode.Filtered)
{
if (request.RecipientFilter?.Role is not null &&
!SystemRoles.All.Contains(request.RecipientFilter.Role))
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem("所选身份类型无效。"));
}
var recipientIds = await ApplyRecipientFilter(
source,
request.RecipientFilter)
.Select(x => x.Id)
.ToListAsync(cancellationToken);
return new RecipientResolution(
MessageAudienceType.Custom,
BuildFilteredAudienceName(request.RecipientFilter),
recipientIds,
null);
}
if (request.RecipientMode == MessageRecipientMode.Selected)
{
var requestedIds = request.RecipientUserIds?
.Distinct()
.ToArray() ?? [];
if (requestedIds.Length == 0)
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem("请至少选择一名收件人。"));
}
if (requestedIds.Length > MaximumSelectedRecipients)
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem(
$"单次最多指定 {MaximumSelectedRecipients} 名收件人。"));
}
var authorizedIds = await source
.Where(x => requestedIds.Contains(x.Id))
.Select(x => x.Id)
.ToListAsync(cancellationToken);
if (authorizedIds.Count != requestedIds.Length)
{
return new RecipientResolution(
default,
null,
null,
ValidationProblem("所选收件人包含无权限访问或已停用的账号。"));
}
return new RecipientResolution(
MessageAudienceType.Custom,
$"指定收件人({authorizedIds.Count} 人)",
authorizedIds,
null);
}
return new RecipientResolution(
default,
null,
null,
ValidationProblem("请选择有效的收件方式。"));
}
private static string BuildFilteredAudienceName(MessageRecipientFilter? filter)
{
if (filter is null) return "当前权限范围内全部账号";
var parts = new List<string>();
if (filter.CollegeId.HasValue) parts.Add("指定学院");
if (filter.Role is not null)
parts.Add(RoleOptions.FirstOrDefault(x => x.Value == filter.Role)?.Label ??
filter.Role);
if (filter.AdministrativeClassId.HasValue) parts.Add("指定行政班");
if (filter.TeachingTaskId.HasValue) parts.Add("指定教学班");
if (filter.Keyword is not null) parts.Add($"关键词“{filter.Keyword}”");
return parts.Count == 0
? "当前权限范围内全部账号"
: string.Join(" · ", parts);
}
private static string PlainText(string html)
{
var withoutTags = Regex.Replace(html, "<[^>]+>", " ");
return WebUtility.HtmlDecode(withoutTags).Trim();
}
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static readonly RoleOption[] RoleOptions =
[
new(SystemRoles.Student, "学生"),
new(SystemRoles.Teacher, "任课教师"),
new(SystemRoles.Counselor, "辅导员"),
new(SystemRoles.CollegeAdmin, "学院管理员"),
new(SystemRoles.AcademicAdmin, "校级教务管理员"),
new(SystemRoles.Leader, "校领导"),
new(SystemRoles.SuperAdmin, "超级管理员")
];
private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails
{
@@ -451,9 +872,34 @@ public sealed class NotificationsController(
}
public sealed record SendMessageRequest(
[property: Required, StringLength(200)] string Title,
[property: Required, StringLength(1000)] string Content,
Guid? TeachingTaskId = null);
[Required, StringLength(200)] string Title,
[Required, StringLength(20000)] string Content,
Guid? TeachingTaskId = null,
MessageRecipientMode RecipientMode = MessageRecipientMode.Scope,
MessageRecipientFilter? RecipientFilter = null,
IReadOnlyCollection<Guid>? RecipientUserIds = null);
public sealed record MessageRecipientFilter(
Guid? CollegeId = null,
string? Role = null,
Guid? AdministrativeClassId = null,
Guid? TeachingTaskId = null,
string? Keyword = null);
public enum MessageRecipientMode
{
Scope = 1,
Filtered = 2,
Selected = 3
}
public sealed record RoleOption(string Value, string Label);
internal sealed record RecipientResolution(
MessageAudienceType AudienceType,
string? AudienceName,
List<Guid>? RecipientIds,
ActionResult? Error);
/// <summary>
/// Centralized helper to send notifications across the app.
@@ -0,0 +1,519 @@
using System.ComponentModel.DataAnnotations;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.Operations;
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)]
[Route("api/operations")]
public sealed class OperationsController(
AppDbContext db,
OperationalHealthService healthService,
DatabaseBackupService backupService,
OperationsOptions options) : ControllerBase
{
[HttpGet("summary")]
public async Task<ActionResult<OperationsSummary>> GetSummary(
CancellationToken cancellationToken)
{
var since = DateTime.UtcNow.AddHours(-24);
var health = await healthService.CheckAsync(cancellationToken);
var backups = await backupService.ListAsync(cancellationToken);
var auditCount = await db.AuditLogs.AsNoTracking()
.CountAsync(x => x.CreatedAt >= since, cancellationToken);
var serverErrorCount = await db.AuditLogs.AsNoTracking()
.CountAsync(
x => x.CreatedAt >= since && x.StatusCode >= 500,
cancellationToken);
var failedJobCount = await CountFailedJobsAsync(
DateTime.UtcNow.AddDays(-7),
cancellationToken);
var alerts = await BuildAlertsAsync(
health,
backups,
serverErrorCount,
failedJobCount,
cancellationToken);
return Ok(new OperationsSummary(
DateTime.UtcNow,
health,
new OperationsCounters(
auditCount,
serverErrorCount,
failedJobCount,
alerts.Count(x => x.Severity == "critical")),
alerts,
backups.FirstOrDefault()));
}
[HttpGet("health")]
public async Task<ActionResult<OperationalHealthSnapshot>> GetHealth(
CancellationToken cancellationToken) =>
Ok(await healthService.CheckAsync(cancellationToken));
[HttpGet("audit-logs")]
public async Task<ActionResult<PagedResult<AuditLogItem>>> GetAuditLogs(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? method = null,
[FromQuery] int? statusCode = null,
[FromQuery] string? userName = null,
[FromQuery] string? path = null,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
CancellationToken cancellationToken = default)
{
var pagingError = ValidatePaging(page, pageSize);
if (pagingError is not null) return pagingError;
var rangeError = ValidateRange(from, to);
if (rangeError is not null) return rangeError;
var query = db.AuditLogs.AsNoTracking().AsQueryable();
if (!string.IsNullOrWhiteSpace(method))
{
var normalizedMethod = method.Trim().ToUpperInvariant();
query = query.Where(x => x.Method == normalizedMethod);
}
if (statusCode.HasValue)
query = query.Where(x => x.StatusCode == statusCode.Value);
if (!string.IsNullOrWhiteSpace(userName))
{
var normalizedUser = userName.Trim();
query = query.Where(x =>
x.UserName != null && x.UserName.Contains(normalizedUser));
}
if (!string.IsNullOrWhiteSpace(path))
{
var normalizedPath = path.Trim();
query = query.Where(x => x.Path.Contains(normalizedPath));
}
query = query.Where(x =>
x.CreatedAt >= (from ?? DateTime.UtcNow.AddDays(-1)));
if (to.HasValue)
query = query.Where(x => x.CreatedAt <= to.Value);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(x => x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(x => new AuditLogItem(
x.Id,
x.UserName,
x.Method,
x.Path,
x.StatusCode,
x.IpAddress,
x.CreatedAt))
.ToListAsync(cancellationToken);
return Ok(new PagedResult<AuditLogItem>(items, total, page, pageSize));
}
[HttpGet("failed-jobs")]
public async Task<ActionResult<PagedResult<FailedBackgroundJobItem>>>
GetFailedJobs(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
[FromQuery] string? kind = null,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
CancellationToken cancellationToken = default)
{
var pagingError = ValidatePaging(page, pageSize);
if (pagingError is not null) return pagingError;
var rangeError = ValidateRange(from, to);
if (rangeError is not null) return rangeError;
var normalizedKind = NormalizeJobKind(kind);
if (kind is not null && normalizedKind is null)
return ValidationProblem("后台任务类型无效。");
var effectiveFrom = from ?? DateTime.UtcNow.AddDays(-30);
var take = checked(page * pageSize);
var rows = new List<FailedBackgroundJobItem>();
var total = 0;
if (normalizedKind is null or "AutomaticSchedule")
{
var query = db.AutomaticScheduleJobs.AsNoTracking()
.Where(x =>
x.Status == AutomaticScheduleJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"AutomaticSchedule",
"自动排课",
x.SchedulePlan == null ? "排课方案" : x.SchedulePlan.Name,
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
if (normalizedKind is null or "SchedulePublish")
{
var query = db.SchedulePublishJobs.AsNoTracking()
.Where(x =>
x.Status == SchedulePublishJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"SchedulePublish",
"课表发布",
x.SchedulePlan == null ? "排课方案" : x.SchedulePlan.Name,
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
if (normalizedKind is null or "MakeupExamAuto")
{
var query = db.MakeupExamAutoJobs.AsNoTracking()
.Where(x =>
x.Status == MakeupExamAutoJobStatus.Failed &&
x.CreatedAt >= effectiveFrom);
if (to.HasValue) query = query.Where(x => x.CreatedAt <= to.Value);
total += await query.CountAsync(cancellationToken);
rows.AddRange(await query
.OrderByDescending(x => x.CompletedAt ?? x.UpdatedAt)
.Take(take)
.Select(x => new FailedBackgroundJobItem(
x.Id,
"MakeupExamAuto",
"补考自动安排",
x.MakeupExamPlan == null ? "补考计划" : x.MakeupExamPlan.Name,
x.ErrorMessage ?? "任务失败但未记录错误详情。",
x.CreatedAt,
x.StartedAt,
x.CompletedAt,
0))
.ToListAsync(cancellationToken));
}
var pageItems = rows
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToArray();
if (pageItems.Length > 0)
{
var ids = pageItems.Select(x => x.Id).ToArray();
var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking()
.Where(x => ids.Contains(x.JobId))
.Select(x => new { x.JobId, x.ProcessingAttempts })
.ToDictionaryAsync(x => x.JobId, x => x.ProcessingAttempts,
cancellationToken);
pageItems = pageItems
.Select(x => x with
{
ProcessingAttempts = attempts.GetValueOrDefault(x.Id)
})
.ToArray();
}
return Ok(new PagedResult<FailedBackgroundJobItem>(
pageItems,
total,
page,
pageSize));
}
[HttpGet("backups")]
public async Task<ActionResult<IReadOnlyCollection<BackupArtifact>>> GetBackups(
CancellationToken cancellationToken) =>
Ok(await backupService.ListAsync(cancellationToken));
[HttpPost("backups")]
public async Task<ActionResult<BackupArtifact>> CreateBackup(
CreateBackupRequest request,
CancellationToken cancellationToken)
{
try
{
var artifact = await backupService.CreateAsync(
request.Note,
cancellationToken);
return CreatedAtAction(
nameof(GetBackups),
new { id = artifact.Id },
artifact);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
return Problem(
title: "数据库备份失败",
detail: SafeMessage(exception),
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
[HttpPost("backups/{backupId}/restore-drill")]
public async Task<ActionResult<RestoreDrillResult>> RunRestoreDrill(
string backupId,
RestoreDrillRequest request,
CancellationToken cancellationToken)
{
if (!request.Confirmation.Equals(
"RESTORE_DRILL",
StringComparison.Ordinal))
{
return ValidationProblem(
"恢复演练必须明确确认,且不会覆盖当前业务数据库。");
}
try
{
return Ok(await backupService.RunRestoreDrillAsync(
backupId,
cancellationToken));
}
catch (FileNotFoundException)
{
return NotFound(new ProblemDetails
{
Title = "备份不存在",
Detail = "指定备份不存在或其文件已被移除。",
Status = StatusCodes.Status404NotFound
});
}
}
private async Task<IReadOnlyCollection<OperationalAlert>> BuildAlertsAsync(
OperationalHealthSnapshot health,
IReadOnlyCollection<BackupArtifact> backups,
int serverErrorCount,
int failedJobCount,
CancellationToken cancellationToken)
{
var alerts = health.Components
.Where(x => x.Status != "healthy")
.Select(x => new OperationalAlert(
$"health-{x.Key}",
x.Status == "unhealthy" ? "critical" : "warning",
"health",
$"{x.Label}状态异常",
x.Detail,
health.CheckedAt))
.ToList();
if (serverErrorCount > 0)
{
var latest = await db.AuditLogs.AsNoTracking()
.Where(x =>
x.CreatedAt >= DateTime.UtcNow.AddHours(-24) &&
x.StatusCode >= 500)
.OrderByDescending(x => x.CreatedAt)
.Select(x => new { x.Path, x.StatusCode, x.CreatedAt })
.FirstOrDefaultAsync(cancellationToken);
alerts.Add(new OperationalAlert(
"http-5xx",
"critical",
"audit",
$"过去 24 小时发生 {serverErrorCount} 次服务端错误",
latest is null
? "请检查服务日志定位异常。"
: $"最近一次为 {latest.StatusCode} {latest.Path}。",
latest?.CreatedAt ?? DateTime.UtcNow));
}
if (failedJobCount > 0)
{
alerts.Add(new OperationalAlert(
"failed-jobs",
"critical",
"jobs",
$"最近 7 天有 {failedJobCount} 个后台任务失败",
"任务已停止或达到重试上限,请在失败任务中查看错误详情。",
DateTime.UtcNow));
}
var retryingFailures = await db.BackgroundJobOutboxMessages.AsNoTracking()
.CountAsync(
x => x.State != BackgroundJobOutboxState.Completed &&
x.LastError != null,
cancellationToken);
if (retryingFailures > 0)
{
alerts.Add(new OperationalAlert(
"retrying-jobs",
"warning",
"jobs",
$"{retryingFailures} 个后台任务正在错误重试",
"任务队列仍会自动重试;若持续出现,请检查依赖服务与任务参数。",
DateTime.UtcNow));
}
var latestBackup = backups.FirstOrDefault();
if (latestBackup is null)
{
alerts.Add(new OperationalAlert(
"backup-missing",
"critical",
"backup",
"尚无可验证的数据库备份",
"立即创建首个备份,并在创建后执行一次隔离恢复演练。",
DateTime.UtcNow));
}
else
{
var ageHours = (DateTime.UtcNow - latestBackup.CreatedAt).TotalHours;
if (ageHours > options.BackupWarningHours)
{
alerts.Add(new OperationalAlert(
"backup-stale",
"warning",
"backup",
$"最近备份已超过 {options.BackupWarningHours} 小时",
$"最近备份创建于 {latestBackup.CreatedAt:u}。",
latestBackup.CreatedAt));
}
if (latestBackup.LastDrillSucceeded == false)
{
alerts.Add(new OperationalAlert(
"restore-drill-failed",
"critical",
"backup",
"最近一次恢复演练失败",
latestBackup.LastDrillDetail ?? "请重新运行演练并检查数据库工具日志。",
latestBackup.LastDrillAt ?? latestBackup.CreatedAt));
}
else if (!latestBackup.LastDrillAt.HasValue)
{
alerts.Add(new OperationalAlert(
"restore-drill-missing",
"warning",
"backup",
"最近备份尚未完成恢复演练",
"恢复演练只写入隔离数据库,不会覆盖当前业务数据。",
latestBackup.CreatedAt));
}
}
return alerts
.OrderBy(x => x.Severity == "critical" ? 0 : 1)
.ThenByDescending(x => x.OccurredAt)
.ToArray();
}
private async Task<int> CountFailedJobsAsync(
DateTime from,
CancellationToken cancellationToken) =>
await db.AutomaticScheduleJobs.AsNoTracking()
.CountAsync(
x => x.Status == AutomaticScheduleJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken) +
await db.SchedulePublishJobs.AsNoTracking()
.CountAsync(
x => x.Status == SchedulePublishJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken) +
await db.MakeupExamAutoJobs.AsNoTracking()
.CountAsync(
x => x.Status == MakeupExamAutoJobStatus.Failed &&
x.CreatedAt >= from,
cancellationToken);
private ActionResult? ValidatePaging(int page, int pageSize)
{
if (page is < 1 or > 100000 || pageSize is < 1 or > 100)
{
return ValidationProblem(
"页码必须在 1 到 100000 之间,每页数量必须在 1 到 100 之间。");
}
return null;
}
private ActionResult? ValidateRange(DateTime? from, DateTime? to)
{
if (from.HasValue && to.HasValue && from.Value > to.Value)
return ValidationProblem("开始时间不能晚于结束时间。");
return null;
}
private static string? NormalizeJobKind(string? kind)
{
if (string.IsNullOrWhiteSpace(kind)) return null;
return kind.Trim() switch
{
"AutomaticSchedule" => "AutomaticSchedule",
"SchedulePublish" => "SchedulePublish",
"MakeupExamAuto" => "MakeupExamAuto",
_ => null
};
}
private static string SafeMessage(Exception exception)
{
var message = exception.GetBaseException().Message;
return message.Length <= 500 ? message : message[..500];
}
}
public sealed record AuditLogItem(
Guid Id,
string? UserName,
string Method,
string Path,
int StatusCode,
string? IpAddress,
DateTime CreatedAt);
public sealed record FailedBackgroundJobItem(
Guid Id,
string Kind,
string KindLabel,
string Context,
string ErrorMessage,
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? CompletedAt,
int ProcessingAttempts);
public sealed record OperationalAlert(
string Id,
string Severity,
string Source,
string Title,
string Detail,
DateTime OccurredAt);
public sealed record OperationsCounters(
int AuditEvents24Hours,
int ServerErrors24Hours,
int FailedJobs7Days,
int CriticalAlerts);
public sealed record OperationsSummary(
DateTime GeneratedAt,
OperationalHealthSnapshot Health,
OperationsCounters Counters,
IReadOnlyCollection<OperationalAlert> Alerts,
BackupArtifact? LatestBackup);
public sealed record CreateBackupRequest([MaxLength(200)] string? Note);
public sealed record RestoreDrillRequest([Required] string Confirmation);
@@ -44,5 +44,6 @@ public enum MessageAudienceType
{
School = 1,
College = 2,
TeachingTask = 3
TeachingTask = 3,
Custom = 4
}
@@ -50,6 +50,16 @@ public sealed class Course : CatalogEntity
public CourseNature Nature { get; set; }
public AssessmentMethod AssessmentMethod { get; set; }
public string? Description { get; set; }
public ICollection<CoursePrerequisite> Prerequisites { get; set; } = [];
public ICollection<CoursePrerequisite> RequiredByCourses { get; set; } = [];
}
public sealed class CoursePrerequisite : EntityBase
{
public Guid CourseId { get; set; }
public Course? Course { get; set; }
public Guid PrerequisiteCourseId { get; set; }
public Course? PrerequisiteCourse { get; set; }
}
public sealed class CourseCategory : CatalogEntity
@@ -26,6 +26,9 @@ public sealed class ExamArrangementService(AppDbContext db)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null)
@@ -26,6 +26,9 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Teachers)
.Include(x => x.Sessions)
.ThenInclude(x => x.TeachingTask)
.ThenInclude(x => x!.Course)
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
if (plan is null)
@@ -0,0 +1,115 @@
using Jiaowu.Api.Domain.Academic;
namespace Jiaowu.Api.Infrastructure.Graduation;
public sealed record AcademicPlanningCourseSnapshot(
Guid CourseId,
string CourseName,
decimal Credits,
int RecommendedSemester,
CurriculumCourseType Type,
IReadOnlyCollection<Guid> PrerequisiteCourseIds);
public sealed record AcademicPlanningPrerequisiteIssue(
Guid CourseId,
Guid PrerequisiteCourseId,
int PlannedSemester,
int? PrerequisitePlannedSemester);
public static class AcademicPlanningRules
{
public const decimal RecommendedSemesterCreditLimit = 24;
public const decimal HeavySemesterCreditLimit = 30;
public static IReadOnlyList<AcademicPlanningPrerequisiteIssue>
FindPrerequisiteIssues(
IEnumerable<AcademicPlanningCourseSnapshot> courses,
IReadOnlySet<Guid> completedCourseIds,
IReadOnlySet<Guid> inProgressCourseIds,
IReadOnlyDictionary<Guid, int> plannedSemesters)
{
var issues = new List<AcademicPlanningPrerequisiteIssue>();
foreach (var course in courses.Where(x =>
plannedSemesters.ContainsKey(x.CourseId)))
{
var plannedSemester = plannedSemesters[course.CourseId];
foreach (var prerequisiteId in course.PrerequisiteCourseIds)
{
if (completedCourseIds.Contains(prerequisiteId) ||
inProgressCourseIds.Contains(prerequisiteId))
continue;
if (!plannedSemesters.TryGetValue(
prerequisiteId,
out var prerequisiteSemester) ||
prerequisiteSemester >= plannedSemester)
{
issues.Add(new AcademicPlanningPrerequisiteIssue(
course.CourseId,
prerequisiteId,
plannedSemester,
plannedSemesters.GetValueOrDefault(prerequisiteId)));
}
}
}
return issues;
}
public static IReadOnlyList<Guid> SuggestNextSemester(
IEnumerable<AcademicPlanningCourseSnapshot> courses,
IReadOnlySet<Guid> completedCourseIds,
IReadOnlySet<Guid> inProgressCourseIds,
int nextSemester,
decimal targetCredits = RecommendedSemesterCreditLimit)
{
var available = courses
.Where(x =>
!completedCourseIds.Contains(x.CourseId) &&
!inProgressCourseIds.Contains(x.CourseId) &&
x.RecommendedSemester <= nextSemester &&
x.PrerequisiteCourseIds.All(prerequisiteId =>
completedCourseIds.Contains(prerequisiteId) ||
inProgressCourseIds.Contains(prerequisiteId)))
.OrderBy(x => x.Type == CurriculumCourseType.Required ? 0 : 1)
.ThenBy(x => x.RecommendedSemester > nextSemester ? 1 : 0)
.ThenBy(x => x.RecommendedSemester)
.ThenBy(x => x.CourseName)
.ToList();
var selected = new List<Guid>();
decimal credits = 0;
foreach (var course in available)
{
var isOverdueRequired =
course.Type == CurriculumCourseType.Required &&
course.RecommendedSemester <= nextSemester;
if (!isOverdueRequired &&
selected.Count > 0 &&
credits + course.Credits > targetCredits)
continue;
selected.Add(course.CourseId);
credits += course.Credits;
if (credits >= targetCredits) break;
}
return selected;
}
public static int EstimateCompletionSemester(
int nextSemester,
int latestPlannedSemester,
decimal remainingCredits,
int remainingRequirementCount)
{
if (remainingCredits <= 0 && remainingRequirementCount <= 0)
return Math.Max(nextSemester - 1, latestPlannedSemester);
var byCredits = (int)Math.Ceiling(
Math.Max(remainingCredits, 0) / RecommendedSemesterCreditLimit);
var byRequirements = (int)Math.Ceiling(
Math.Max(remainingRequirementCount, 0) / 6m);
var additionalSemesters = Math.Max(1, Math.Max(byCredits, byRequirements));
return Math.Max(nextSemester - 1, latestPlannedSemester) +
additionalSemesters;
}
}
@@ -0,0 +1,584 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text.Json;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.Data.Sqlite;
using MySql.Data.MySqlClient;
namespace Jiaowu.Api.Infrastructure.Operations;
public sealed record BackupArtifact(
string Id,
string FileName,
string Provider,
DateTime CreatedAt,
long SizeBytes,
string Sha256,
string? Note,
DateTime? LastDrillAt,
bool? LastDrillSucceeded,
string? LastDrillDetail,
long? LastDrillDurationMilliseconds);
public sealed record RestoreDrillResult(
string BackupId,
bool Succeeded,
DateTime CompletedAt,
string Detail,
long DurationMilliseconds,
int? TableCount);
public sealed class DatabaseBackupService(
DatabaseOptions databaseOptions,
OperationsOptions options,
IConfiguration configuration,
IHostEnvironment environment,
ILogger<DatabaseBackupService> logger)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
private readonly SemaphoreSlim operationLock = new(1, 1);
private readonly string backupDirectory = ResolveBackupDirectory(
options.BackupDirectory,
environment.ContentRootPath);
public async Task<IReadOnlyCollection<BackupArtifact>> ListAsync(
CancellationToken cancellationToken)
{
Directory.CreateDirectory(backupDirectory);
var items = new List<BackupArtifact>();
foreach (var metadataPath in Directory.EnumerateFiles(
backupDirectory,
"*.metadata.json",
SearchOption.TopDirectoryOnly))
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var stream = File.OpenRead(metadataPath);
var artifact = await JsonSerializer.DeserializeAsync<BackupArtifact>(
stream,
JsonOptions,
cancellationToken);
if (artifact is not null &&
File.Exists(Path.Combine(backupDirectory, artifact.FileName)))
{
items.Add(artifact);
}
}
catch (Exception exception) when (
exception is IOException or UnauthorizedAccessException or JsonException)
{
logger.LogWarning(
exception,
"Unable to read backup metadata {MetadataFile}.",
Path.GetFileName(metadataPath));
}
}
return items
.OrderByDescending(x => x.CreatedAt)
.ToArray();
}
public async Task<BackupArtifact> CreateAsync(
string? note,
CancellationToken cancellationToken)
{
await operationLock.WaitAsync(cancellationToken);
try
{
Directory.CreateDirectory(backupDirectory);
var createdAt = DateTime.UtcNow;
var id = $"{createdAt:yyyyMMddHHmmss}-{Guid.NewGuid():N}"[..29];
var provider = NormalizeProvider(databaseOptions.Provider);
var extension = provider == "SQLite" ? ".sqlite" : ".sql";
var fileName = $"jiaowu-{id}{extension}";
var backupPath = Path.Combine(backupDirectory, fileName);
try
{
if (provider == "SQLite")
await CreateSqliteBackupAsync(backupPath, cancellationToken);
else
await CreateMySqlBackupAsync(backupPath, cancellationToken);
var fileInfo = new FileInfo(backupPath);
var artifact = new BackupArtifact(
id,
fileName,
provider,
createdAt,
fileInfo.Length,
await ComputeHashAsync(backupPath, cancellationToken),
NormalizeNote(note),
null,
null,
null,
null);
await WriteMetadataAsync(artifact, cancellationToken);
return artifact;
}
catch
{
if (File.Exists(backupPath))
File.Delete(backupPath);
throw;
}
}
finally
{
operationLock.Release();
}
}
public async Task<RestoreDrillResult> RunRestoreDrillAsync(
string backupId,
CancellationToken cancellationToken)
{
await operationLock.WaitAsync(cancellationToken);
try
{
var artifact = (await ListAsync(cancellationToken))
.SingleOrDefault(x => x.Id.Equals(backupId, StringComparison.Ordinal));
if (artifact is null)
throw new FileNotFoundException("未找到指定备份。");
var backupPath = Path.Combine(backupDirectory, artifact.FileName);
var actualHash = await ComputeHashAsync(backupPath, cancellationToken);
if (!CryptographicOperations.FixedTimeEquals(
Convert.FromHexString(artifact.Sha256),
Convert.FromHexString(actualHash)))
{
var damaged = await CompleteDrillAsync(
artifact,
false,
"备份文件校验和不一致,恢复演练已中止。",
0,
null,
cancellationToken);
return damaged;
}
var stopwatch = Stopwatch.StartNew();
RestoreDrillResult result;
try
{
var tableCount = artifact.Provider == "SQLite"
? await DrillSqliteAsync(backupPath, cancellationToken)
: await DrillMySqlAsync(backupPath, cancellationToken);
stopwatch.Stop();
result = await CompleteDrillAsync(
artifact,
true,
$"已在隔离数据库完成恢复并通过完整性检查,共发现 {tableCount} 张业务表。",
stopwatch.ElapsedMilliseconds,
tableCount,
cancellationToken);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
logger.LogError(
exception,
"Restore drill failed for backup {BackupId}.",
artifact.Id);
result = await CompleteDrillAsync(
artifact,
false,
$"恢复演练失败:{SafeMessage(exception)}",
stopwatch.ElapsedMilliseconds,
null,
cancellationToken);
}
return result;
}
finally
{
operationLock.Release();
}
}
private async Task CreateSqliteBackupAsync(
string backupPath,
CancellationToken cancellationToken)
{
var sourceBuilder = new SqliteConnectionStringBuilder(
configuration.GetConnectionString("SQLite")
?? throw new InvalidOperationException("缺少 ConnectionStrings:SQLite。"));
if (!Path.IsPathRooted(sourceBuilder.DataSource))
{
sourceBuilder.DataSource = Path.GetFullPath(
sourceBuilder.DataSource,
environment.ContentRootPath);
}
var destinationBuilder = new SqliteConnectionStringBuilder
{
DataSource = backupPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
};
await using var source = new SqliteConnection(sourceBuilder.ConnectionString);
await using var destination = new SqliteConnection(destinationBuilder.ConnectionString);
await source.OpenAsync(cancellationToken);
await destination.OpenAsync(cancellationToken);
source.BackupDatabase(destination);
}
private async Task CreateMySqlBackupAsync(
string backupPath,
CancellationToken cancellationToken)
{
var connection = GetMySqlConnectionBuilder();
var arguments = new List<string>
{
"--protocol=tcp",
$"--host={connection.Server}",
$"--port={connection.Port}",
$"--user={connection.UserID}",
"--single-transaction",
"--quick",
"--routines",
"--triggers",
"--events",
"--hex-blob",
"--default-character-set=utf8mb4"
};
arguments.AddRange(options.MySqlAdditionalArguments);
arguments.Add(connection.Database);
await using var output = new FileStream(
backupPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
81920,
FileOptions.Asynchronous);
await RunToolAsync(
options.MySqlDumpPath,
arguments,
connection.Password,
standardInput: null,
standardOutput: output,
cancellationToken);
}
private static async Task<int> DrillSqliteAsync(
string backupPath,
CancellationToken cancellationToken)
{
var drillPath = Path.Combine(
Path.GetDirectoryName(backupPath)!,
$".restore-drill-{Guid.NewGuid():N}.sqlite");
try
{
var sourceBuilder = new SqliteConnectionStringBuilder
{
DataSource = backupPath,
Mode = SqliteOpenMode.ReadOnly,
Pooling = false
};
var drillBuilder = new SqliteConnectionStringBuilder
{
DataSource = drillPath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = false
};
await using var source = new SqliteConnection(sourceBuilder.ConnectionString);
await using var drill = new SqliteConnection(drillBuilder.ConnectionString);
await source.OpenAsync(cancellationToken);
await drill.OpenAsync(cancellationToken);
source.BackupDatabase(drill);
await using var integrity = drill.CreateCommand();
integrity.CommandText = "PRAGMA integrity_check;";
var integrityResult = Convert.ToString(
await integrity.ExecuteScalarAsync(cancellationToken));
if (!string.Equals(integrityResult, "ok", StringComparison.OrdinalIgnoreCase))
throw new InvalidDataException($"SQLite 完整性检查返回 {integrityResult ?? ""}。");
await using var tables = drill.CreateCommand();
tables.CommandText =
"SELECT COUNT(*) FROM sqlite_master " +
"WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
return Convert.ToInt32(await tables.ExecuteScalarAsync(cancellationToken));
}
finally
{
if (File.Exists(drillPath))
File.Delete(drillPath);
if (File.Exists($"{drillPath}-shm"))
File.Delete($"{drillPath}-shm");
if (File.Exists($"{drillPath}-wal"))
File.Delete($"{drillPath}-wal");
}
}
private async Task<int> DrillMySqlAsync(
string backupPath,
CancellationToken cancellationToken)
{
var connection = GetMySqlConnectionBuilder();
var drillDatabase = $"jiaowu_restore_drill_{DateTime.UtcNow:yyyyMMddHHmmss}_" +
Guid.NewGuid().ToString("N")[..8];
var adminBuilder = new MySqlConnectionStringBuilder(connection.ConnectionString)
{
Database = ""
};
await using var admin = new MySqlConnection(adminBuilder.ConnectionString);
await admin.OpenAsync(cancellationToken);
try
{
await using (var create = admin.CreateCommand())
{
create.CommandText =
$"CREATE DATABASE `{drillDatabase}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;";
await create.ExecuteNonQueryAsync(cancellationToken);
}
var arguments = new List<string>
{
"--protocol=tcp",
$"--host={connection.Server}",
$"--port={connection.Port}",
$"--user={connection.UserID}",
"--default-character-set=utf8mb4"
};
arguments.AddRange(options.MySqlAdditionalArguments);
arguments.Add($"--database={drillDatabase}");
await using (var input = new FileStream(
backupPath,
FileMode.Open,
FileAccess.Read,
FileShare.Read,
81920,
FileOptions.Asynchronous))
{
await RunToolAsync(
options.MySqlClientPath,
arguments,
connection.Password,
input,
standardOutput: null,
cancellationToken);
}
await using var count = admin.CreateCommand();
count.CommandText =
"SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema = @schema AND table_type = 'BASE TABLE';";
count.Parameters.AddWithValue("@schema", drillDatabase);
return Convert.ToInt32(await count.ExecuteScalarAsync(cancellationToken));
}
finally
{
try
{
await using var drop = admin.CreateCommand();
drop.CommandText = $"DROP DATABASE IF EXISTS `{drillDatabase}`;";
await drop.ExecuteNonQueryAsync(CancellationToken.None);
}
catch (Exception exception)
{
logger.LogCritical(
exception,
"Unable to remove isolated restore drill database {DatabaseName}.",
drillDatabase);
}
}
}
private async Task RunToolAsync(
string executable,
IReadOnlyCollection<string> arguments,
string password,
Stream? standardInput,
Stream? standardOutput,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = executable,
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardInput = standardInput is not null,
RedirectStandardOutput = standardOutput is not null,
CreateNoWindow = true
};
foreach (var argument in arguments)
startInfo.ArgumentList.Add(argument);
if (!string.IsNullOrEmpty(password))
startInfo.Environment["MYSQL_PWD"] = password;
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException($"无法启动数据库工具 {executable}。");
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromMinutes(options.ToolTimeoutMinutes));
var errorTask = process.StandardError.ReadToEndAsync(timeout.Token);
var inputTask = standardInput is null
? Task.CompletedTask
: CopyInputAsync(standardInput, process.StandardInput.BaseStream, timeout.Token);
var outputTask = standardOutput is null
? Task.CompletedTask
: process.StandardOutput.BaseStream.CopyToAsync(
standardOutput,
timeout.Token);
try
{
await Task.WhenAll(
process.WaitForExitAsync(timeout.Token),
inputTask,
outputTask);
}
catch (OperationCanceledException)
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
throw;
}
var error = await errorTask;
if (process.ExitCode != 0)
throw new InvalidOperationException(
$"数据库工具执行失败(退出码 {process.ExitCode}):{TrimToolError(error)}");
}
private static async Task CopyInputAsync(
Stream input,
Stream processInput,
CancellationToken cancellationToken)
{
await input.CopyToAsync(processInput, cancellationToken);
await processInput.FlushAsync(cancellationToken);
processInput.Close();
}
private MySqlConnectionStringBuilder GetMySqlConnectionBuilder()
{
var value = configuration.GetConnectionString("OperationsMySql");
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException(
"MySQL 备份与恢复演练必须配置独立的 " +
"ConnectionStrings:OperationsMySql 运维账号,不能复用日常业务账号。");
}
var builder = new MySqlConnectionStringBuilder(value);
if (string.IsNullOrWhiteSpace(builder.Database))
throw new InvalidOperationException(
"OperationsMySql 连接字符串未指定业务数据库名称。");
return builder;
}
private async Task<RestoreDrillResult> CompleteDrillAsync(
BackupArtifact artifact,
bool succeeded,
string detail,
long durationMilliseconds,
int? tableCount,
CancellationToken cancellationToken)
{
var completedAt = DateTime.UtcNow;
var updated = artifact with
{
LastDrillAt = completedAt,
LastDrillSucceeded = succeeded,
LastDrillDetail = detail,
LastDrillDurationMilliseconds = durationMilliseconds
};
await WriteMetadataAsync(updated, cancellationToken);
return new RestoreDrillResult(
artifact.Id,
succeeded,
completedAt,
detail,
durationMilliseconds,
tableCount);
}
private async Task WriteMetadataAsync(
BackupArtifact artifact,
CancellationToken cancellationToken)
{
var metadataPath = Path.Combine(
backupDirectory,
$"{artifact.Id}.metadata.json");
var temporaryPath = $"{metadataPath}.{Guid.NewGuid():N}.tmp";
try
{
await using (var stream = new FileStream(
temporaryPath,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
16384,
FileOptions.Asynchronous))
{
await JsonSerializer.SerializeAsync(
stream,
artifact,
JsonOptions,
cancellationToken);
}
File.Move(temporaryPath, metadataPath, overwrite: true);
}
finally
{
if (File.Exists(temporaryPath))
File.Delete(temporaryPath);
}
}
private static async Task<string> ComputeHashAsync(
string path,
CancellationToken cancellationToken)
{
await using var stream = File.OpenRead(path);
return Convert.ToHexString(
await SHA256.HashDataAsync(stream, cancellationToken));
}
private static string ResolveBackupDirectory(
string configuredPath,
string contentRoot)
{
if (string.IsNullOrWhiteSpace(configuredPath))
throw new InvalidOperationException("Operations:BackupDirectory 不能为空。");
return Path.IsPathRooted(configuredPath)
? Path.GetFullPath(configuredPath)
: Path.GetFullPath(configuredPath, contentRoot);
}
private static string NormalizeProvider(string provider) =>
provider.Equals("SQLite", StringComparison.OrdinalIgnoreCase)
? "SQLite"
: provider.Equals("MySql", StringComparison.OrdinalIgnoreCase)
? "MySql"
: throw new InvalidOperationException($"不支持数据库 Provider '{provider}'。");
private static string? NormalizeNote(string? note)
{
if (string.IsNullOrWhiteSpace(note)) return null;
var trimmed = note.Trim();
return trimmed.Length <= 200 ? trimmed : trimmed[..200];
}
private static string SafeMessage(Exception exception)
{
var message = exception.GetBaseException().Message;
return message.Length <= 500 ? message : message[..500];
}
private static string TrimToolError(string error)
{
var trimmed = error.Trim();
if (trimmed.Length == 0) return "未返回错误详情。";
return trimmed.Length <= 500 ? trimmed : trimmed[..500];
}
}
@@ -0,0 +1,200 @@
using System.Diagnostics;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Distributed;
namespace Jiaowu.Api.Infrastructure.Operations;
public sealed record OperationalComponentHealth(
string Key,
string Label,
string Status,
string Backend,
long? LatencyMilliseconds,
string Detail);
public sealed record OperationalHealthSnapshot(
DateTime CheckedAt,
string OverallStatus,
IReadOnlyCollection<OperationalComponentHealth> Components,
BackgroundJobBacklogSnapshot? Backlog);
public sealed class OperationalHealthService(
AppDbContext db,
IServiceProvider services,
IBackgroundJobTransport transport,
BackgroundJobMonitoringService monitoring,
DatabaseOptions databaseOptions,
AppCacheOptions cacheOptions,
IConfiguration configuration)
{
public async Task<OperationalHealthSnapshot> CheckAsync(
CancellationToken cancellationToken)
{
var database = await CheckDatabaseAsync(cancellationToken);
var cache = await CheckCacheAsync(cancellationToken);
var (messaging, backlog) = await CheckMessagingAsync(cancellationToken);
var components = new[] { database, cache, messaging };
var overall = components.Any(x => x.Status == "unhealthy")
? "unhealthy"
: components.Any(x => x.Status == "warning")
? "warning"
: "healthy";
return new OperationalHealthSnapshot(
DateTime.UtcNow,
overall,
components,
backlog);
}
private async Task<OperationalComponentHealth> CheckDatabaseAsync(
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
try
{
var canConnect = await db.Database.CanConnectAsync(cancellationToken);
stopwatch.Stop();
return canConnect
? new OperationalComponentHealth(
"database",
"数据库",
"healthy",
databaseOptions.Provider,
stopwatch.ElapsedMilliseconds,
"连接与基础查询正常。")
: new OperationalComponentHealth(
"database",
"数据库",
"unhealthy",
databaseOptions.Provider,
stopwatch.ElapsedMilliseconds,
"无法建立数据库连接。");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
return new OperationalComponentHealth(
"database",
"数据库",
"unhealthy",
databaseOptions.Provider,
stopwatch.ElapsedMilliseconds,
SafeMessage(exception));
}
}
private async Task<OperationalComponentHealth> CheckCacheAsync(
CancellationToken cancellationToken)
{
if (!cacheOptions.Enabled)
{
return new OperationalComponentHealth(
"cache",
"缓存",
"warning",
"disabled",
null,
"缓存已通过配置关闭,所有查询将直接访问数据源。");
}
var hasRedisConfiguration = !string.IsNullOrWhiteSpace(
configuration.GetConnectionString("Redis"));
var distributedCache = services.GetService<IDistributedCache>();
if (!hasRedisConfiguration || distributedCache is null)
{
return new OperationalComponentHealth(
"cache",
"缓存",
"healthy",
"memory",
null,
"使用进程内混合缓存;服务重启后缓存会自然重建。");
}
var stopwatch = Stopwatch.StartNew();
try
{
await distributedCache.GetAsync(
"jiaowu:operations:health-probe",
cancellationToken);
stopwatch.Stop();
return new OperationalComponentHealth(
"cache",
"缓存",
"healthy",
"redis",
stopwatch.ElapsedMilliseconds,
"Redis 连接与读取探针正常。");
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
return new OperationalComponentHealth(
"cache",
"缓存",
"unhealthy",
"redis",
stopwatch.ElapsedMilliseconds,
SafeMessage(exception));
}
}
private async Task<(OperationalComponentHealth Component,
BackgroundJobBacklogSnapshot? Backlog)> CheckMessagingAsync(
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
BackgroundJobBacklogSnapshot? backlog = null;
try
{
var healthy = await transport.CheckHealthAsync(cancellationToken);
backlog = await monitoring.GetSnapshotAsync(cancellationToken);
stopwatch.Stop();
var stuck = backlog.ExpiredLeases > 0 ||
backlog.OldestUnfinishedAgeSeconds > 1800;
var status = !healthy
? "unhealthy"
: stuck
? "warning"
: "healthy";
var detail = !healthy
? "后台任务传输不可用。"
: stuck
? $"发现 {backlog.ExpiredLeases} 个过期租约,最早未完成任务已等待 " +
$"{Math.Round(backlog.OldestUnfinishedAgeSeconds ?? 0)} 秒。"
: $"待发布 {backlog.Pending + backlog.Publishing}" +
$"待处理 {backlog.Published + backlog.Processing}。";
return (
new OperationalComponentHealth(
"messaging",
"后台任务通道",
status,
transport.IsDurable ? "rabbitmq" : "memory",
stopwatch.ElapsedMilliseconds,
detail),
backlog);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
stopwatch.Stop();
return (
new OperationalComponentHealth(
"messaging",
"后台任务通道",
"unhealthy",
transport.IsDurable ? "rabbitmq" : "memory",
stopwatch.ElapsedMilliseconds,
SafeMessage(exception)),
backlog);
}
}
private static string SafeMessage(Exception exception)
{
var message = exception.GetBaseException().Message;
return message.Length <= 300 ? message : message[..300];
}
}
@@ -0,0 +1,13 @@
namespace Jiaowu.Api.Infrastructure.Operations;
public sealed class OperationsOptions
{
public const string SectionName = "Operations";
public string BackupDirectory { get; set; } = "data/backups";
public int BackupWarningHours { get; set; } = 24;
public int ToolTimeoutMinutes { get; set; } = 30;
public string MySqlDumpPath { get; set; } = "mysqldump";
public string MySqlClientPath { get; set; } = "mysql";
public string[] MySqlAdditionalArguments { get; set; } = [];
}
@@ -22,6 +22,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<Student> Students => Set<Student>();
public DbSet<CourseCategory> CourseCategories => Set<CourseCategory>();
public DbSet<Course> Courses => Set<Course>();
public DbSet<CoursePrerequisite> CoursePrerequisites => Set<CoursePrerequisite>();
public DbSet<CurriculumPlan> CurriculumPlans => Set<CurriculumPlan>();
public DbSet<CurriculumModule> CurriculumModules => Set<CurriculumModule>();
public DbSet<CurriculumCourse> CurriculumCourses => Set<CurriculumCourse>();
@@ -247,6 +248,20 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CoursePrerequisite>(entity =>
{
entity.HasIndex(x => new { x.CourseId, x.PrerequisiteCourseId })
.IsUnique();
entity.HasOne(x => x.Course)
.WithMany(x => x.Prerequisites)
.HasForeignKey(x => x.CourseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(x => x.PrerequisiteCourse)
.WithMany(x => x.RequiredByCourses)
.HasForeignKey(x => x.PrerequisiteCourseId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<CurriculumPlan>(entity =>
{
entity.Property(x => x.Name).HasMaxLength(120);
@@ -965,7 +980,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
builder.Entity<Notification>(entity =>
{
entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasMaxLength(1000);
entity.Property(x => x.Content).HasColumnType("longtext");
entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.UserId, x.IsRead });
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
@@ -981,7 +996,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
{
entity.Property(x => x.SenderName).HasMaxLength(100);
entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasMaxLength(1000);
entity.Property(x => x.Content).HasColumnType("longtext");
entity.Property(x => x.AudienceName).HasMaxLength(200);
entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
@@ -62,6 +62,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260726_33_background_job_outbox";
private const string CourseAdjustmentOccurrencesMigration =
"20260727_34_course_adjustment_occurrences";
private const string AcademicPlanningPrerequisitesMigration =
"20260727_35_academic_planning_prerequisites";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -453,6 +455,19 @@ public sealed class DevelopmentSqliteMigrator(
BackgroundJobOutboxMigration,
backgroundJobOutboxExists ? [] : BackgroundJobOutboxStatements,
cancellationToken);
var coursePrerequisitesExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'CoursePrerequisites'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
AcademicPlanningPrerequisitesMigration,
coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -2090,4 +2105,31 @@ public sealed class DevelopmentSqliteMigrator(
ON "BackgroundJobOutboxMessages" ("State", "CompletedAt");
"""
];
private static readonly string[] AcademicPlanningPrerequisiteStatements =
[
"""
CREATE TABLE "CoursePrerequisites" (
"Id" TEXT NOT NULL CONSTRAINT "PK_CoursePrerequisites" PRIMARY KEY,
"CourseId" TEXT NOT NULL,
"PrerequisiteCourseId" TEXT NOT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL,
CONSTRAINT "FK_CoursePrerequisites_Courses_CourseId"
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id")
ON DELETE CASCADE,
CONSTRAINT "FK_CoursePrerequisites_Courses_PrerequisiteCourseId"
FOREIGN KEY ("PrerequisiteCourseId") REFERENCES "Courses" ("Id")
ON DELETE RESTRICT
);
""",
"""
CREATE UNIQUE INDEX "IX_CoursePrerequisites_CourseId_PrerequisiteCourseId"
ON "CoursePrerequisites" ("CourseId", "PrerequisiteCourseId");
""",
"""
CREATE INDEX "IX_CoursePrerequisites_PrerequisiteCourseId"
ON "CoursePrerequisites" ("PrerequisiteCourseId");
"""
];
}
@@ -0,0 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class AcademicPlanningPrerequisites : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CoursePrerequisites",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
PrerequisiteCourseId = table.Column<Guid>(type: "char(36)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CoursePrerequisites", x => x.Id);
table.ForeignKey(
name: "FK_CoursePrerequisites_Courses_CourseId",
column: x => x.CourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CoursePrerequisites_Courses_PrerequisiteCourseId",
column: x => x.PrerequisiteCourseId,
principalTable: "Courses",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_CoursePrerequisites_CourseId_PrerequisiteCourseId",
table: "CoursePrerequisites",
columns: new[] { "CourseId", "PrerequisiteCourseId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_CoursePrerequisites_PrerequisiteCourseId",
table: "CoursePrerequisites",
column: "PrerequisiteCourseId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CoursePrerequisites");
}
}
}
@@ -0,0 +1,54 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class MessageRichContent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "Notifications",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(1000)",
oldMaxLength: 1000);
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "MessageDispatches",
type: "longtext",
nullable: false,
oldClrType: typeof(string),
oldType: "varchar(1000)",
oldMaxLength: 1000);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "Notifications",
type: "varchar(1000)",
maxLength: 1000,
nullable: false,
oldClrType: typeof(string),
oldType: "longtext");
migrationBuilder.AlterColumn<string>(
name: "Content",
table: "MessageDispatches",
type: "varchar(1000)",
maxLength: 1000,
nullable: false,
oldClrType: typeof(string),
oldType: "longtext");
}
}
}
@@ -908,6 +908,34 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("CourseExemptions");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid>("CourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("PrerequisiteCourseId")
.HasColumnType("char(36)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("PrerequisiteCourseId");
b.HasIndex("CourseId", "PrerequisiteCourseId")
.IsUnique();
b.ToTable("CoursePrerequisites");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.Property<Guid>("Id")
@@ -2338,8 +2366,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -2385,8 +2412,7 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
@@ -3818,6 +3844,25 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("TeachingTask");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CoursePrerequisite", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course")
.WithMany("Prerequisites")
.HasForeignKey("CourseId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Jiaowu.Api.Domain.Academic.Course", "PrerequisiteCourse")
.WithMany("RequiredByCourses")
.HasForeignKey("PrerequisiteCourseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Course");
b.Navigation("PrerequisiteCourse");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound")
@@ -4734,6 +4779,13 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.Navigation("Records");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b =>
{
b.Navigation("Prerequisites");
b.Navigation("RequiredByCourses");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b =>
{
b.Navigation("Enrollments");
+21
View File
@@ -8,6 +8,7 @@ using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Exams;
using Jiaowu.Api.Infrastructure.Middleware;
using Jiaowu.Api.Infrastructure.OfficialDocuments;
using Jiaowu.Api.Infrastructure.Operations;
using Jiaowu.Api.Infrastructure.Persistence;
using Jiaowu.Api.Infrastructure.Scheduling;
using Jiaowu.Api.Infrastructure.Timetables;
@@ -74,6 +75,9 @@ var officialDocumentOptions = builder.Configuration
var backgroundJobOptions = builder.Configuration
.GetSection(BackgroundJobOptions.SectionName)
.Get<BackgroundJobOptions>() ?? new BackgroundJobOptions();
var operationsOptions = builder.Configuration
.GetSection(OperationsOptions.SectionName)
.Get<OperationsOptions>() ?? new OperationsOptions();
var rabbitMqOptions = builder.Configuration
.GetSection(RabbitMqOptions.SectionName)
.Get<RabbitMqOptions>() ?? new RabbitMqOptions();
@@ -167,10 +171,25 @@ if (backgroundJobOptions.UsesRabbitMq &&
"生产环境启用 RabbitMQ 时不能使用默认 guest 凭据。");
}
if (string.IsNullOrWhiteSpace(operationsOptions.BackupDirectory) ||
operationsOptions.BackupWarningHours is < 1 or > 8760 ||
operationsOptions.ToolTimeoutMinutes is < 1 or > 240 ||
string.IsNullOrWhiteSpace(operationsOptions.MySqlDumpPath) ||
string.IsNullOrWhiteSpace(operationsOptions.MySqlClientPath) ||
operationsOptions.MySqlAdditionalArguments.Length > 20 ||
operationsOptions.MySqlAdditionalArguments.Any(argument =>
string.IsNullOrWhiteSpace(argument) ||
argument.Length > 300 ||
!argument.StartsWith("--", StringComparison.Ordinal)))
{
throw new InvalidOperationException("Operations 运维与备份配置超出允许范围。");
}
builder.Services.AddSingleton(databaseOptions);
builder.Services.AddSingleton(cacheOptions);
builder.Services.AddSingleton(officialDocumentOptions);
builder.Services.AddSingleton(backgroundJobOptions);
builder.Services.AddSingleton(operationsOptions);
builder.Services.AddSingleton(rabbitMqOptions);
builder.Services.Configure<OfficialDocumentOptions>(
builder.Configuration.GetSection(OfficialDocumentOptions.SectionName));
@@ -277,6 +296,8 @@ builder.Services.AddScoped<MakeupExamArrangementService>();
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
builder.Services.AddSingleton<BackgroundJobTelemetry>();
builder.Services.AddScoped<BackgroundJobMonitoringService>();
builder.Services.AddScoped<OperationalHealthService>();
builder.Services.AddSingleton<DatabaseBackupService>();
builder.Services.AddSingleton<BackgroundJobRunner>();
if (backgroundJobOptions.UsesRabbitMq)
{
+8
View File
@@ -19,6 +19,14 @@
"AnalyticsLocalExpirationSeconds": 30,
"MaximumPayloadKilobytes": 2048
},
"Operations": {
"BackupDirectory": "data/backups",
"BackupWarningHours": 24,
"ToolTimeoutMinutes": 30,
"MySqlDumpPath": "mysqldump",
"MySqlClientPath": "mysql",
"MySqlAdditionalArguments": []
},
"BackgroundJobs": {
"Transport": "InMemory",
"PollIntervalMilliseconds": 500,
@@ -0,0 +1,232 @@
using System.Text.Json;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class AcademicPlanningControllerTests
{
[Fact]
public async Task Simulation_reuses_published_grade_and_checks_prerequisites()
{
await using var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
await using var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var user = new ApplicationUser
{
Id = Guid.NewGuid(),
UserName = "student",
NormalizedUserName = "STUDENT",
DisplayName = "规划学生"
};
var college = new College { Code = "CS", Name = "计算机学院" };
var major = new Major
{
Code = "SE",
Name = "软件工程",
CollegeId = college.Id,
DegreeType = "工学",
SchoolingYears = 4
};
var administrativeClass = new AdministrativeClass
{
Code = "SE2501",
Name = "软件工程2501班",
MajorId = major.Id,
Grade = 2025
};
var student = new Student
{
StudentNumber = "20250001",
Name = "规划学生",
AdministrativeClassId = administrativeClass.Id,
EnrollmentYear = 2025,
EnrollmentDate = new DateOnly(2025, 9, 1),
UserId = user.Id
};
var pastTerm = new AcademicTerm
{
Code = "2025-A",
Name = "2025—2026 学年秋季学期",
AcademicYear = "2025-2026",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2025, 9, 1),
EndDate = new DateOnly(2026, 1, 15)
};
var currentTerm = new AcademicTerm
{
Code = "2025-S",
Name = "2025—2026 学年春季学期",
AcademicYear = "2025-2026",
Season = TermSeason.Spring,
StartDate = new DateOnly(2026, 2, 20),
EndDate = new DateOnly(2026, 7, 10),
IsCurrent = true
};
var introduction = new Course
{
Code = "CS101",
Name = "程序设计基础",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination
};
var dataStructures = new Course
{
Code = "CS201",
Name = "数据结构",
CollegeId = college.Id,
Credits = 4,
TotalHours = 64,
LectureHours = 48,
PracticeHours = 16,
Nature = CourseNature.MajorRequired,
AssessmentMethod = AssessmentMethod.Examination,
Prerequisites =
[
new CoursePrerequisite
{
PrerequisiteCourseId = introduction.Id
}
]
};
var plan = new CurriculumPlan
{
MajorId = major.Id,
Name = "软件工程本科培养方案",
Version = "2025",
EffectiveGrade = 2025,
TotalCredits = 8,
Status = CurriculumPlanStatus.Published,
PublishedAt = DateTime.UtcNow,
Modules =
[
new CurriculumModule
{
Code = "M01",
Name = "专业基础",
RequiredCredits = 0,
Courses =
[
new CurriculumCourse
{
CourseId = introduction.Id,
RecommendedSemester = 1,
Type = CurriculumCourseType.Required
},
new CurriculumCourse
{
CourseId = dataStructures.Id,
RecommendedSemester = 3,
Type = CurriculumCourseType.Required
}
]
}
]
};
var teachingTask = new TeachingTask
{
TaskNumber = "2025-CS101-01",
Name = "程序设计基础",
AcademicTermId = pastTerm.Id,
CourseId = introduction.Id,
Capacity = 50,
Status = TeachingTaskStatus.Closed
};
var gradeSheet = new GradeSheet
{
TeachingTaskId = teachingTask.Id,
Status = GradeSheetStatus.Published,
PublishedAt = DateTime.UtcNow,
Records =
[
new GradeRecord
{
StudentId = student.Id,
TotalScore = 82,
GradePoint = 3.2m
}
]
};
db.AddRange(
user,
college,
major,
administrativeClass,
student,
pastTerm,
currentTerm,
introduction,
dataStructures,
plan,
teachingTask,
gradeSheet);
await db.SaveChangesAsync();
var controller = new AcademicPlanningController(
db,
new StudentDataScope(user.Id));
var overview = Assert.IsType<OkObjectResult>(
await controller.Get(default));
using var overviewJson = ToJson(overview.Value);
Assert.Equal(
4,
overviewJson.RootElement
.GetProperty("baseline")
.GetProperty("earnedCredits")
.GetDecimal());
Assert.Equal(
dataStructures.Id,
overviewJson.RootElement
.GetProperty("nextSemesterSuggestion")[0]
.GetProperty("courseId")
.GetGuid());
var result = Assert.IsType<OkObjectResult>(
await controller.Simulate(
new AcademicPlanningRequest(
[
new AcademicPlanningTermRequest(
3,
[dataStructures.Id])
]),
default));
using var resultJson = ToJson(result.Value);
Assert.Empty(resultJson.RootElement.GetProperty("conflicts").EnumerateArray());
var projected = resultJson.RootElement.GetProperty("projected");
Assert.Equal(8, projected.GetProperty("earnedCredits").GetDecimal());
Assert.Equal(
(int)GraduationAuditConclusion.Eligible,
projected.GetProperty("graduationConclusion").GetInt32());
}
private static JsonDocument ToJson(object? value) => JsonDocument.Parse(
JsonSerializer.Serialize(
value,
new JsonSerializerOptions(JsonSerializerDefaults.Web)));
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
userId,
"规划学生",
null,
DataScope.Self,
new HashSet<string>([SystemRoles.Student]));
}
}
@@ -0,0 +1,87 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Infrastructure.Graduation;
namespace Jiaowu.Api.Tests;
public sealed class AcademicPlanningRulesTests
{
private static readonly Guid AdvancedCourseId = Guid.NewGuid();
private static readonly Guid PrerequisiteCourseId = Guid.NewGuid();
private static readonly AcademicPlanningCourseSnapshot[] Courses =
[
new(
PrerequisiteCourseId,
"程序设计基础",
4,
1,
CurriculumCourseType.Required,
[]),
new(
AdvancedCourseId,
"数据结构",
4,
2,
CurriculumCourseType.Required,
[PrerequisiteCourseId])
];
[Fact]
public void Prerequisite_must_be_completed_or_planned_in_an_earlier_term()
{
var sameTerm = AcademicPlanningRules.FindPrerequisiteIssues(
Courses,
new HashSet<Guid>(),
new HashSet<Guid>(),
new Dictionary<Guid, int>
{
[PrerequisiteCourseId] = 3,
[AdvancedCourseId] = 3
});
var correctOrder = AcademicPlanningRules.FindPrerequisiteIssues(
Courses,
new HashSet<Guid>(),
new HashSet<Guid>(),
new Dictionary<Guid, int>
{
[PrerequisiteCourseId] = 3,
[AdvancedCourseId] = 4
});
Assert.Single(sameTerm);
Assert.Empty(correctOrder);
}
[Fact]
public void In_progress_prerequisite_unlocks_next_term_suggestion()
{
var suggestion = AcademicPlanningRules.SuggestNextSemester(
Courses,
new HashSet<Guid>(),
new HashSet<Guid>([PrerequisiteCourseId]),
2);
Assert.Equal([AdvancedCourseId], suggestion);
}
[Theory]
[InlineData(0, 0, 5, 4, 4)]
[InlineData(25, 0, 5, 4, 6)]
[InlineData(0, 7, 5, 4, 6)]
public void Completion_estimate_uses_credit_and_requirement_capacity(
decimal remainingCredits,
int remainingRequirements,
int nextSemester,
int latestPlannedSemester,
int expectedSemester)
{
Assert.Equal(
expectedSemester,
AcademicPlanningRules.EstimateCompletionSemester(
nextSemester,
latestPlannedSemester,
remainingCredits,
remainingRequirements));
}
}
@@ -0,0 +1,261 @@
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace Jiaowu.Api.Tests;
public sealed class DashboardControllerTests
{
[Fact]
public async Task College_dashboard_is_scoped_and_only_counts_actionable_stage()
{
await using var database = await DashboardDatabase.CreateAsync();
var controller = new DashboardController(
database.Db,
new TestDataScope(
database.FirstCollegeId,
DataScope.College,
SystemRoles.CollegeAdmin));
var result = await controller.Get(CancellationToken.None);
var response = Assert.IsType<DashboardResponse>(
Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.Equal("College", response.Audience.Level);
Assert.Equal("第一学院", response.Audience.ScopeName);
Assert.Equal(1, response.Counts.Students);
Assert.Equal(1, response.Counts.Teachers);
Assert.Equal(1, response.Counts.TeachingTasks);
Assert.Equal(1, response.Pending.TeacherApplications);
Assert.Equal(1, response.Pending.StudentStatusChanges);
}
[Fact]
public async Task School_dashboard_uses_school_stage_and_all_colleges()
{
await using var database = await DashboardDatabase.CreateAsync();
var controller = new DashboardController(
database.Db,
new TestDataScope(
null,
DataScope.All,
SystemRoles.AcademicAdmin));
var result = await controller.Get(CancellationToken.None);
var response = Assert.IsType<DashboardResponse>(
Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.Equal("School", response.Audience.Level);
Assert.Equal(2, response.Counts.Students);
Assert.Equal(2, response.Counts.Teachers);
Assert.Equal(2, response.Counts.TeachingTasks);
Assert.Equal(2, response.Pending.TeacherApplications);
Assert.Equal(1, response.Pending.StudentStatusChanges);
}
private sealed class DashboardDatabase : IAsyncDisposable
{
private readonly SqliteConnection connection;
private DashboardDatabase(
SqliteConnection connection,
AppDbContext db,
Guid firstCollegeId)
{
this.connection = connection;
Db = db;
FirstCollegeId = firstCollegeId;
}
public AppDbContext Db { get; }
public Guid FirstCollegeId { get; }
public static async Task<DashboardDatabase> CreateAsync()
{
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
var db = new AppDbContext(options);
await db.Database.EnsureCreatedAsync();
var term = new AcademicTerm
{
Code = "2026-1",
Name = "2026—2027 学年第一学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15),
IsCurrent = true
};
var firstCollege = new College
{
Code = "C01",
Name = "第一学院"
};
var secondCollege = new College
{
Code = "C02",
Name = "第二学院"
};
var firstMajor = CreateMajor("M01", firstCollege);
var secondMajor = CreateMajor("M02", secondCollege);
var firstClass = CreateClass("CL01", firstMajor);
var secondClass = CreateClass("CL02", secondMajor);
var firstStudent = CreateStudent("S01", firstClass);
var secondStudent = CreateStudent("S02", secondClass);
var firstTeacher = CreateTeacher("T01", firstCollege);
var secondTeacher = CreateTeacher("T02", secondCollege);
var firstCourse = CreateCourse("COURSE01", firstCollege);
var secondCourse = CreateCourse("COURSE02", secondCollege);
var firstTask = CreateTask("TASK01", term, firstCourse);
var secondTask = CreateTask("TASK02", term, secondCourse);
db.AddRange(
term,
firstCollege,
secondCollege,
firstMajor,
secondMajor,
firstClass,
secondClass,
firstStudent,
secondStudent,
firstTeacher,
secondTeacher,
firstCourse,
secondCourse,
firstTask,
secondTask,
new TeacherCourseApplication
{
AcademicTerm = term,
Teacher = firstTeacher,
Course = firstCourse,
Status = TeacherCourseApplicationStatus.Pending
},
new TeacherCourseApplication
{
AcademicTerm = term,
Teacher = secondTeacher,
Course = secondCourse,
Status = TeacherCourseApplicationStatus.Pending
},
CreateStatusChange(
firstStudent,
StudentStatusChangeState.CounselorApproved),
CreateStatusChange(
firstStudent,
StudentStatusChangeState.CollegeApproved),
CreateStatusChange(
secondStudent,
StudentStatusChangeState.Submitted));
await db.SaveChangesAsync();
return new DashboardDatabase(connection, db, firstCollege.Id);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await connection.DisposeAsync();
}
private static Major CreateMajor(string code, College college) => new()
{
Code = code,
Name = $"专业 {code}",
College = college,
DegreeType = "本科"
};
private static AdministrativeClass CreateClass(
string code,
Major major) => new()
{
Code = code,
Name = $"班级 {code}",
Major = major,
Grade = 2026
};
private static Student CreateStudent(
string number,
AdministrativeClass administrativeClass) => new()
{
StudentNumber = number,
Name = $"学生 {number}",
AdministrativeClass = administrativeClass,
EnrollmentYear = 2026,
EnrollmentDate = new DateOnly(2026, 9, 1),
Status = StudentStatus.Active
};
private static Teacher CreateTeacher(
string number,
College college) => new()
{
TeacherNumber = number,
Name = $"教师 {number}",
College = college,
Status = TeacherStatus.Active
};
private static Course CreateCourse(string code, College college) => new()
{
Code = code,
Name = $"课程 {code}",
College = college,
Nature = CourseNature.MajorRequired,
Credits = 2,
TotalHours = 32,
LectureHours = 32,
AssessmentMethod = AssessmentMethod.Examination
};
private static TeachingTask CreateTask(
string number,
AcademicTerm term,
Course course) => new()
{
TaskNumber = number,
Name = $"教学班 {number}",
AcademicTerm = term,
Course = course,
Capacity = 40,
Status = TeachingTaskStatus.Published
};
private static StudentStatusChange CreateStatusChange(
Student student,
StudentStatusChangeState state) => new()
{
Student = student,
Type = StudentStatusChangeType.Suspension,
OriginalStatus = StudentStatus.Active,
TargetStatus = StudentStatus.Suspended,
Reason = "测试学籍异动流程",
State = state
};
}
private sealed class TestDataScope(
Guid? collegeId,
DataScope dataScope,
string role) : ICurrentUserDataScope
{
public CurrentUserScope Current { get; } = new(
Guid.NewGuid(),
"测试管理员",
collegeId,
dataScope,
new HashSet<string> { role });
}
}
@@ -31,6 +31,7 @@ public sealed class ExamArrangementServiceTests
plan.Sessions.Add(untouched);
db.ExamPlans.Add(plan);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var result = await new ExamArrangementService(db).ArrangeAsync(
plan.Id,
@@ -39,11 +40,14 @@ public sealed class ExamArrangementServiceTests
assignInvigilators: false,
CancellationToken.None);
var persistedSelected = await db.ExamSessions.FindAsync(selected.Id);
var persistedUntouched = await db.ExamSessions.FindAsync(untouched.Id);
Assert.True(result.Success);
Assert.NotNull(selected.ClassroomId);
Assert.Null(untouched.ClassroomId);
Assert.Empty(selected.Invigilators);
Assert.NotNull(persistedSelected!.ClassroomId);
Assert.Null(persistedUntouched!.ClassroomId);
Assert.Empty(persistedSelected.Invigilators);
Assert.Contains("1个场次处理完成", result.Message);
Assert.Contains("程序设计", result.Message);
}
[Fact]
@@ -69,6 +73,7 @@ public sealed class ExamArrangementServiceTests
plan.Sessions.Add(untouched);
db.MakeupExamPlans.Add(plan);
await db.SaveChangesAsync();
db.ChangeTracker.Clear();
var result = await new MakeupExamArrangementService(db).ArrangeAsync(
plan.Id,
@@ -77,11 +82,14 @@ public sealed class ExamArrangementServiceTests
assignInvigilators: false,
CancellationToken.None);
var persistedSelected = await db.MakeupExamSessions.FindAsync(selected.Id);
var persistedUntouched = await db.MakeupExamSessions.FindAsync(untouched.Id);
Assert.True(result.Success);
Assert.NotNull(selected.ClassroomId);
Assert.Null(untouched.ClassroomId);
Assert.Empty(selected.Invigilators);
Assert.NotNull(persistedSelected!.ClassroomId);
Assert.Null(persistedUntouched!.ClassroomId);
Assert.Empty(persistedSelected.Invigilators);
Assert.Contains("1个场次处理完成", result.Message);
Assert.Contains("程序设计", result.Message);
}
private static ExamSession NewExamSession(Guid planId, Guid taskId) => new()
@@ -3,14 +3,50 @@ using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Infrastructure.Auth;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using Microsoft.AspNetCore.Routing;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Jiaowu.Api.Tests;
public sealed class NotificationsControllerTests
{
[Fact]
public void Send_message_request_validation_metadata_is_compatible_with_mvc_record_binding()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddControllers()
.AddApplicationPart(typeof(NotificationsController).Assembly);
using var serviceProvider = services.BuildServiceProvider();
var objectValidator = serviceProvider.GetRequiredService<IObjectModelValidator>();
var httpContext = new DefaultHttpContext
{
RequestServices = serviceProvider
};
var actionContext = new ActionContext(
httpContext,
new RouteData(),
new ActionDescriptor(),
new ModelStateDictionary());
var request = new SendMessageRequest(
"校内通知",
"<p>这是一条用于验证请求模型绑定的通知。</p>");
var exception = Record.Exception(() =>
objectValidator.Validate(actionContext, null, string.Empty, request));
Assert.Null(exception);
Assert.True(actionContext.ModelState.IsValid);
}
[Fact]
public async Task College_administrator_can_only_send_to_enabled_users_in_own_college()
{
@@ -119,6 +155,85 @@ public sealed class NotificationsControllerTests
Assert.Contains(fixture.ClassStudentUser.Id, recipientIds);
}
[Fact]
public async Task School_administrator_can_filter_recipients_by_college()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.AcademicAdmin));
var result = await controller.Send(
new SendMessageRequest(
"第二学院通知",
"<p><strong>仅发送</strong>给第二学院。</p>",
RecipientMode: MessageRecipientMode.Filtered,
RecipientFilter: new MessageRecipientFilter(
CollegeId: fixture.SecondCollege.Id)),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var notification = Assert.Single(
await fixture.Db.Notifications.ToListAsync());
Assert.Equal(fixture.OutsideRecipient.Id, notification.UserId);
var dispatch = await fixture.Db.MessageDispatches.SingleAsync();
Assert.Equal(MessageAudienceType.Custom, dispatch.AudienceType);
Assert.Equal("指定学院", dispatch.AudienceName);
}
[Fact]
public async Task College_administrator_cannot_select_recipient_outside_college()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.CollegeAdmin));
var result = await controller.Send(
new SendMessageRequest(
"越权消息",
"<p>不应发送。</p>",
RecipientMode: MessageRecipientMode.Selected,
RecipientUserIds: [fixture.OutsideRecipient.Id]),
CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
Assert.Empty(await fixture.Db.Notifications.ToListAsync());
Assert.Empty(await fixture.Db.MessageDispatches.ToListAsync());
}
[Fact]
public async Task Administrator_can_send_rich_content_larger_than_legacy_limit()
{
await using var fixture = await NotificationFixture.CreateAsync();
var controller = new NotificationsController(
fixture.Db,
new TestDataScope(
fixture.Sender.Id,
fixture.FirstCollege.Id,
SystemRoles.AcademicAdmin));
var content = $"<h2>教学安排</h2><p>{new string('内', 1500)}</p>";
var result = await controller.Send(
new SendMessageRequest(
"富文本通知",
content,
RecipientMode: MessageRecipientMode.Selected,
RecipientUserIds: [fixture.ClassStudentUser.Id]),
CancellationToken.None);
Assert.IsType<OkObjectResult>(result);
var notification = await fixture.Db.Notifications.SingleAsync();
Assert.Equal(content, notification.Content);
Assert.Equal(fixture.ClassStudentUser.Id, notification.UserId);
}
[Fact]
public async Task Publishing_course_grades_automatically_notifies_roster_students()
{
@@ -189,6 +304,7 @@ public sealed class NotificationsControllerTests
SqliteConnection connection,
AppDbContext db,
College firstCollege,
College secondCollege,
ApplicationUser sender,
ApplicationUser collegeRecipient,
ApplicationUser outsideRecipient,
@@ -201,6 +317,7 @@ public sealed class NotificationsControllerTests
this.connection = connection;
Db = db;
FirstCollege = firstCollege;
SecondCollege = secondCollege;
Sender = sender;
CollegeRecipient = collegeRecipient;
OutsideRecipient = outsideRecipient;
@@ -213,6 +330,7 @@ public sealed class NotificationsControllerTests
public AppDbContext Db { get; }
public College FirstCollege { get; }
public College SecondCollege { get; }
public ApplicationUser Sender { get; }
public ApplicationUser CollegeRecipient { get; }
public ApplicationUser OutsideRecipient { get; }
@@ -362,6 +480,7 @@ public sealed class NotificationsControllerTests
connection,
db,
firstCollege,
secondCollege,
sender,
collegeRecipient,
outsideRecipient,
@@ -0,0 +1,252 @@
using System.Reflection;
using Jiaowu.Api.Contracts;
using Jiaowu.Api.Controllers;
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
using Jiaowu.Api.Domain.System;
using Jiaowu.Api.Infrastructure.BackgroundJobs;
using Jiaowu.Api.Infrastructure.Caching;
using Jiaowu.Api.Infrastructure.Operations;
using Jiaowu.Api.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jiaowu.Api.Tests;
public sealed class OperationsControllerTests
{
[Fact]
public void Controller_is_restricted_to_super_admin()
{
var authorize = typeof(OperationsController)
.GetCustomAttribute<AuthorizeAttribute>();
Assert.NotNull(authorize);
Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles);
}
[Fact]
public async Task Audit_and_failed_job_queries_return_operational_records()
{
var root = CreateTemporaryRoot();
try
{
await using var fixture = await OperationsFixture.CreateAsync(root);
var term = new AcademicTerm
{
Code = "OPS-TERM",
Name = "运维测试学期",
AcademicYear = "2026-2027",
Season = TermSeason.Autumn,
StartDate = new DateOnly(2026, 9, 1),
EndDate = new DateOnly(2027, 1, 15)
};
var plan = new SchedulePlan
{
AcademicTerm = term,
Name = "恢复演练排课方案",
Version = "V1"
};
var job = new AutomaticScheduleJob
{
SchedulePlan = plan,
Status = AutomaticScheduleJobStatus.Failed,
ErrorMessage = "排课求解器未找到可行解。",
CompletedAt = DateTime.UtcNow
};
var outbox = BackgroundJobOutboxMessage.Create(
BackgroundJobKind.AutomaticSchedule,
job.Id);
outbox.State = BackgroundJobOutboxState.Completed;
outbox.ProcessingAttempts = 3;
fixture.Db.AuditLogs.Add(new AuditLog
{
UserName = "root",
Method = "POST",
Path = "/api/schedules",
StatusCode = 500,
IpAddress = "127.0.0.1"
});
fixture.Db.AddRange(term, plan, job, outbox);
await fixture.Db.SaveChangesAsync();
var auditAction = await fixture.Controller.GetAuditLogs(
cancellationToken: CancellationToken.None);
var auditResult = Assert.IsType<OkObjectResult>(auditAction.Result);
var auditPage = Assert.IsType<PagedResult<AuditLogItem>>(auditResult.Value);
Assert.Single(auditPage.Items);
Assert.Equal(500, auditPage.Items.Single().StatusCode);
var jobsAction = await fixture.Controller.GetFailedJobs(
cancellationToken: CancellationToken.None);
var jobsResult = Assert.IsType<OkObjectResult>(jobsAction.Result);
var jobsPage =
Assert.IsType<PagedResult<FailedBackgroundJobItem>>(jobsResult.Value);
var failed = Assert.Single(jobsPage.Items);
Assert.Equal("AutomaticSchedule", failed.Kind);
Assert.Equal(3, failed.ProcessingAttempts);
Assert.Contains("可行解", failed.ErrorMessage);
}
finally
{
DeleteTemporaryRoot(root);
}
}
[Fact]
public async Task Sqlite_backup_can_be_verified_in_an_isolated_restore_drill()
{
var root = CreateTemporaryRoot();
try
{
await using var fixture = await OperationsFixture.CreateAsync(root);
fixture.Db.AuditLogs.Add(new AuditLog
{
UserName = "backup-test",
Method = "POST",
Path = "/api/test",
StatusCode = 204
});
await fixture.Db.SaveChangesAsync();
var artifact = await fixture.Backups.CreateAsync(
"自动化测试",
CancellationToken.None);
var result = await fixture.Backups.RunRestoreDrillAsync(
artifact.Id,
CancellationToken.None);
var listed = await fixture.Backups.ListAsync(CancellationToken.None);
Assert.True(File.Exists(Path.Combine(
root,
"backups",
artifact.FileName)));
Assert.Equal(64, artifact.Sha256.Length);
Assert.True(result.Succeeded, result.Detail);
Assert.True(result.TableCount > 0);
Assert.True(Assert.Single(listed).LastDrillSucceeded);
}
finally
{
DeleteTemporaryRoot(root);
}
}
private static string CreateTemporaryRoot()
{
var root = Path.Combine(
Path.GetTempPath(),
$"jiaowu-operations-{Guid.NewGuid():N}");
Directory.CreateDirectory(root);
return root;
}
private static void DeleteTemporaryRoot(string root)
{
var resolved = Path.GetFullPath(root);
var temporary = Path.GetFullPath(Path.GetTempPath());
Assert.StartsWith(temporary, resolved, StringComparison.OrdinalIgnoreCase);
if (Directory.Exists(resolved))
Directory.Delete(resolved, recursive: true);
}
private sealed class OperationsFixture : IAsyncDisposable
{
private readonly ServiceProvider provider;
private OperationsFixture(
ServiceProvider provider,
AppDbContext db,
OperationsController controller,
DatabaseBackupService backups)
{
this.provider = provider;
Db = db;
Controller = controller;
Backups = backups;
}
public AppDbContext Db { get; }
public OperationsController Controller { get; }
public DatabaseBackupService Backups { get; }
public static async Task<OperationsFixture> CreateAsync(string root)
{
var databasePath = Path.Combine(root, "source.sqlite");
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:SQLite"] =
$"Data Source={databasePath};Pooling=False"
})
.Build();
var databaseOptions = new DatabaseOptions { Provider = "SQLite" };
var cacheOptions = new AppCacheOptions();
var operationsOptions = new OperationsOptions
{
BackupDirectory = "backups"
};
var dbContextOptions = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(configuration.GetConnectionString("SQLite"))
.Options;
var db = new AppDbContext(dbContextOptions);
await db.Database.EnsureCreatedAsync();
var services = new ServiceCollection()
.AddLogging()
.BuildServiceProvider();
var logger = services.GetRequiredService<
ILogger<DatabaseBackupService>>();
var environment = new TestHostEnvironment
{
ContentRootPath = root
};
var transport = new InMemoryBackgroundJobTransport();
var monitoring = new BackgroundJobMonitoringService(db);
var health = new OperationalHealthService(
db,
services,
transport,
monitoring,
databaseOptions,
cacheOptions,
configuration);
var backups = new DatabaseBackupService(
databaseOptions,
operationsOptions,
configuration,
environment,
logger);
var controller = new OperationsController(
db,
health,
backups,
operationsOptions);
return new OperationsFixture(services, db, controller, backups);
}
public async ValueTask DisposeAsync()
{
await Db.DisposeAsync();
await provider.DisposeAsync();
}
}
private sealed class TestHostEnvironment : IWebHostEnvironment
{
public string ApplicationName { get; set; } = "Jiaowu.Api.Tests";
public IFileProvider WebRootFileProvider { get; set; } = new NullFileProvider();
public string WebRootPath { get; set; } = "";
public string EnvironmentName { get; set; } = Environments.Development;
public string ContentRootPath { get; set; } = "";
public IFileProvider ContentRootFileProvider { get; set; } =
new NullFileProvider();
}
}
+2
View File
@@ -1 +1,3 @@
VITE_API_BASE_URL=/api
# 自托管 CKEditor 5GPL 兼容项目可保留 GPL;商业部署请在构建时填写正式许可证键。
VITE_CKEDITOR_LICENSE_KEY=GPL
+2565 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -9,8 +9,11 @@
"preview": "vite preview"
},
"dependencies": {
"@ckeditor/ckeditor5-vue": "^8.2.0",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.18.1",
"ckeditor5": "^48.3.1",
"dompurify": "^3.4.12",
"echarts": "^6.1.0",
"element-plus": "^2.14.3",
"html2canvas": "^1.4.1",
+2
View File
@@ -38,6 +38,7 @@ declare module 'vue' {
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElResult: typeof import('element-plus/es')['ElResult']
ElSegmented: typeof import('element-plus/es')['ElSegmented']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider']
@@ -49,6 +50,7 @@ declare module 'vue' {
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeSelect: typeof import('element-plus/es')['ElTimeSelect']
RichMessageContent: typeof import('./components/RichMessageContent.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed } from 'vue'
import DOMPurify from 'dompurify'
const props = defineProps<{
content: string
rich?: boolean
}>()
const safeHtml = computed(() => DOMPurify.sanitize(props.content, {
ALLOWED_TAGS: [
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's',
'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'blockquote', 'a',
'figure', 'figcaption', 'img',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'colgroup', 'col',
],
ALLOWED_ATTR: [
'href', 'target', 'rel',
'src', 'alt', 'title', 'width', 'height', 'class', 'style',
'colspan', 'rowspan', 'scope',
],
ALLOW_DATA_ATTR: false,
}))
</script>
<template>
<div v-if="rich" class="rich-message-content" v-html="safeHtml" />
<p v-else class="plain-message-content">{{ content }}</p>
</template>
<style scoped>
.plain-message-content {
margin: 0;
white-space: pre-wrap;
}
.rich-message-content {
overflow-wrap: anywhere;
color: inherit;
font-size: inherit;
line-height: 1.75;
}
.rich-message-content::after {
display: block;
clear: both;
content: '';
}
.rich-message-content :deep(p),
.rich-message-content :deep(ul),
.rich-message-content :deep(ol),
.rich-message-content :deep(blockquote) {
margin: 0 0 .65em;
}
.rich-message-content :deep(p:last-child),
.rich-message-content :deep(ul:last-child),
.rich-message-content :deep(ol:last-child),
.rich-message-content :deep(blockquote:last-child) {
margin-bottom: 0;
}
.rich-message-content :deep(h2),
.rich-message-content :deep(h3),
.rich-message-content :deep(h4) {
margin: .8em 0 .35em;
color: var(--ink);
font-family: inherit;
}
.rich-message-content :deep(h2) { font-size: 1.25em; }
.rich-message-content :deep(h3) { font-size: 1.12em; }
.rich-message-content :deep(h4) { font-size: 1em; }
.rich-message-content :deep(ul),
.rich-message-content :deep(ol) {
padding-left: 1.5em;
}
.rich-message-content :deep(blockquote) {
padding: .55em .9em;
border-left: 3px solid var(--teal);
background: #f3f8f7;
}
.rich-message-content :deep(a) {
color: var(--indigo);
text-decoration: underline;
text-underline-offset: 2px;
}
.rich-message-content :deep(figure) {
margin: .9em auto;
}
.rich-message-content :deep(figure.image) {
max-width: 100%;
}
.rich-message-content :deep(img) {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
border-radius: 6px;
}
.rich-message-content :deep(figcaption) {
margin-top: .4em;
color: var(--muted);
font-size: .86em;
line-height: 1.5;
text-align: center;
}
.rich-message-content :deep(.image-style-align-left),
.rich-message-content :deep(.image-style-block-align-left) {
margin-right: 1.25em;
margin-left: 0;
}
.rich-message-content :deep(.image-style-align-right),
.rich-message-content :deep(.image-style-block-align-right),
.rich-message-content :deep(.image-style-side) {
margin-right: 0;
margin-left: auto;
}
.rich-message-content :deep(.image-style-wrap-text.image-style-align-left) {
float: left;
}
.rich-message-content :deep(.image-style-wrap-text.image-style-align-right),
.rich-message-content :deep(.image-style-side) {
float: right;
margin-left: 1.25em;
}
.rich-message-content :deep(figure.table) {
max-width: 100%;
overflow-x: auto;
}
.rich-message-content :deep(table) {
width: 100%;
border-collapse: collapse;
background: #fff;
}
.rich-message-content :deep(th),
.rich-message-content :deep(td) {
min-width: 4.5em;
padding: .55em .7em;
border: 1px solid var(--line);
vertical-align: top;
}
.rich-message-content :deep(th) {
background: #f3f5f8;
color: var(--ink);
font-weight: 650;
text-align: left;
}
</style>
+5
View File
@@ -97,6 +97,7 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
{ path: '/students', label: '学生档案' },
),
...whenVisible(auth.isSuperAdmin, { path: '/users', label: '用户与权限' }),
...whenVisible(auth.isSuperAdmin, { path: '/operations', label: '运维与审计' }),
],
},
{
@@ -205,6 +206,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
key: 'graduation',
label: '毕业管理',
items: [
...whenVisible(
isStudent.value,
{ path: '/academic-planning', label: '学业规划与毕业模拟' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
{ path: '/graduation-audits', label: isStudent.value ? '毕业资格' : '毕业审核' },
+12
View File
@@ -118,6 +118,12 @@ const router = createRouter({
component: () => import('../views/StudentCurriculumView.vue'),
meta: { roles: ['Student'] },
},
{
path: 'academic-planning',
name: 'academic-planning',
component: () => import('../views/AcademicPlanningView.vue'),
meta: { roles: ['Student'] },
},
{
path: 'teaching-tasks',
name: 'teaching-tasks',
@@ -285,6 +291,12 @@ const router = createRouter({
component: () => import('../views/UsersView.vue'),
meta: { roles: ['SuperAdmin'] },
},
{
path: 'operations',
name: 'operations',
component: () => import('../views/OperationsConsoleView.vue'),
meta: { roles: ['SuperAdmin'] },
},
{
path: 'evaluations',
name: 'evaluations',
+815
View File
@@ -0,0 +1,815 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Delete, MagicStick, Refresh, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
interface PlanningTerm {
semester: number
label: string
isBeyondStandard: boolean
courseIds: string[]
}
const loading = ref(false)
const simulationLoading = ref(false)
const initialized = ref(false)
const payload = ref<any>(null)
const simulation = ref<any>(null)
const terms = ref<PlanningTerm[]>([])
const keyword = ref('')
const moduleFilter = ref('')
let simulationTimer: ReturnType<typeof setTimeout> | undefined
const courses = computed<any[]>(() => payload.value?.courses ?? [])
const baseline = computed(() => payload.value?.baseline ?? {})
const projected = computed(() => simulation.value?.projected ?? {
earnedCredits: baseline.value.earnedCredits ?? 0,
planCompletedCredits: baseline.value.planCompletedCredits ?? 0,
creditGap: baseline.value.creditGap ?? 0,
completionRate: baseline.value.completionRate ?? 0,
requirementCount: baseline.value.requirementCount ?? 0,
passedRequirementCount: baseline.value.passedRequirementCount ?? 0,
missingRequirements: baseline.value.missingRequirements ?? [],
graduationConclusion: 'Ineligible',
estimatedGraduationTerm: '等待模拟',
isBeyondStandard: false,
})
const modules = computed(() =>
[...new Map(courses.value.map((course) =>
[course.moduleCode, { code: course.moduleCode, name: course.moduleName }],
)).values()],
)
const assigned = computed(() => {
const result = new Map<string, number>()
for (const term of terms.value) {
for (const courseId of term.courseIds) result.set(courseId, term.semester)
}
return result
})
const remainingCourses = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
return courses.value.filter((course) => {
if (['Completed', 'InProgress', 'Retaking'].includes(course.status)) return false
if (moduleFilter.value && course.moduleCode !== moduleFilter.value) return false
return !normalized ||
`${course.courseCode} ${course.courseName} ${course.moduleName}`
.toLowerCase()
.includes(normalized)
})
})
const plannedCourseCount = computed(() =>
terms.value.reduce((count, term) => count + term.courseIds.length, 0),
)
const plannedCredits = computed(() =>
courses.value
.filter((course) => assigned.value.has(course.courseId))
.reduce((sum, course) => sum + Number(course.credits), 0),
)
const conflictCourseIds = computed(() =>
new Set<string>((simulation.value?.conflicts ?? []).map((item: any) => item.courseId)),
)
const termSummaryMap = computed(() =>
new Map<number, any>((simulation.value?.termSummaries ?? [])
.map((item: any) => [item.semester, item])),
)
function courseById(courseId: string) {
return courses.value.find((course) => course.courseId === courseId)
}
function typeLabel(type: string) {
return type === 'Required' ? '指定必修' : '组内选修'
}
function setCourseSemester(courseId: string, semester?: number) {
for (const term of terms.value) {
term.courseIds = term.courseIds.filter((id) => id !== courseId)
}
if (semester) {
const target = terms.value.find((term) => term.semester === semester)
if (target) target.courseIds.push(courseId)
}
}
function removeCourse(courseId: string) {
setCourseSemester(courseId)
}
function applySuggestion() {
const target = terms.value.find(
(term) => term.semester === payload.value?.plan?.nextSemester,
)
if (!target) return
const ids = (payload.value?.nextSemesterSuggestion ?? [])
.map((course: any) => course.courseId)
.filter((courseId: string) => !assigned.value.has(courseId))
target.courseIds.push(...ids)
ElMessage.success(`已把 ${ids.length} 门建议课程安排到下一学期`)
}
function clearPlan() {
for (const term of terms.value) term.courseIds = []
}
async function simulate() {
simulationLoading.value = true
try {
simulation.value = (await http.post('/student/academic-planning/simulate', {
terms: terms.value.map((term) => ({
semester: term.semester,
courseIds: term.courseIds,
})),
})).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
simulationLoading.value = false
}
}
function scheduleSimulation() {
if (!initialized.value) return
if (simulationTimer) clearTimeout(simulationTimer)
simulationTimer = setTimeout(simulate, 180)
}
async function load() {
loading.value = true
initialized.value = false
try {
payload.value = (await http.get('/student/academic-planning')).data
terms.value = payload.value.terms.map((term: any) => ({
...term,
courseIds: [],
}))
initialized.value = true
await simulate()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
watch(terms, scheduleSimulation, { deep: true })
onMounted(load)
onBeforeUnmount(() => {
if (simulationTimer) clearTimeout(simulationTimer)
})
</script>
<template>
<div class="page-stack academic-planning-page" v-loading="loading">
<section class="page-intro">
<div>
<span class="section-kicker">DEGREE FLIGHT PLAN</span>
<h2>学业规划与毕业模拟</h2>
<p>把未来课程排进学期航线实时检查培养方案完成度先修顺序与毕业时间</p>
</div>
<div class="page-actions">
<el-button :icon="Refresh" @click="load">重置数据</el-button>
<el-button
type="primary"
:icon="MagicStick"
:disabled="!payload?.nextSemesterSuggestion?.length"
@click="applySuggestion"
>
采用下学期建议
</el-button>
</div>
</section>
<template v-if="payload">
<section class="planning-cockpit">
<div class="student-call-sign">
<span>{{ payload.student.enrollmentYear }} · {{ payload.student.collegeName }}</span>
<h3>{{ payload.student.name }}的毕业航线</h3>
<p>{{ payload.student.studentNumber }} · {{ payload.student.majorName }} · {{ payload.plan.name }} {{ payload.plan.version }}</p>
</div>
<div class="completion-dial" aria-label="模拟培养方案完成度">
<span>方案完成度</span>
<b>{{ projected.completionRate }}<small>%</small></b>
<i><em :style="{ width: `${projected.completionRate}%` }" /></i>
</div>
<div class="arrival-board">
<span>预计毕业</span>
<b>{{ projected.estimatedGraduationTerm }}</b>
<small :class="{ delayed: projected.isBeyondStandard }">
{{ projected.isBeyondStandard ? '超过标准学制' : '标准学制内' }}
</small>
</div>
</section>
<section class="planning-metrics" aria-label="毕业模拟摘要">
<div>
<span>已获 / 预计学分</span>
<b>{{ baseline.earnedCredits }} <small></small> {{ projected.earnedCredits }}</b>
</div>
<div :class="{ alert: projected.creditGap > 0 }">
<span>毕业学分缺口</span>
<b>{{ projected.creditGap }}<small> 学分</small></b>
</div>
<div>
<span>培养要求</span>
<b>{{ projected.passedRequirementCount }}<small> / {{ projected.requirementCount }} </small></b>
</div>
<div>
<span>本次模拟</span>
<b>{{ plannedCourseCount }}<small> · {{ plannedCredits }} 学分</small></b>
</div>
</section>
<section class="next-term-brief">
<header>
<span>NEXT TERM ADVICE</span>
<h3>下一学期建议</h3>
<p>{{ payload.terms[0]?.label }}</p>
</header>
<div v-if="payload.nextSemesterSuggestion.length" class="suggestion-list">
<article
v-for="course in payload.nextSemesterSuggestion"
:key="course.courseId"
>
<div>
<span>{{ course.courseCode }} · {{ typeLabel(course.type) }}</span>
<b>{{ course.courseName }}</b>
</div>
<small>{{ course.credits }} 学分</small>
<p>{{ course.reason }}</p>
</article>
</div>
<p v-else class="no-suggestion">
结合当前在读课程先修条件和培养方案建议学期下一学期暂无需要额外安排的课程仍可在下方课程清单模拟其他路径
</p>
</section>
<section
v-if="payload.latestGraduationAudit"
class="official-audit-strip"
>
<div>
<span>最近正式毕业审核</span>
<b>{{ payload.latestGraduationAudit.batchName }}</b>
<small>{{ payload.latestGraduationAudit.publishedAt?.slice(0, 10) }}</small>
</div>
<p>
正式结果{{ payload.latestGraduationAudit.conclusion === 'Eligible' ? '符合毕业条件' : '暂不符合毕业条件' }}
· 已认定 {{ payload.latestGraduationAudit.earnedCredits }} 学分
</p>
<em>模拟不会更改正式审核结果</em>
</section>
<section
v-if="simulation?.conflicts?.length || simulation?.warnings?.length"
class="planning-alerts"
>
<article v-for="item in simulation.conflicts" :key="`${item.type}-${item.courseId}-${item.prerequisiteCourseId}`">
<span>先修冲突</span>
<p>{{ item.message }}</p>
</article>
<article v-for="(item, index) in simulation.warnings" :key="`${item.type}-${index}`" class="warning">
<span>安排提示</span>
<p>{{ item.message }}</p>
</article>
</section>
<section class="planner-workspace">
<aside class="course-pool">
<header>
<div>
<span>COURSE MANIFEST</span>
<h3>待规划课程</h3>
</div>
<b>{{ remainingCourses.length }}</b>
</header>
<div class="pool-filters">
<el-input
v-model="keyword"
:prefix-icon="Search"
clearable
placeholder="搜索课程"
/>
<el-select v-model="moduleFilter" clearable placeholder="全部培养模块">
<el-option
v-for="module in modules"
:key="module.code"
:label="module.name"
:value="module.code"
/>
</el-select>
</div>
<div class="pool-list">
<article
v-for="course in remainingCourses"
:key="course.courseId"
:class="{
assigned: assigned.has(course.courseId),
conflicted: conflictCourseIds.has(course.courseId),
}"
>
<div class="course-manifest-line">
<span>{{ course.courseCode }} · {{ course.moduleName }}</span>
<i>{{ course.credits }} 学分</i>
</div>
<h4>{{ course.courseName }}</h4>
<div class="course-flags">
<span>{{ typeLabel(course.type) }}</span>
<span>建议第 {{ course.recommendedSemester }} 学期</span>
<span v-if="course.status === 'Failed'" class="failed">有未通过记录</span>
</div>
<p v-if="course.prerequisites.length">
先修
<template v-for="(item, index) in course.prerequisites" :key="item.courseId">
<b :class="{ ready: item.isCompleted || item.isInProgress }">{{ item.courseName }}</b>{{ Number(index) < course.prerequisites.length - 1 ? '、' : '' }}
</template>
</p>
<el-select
:model-value="assigned.get(course.courseId)"
clearable
placeholder="安排到学期"
@change="setCourseSemester(course.courseId, $event)"
>
<el-option
v-for="term in terms"
:key="term.semester"
:label="`第 ${term.semester} 学期 · ${term.label}`"
:value="term.semester"
/>
</el-select>
</article>
<el-empty v-if="!remainingCourses.length" description="没有符合条件的待规划课程" />
</div>
</aside>
<main class="semester-runway" v-loading="simulationLoading">
<header>
<div>
<span>SEMESTER RUNWAY</span>
<h3>未来学期航线</h3>
<p>课程必须在其先修课程之后单学期超过 30 学分会标记负荷风险</p>
</div>
<el-button text :icon="Delete" @click="clearPlan">清空安排</el-button>
</header>
<div class="runway-line">
<article
v-for="term in terms"
:key="term.semester"
class="semester-stop"
:class="{ beyond: term.isBeyondStandard }"
>
<div class="semester-marker">
<i>{{ term.semester }}</i>
<span>{{ term.isBeyondStandard ? '延长学期' : `${term.semester} 学期` }}</span>
</div>
<div class="semester-sheet">
<header>
<div>
<span>{{ term.label }}</span>
<h4>{{ term.semester === payload.plan.nextSemester ? '下一学期' : `未来第 ${term.semester - payload.plan.currentSemester} 学期` }}</h4>
</div>
<b>
{{ termSummaryMap.get(term.semester)?.credits ?? 0 }}
<small>学分</small>
</b>
</header>
<div v-if="term.courseIds.length" class="scheduled-courses">
<article
v-for="courseId in term.courseIds"
:key="courseId"
:class="{ conflicted: conflictCourseIds.has(courseId) }"
>
<div>
<span>{{ courseById(courseId)?.courseCode }} · {{ typeLabel(courseById(courseId)?.type) }}</span>
<b>{{ courseById(courseId)?.courseName }}</b>
</div>
<small>{{ courseById(courseId)?.credits }} 学分</small>
<el-button
text
type="danger"
aria-label="移除课程"
@click="removeCourse(courseId)"
>×</el-button>
</article>
</div>
<p v-else class="empty-semester">尚未安排课程可从左侧课程清单选择学期</p>
</div>
</article>
<div class="graduation-gate" :class="{ ready: projected.graduationConclusion === 'Eligible' }">
<span>GRADUATION GATE</span>
<b>{{ projected.graduationConclusion === 'Eligible' ? '模拟达到毕业条件' : '仍有培养要求未完成' }}</b>
<p>{{ projected.estimatedGraduationTerm }}</p>
</div>
</div>
</main>
</section>
<section class="planning-closeout">
<div>
<span>剩余培养要求</span>
<h3>{{ projected.missingRequirements?.length ? `还需完成 ${projected.missingRequirements.length}` : '培养要求已覆盖' }}</h3>
</div>
<div class="missing-requirements">
<span v-for="item in projected.missingRequirements" :key="item">{{ item }}</span>
<em v-if="!projected.missingRequirements?.length">本次模拟已覆盖全部指定课程和课程组要求</em>
</div>
<ul>
<li v-for="item in payload.assumptions" :key="item">{{ item }}</li>
</ul>
</section>
</template>
</div>
</template>
<style scoped>
.planning-cockpit {
min-height: 154px;
display: grid;
grid-template-columns: minmax(300px, 1.2fr) minmax(230px, .7fr) minmax(280px, .9fr);
color: white;
border: 1px solid #15275b;
background:
linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px),
linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px),
linear-gradient(112deg, #142557, #253e78 63%, #0c6f70);
background-size: 26px 26px, 26px 26px, auto;
}
.student-call-sign,
.completion-dial,
.arrival-board { padding: 26px 28px; }
.student-call-sign > span,
.completion-dial > span,
.arrival-board > span,
.course-pool header span,
.semester-runway > header span {
color: #69d8c7;
font: 700 9px/1.2 Consolas, monospace;
letter-spacing: .12em;
}
.student-call-sign h3 {
margin: 10px 0 7px;
font-family: "STZhongsong", "Songti SC", serif;
font-size: 25px;
letter-spacing: .04em;
}
.student-call-sign p { margin: 0; color: #c8d1e6; font-size: 10px; }
.completion-dial,
.arrival-board {
border-left: 1px solid rgba(255,255,255,.16);
}
.completion-dial b {
display: block;
margin: 12px 0 11px;
font: 700 34px/1 Consolas, monospace;
}
.completion-dial b small { font-size: 13px; }
.completion-dial i {
display: block;
height: 6px;
background: rgba(255,255,255,.17);
}
.completion-dial em {
display: block;
height: 100%;
background: #5fe0c8;
transition: width .2s ease;
}
.arrival-board b {
display: block;
margin: 14px 0 10px;
font-family: "STZhongsong", "Songti SC", serif;
font-size: 16px;
line-height: 1.45;
}
.arrival-board small {
padding-left: 8px;
color: #76e1cf;
border-left: 3px solid #5fe0c8;
font-size: 10px;
}
.arrival-board small.delayed { color: #ffd48a; border-color: #e8a840; }
.planning-metrics {
display: grid;
grid-template-columns: repeat(4, 1fr);
border: 1px solid var(--line);
background: white;
}
.planning-metrics > div {
min-height: 80px;
padding: 15px 19px;
display: grid;
align-content: center;
border-right: 1px solid var(--line);
box-shadow: inset 0 3px #23827a;
}
.planning-metrics > div:last-child { border-right: 0; }
.planning-metrics > div.alert { box-shadow: inset 0 3px #bd5a4f; }
.planning-metrics span { color: var(--muted); font-size: 9px; }
.planning-metrics b {
margin-top: 8px;
color: var(--ink);
font: 700 21px/1 Consolas, monospace;
}
.planning-metrics small { color: var(--muted); font-size: 9px; }
.next-term-brief {
min-height: 74px;
padding: 13px 16px;
display: grid;
grid-template-columns: 180px 1fr;
gap: 18px;
align-items: center;
border: 1px solid #cfd8e5;
background: #f7f9fc;
}
.next-term-brief > header span {
color: var(--teal);
font: 700 9px/1.2 Consolas, monospace;
letter-spacing: .1em;
}
.next-term-brief > header h3 { margin: 5px 0 3px; font-size: 14px; }
.next-term-brief > header p,
.no-suggestion { margin: 0; color: var(--muted); font-size: 9px; line-height: 1.6; }
.suggestion-list { display: flex; flex-wrap: wrap; gap: 7px; }
.suggestion-list article {
min-width: 220px;
padding: 9px 11px;
display: grid;
grid-template-columns: 1fr auto;
gap: 4px 12px;
border-left: 3px solid #168377;
background: white;
}
.suggestion-list span { color: var(--teal); font-size: 8px; }
.suggestion-list b { display: block; margin-top: 3px; font-size: 10px; }
.suggestion-list small { color: var(--indigo); font: 700 10px/1.2 Consolas, monospace; }
.suggestion-list p {
grid-column: 1 / -1;
margin: 0;
color: var(--muted);
font-size: 8px;
}
.official-audit-strip {
min-height: 58px;
padding: 10px 16px;
display: grid;
grid-template-columns: minmax(220px, .8fr) 1fr auto;
gap: 22px;
align-items: center;
border: 1px solid #cdd6e5;
background: #f5f8fc;
}
.official-audit-strip div { display: grid; gap: 3px; }
.official-audit-strip span { color: var(--teal); font-size: 9px; font-weight: 700; }
.official-audit-strip b { font-size: 11px; }
.official-audit-strip small,
.official-audit-strip p { margin: 0; color: var(--muted); font-size: 9px; }
.official-audit-strip em {
padding: 5px 8px;
color: #52617a;
border: 1px solid #cdd5e1;
font-size: 9px;
font-style: normal;
}
.planning-alerts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.planning-alerts article {
min-height: 55px;
padding: 10px 13px;
display: grid;
grid-template-columns: 74px 1fr;
gap: 10px;
align-items: center;
border: 1px solid #e2b9b5;
background: #fff7f6;
}
.planning-alerts article.warning { border-color: #e4cca0; background: #fffaf0; }
.planning-alerts span { color: #a4423b; font-size: 9px; font-weight: 700; }
.planning-alerts .warning span { color: #9b6417; }
.planning-alerts p { margin: 0; color: #5d4b4b; font-size: 10px; line-height: 1.5; }
.planner-workspace {
display: grid;
grid-template-columns: minmax(310px, 360px) minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.course-pool,
.semester-runway {
border: 1px solid var(--line);
background: white;
}
.course-pool {
position: sticky;
top: 14px;
}
.course-pool > header,
.semester-runway > header {
min-height: 72px;
padding: 15px 17px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--line);
background: #f7f9fc;
}
.course-pool h3,
.semester-runway h3 { margin: 5px 0 0; font-size: 15px; }
.course-pool > header b {
color: var(--indigo);
font: 700 25px/1 Consolas, monospace;
}
.pool-filters {
padding: 11px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
border-bottom: 1px solid var(--line);
}
.pool-list {
max-height: 680px;
overflow: auto;
}
.pool-list > article {
padding: 14px;
border-bottom: 1px solid #e8ebf0;
box-shadow: inset 3px 0 #8d97aa;
transition: background .15s ease, box-shadow .15s ease;
}
.pool-list > article.assigned { background: #f1f8f7; box-shadow: inset 3px 0 #0d8175; }
.pool-list > article.conflicted { background: #fff6f5; box-shadow: inset 3px 0 #b64b43; }
.course-manifest-line {
display: flex;
justify-content: space-between;
gap: 8px;
}
.course-manifest-line span { color: var(--teal); font: 700 9px/1.2 Consolas, monospace; }
.course-manifest-line i { color: var(--muted); font-size: 9px; font-style: normal; }
.pool-list h4 { margin: 7px 0; font-size: 13px; }
.course-flags { display: flex; flex-wrap: wrap; gap: 5px; }
.course-flags span {
padding: 3px 5px;
color: #58657a;
background: #edf0f4;
font-size: 8px;
}
.course-flags span.failed { color: #a43f3b; background: #faeae8; }
.pool-list article > p { margin: 8px 0; color: var(--muted); font-size: 9px; line-height: 1.5; }
.pool-list article > p b { color: #a4423b; font-weight: 600; }
.pool-list article > p b.ready { color: #0b796f; }
.pool-list .el-select { width: 100%; margin-top: 9px; }
.semester-runway > header p { margin: 5px 0 0; color: var(--muted); font-size: 9px; }
.runway-line {
position: relative;
padding: 18px 18px 22px 82px;
}
.runway-line::before {
position: absolute;
top: 31px;
bottom: 64px;
left: 43px;
width: 2px;
content: "";
background: linear-gradient(#233b77, #0d8275 76%, #d0d6df);
}
.semester-stop {
position: relative;
min-height: 116px;
margin-bottom: 13px;
}
.semester-marker {
position: absolute;
top: 15px;
left: -67px;
width: 52px;
display: grid;
justify-items: center;
gap: 5px;
}
.semester-marker i {
width: 31px;
height: 31px;
display: grid;
place-items: center;
color: white;
border: 4px solid white;
outline: 1px solid #263c78;
background: #263c78;
font: 700 11px/1 Consolas, monospace;
font-style: normal;
}
.semester-marker span { color: var(--muted); font-size: 8px; text-align: center; }
.semester-stop.beyond .semester-marker i { outline-color: #a7752a; background: #a7752a; }
.semester-sheet { border: 1px solid #dce1e9; }
.semester-sheet > header {
min-height: 61px;
padding: 10px 14px;
display: flex;
align-items: center;
justify-content: space-between;
background: #f8f9fb;
border-bottom: 1px solid #e1e5eb;
}
.semester-sheet header span { color: var(--teal); font-size: 9px; }
.semester-sheet header h4 { margin: 4px 0 0; font-size: 13px; }
.semester-sheet header > b { color: var(--indigo); font: 700 18px/1 Consolas, monospace; }
.semester-sheet header > b small { color: var(--muted); font-size: 8px; }
.scheduled-courses {
padding: 9px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.scheduled-courses article {
min-height: 58px;
padding: 9px 8px 9px 11px;
display: grid;
grid-template-columns: 1fr auto 25px;
gap: 8px;
align-items: center;
border-left: 3px solid #0c8275;
background: #f1f8f7;
}
.scheduled-courses article.conflicted { border-color: #b64b43; background: #fff1ef; }
.scheduled-courses span { color: var(--muted); font-size: 8px; }
.scheduled-courses b { display: block; margin-top: 4px; font-size: 10px; }
.scheduled-courses small { color: var(--muted); font-size: 8px; }
.empty-semester { margin: 0; padding: 18px 14px; color: #9199a8; font-size: 9px; }
.graduation-gate {
min-height: 76px;
padding: 14px 18px;
display: grid;
grid-template-columns: 145px 1fr auto;
gap: 16px;
align-items: center;
color: #5d6676;
border: 1px dashed #aeb6c3;
background: #f7f8fa;
}
.graduation-gate.ready {
color: #075f59;
border-color: #2b9187;
background: #edf8f6;
}
.graduation-gate span { font: 700 9px/1.2 Consolas, monospace; letter-spacing: .1em; }
.graduation-gate b { font-size: 13px; }
.graduation-gate p { margin: 0; font-size: 9px; }
.planning-closeout {
padding: 19px;
display: grid;
grid-template-columns: 190px 1fr minmax(260px, .7fr);
gap: 20px;
align-items: start;
border: 1px solid var(--line);
background: white;
}
.planning-closeout > div:first-child span { color: var(--teal); font-size: 9px; font-weight: 700; }
.planning-closeout h3 { margin: 7px 0 0; font-size: 15px; }
.missing-requirements { display: flex; flex-wrap: wrap; gap: 6px; }
.missing-requirements span {
padding: 5px 7px;
color: #705829;
border: 1px solid #ead6aa;
background: #fff9ed;
font-size: 9px;
}
.missing-requirements em { color: #0d766d; font-size: 10px; font-style: normal; }
.planning-closeout ul { margin: 0; padding-left: 16px; color: var(--muted); font-size: 9px; line-height: 1.7; }
@media (max-width: 1050px) {
.planning-cockpit { grid-template-columns: 1.2fr .8fr; }
.arrival-board { grid-column: 1 / -1; border-top: 1px solid rgba(255,255,255,.16); border-left: 0; }
.planner-workspace { grid-template-columns: 300px minmax(0, 1fr); }
.scheduled-courses { grid-template-columns: 1fr; }
.planning-closeout { grid-template-columns: 170px 1fr; }
.planning-closeout ul { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
.planning-cockpit { grid-template-columns: 1fr; }
.completion-dial,
.arrival-board { border-top: 1px solid rgba(255,255,255,.16); border-left: 0; }
.planning-metrics { grid-template-columns: 1fr 1fr; }
.planning-metrics > div:nth-child(2) { border-right: 0; }
.planning-metrics > div:nth-child(-n + 2) { border-bottom: 1px solid var(--line); }
.official-audit-strip { grid-template-columns: 1fr; gap: 7px; }
.official-audit-strip em { width: max-content; }
.planning-alerts { grid-template-columns: 1fr; }
.next-term-brief { grid-template-columns: 1fr; gap: 8px; }
.planner-workspace { grid-template-columns: 1fr; }
.course-pool { position: static; }
.pool-list { max-height: 460px; }
.runway-line { padding-left: 64px; }
.runway-line::before { left: 31px; }
.semester-marker { left: -55px; }
.graduation-gate { grid-template-columns: 1fr; gap: 6px; }
.planning-closeout { grid-template-columns: 1fr; }
.planning-closeout ul { grid-column: auto; }
}
@media (prefers-reduced-motion: reduce) {
.completion-dial em,
.pool-list > article { transition: none; }
}
</style>
+36 -1
View File
@@ -11,6 +11,7 @@ const rows = ref<any[]>([])
const total = ref(0)
const colleges = ref<any[]>([])
const categories = ref<any[]>([])
const courseOptions = ref<any[]>([])
const dialogVisible = ref(false)
const editingId = ref('')
const importing = ref(false)
@@ -61,6 +62,9 @@ const maintenanceScope = computed(() =>
? `${formColleges.value[0]?.name ?? '所属学院'} · 专业课与实践课`
: '全校课程 · 全性质',
)
const prerequisiteOptions = computed(() =>
courseOptions.value.filter((item) => item.id !== editingId.value),
)
function canManageRow(row: any) {
return row.canManage === true
@@ -81,6 +85,7 @@ function resetForm(row?: any) {
nature: isCollegeAdmin.value ? 'MajorRequired' : 'GeneralRequired',
assessmentMethod: 'Examination',
description: '',
prerequisiteCourseIds: [],
isEnabled: true,
sortOrder: 0,
}, row ?? {})
@@ -168,6 +173,7 @@ async function handleImport(event: Event) {
)
query.page = 1
await load()
courseOptions.value = (await http.get('/courses/options')).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
@@ -203,6 +209,7 @@ async function save() {
ElMessage.success(editingId.value ? '课程已更新' : '课程已加入课程库')
dialogVisible.value = false
await load()
courseOptions.value = (await http.get('/courses/options')).data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
@@ -224,12 +231,14 @@ async function remove(row: any) {
}
onMounted(async () => {
const [collegeRes, categoryRes] = await Promise.all([
const [collegeRes, categoryRes, courseRes] = await Promise.all([
http.get('/base-data/colleges'),
http.get('/base-data/course-categories'),
http.get('/courses/options'),
])
colleges.value = collegeRes.data
categories.value = categoryRes.data
courseOptions.value = courseRes.data
await load()
})
</script>
@@ -331,6 +340,14 @@ onMounted(async () => {
<el-table-column label="考核" width="80">
<template #default="{ row }">{{ assessmentLabels[row.assessmentMethod] }}</template>
</el-table-column>
<el-table-column label="先修课程" min-width="180">
<template #default="{ row }">
<span v-if="row.prerequisites?.length" class="prerequisite-list">
{{ row.prerequisites.map((item: any) => item.code).join('、') }}
</span>
<span v-else></span>
</template>
</el-table-column>
<el-table-column label="状态" width="85">
<template #default="{ row }">
<span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span>
@@ -407,6 +424,24 @@ onMounted(async () => {
<el-form-item label="讲授学时"><el-input-number v-model="form.lectureHours" :min="0" /></el-form-item>
<el-form-item label="实践学时"><el-input-number v-model="form.practiceHours" :min="0" /></el-form-item>
</div>
<el-form-item label="先修课程">
<el-select
v-model="form.prerequisiteCourseIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
placeholder="选择修读本课程前必须通过的课程"
>
<el-option
v-for="item in prerequisiteOptions"
:key="item.id"
:label="`${item.code} · ${item.name}`"
:value="item.id"
/>
</el-select>
<span class="field-hint">学生规划时会检查这些课程是否已经通过或安排在更早的模拟学期</span>
</el-form-item>
<el-form-item label="课程简介"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
<div class="form-grid compact">
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -88,7 +88,10 @@ onMounted(load)
<h2>我的培养方案</h2>
<p>按培养方案核对已完成在读重修与尚未通过的课程</p>
</div>
<div class="page-actions">
<el-button @click="$router.push('/academic-planning')">学业规划与毕业模拟</el-button>
<el-button :icon="Refresh" @click="load">刷新进度</el-button>
</div>
</section>
<section v-if="student" class="student-plan-head">