Compare commits
@@ -17,6 +17,7 @@ BackgroundJobs__Transport=InMemory
|
|||||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||||
BackgroundJobs__SchedulePublishConcurrency=1
|
BackgroundJobs__SchedulePublishConcurrency=1
|
||||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||||
|
BackgroundJobs__ExamArrangementConcurrency=1
|
||||||
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
# RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||||
# RabbitMq__Port=5671
|
# RabbitMq__Port=5671
|
||||||
# RabbitMq__UserName=jiaowu
|
# RabbitMq__UserName=jiaowu
|
||||||
@@ -52,6 +53,9 @@ Jwt__ExpireMinutes=60
|
|||||||
|
|
||||||
AllowedHosts=jiaowu.example.edu.cn
|
AllowedHosts=jiaowu.example.edu.cn
|
||||||
Cors__Origins__0=https://jiaowu.example.edu.cn
|
Cors__Origins__0=https://jiaowu.example.edu.cn
|
||||||
|
Cors__Origins__1=capacitor://localhost
|
||||||
|
Cors__Origins__2=https://localhost
|
||||||
|
Cors__Origins__3=http://localhost
|
||||||
|
|
||||||
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
|
# 二维码使用的公网根地址;反向代理部署时必须填写最终 HTTPS 地址。
|
||||||
OfficialDocuments__InstitutionName=明序大学
|
OfficialDocuments__InstitutionName=明序大学
|
||||||
|
|||||||
@@ -47,6 +47,68 @@ dotnet run --project src/Jiaowu.Api
|
|||||||
|
|
||||||
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
|
访问 `http://localhost:5255`。`/api` 和静态页面由同一个 ASP.NET Core 服务提供,`/base-data` 等前端路由刷新时也会回退到 `index.html`。
|
||||||
|
|
||||||
|
## Capacitor Android App
|
||||||
|
|
||||||
|
`web/.env.capacitor` 配置 App 使用的 HTTPS API 与公开站点地址。生成或更新
|
||||||
|
Android 工程前先构建并同步原生插件:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location web
|
||||||
|
npm ci
|
||||||
|
npm run build:capacitor
|
||||||
|
npm run cap:sync
|
||||||
|
npm run cap:open:android
|
||||||
|
```
|
||||||
|
|
||||||
|
学生在 App 的“我的考勤”中可调用原生相机扫描教师展示的签到二维码;二维码由服务端
|
||||||
|
签名、每 10 秒刷新并在 20 秒后失效,扫码后先显示课程和签到时限,仍需学生确认才
|
||||||
|
提交。教师可直接在手机 App 发起定位签到,以教师手机的原生精确位置作为签到点;
|
||||||
|
教室电脑没有定位模块时不影响该流程。服务端校验课程名单、签到时间、距离和定位精度,
|
||||||
|
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
|
||||||
|
最低版本为 API 26;相机和精确位置权限均按需申请。
|
||||||
|
|
||||||
|
### App 前端热更新
|
||||||
|
|
||||||
|
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
|
||||||
|
新增或升级 Capacitor 插件、修改原生权限、Android/iOS 工程或原生版本号时,仍必须
|
||||||
|
重新构建并安装 App。首次启用更新器也需要发布一次包含更新插件的新 App,之后普通
|
||||||
|
前端修复不再需要重新打包。
|
||||||
|
|
||||||
|
生成更新 ZIP:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location web
|
||||||
|
npm ci
|
||||||
|
npm run ota:package -- --version 1.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
ZIP 会生成到 `.artifacts/app-updates`,根目录直接包含 `index.html`。使用
|
||||||
|
SuperAdmin 进入“运维与审计 → App 前端热更新”,上传 ZIP,填写目标平台、通道和
|
||||||
|
兼容的原生版本后先保存为草稿,再执行发布。当前 Android 工程的 `versionName` 为
|
||||||
|
`1.0`,因此对应更新包的“兼容原生版本”应填写 `1.0`。
|
||||||
|
|
||||||
|
App 启动后向 `/api/app-updates/latest` 检查版本,在后台下载并校验服务端提供的
|
||||||
|
SHA-256,下次启动时切换。新资源若未能成功启动,原生更新器会自动回滚。再次发布
|
||||||
|
已归档版本即可回滚正式通道;不同原生版本、Android/iOS、测试/正式通道彼此隔离。
|
||||||
|
更新版本元数据和 ZIP 保存在数据库中,部署新服务端版本前必须先执行
|
||||||
|
`--migrate-only`。
|
||||||
|
|
||||||
|
### Android 开屏、快捷入口与桌面组件
|
||||||
|
|
||||||
|
Android App 在系统静态启动页之后显示智能问候:优先使用当前登录姓名和春节、端午、
|
||||||
|
中秋、国庆等节日文案,其次按早上、中午、下午和晚上展示问候;轻触可立即跳过,并
|
||||||
|
遵守系统“减少动画”设置。
|
||||||
|
|
||||||
|
长按 App 图标提供“我的课表、考试安排、课堂签到、消息中心”四个快捷入口。“课堂
|
||||||
|
签到”会按当前角色将学生带到扫码/定位签到,将教师带到发起签到。桌面组件提供“今日
|
||||||
|
课表”和“近期考试”,展示 App 最近一次成功加载并安全写入 Android 本地缓存的数据;
|
||||||
|
退出账号时会清空组件,跨日且尚未打开 App 刷新时不会继续展示过期的今日课表。
|
||||||
|
|
||||||
|
原生 Java、清单和组件资源模板保存在 `web/native/android`。每次运行
|
||||||
|
`npm run cap:sync` 后,`configure-capacitor.mjs` 会把模板同步到被 Git 忽略的
|
||||||
|
`web/android` 生成目录。上述能力涉及 Android 原生代码,首次加入或以后修改时必须
|
||||||
|
重新构建 App,不能通过前端 OTA 单独下发。
|
||||||
|
|
||||||
## MySQL 8.4 生产部署
|
## MySQL 8.4 生产部署
|
||||||
|
|
||||||
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
非 Development 环境只允许使用 MySQL。构建发布包与数据库配置相互独立:
|
||||||
@@ -280,6 +342,7 @@ BackgroundJobs__Transport=RabbitMq
|
|||||||
BackgroundJobs__AutomaticScheduleConcurrency=1
|
BackgroundJobs__AutomaticScheduleConcurrency=1
|
||||||
BackgroundJobs__SchedulePublishConcurrency=1
|
BackgroundJobs__SchedulePublishConcurrency=1
|
||||||
BackgroundJobs__MakeupExamAutoConcurrency=1
|
BackgroundJobs__MakeupExamAutoConcurrency=1
|
||||||
|
BackgroundJobs__ExamArrangementConcurrency=1
|
||||||
RabbitMq__HostName=rabbitmq.example.edu.cn
|
RabbitMq__HostName=rabbitmq.example.edu.cn
|
||||||
RabbitMq__Port=5671
|
RabbitMq__Port=5671
|
||||||
RabbitMq__UserName=jiaowu
|
RabbitMq__UserName=jiaowu
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ x-jiaowu-environment: &jiaowu-environment
|
|||||||
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
BackgroundJobs__AutomaticScheduleConcurrency: "${BACKGROUND_JOB_AUTOMATIC_SCHEDULE_CONCURRENCY:-1}"
|
||||||
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
BackgroundJobs__SchedulePublishConcurrency: "${BACKGROUND_JOB_SCHEDULE_PUBLISH_CONCURRENCY:-1}"
|
||||||
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
|
BackgroundJobs__MakeupExamAutoConcurrency: "${BACKGROUND_JOB_MAKEUP_EXAM_AUTO_CONCURRENCY:-1}"
|
||||||
|
BackgroundJobs__ExamArrangementConcurrency: "${BACKGROUND_JOB_EXAM_ARRANGEMENT_CONCURRENCY:-1}"
|
||||||
RabbitMq__HostName: rabbitmq
|
RabbitMq__HostName: rabbitmq
|
||||||
RabbitMq__Port: "5672"
|
RabbitMq__Port: "5672"
|
||||||
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
|
RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}"
|
||||||
|
|||||||
@@ -0,0 +1,440 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Data;
|
||||||
|
using System.IO.Compression;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using Jiaowu.Api.Contracts;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/app-updates")]
|
||||||
|
public sealed partial class AppUpdatesController(AppDbContext db) : ControllerBase
|
||||||
|
{
|
||||||
|
private const long MaximumBundleBytes = 30 * 1024 * 1024;
|
||||||
|
private const long MaximumExpandedBytes = 150 * 1024 * 1024;
|
||||||
|
private const int MaximumZipEntries = 10_000;
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("app-updates")]
|
||||||
|
[HttpGet("latest")]
|
||||||
|
public async Task<ActionResult<AppUpdateCheckResponse>> GetLatest(
|
||||||
|
[FromQuery, Required] string platform,
|
||||||
|
[FromQuery, Required] string nativeVersion,
|
||||||
|
[FromQuery] string channel = "production",
|
||||||
|
[FromQuery] string? currentVersion = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||||
|
return ValidationProblem("平台必须为 android 或 ios。");
|
||||||
|
if (!TryParseChannel(channel, out var parsedChannel))
|
||||||
|
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||||
|
|
||||||
|
var normalizedNativeVersion = nativeVersion.Trim();
|
||||||
|
if (!NativeVersionRegex().IsMatch(normalizedNativeVersion))
|
||||||
|
return ValidationProblem("原生版本格式无效。");
|
||||||
|
|
||||||
|
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Platform == parsedPlatform &&
|
||||||
|
x.Channel == parsedChannel &&
|
||||||
|
x.NativeVersion == normalizedNativeVersion &&
|
||||||
|
x.Status == AppUpdateReleaseStatus.Published)
|
||||||
|
.OrderByDescending(x => x.PublishedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (release is null ||
|
||||||
|
string.Equals(
|
||||||
|
release.Version,
|
||||||
|
currentVersion?.Trim(),
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Ok(new AppUpdateCheckResponse(
|
||||||
|
false,
|
||||||
|
release?.Version,
|
||||||
|
normalizedNativeVersion,
|
||||||
|
parsedPlatform,
|
||||||
|
parsedChannel,
|
||||||
|
null,
|
||||||
|
release?.ReleaseNotes,
|
||||||
|
release?.FileSize,
|
||||||
|
release?.Sha256,
|
||||||
|
release?.PublishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new AppUpdateCheckResponse(
|
||||||
|
true,
|
||||||
|
release.Version,
|
||||||
|
release.NativeVersion,
|
||||||
|
release.Platform,
|
||||||
|
release.Channel,
|
||||||
|
$"app-updates/releases/{release.Id}/bundle",
|
||||||
|
release.ReleaseNotes,
|
||||||
|
release.FileSize,
|
||||||
|
release.Sha256,
|
||||||
|
release.PublishedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[EnableRateLimiting("app-updates")]
|
||||||
|
[HttpGet("releases/{id:guid}/bundle")]
|
||||||
|
public async Task<IActionResult> DownloadBundle(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var release = await db.AppUpdateReleases.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Id == id &&
|
||||||
|
x.Status != AppUpdateReleaseStatus.Draft)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.BundleContent,
|
||||||
|
x.FileName,
|
||||||
|
x.Sha256
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (release is null) return NotFound();
|
||||||
|
|
||||||
|
Response.Headers.ETag = $"\"sha256-{release.Sha256}\"";
|
||||||
|
Response.Headers.CacheControl = "public,max-age=31536000,immutable";
|
||||||
|
return File(
|
||||||
|
release.BundleContent,
|
||||||
|
"application/zip",
|
||||||
|
release.FileName,
|
||||||
|
enableRangeProcessing: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||||
|
[HttpGet("releases")]
|
||||||
|
public async Task<ActionResult<PagedResult<AppUpdateReleaseItem>>> GetReleases(
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20,
|
||||||
|
[FromQuery] string? platform = null,
|
||||||
|
[FromQuery] string? channel = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (page < 1 || pageSize is < 1 or > 100)
|
||||||
|
return ValidationProblem("页码必须大于零,每页数量必须在 1 到 100 之间。");
|
||||||
|
|
||||||
|
var query = db.AppUpdateReleases.AsNoTracking().AsQueryable();
|
||||||
|
if (!string.IsNullOrWhiteSpace(platform))
|
||||||
|
{
|
||||||
|
if (!TryParsePlatform(platform, out var parsedPlatform))
|
||||||
|
return ValidationProblem("平台必须为 android 或 ios。");
|
||||||
|
query = query.Where(x => x.Platform == parsedPlatform);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(channel))
|
||||||
|
{
|
||||||
|
if (!TryParseChannel(channel, out var parsedChannel))
|
||||||
|
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||||
|
query = query.Where(x => x.Channel == parsedChannel);
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await query.CountAsync(cancellationToken);
|
||||||
|
var rows = await query
|
||||||
|
.OrderByDescending(x => x.PublishedAt ?? x.CreatedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.Select(x => new AppUpdateReleaseItem(
|
||||||
|
x.Id,
|
||||||
|
x.Platform,
|
||||||
|
x.Channel,
|
||||||
|
x.Version,
|
||||||
|
x.NativeVersion,
|
||||||
|
x.Status,
|
||||||
|
x.ReleaseNotes,
|
||||||
|
x.FileName,
|
||||||
|
x.FileSize,
|
||||||
|
x.Sha256,
|
||||||
|
x.CreatedByUserName,
|
||||||
|
x.CreatedAt,
|
||||||
|
x.PublishedByUserName,
|
||||||
|
x.PublishedAt))
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
return Ok(new PagedResult<AppUpdateReleaseItem>(
|
||||||
|
rows,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
[RequestSizeLimit(MaximumBundleBytes + 1024 * 1024)]
|
||||||
|
[HttpPost("releases")]
|
||||||
|
public async Task<ActionResult<AppUpdateReleaseItem>> UploadRelease(
|
||||||
|
[FromForm] AppUpdateUploadRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryParsePlatform(request.Platform, out var platform))
|
||||||
|
return ValidationProblem("平台必须为 android 或 ios。");
|
||||||
|
if (!TryParseChannel(request.Channel, out var channel))
|
||||||
|
return ValidationProblem("更新通道必须为 production 或 staging。");
|
||||||
|
|
||||||
|
var version = request.Version.Trim();
|
||||||
|
var nativeVersion = request.NativeVersion.Trim();
|
||||||
|
if (!ReleaseVersionRegex().IsMatch(version))
|
||||||
|
return ValidationProblem(
|
||||||
|
"热更新版本必须使用语义版本,例如 1.0.1 或 1.0.1-beta.1。");
|
||||||
|
if (!NativeVersionRegex().IsMatch(nativeVersion))
|
||||||
|
return ValidationProblem("原生版本格式无效,例如 1.0 或 1.0.0。");
|
||||||
|
if (request.ReleaseNotes?.Trim().Length > 1000)
|
||||||
|
return ValidationProblem("更新说明不能超过 1000 字。");
|
||||||
|
if (request.Bundle.Length is <= 0 or > MaximumBundleBytes)
|
||||||
|
return ValidationProblem("更新包必须大于 0 字节且不超过 30 MB。");
|
||||||
|
|
||||||
|
var exists = await db.AppUpdateReleases.AsNoTracking()
|
||||||
|
.AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.Platform == platform &&
|
||||||
|
x.Channel == channel &&
|
||||||
|
x.NativeVersion == nativeVersion &&
|
||||||
|
x.Version == version,
|
||||||
|
cancellationToken);
|
||||||
|
if (exists)
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "更新版本已存在",
|
||||||
|
Detail = "同一平台、通道和原生版本下不能重复上传相同版本。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
await using var source = request.Bundle.OpenReadStream();
|
||||||
|
await using var buffer = new MemoryStream(
|
||||||
|
checked((int)request.Bundle.Length));
|
||||||
|
await source.CopyToAsync(buffer, cancellationToken);
|
||||||
|
var content = buffer.ToArray();
|
||||||
|
var archiveError = ValidateArchive(content);
|
||||||
|
if (archiveError is not null) return ValidationProblem(archiveError);
|
||||||
|
|
||||||
|
var fileName = Path.GetFileName(request.Bundle.FileName.Trim());
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || fileName.Length > 180)
|
||||||
|
fileName = $"jiaowu-web-{version}.zip";
|
||||||
|
|
||||||
|
var release = new AppUpdateRelease
|
||||||
|
{
|
||||||
|
Platform = platform,
|
||||||
|
Channel = channel,
|
||||||
|
Version = version,
|
||||||
|
NativeVersion = nativeVersion,
|
||||||
|
ReleaseNotes = NullIfWhiteSpace(request.ReleaseNotes),
|
||||||
|
FileName = fileName,
|
||||||
|
FileSize = content.LongLength,
|
||||||
|
Sha256 = Convert.ToHexString(
|
||||||
|
SHA256.HashData(content))
|
||||||
|
.ToLowerInvariant(),
|
||||||
|
BundleContent = content,
|
||||||
|
CreatedByUserName = User.Identity?.Name ?? "unknown"
|
||||||
|
};
|
||||||
|
db.AppUpdateReleases.Add(release);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return CreatedAtAction(
|
||||||
|
nameof(GetReleases),
|
||||||
|
new { id = release.Id },
|
||||||
|
ToItem(release));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||||
|
[HttpPost("releases/{id:guid}/publish")]
|
||||||
|
public async Task<ActionResult<AppUpdateReleaseItem>> PublishRelease(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var strategy = db.Database.CreateExecutionStrategy();
|
||||||
|
var published = await strategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
await using var transaction = await db.Database.BeginTransactionAsync(
|
||||||
|
IsolationLevel.Serializable,
|
||||||
|
cancellationToken);
|
||||||
|
var target = await db.AppUpdateReleases
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (target is null) return null;
|
||||||
|
|
||||||
|
var currentlyPublished = await db.AppUpdateReleases
|
||||||
|
.Where(x =>
|
||||||
|
x.Id != target.Id &&
|
||||||
|
x.Platform == target.Platform &&
|
||||||
|
x.Channel == target.Channel &&
|
||||||
|
x.NativeVersion == target.NativeVersion &&
|
||||||
|
x.Status == AppUpdateReleaseStatus.Published)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var release in currentlyPublished)
|
||||||
|
release.Status = AppUpdateReleaseStatus.Archived;
|
||||||
|
|
||||||
|
target.Status = AppUpdateReleaseStatus.Published;
|
||||||
|
target.PublishedAt = DateTime.UtcNow;
|
||||||
|
target.PublishedByUserName = User.Identity?.Name ?? "unknown";
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
return ToItem(target);
|
||||||
|
});
|
||||||
|
|
||||||
|
return published is null ? NotFound() : Ok(published);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||||
|
[HttpDelete("releases/{id:guid}")]
|
||||||
|
public async Task<IActionResult> DeleteRelease(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var release = await db.AppUpdateReleases
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (release is null) return NotFound();
|
||||||
|
if (release.Status == AppUpdateReleaseStatus.Published)
|
||||||
|
return Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "正式版本不能删除",
|
||||||
|
Detail = "请先发布另一个兼容版本,再删除已归档的更新包。",
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
db.AppUpdateReleases.Remove(release);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ValidateArchive(byte[] content)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var stream = new MemoryStream(content, writable: false);
|
||||||
|
using var archive = new ZipArchive(
|
||||||
|
stream,
|
||||||
|
ZipArchiveMode.Read,
|
||||||
|
leaveOpen: false);
|
||||||
|
if (archive.Entries.Count is 0 or > MaximumZipEntries)
|
||||||
|
return $"更新包文件数量必须在 1 到 {MaximumZipEntries} 之间。";
|
||||||
|
|
||||||
|
long expandedBytes = 0;
|
||||||
|
var hasRootIndex = false;
|
||||||
|
foreach (var entry in archive.Entries)
|
||||||
|
{
|
||||||
|
var normalized = entry.FullName.Replace('\\', '/');
|
||||||
|
if (normalized.StartsWith('/') ||
|
||||||
|
normalized.Split('/').Any(part => part == ".."))
|
||||||
|
{
|
||||||
|
return "更新包包含不安全的文件路径。";
|
||||||
|
}
|
||||||
|
if (string.Equals(
|
||||||
|
normalized,
|
||||||
|
"index.html",
|
||||||
|
StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
hasRootIndex = true;
|
||||||
|
}
|
||||||
|
expandedBytes = checked(expandedBytes + entry.Length);
|
||||||
|
if (expandedBytes > MaximumExpandedBytes)
|
||||||
|
return "更新包解压后的总大小不能超过 150 MB。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasRootIndex
|
||||||
|
? null
|
||||||
|
: "更新包根目录必须包含 index.html;请直接压缩 dist 目录中的内容。";
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (
|
||||||
|
exception is InvalidDataException or IOException or OverflowException)
|
||||||
|
{
|
||||||
|
return "更新包不是有效的 ZIP 文件,或文件结构已损坏。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParsePlatform(
|
||||||
|
string value,
|
||||||
|
out AppUpdatePlatform platform) =>
|
||||||
|
Enum.TryParse(value.Trim(), ignoreCase: true, out platform) &&
|
||||||
|
Enum.IsDefined(platform);
|
||||||
|
|
||||||
|
private static bool TryParseChannel(
|
||||||
|
string value,
|
||||||
|
out AppUpdateChannel channel) =>
|
||||||
|
Enum.TryParse(value.Trim(), ignoreCase: true, out channel) &&
|
||||||
|
Enum.IsDefined(channel);
|
||||||
|
|
||||||
|
private static string? NullIfWhiteSpace(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static AppUpdateReleaseItem ToItem(AppUpdateRelease release) =>
|
||||||
|
new(
|
||||||
|
release.Id,
|
||||||
|
release.Platform,
|
||||||
|
release.Channel,
|
||||||
|
release.Version,
|
||||||
|
release.NativeVersion,
|
||||||
|
release.Status,
|
||||||
|
release.ReleaseNotes,
|
||||||
|
release.FileName,
|
||||||
|
release.FileSize,
|
||||||
|
release.Sha256,
|
||||||
|
release.CreatedByUserName,
|
||||||
|
release.CreatedAt,
|
||||||
|
release.PublishedByUserName,
|
||||||
|
release.PublishedAt);
|
||||||
|
|
||||||
|
[GeneratedRegex(
|
||||||
|
@"^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||||
|
RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex ReleaseVersionRegex();
|
||||||
|
|
||||||
|
[GeneratedRegex(
|
||||||
|
@"^\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$",
|
||||||
|
RegexOptions.CultureInvariant)]
|
||||||
|
private static partial Regex NativeVersionRegex();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AppUpdateUploadRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public required IFormFile Bundle { get; init; }
|
||||||
|
|
||||||
|
[Required, MaxLength(20)]
|
||||||
|
public required string Platform { get; init; }
|
||||||
|
|
||||||
|
[Required, MaxLength(20)]
|
||||||
|
public required string Channel { get; init; }
|
||||||
|
|
||||||
|
[Required, MaxLength(40)]
|
||||||
|
public required string Version { get; init; }
|
||||||
|
|
||||||
|
[Required, MaxLength(40)]
|
||||||
|
public required string NativeVersion { get; init; }
|
||||||
|
|
||||||
|
[MaxLength(1000)]
|
||||||
|
public string? ReleaseNotes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AppUpdateCheckResponse(
|
||||||
|
bool Available,
|
||||||
|
string? Version,
|
||||||
|
string NativeVersion,
|
||||||
|
AppUpdatePlatform Platform,
|
||||||
|
AppUpdateChannel Channel,
|
||||||
|
string? DownloadUrl,
|
||||||
|
string? ReleaseNotes,
|
||||||
|
long? FileSize,
|
||||||
|
string? Sha256,
|
||||||
|
DateTime? PublishedAt);
|
||||||
|
|
||||||
|
public sealed record AppUpdateReleaseItem(
|
||||||
|
Guid Id,
|
||||||
|
AppUpdatePlatform Platform,
|
||||||
|
AppUpdateChannel Channel,
|
||||||
|
string Version,
|
||||||
|
string NativeVersion,
|
||||||
|
AppUpdateReleaseStatus Status,
|
||||||
|
string? ReleaseNotes,
|
||||||
|
string FileName,
|
||||||
|
long FileSize,
|
||||||
|
string Sha256,
|
||||||
|
string CreatedByUserName,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
string? PublishedByUserName,
|
||||||
|
DateTime? PublishedAt);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
using ClosedXML.Excel;
|
using ClosedXML.Excel;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
@@ -180,7 +181,6 @@ public sealed class AttendanceController(
|
|||||||
{
|
{
|
||||||
sheet.Id,
|
sheet.Id,
|
||||||
sheet.CheckInMethod,
|
sheet.CheckInMethod,
|
||||||
sheet.CheckInToken,
|
|
||||||
sheet.CheckInStartsAt,
|
sheet.CheckInStartsAt,
|
||||||
sheet.CheckInEndsAt
|
sheet.CheckInEndsAt
|
||||||
});
|
});
|
||||||
@@ -200,7 +200,6 @@ public sealed class AttendanceController(
|
|||||||
x.AttendanceDate,
|
x.AttendanceDate,
|
||||||
x.Status,
|
x.Status,
|
||||||
x.CheckInMethod,
|
x.CheckInMethod,
|
||||||
x.CheckInToken,
|
|
||||||
x.CheckInStartsAt,
|
x.CheckInStartsAt,
|
||||||
x.CheckInEndsAt,
|
x.CheckInEndsAt,
|
||||||
x.TargetLatitude,
|
x.TargetLatitude,
|
||||||
@@ -247,6 +246,103 @@ public sealed class AttendanceController(
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
var canEdit = sheet.Status == AttendanceSheetStatus.Draft && canManage;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var attempts = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.Where(x => x.AttendanceSheetId == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.StudentId,
|
||||||
|
x.CreatedAt,
|
||||||
|
x.IsSuccessful,
|
||||||
|
x.FailureCode,
|
||||||
|
x.DeviceIdentifierHash,
|
||||||
|
x.DevicePlatform,
|
||||||
|
x.IpAddress,
|
||||||
|
x.RiskFlags
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var attemptsByStudent = attempts
|
||||||
|
.GroupBy(x => x.StudentId)
|
||||||
|
.ToDictionary(x => x.Key, x => x.OrderByDescending(a => a.CreatedAt).ToList());
|
||||||
|
var deviceHashes = attempts
|
||||||
|
.Where(x => x.IsSuccessful && x.DeviceIdentifierHash != null)
|
||||||
|
.Select(x => x.DeviceIdentifierHash!)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
var deviceReuseCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
if (deviceHashes.Count > 0)
|
||||||
|
{
|
||||||
|
var reuseRows = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.IsSuccessful &&
|
||||||
|
x.CreatedAt >= now.AddHours(-24) &&
|
||||||
|
x.DeviceIdentifierHash != null)
|
||||||
|
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
|
||||||
|
.GroupBy(x => x.DeviceIdentifierHash!)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
DeviceIdentifierHash = x.Key,
|
||||||
|
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
deviceReuseCounts = reuseRows.ToDictionary(
|
||||||
|
x => x.DeviceIdentifierHash,
|
||||||
|
x => x.StudentCount,
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseRecords = sheet.Records.Select(r =>
|
||||||
|
{
|
||||||
|
var studentAttempts = attemptsByStudent.GetValueOrDefault(r.StudentId) ?? [];
|
||||||
|
var latestSuccess = studentAttempts.FirstOrDefault(x => x.IsSuccessful);
|
||||||
|
var riskFlags = studentAttempts
|
||||||
|
.SelectMany(x => ParseRiskFlags(x.RiskFlags))
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
if (studentAttempts.Count(x => !x.IsSuccessful) >= 3)
|
||||||
|
riskFlags.Add("RepeatedFailures");
|
||||||
|
if (studentAttempts.Count(x => x.CreatedAt >= now.AddMinutes(-2)) >= 6)
|
||||||
|
riskFlags.Add("HighFrequency");
|
||||||
|
var sharedDeviceStudentCount = latestSuccess?.DeviceIdentifierHash is { } deviceHash
|
||||||
|
? deviceReuseCounts.GetValueOrDefault(deviceHash)
|
||||||
|
: 0;
|
||||||
|
if (sharedDeviceStudentCount > 1)
|
||||||
|
riskFlags.Add("SharedDevice");
|
||||||
|
var sameIpStudentCount = latestSuccess?.IpAddress is { } ipAddress
|
||||||
|
? attempts
|
||||||
|
.Where(x => x.IsSuccessful && x.IpAddress == ipAddress)
|
||||||
|
.Select(x => x.StudentId)
|
||||||
|
.Distinct()
|
||||||
|
.Count()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
r.StudentId,
|
||||||
|
r.StudentNumber,
|
||||||
|
r.Name,
|
||||||
|
r.ClassName,
|
||||||
|
r.Status,
|
||||||
|
r.Notes,
|
||||||
|
r.CheckInAt,
|
||||||
|
r.CheckedInMethod,
|
||||||
|
r.CheckInAccuracyMeters,
|
||||||
|
r.CheckInDistanceMeters,
|
||||||
|
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
||||||
|
IsDeferred = deferredStudentIds.Contains(r.StudentId),
|
||||||
|
CheckInAudit = latestSuccess is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
latestSuccess.IpAddress,
|
||||||
|
latestSuccess.DevicePlatform,
|
||||||
|
DeviceCode = latestSuccess.DeviceIdentifierHash?[..8],
|
||||||
|
SharedDeviceStudentCount = sharedDeviceStudentCount,
|
||||||
|
SameIpStudentCount = sameIpStudentCount
|
||||||
|
},
|
||||||
|
AttemptCount = studentAttempts.Count,
|
||||||
|
FailedAttemptCount = studentAttempts.Count(x => !x.IsSuccessful),
|
||||||
|
RiskFlags = riskFlags.OrderBy(x => x).ToArray()
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
Sheet = new
|
Sheet = new
|
||||||
@@ -257,7 +353,6 @@ public sealed class AttendanceController(
|
|||||||
sheet.AttendanceDate,
|
sheet.AttendanceDate,
|
||||||
sheet.Status,
|
sheet.Status,
|
||||||
sheet.CheckInMethod,
|
sheet.CheckInMethod,
|
||||||
CheckInToken = canManage ? sheet.CheckInToken : null,
|
|
||||||
sheet.CheckInStartsAt,
|
sheet.CheckInStartsAt,
|
||||||
sheet.CheckInEndsAt,
|
sheet.CheckInEndsAt,
|
||||||
sheet.TargetLatitude,
|
sheet.TargetLatitude,
|
||||||
@@ -276,21 +371,16 @@ public sealed class AttendanceController(
|
|||||||
sheet.TaskName,
|
sheet.TaskName,
|
||||||
sheet.CourseCode,
|
sheet.CourseCode,
|
||||||
sheet.CourseName,
|
sheet.CourseName,
|
||||||
Records = sheet.Records.Select(r => new
|
Records = responseRecords,
|
||||||
|
RiskSummary = new
|
||||||
{
|
{
|
||||||
r.StudentId,
|
RiskStudentCount = responseRecords.Count(x => x.RiskFlags.Length > 0),
|
||||||
r.StudentNumber,
|
SharedDeviceStudentCount = responseRecords.Count(
|
||||||
r.Name,
|
x => x.RiskFlags.Contains("SharedDevice")),
|
||||||
r.ClassName,
|
FrequentAttemptStudentCount = responseRecords.Count(
|
||||||
r.Status,
|
x => x.RiskFlags.Contains("HighFrequency") ||
|
||||||
r.Notes,
|
x.RiskFlags.Contains("RepeatedFailures"))
|
||||||
r.CheckInAt,
|
}
|
||||||
r.CheckedInMethod,
|
|
||||||
r.CheckInAccuracyMeters,
|
|
||||||
r.CheckInDistanceMeters,
|
|
||||||
IsExempt = exemptStudentIds.Contains(r.StudentId),
|
|
||||||
IsDeferred = deferredStudentIds.Contains(r.StudentId)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
CanEdit = canEdit
|
CanEdit = canEdit
|
||||||
});
|
});
|
||||||
@@ -503,6 +593,39 @@ public sealed class AttendanceController(
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}/qr-challenge")]
|
||||||
|
[Authorize(Roles = AttendanceRoles)]
|
||||||
|
public async Task<ActionResult> GetQrChallenge(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await db.AttendanceSheets
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanManageSheet(sheet)) return Forbid();
|
||||||
|
if (sheet.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||||
|
string.IsNullOrWhiteSpace(sheet.CheckInToken))
|
||||||
|
return ConflictProblem("该考勤表不是扫码签到。");
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (!IsCheckInOpen(
|
||||||
|
sheet.Status,
|
||||||
|
sheet.CheckInMethod,
|
||||||
|
sheet.CheckInStartsAt,
|
||||||
|
sheet.CheckInEndsAt,
|
||||||
|
now))
|
||||||
|
return ConflictProblem("签到尚未开始或已经结束。");
|
||||||
|
|
||||||
|
var challenge = AttendanceCheckInChallenge.Create(
|
||||||
|
sheet.Id,
|
||||||
|
sheet.CheckInToken,
|
||||||
|
now);
|
||||||
|
return Ok(challenge);
|
||||||
|
}
|
||||||
|
|
||||||
// ═══════════════ Student endpoints ═══════════════
|
// ═══════════════ Student endpoints ═══════════════
|
||||||
|
|
||||||
[HttpGet("check-in-info")]
|
[HttpGet("check-in-info")]
|
||||||
@@ -516,11 +639,13 @@ public sealed class AttendanceController(
|
|||||||
return ConflictProblem("当前账号未关联学生档案。");
|
return ConflictProblem("当前账号未关联学生档案。");
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
if (!AttendanceCheckInChallenge.TryReadSheetId(token, out var sheetId))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
var activity = await db.AttendanceRecords.AsNoTracking()
|
var activity = await db.AttendanceRecords.AsNoTracking()
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.StudentId == studentId.Value &&
|
x.StudentId == studentId.Value &&
|
||||||
x.AttendanceSheet!.CheckInToken == token.Trim())
|
x.AttendanceSheetId == sheetId)
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
SheetId = x.AttendanceSheetId,
|
SheetId = x.AttendanceSheetId,
|
||||||
@@ -530,6 +655,7 @@ public sealed class AttendanceController(
|
|||||||
x.AttendanceSheet.CheckInMethod,
|
x.AttendanceSheet.CheckInMethod,
|
||||||
x.AttendanceSheet.CheckInStartsAt,
|
x.AttendanceSheet.CheckInStartsAt,
|
||||||
x.AttendanceSheet.CheckInEndsAt,
|
x.AttendanceSheet.CheckInEndsAt,
|
||||||
|
x.AttendanceSheet.CheckInToken,
|
||||||
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
CourseCode = x.AttendanceSheet.TeachingTask!.Course!.Code,
|
||||||
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
CourseName = x.AttendanceSheet.TeachingTask.Course.Name,
|
||||||
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
TaskNumber = x.AttendanceSheet.TeachingTask.TaskNumber,
|
||||||
@@ -539,6 +665,13 @@ public sealed class AttendanceController(
|
|||||||
if (activity is null) return NotFound();
|
if (activity is null) return NotFound();
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
if (activity.CheckInMethod != AttendanceCheckInMethod.QrCode ||
|
||||||
|
!AttendanceCheckInChallenge.IsValid(
|
||||||
|
token,
|
||||||
|
activity.SheetId,
|
||||||
|
activity.CheckInToken,
|
||||||
|
now))
|
||||||
|
return NotFound();
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
activity.SheetId,
|
activity.SheetId,
|
||||||
@@ -610,8 +743,11 @@ public sealed class AttendanceController(
|
|||||||
.Where(x => x.StudentId == studentId.Value);
|
.Where(x => x.StudentId == studentId.Value);
|
||||||
if (!string.IsNullOrWhiteSpace(request.Token))
|
if (!string.IsNullOrWhiteSpace(request.Token))
|
||||||
{
|
{
|
||||||
var token = request.Token.Trim();
|
if (!AttendanceCheckInChallenge.TryReadSheetId(
|
||||||
source = source.Where(x => x.AttendanceSheet!.CheckInToken == token);
|
request.Token.Trim(),
|
||||||
|
out var tokenSheetId))
|
||||||
|
return NotFound();
|
||||||
|
source = source.Where(x => x.AttendanceSheetId == tokenSheetId);
|
||||||
}
|
}
|
||||||
else if (request.AttendanceSheetId.HasValue)
|
else if (request.AttendanceSheetId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -627,8 +763,59 @@ public sealed class AttendanceController(
|
|||||||
if (record?.AttendanceSheet is null) return NotFound();
|
if (record?.AttendanceSheet is null) return NotFound();
|
||||||
var sheet = record.AttendanceSheet;
|
var sheet = record.AttendanceSheet;
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
async Task<ActionResult> RejectAttemptAsync(
|
||||||
|
string failureCode,
|
||||||
|
string detail,
|
||||||
|
double? distanceMeters = null)
|
||||||
|
{
|
||||||
|
await AddCheckInAttemptAsync(
|
||||||
|
sheet,
|
||||||
|
studentId.Value,
|
||||||
|
request,
|
||||||
|
false,
|
||||||
|
failureCode,
|
||||||
|
distanceMeters,
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return ConflictProblem(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode &&
|
||||||
|
!AttendanceCheckInChallenge.IsValid(
|
||||||
|
request.Token,
|
||||||
|
sheet.Id,
|
||||||
|
sheet.CheckInToken,
|
||||||
|
now))
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"InvalidQrChallenge",
|
||||||
|
"签到二维码已失效,请重新扫描教师当前展示的二维码。");
|
||||||
|
if (!IsCheckInOpen(
|
||||||
|
sheet.Status,
|
||||||
|
sheet.CheckInMethod,
|
||||||
|
sheet.CheckInStartsAt,
|
||||||
|
sheet.CheckInEndsAt,
|
||||||
|
now))
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"CheckInClosed",
|
||||||
|
"签到尚未开始或已经结束。");
|
||||||
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"ManualSheet",
|
||||||
|
"该考勤表不支持学生在线签到。");
|
||||||
if (record.CheckInAt.HasValue)
|
if (record.CheckInAt.HasValue)
|
||||||
{
|
{
|
||||||
|
await AddCheckInAttemptAsync(
|
||||||
|
sheet,
|
||||||
|
studentId.Value,
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
record.CheckInDistanceMeters,
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
AlreadyCheckedIn = true,
|
AlreadyCheckedIn = true,
|
||||||
@@ -636,37 +823,35 @@ public sealed class AttendanceController(
|
|||||||
record.CheckInDistanceMeters
|
record.CheckInDistanceMeters
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!IsCheckInOpen(
|
|
||||||
sheet.Status,
|
|
||||||
sheet.CheckInMethod,
|
|
||||||
sheet.CheckInStartsAt,
|
|
||||||
sheet.CheckInEndsAt,
|
|
||||||
now))
|
|
||||||
return ConflictProblem("签到尚未开始或已经结束。");
|
|
||||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.Manual)
|
|
||||||
return ConflictProblem("该考勤表不支持学生在线签到。");
|
|
||||||
|
|
||||||
double? distanceMeters = null;
|
double? distanceMeters = null;
|
||||||
if (sheet.CheckInMethod == AttendanceCheckInMethod.QrCode)
|
if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(request.Token) ||
|
|
||||||
!string.Equals(
|
|
||||||
sheet.CheckInToken,
|
|
||||||
request.Token.Trim(),
|
|
||||||
StringComparison.Ordinal))
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
else if (sheet.CheckInMethod == AttendanceCheckInMethod.Location)
|
|
||||||
{
|
{
|
||||||
if (request.Latitude is < -90 or > 90 ||
|
if (request.Latitude is < -90 or > 90 ||
|
||||||
request.Longitude is < -180 or > 180 ||
|
request.Longitude is < -180 or > 180 ||
|
||||||
request.Latitude is null ||
|
request.Latitude is null ||
|
||||||
request.Longitude is null)
|
request.Longitude is null)
|
||||||
return ConflictProblem("未获取到有效的当前位置。");
|
return await RejectAttemptAsync(
|
||||||
|
"InvalidLocation",
|
||||||
|
"未获取到有效的当前位置。");
|
||||||
if (sheet.TargetLatitude is null ||
|
if (sheet.TargetLatitude is null ||
|
||||||
sheet.TargetLongitude is null ||
|
sheet.TargetLongitude is null ||
|
||||||
sheet.LocationRadiusMeters is null)
|
sheet.LocationRadiusMeters is null)
|
||||||
return ConflictProblem("签到活动没有配置有效的位置范围。");
|
return await RejectAttemptAsync(
|
||||||
|
"LocationNotConfigured",
|
||||||
|
"签到活动没有配置有效的位置范围。");
|
||||||
|
var maximumAllowedAccuracyMeters = Math.Min(
|
||||||
|
100d,
|
||||||
|
sheet.LocationRadiusMeters.Value);
|
||||||
|
if (request.AccuracyMeters is null ||
|
||||||
|
!double.IsFinite(request.AccuracyMeters.Value) ||
|
||||||
|
request.AccuracyMeters <= 0 ||
|
||||||
|
request.AccuracyMeters > maximumAllowedAccuracyMeters)
|
||||||
|
{
|
||||||
|
return await RejectAttemptAsync(
|
||||||
|
"InsufficientAccuracy",
|
||||||
|
$"当前定位精度不足,请在精度达到 {Math.Round(maximumAllowedAccuracyMeters)} 米以内后重试。");
|
||||||
|
}
|
||||||
|
|
||||||
distanceMeters = CalculateDistanceMeters(
|
distanceMeters = CalculateDistanceMeters(
|
||||||
(double)sheet.TargetLatitude.Value,
|
(double)sheet.TargetLatitude.Value,
|
||||||
@@ -675,8 +860,10 @@ public sealed class AttendanceController(
|
|||||||
(double)request.Longitude.Value);
|
(double)request.Longitude.Value);
|
||||||
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
if (distanceMeters > sheet.LocationRadiusMeters.Value)
|
||||||
{
|
{
|
||||||
return ConflictProblem(
|
return await RejectAttemptAsync(
|
||||||
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。");
|
"OutsideGeofence",
|
||||||
|
$"当前位置距签到点约 {Math.Round(distanceMeters.Value)} 米,超出 {sheet.LocationRadiusMeters.Value} 米签到范围。",
|
||||||
|
distanceMeters);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,6 +880,15 @@ public sealed class AttendanceController(
|
|||||||
? request.AccuracyMeters
|
? request.AccuracyMeters
|
||||||
: null;
|
: null;
|
||||||
record.CheckInDistanceMeters = distanceMeters;
|
record.CheckInDistanceMeters = distanceMeters;
|
||||||
|
await AddCheckInAttemptAsync(
|
||||||
|
sheet,
|
||||||
|
studentId.Value,
|
||||||
|
request,
|
||||||
|
true,
|
||||||
|
null,
|
||||||
|
distanceMeters,
|
||||||
|
now,
|
||||||
|
cancellationToken);
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
@@ -986,6 +1182,103 @@ public sealed class AttendanceController(
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task AddCheckInAttemptAsync(
|
||||||
|
AttendanceSheet sheet,
|
||||||
|
Guid studentId,
|
||||||
|
AttendanceCheckInRequest request,
|
||||||
|
bool isSuccessful,
|
||||||
|
string? failureCode,
|
||||||
|
double? distanceMeters,
|
||||||
|
DateTime now,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var deviceIdentifierHash = HashDeviceIdentifier(request.DeviceId);
|
||||||
|
var riskFlags = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
if (deviceIdentifierHash is null)
|
||||||
|
{
|
||||||
|
riskFlags.Add("MissingDeviceId");
|
||||||
|
}
|
||||||
|
else if (await db.AttendanceCheckInAttempts.AsNoTracking().AnyAsync(
|
||||||
|
x =>
|
||||||
|
x.IsSuccessful &&
|
||||||
|
x.StudentId != studentId &&
|
||||||
|
x.DeviceIdentifierHash == deviceIdentifierHash &&
|
||||||
|
x.CreatedAt >= now.AddHours(-24),
|
||||||
|
cancellationToken))
|
||||||
|
{
|
||||||
|
riskFlags.Add("SharedDevice");
|
||||||
|
}
|
||||||
|
|
||||||
|
var recentAttemptCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.StudentId == studentId &&
|
||||||
|
x.CreatedAt >= now.AddMinutes(-2),
|
||||||
|
cancellationToken);
|
||||||
|
if (recentAttemptCount >= 5)
|
||||||
|
riskFlags.Add("HighFrequency");
|
||||||
|
|
||||||
|
if (!isSuccessful)
|
||||||
|
{
|
||||||
|
var recentFailureCount = await db.AttendanceCheckInAttempts.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.StudentId == studentId &&
|
||||||
|
!x.IsSuccessful &&
|
||||||
|
x.CreatedAt >= now.AddMinutes(-5),
|
||||||
|
cancellationToken);
|
||||||
|
if (recentFailureCount >= 2)
|
||||||
|
riskFlags.Add("RepeatedFailures");
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = ControllerContext.HttpContext;
|
||||||
|
db.AttendanceCheckInAttempts.Add(new AttendanceCheckInAttempt
|
||||||
|
{
|
||||||
|
AttendanceSheetId = sheet.Id,
|
||||||
|
StudentId = studentId,
|
||||||
|
CheckInMethod = sheet.CheckInMethod,
|
||||||
|
IsSuccessful = isSuccessful,
|
||||||
|
FailureCode = failureCode,
|
||||||
|
DeviceIdentifierHash = deviceIdentifierHash,
|
||||||
|
DevicePlatform = Limit(Normalize(request.DevicePlatform), 32),
|
||||||
|
IpAddress = Limit(
|
||||||
|
context?.Connection.RemoteIpAddress?.ToString(),
|
||||||
|
64),
|
||||||
|
UserAgent = Limit(
|
||||||
|
context?.Request.Headers.UserAgent.ToString(),
|
||||||
|
500),
|
||||||
|
RiskFlags = riskFlags.Count == 0
|
||||||
|
? null
|
||||||
|
: string.Join(',', riskFlags.OrderBy(x => x)),
|
||||||
|
Latitude = request.Latitude,
|
||||||
|
Longitude = request.Longitude,
|
||||||
|
AccuracyMeters = request.AccuracyMeters,
|
||||||
|
DistanceMeters = distanceMeters,
|
||||||
|
CreatedAt = now,
|
||||||
|
UpdatedAt = now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? HashDeviceIdentifier(string? deviceId)
|
||||||
|
{
|
||||||
|
var normalized = Normalize(deviceId)?.ToLowerInvariant();
|
||||||
|
return normalized is null
|
||||||
|
? null
|
||||||
|
: Convert.ToHexString(
|
||||||
|
SHA256.HashData(Encoding.UTF8.GetBytes(normalized)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<string> ParseRiskFlags(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value)
|
||||||
|
? []
|
||||||
|
: value.Split(
|
||||||
|
',',
|
||||||
|
StringSplitOptions.RemoveEmptyEntries |
|
||||||
|
StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
private static string? Limit(string? value, int maximumLength) =>
|
||||||
|
value is null || value.Length <= maximumLength
|
||||||
|
? value
|
||||||
|
: value[..maximumLength];
|
||||||
|
|
||||||
private static bool IsCheckInOpen(
|
private static bool IsCheckInOpen(
|
||||||
AttendanceSheetStatus status,
|
AttendanceSheetStatus status,
|
||||||
AttendanceCheckInMethod method,
|
AttendanceCheckInMethod method,
|
||||||
@@ -1380,7 +1673,9 @@ public sealed record AttendanceCheckInRequest(
|
|||||||
[MaxLength(64)] string? Token,
|
[MaxLength(64)] string? Token,
|
||||||
[Range(-90, 90)] decimal? Latitude,
|
[Range(-90, 90)] decimal? Latitude,
|
||||||
[Range(-180, 180)] decimal? Longitude,
|
[Range(-180, 180)] decimal? Longitude,
|
||||||
[Range(0, 5000)] double? AccuracyMeters);
|
[Range(0, 5000)] double? AccuracyMeters,
|
||||||
|
[MaxLength(128)] string? DeviceId = null,
|
||||||
|
[MaxLength(32)] string? DevicePlatform = null);
|
||||||
|
|
||||||
public sealed record AttendanceCourseStatistics(
|
public sealed record AttendanceCourseStatistics(
|
||||||
AttendanceStatisticsCourse Course,
|
AttendanceStatisticsCourse Course,
|
||||||
|
|||||||
@@ -145,6 +145,8 @@ public sealed class CoursesController(
|
|||||||
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
|
CategoryName = x.CourseCategory != null ? x.CourseCategory.Name : null,
|
||||||
x.Credits,
|
x.Credits,
|
||||||
x.TotalHours,
|
x.TotalHours,
|
||||||
|
x.LectureHours,
|
||||||
|
x.PracticeHours,
|
||||||
x.Nature,
|
x.Nature,
|
||||||
x.AssessmentMethod
|
x.AssessmentMethod
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,945 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize]
|
||||||
|
[Route("api/experiment-grades")]
|
||||||
|
public sealed class ExperimentGradesController(
|
||||||
|
AppDbContext db,
|
||||||
|
ICurrentUserDataScope currentUserDataScope) : ControllerBase
|
||||||
|
{
|
||||||
|
private const string Managers =
|
||||||
|
SystemRoles.SuperAdmin + "," +
|
||||||
|
SystemRoles.AcademicAdmin + "," +
|
||||||
|
SystemRoles.CollegeAdmin + "," +
|
||||||
|
SystemRoles.Teacher;
|
||||||
|
|
||||||
|
private const string Reviewers =
|
||||||
|
SystemRoles.SuperAdmin + "," +
|
||||||
|
SystemRoles.AcademicAdmin + "," +
|
||||||
|
SystemRoles.CollegeAdmin;
|
||||||
|
|
||||||
|
private const string Publishers =
|
||||||
|
SystemRoles.SuperAdmin + "," +
|
||||||
|
SystemRoles.AcademicAdmin;
|
||||||
|
|
||||||
|
[HttpGet("management")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetManagement(
|
||||||
|
Guid? academicTermId,
|
||||||
|
ExperimentGradeSheetStatus? status,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var source = ScopedProjects().AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Status == ExperimentProjectStatus.Published ||
|
||||||
|
x.Status == ExperimentProjectStatus.Closed);
|
||||||
|
if (academicTermId.HasValue)
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.TeachingTask!.AcademicTermId == academicTermId);
|
||||||
|
if (status.HasValue)
|
||||||
|
source = source.Where(x =>
|
||||||
|
x.GradeSheet != null &&
|
||||||
|
x.GradeSheet.Status == status.Value);
|
||||||
|
|
||||||
|
return Ok(await source
|
||||||
|
.OrderByDescending(x => x.TeachingTask!.AcademicTerm!.StartDate)
|
||||||
|
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||||
|
.ThenBy(x => x.Code)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.ArrangementMode,
|
||||||
|
ProjectStatus = x.Status,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TeachingTask!.TaskNumber,
|
||||||
|
TaskName = x.TeachingTask.Name,
|
||||||
|
AcademicTermId = x.TeachingTask.AcademicTermId,
|
||||||
|
TermName = x.TeachingTask.AcademicTerm!.Name,
|
||||||
|
CourseCode = x.TeachingTask.Course!.Code,
|
||||||
|
CourseName = x.TeachingTask.Course.Name,
|
||||||
|
CollegeName = x.TeachingTask.Course.College!.Name,
|
||||||
|
TeacherNames = x.TeachingTask.Teachers
|
||||||
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
|
.Select(item => item.Teacher!.Name),
|
||||||
|
Sheet = x.GradeSheet == null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
x.GradeSheet.Id,
|
||||||
|
x.GradeSheet.Status,
|
||||||
|
x.GradeSheet.ContributionWeight,
|
||||||
|
x.GradeSheet.PassScore,
|
||||||
|
ItemCount = x.GradeSheet.Items.Count,
|
||||||
|
StudentCount = x.GradeSheet.Records.Count,
|
||||||
|
CompletedCount = x.GradeSheet.Records.Count(record =>
|
||||||
|
record.ParticipationStatus !=
|
||||||
|
ExperimentParticipationStatus.Pending),
|
||||||
|
ScoredCount = x.GradeSheet.Records.Count(record =>
|
||||||
|
record.TotalScore != null),
|
||||||
|
PassedCount = x.GradeSheet.Records.Count(record =>
|
||||||
|
record.IsPassed == true),
|
||||||
|
x.GradeSheet.SubmittedAt,
|
||||||
|
x.GradeSheet.ReviewedAt,
|
||||||
|
x.GradeSheet.PublishedAt
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("mine")]
|
||||||
|
[Authorize(Roles = SystemRoles.Student)]
|
||||||
|
public async Task<ActionResult> GetMine(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var student = await CurrentStudentAsync(cancellationToken);
|
||||||
|
if (student is null)
|
||||||
|
return ConflictProblem("当前账号未关联有效学生档案。");
|
||||||
|
|
||||||
|
return Ok(await db.ExperimentGradeRecords.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.StudentId == student.Id &&
|
||||||
|
x.ExperimentGradeSheet!.Status ==
|
||||||
|
ExperimentGradeSheetStatus.Published)
|
||||||
|
.OrderByDescending(x =>
|
||||||
|
x.ExperimentGradeSheet!.PublishedAt)
|
||||||
|
.ThenBy(x =>
|
||||||
|
x.ExperimentGradeSheet!.ExperimentProject!.Code)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.ExperimentGradeSheetId,
|
||||||
|
ProjectId = x.ExperimentGradeSheet!.ExperimentProjectId,
|
||||||
|
ProjectCode =
|
||||||
|
x.ExperimentGradeSheet.ExperimentProject!.Code,
|
||||||
|
ProjectName =
|
||||||
|
x.ExperimentGradeSheet.ExperimentProject.Name,
|
||||||
|
x.ExperimentGradeSheet.ExperimentProject.ArrangementMode,
|
||||||
|
CourseCode = x.ExperimentGradeSheet.ExperimentProject
|
||||||
|
.TeachingTask!.Course!.Code,
|
||||||
|
CourseName = x.ExperimentGradeSheet.ExperimentProject
|
||||||
|
.TeachingTask.Course.Name,
|
||||||
|
TermName = x.ExperimentGradeSheet.ExperimentProject
|
||||||
|
.TeachingTask.AcademicTerm!.Name,
|
||||||
|
x.ExperimentGradeSheet.PassScore,
|
||||||
|
x.ParticipationStatus,
|
||||||
|
x.TotalScore,
|
||||||
|
x.IsPassed,
|
||||||
|
x.SafetyViolation,
|
||||||
|
x.AttemptNumber,
|
||||||
|
x.SubmissionReference,
|
||||||
|
x.SubmittedAt,
|
||||||
|
x.IsLate,
|
||||||
|
x.TeacherComment,
|
||||||
|
Items = x.ItemScores
|
||||||
|
.OrderBy(score =>
|
||||||
|
score.ExperimentGradeItem!.SortOrder)
|
||||||
|
.Select(score => new
|
||||||
|
{
|
||||||
|
score.ExperimentGradeItemId,
|
||||||
|
score.ExperimentGradeItem!.Name,
|
||||||
|
score.ExperimentGradeItem.Kind,
|
||||||
|
score.ExperimentGradeItem.Weight,
|
||||||
|
score.Score,
|
||||||
|
score.Comment
|
||||||
|
}),
|
||||||
|
x.ExperimentGradeSheet.PublishedAt
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> CreateSheet(
|
||||||
|
ExperimentGradeSheetRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var items = BuildItems(request.Items);
|
||||||
|
if (!ExperimentGradeCalculator.AreWeightsValid(
|
||||||
|
request.ContributionWeight,
|
||||||
|
request.PassScore,
|
||||||
|
items))
|
||||||
|
return ValidationProblem(
|
||||||
|
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
|
||||||
|
|
||||||
|
var project = await ScopedProjects()
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x =>
|
||||||
|
x.Id == request.ExperimentProjectId,
|
||||||
|
cancellationToken);
|
||||||
|
if (project is null) return NotFound();
|
||||||
|
if (project.Status is not (
|
||||||
|
ExperimentProjectStatus.Published or
|
||||||
|
ExperimentProjectStatus.Closed))
|
||||||
|
return ConflictProblem("只有已发布或已关闭实验项目可以建立成绩单。");
|
||||||
|
if (!CanInitialize(project.TeachingTask!)) return Forbid();
|
||||||
|
if (await db.ExperimentGradeSheets.AnyAsync(x =>
|
||||||
|
x.ExperimentProjectId == project.Id,
|
||||||
|
cancellationToken))
|
||||||
|
return ConflictProblem("该实验项目已经建立成绩单。");
|
||||||
|
|
||||||
|
var participants = await LoadParticipantsAsync(
|
||||||
|
project,
|
||||||
|
cancellationToken);
|
||||||
|
if (participants.Count == 0)
|
||||||
|
return ConflictProblem("实验项目当前没有可评分学生。");
|
||||||
|
|
||||||
|
var sheet = new ExperimentGradeSheet
|
||||||
|
{
|
||||||
|
ExperimentProjectId = project.Id,
|
||||||
|
ContributionWeight = request.ContributionWeight,
|
||||||
|
PassScore = request.PassScore,
|
||||||
|
Items = items,
|
||||||
|
Records = participants.Select(participant =>
|
||||||
|
new ExperimentGradeRecord
|
||||||
|
{
|
||||||
|
StudentId = participant.StudentId,
|
||||||
|
ExperimentSessionId = participant.SessionId,
|
||||||
|
ItemScores = items.Select(item =>
|
||||||
|
new ExperimentGradeItemScore
|
||||||
|
{
|
||||||
|
ExperimentGradeItemId = item.Id
|
||||||
|
}).ToList()
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
db.ExperimentGradeSheets.Add(sheet);
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Created(string.Empty, new { sheet.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("sheets/{id:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetSheet(
|
||||||
|
Guid id,
|
||||||
|
int recordPage = 1,
|
||||||
|
int recordPageSize = 50,
|
||||||
|
string? studentKeyword = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
recordPage = Math.Max(1, recordPage);
|
||||||
|
recordPageSize = Math.Clamp(recordPageSize, 10, 100);
|
||||||
|
studentKeyword = string.IsNullOrWhiteSpace(studentKeyword)
|
||||||
|
? null
|
||||||
|
: studentKeyword.Trim();
|
||||||
|
|
||||||
|
var sheet = await ScopedSheets().AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.ExperimentProjectId,
|
||||||
|
ProjectCode = x.ExperimentProject!.Code,
|
||||||
|
ProjectName = x.ExperimentProject.Name,
|
||||||
|
x.ExperimentProject.ArrangementMode,
|
||||||
|
x.ExperimentProject.TeachingTaskId,
|
||||||
|
x.ExperimentProject.TeachingTask!.TaskNumber,
|
||||||
|
TaskName = x.ExperimentProject.TeachingTask.Name,
|
||||||
|
AcademicTermId =
|
||||||
|
x.ExperimentProject.TeachingTask.AcademicTermId,
|
||||||
|
TermName = x.ExperimentProject.TeachingTask
|
||||||
|
.AcademicTerm!.Name,
|
||||||
|
CourseCollegeId = x.ExperimentProject.TeachingTask
|
||||||
|
.Course!.CollegeId,
|
||||||
|
CourseCollegeName = x.ExperimentProject.TeachingTask
|
||||||
|
.Course.College!.Name,
|
||||||
|
CourseCode =
|
||||||
|
x.ExperimentProject.TeachingTask.Course.Code,
|
||||||
|
CourseName =
|
||||||
|
x.ExperimentProject.TeachingTask.Course.Name,
|
||||||
|
TeacherNames = x.ExperimentProject.TeachingTask.Teachers
|
||||||
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
|
.Select(item => item.Teacher!.Name),
|
||||||
|
x.ContributionWeight,
|
||||||
|
x.PassScore,
|
||||||
|
Items = x.Items.OrderBy(item => item.SortOrder)
|
||||||
|
.Select(item => new
|
||||||
|
{
|
||||||
|
item.Id,
|
||||||
|
item.Name,
|
||||||
|
item.Kind,
|
||||||
|
item.Weight
|
||||||
|
}),
|
||||||
|
x.Status,
|
||||||
|
x.ReviewComment,
|
||||||
|
x.SubmittedAt,
|
||||||
|
x.ReviewedAt,
|
||||||
|
x.PublishedAt,
|
||||||
|
x.CreatedAt,
|
||||||
|
x.UpdatedAt
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
|
||||||
|
var recordsSource = db.ExperimentGradeRecords.AsNoTracking()
|
||||||
|
.Where(x => x.ExperimentGradeSheetId == id);
|
||||||
|
if (studentKeyword is not null)
|
||||||
|
recordsSource = recordsSource.Where(x =>
|
||||||
|
x.Student!.StudentNumber.Contains(studentKeyword) ||
|
||||||
|
x.Student.Name.Contains(studentKeyword) ||
|
||||||
|
x.Student.AdministrativeClass!.Name.Contains(studentKeyword));
|
||||||
|
var recordTotal = await recordsSource.CountAsync(cancellationToken);
|
||||||
|
var records = await recordsSource
|
||||||
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Skip((recordPage - 1) * recordPageSize)
|
||||||
|
.Take(recordPageSize)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber,
|
||||||
|
x.Student.Name,
|
||||||
|
ClassName = x.Student.AdministrativeClass!.Name,
|
||||||
|
x.ExperimentSessionId,
|
||||||
|
Session = x.ExperimentSession == null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
x.ExperimentSession.SessionDate,
|
||||||
|
x.ExperimentSession.StartPeriod,
|
||||||
|
x.ExperimentSession.PeriodCount,
|
||||||
|
ClassroomName =
|
||||||
|
x.ExperimentSession.Classroom!.Name
|
||||||
|
},
|
||||||
|
x.ParticipationStatus,
|
||||||
|
x.TotalScore,
|
||||||
|
x.IsPassed,
|
||||||
|
x.SafetyViolation,
|
||||||
|
x.AttemptNumber,
|
||||||
|
x.SubmissionReference,
|
||||||
|
x.SubmittedAt,
|
||||||
|
x.IsLate,
|
||||||
|
x.TeacherComment,
|
||||||
|
ItemScores = x.ItemScores
|
||||||
|
.OrderBy(score =>
|
||||||
|
score.ExperimentGradeItem!.SortOrder)
|
||||||
|
.Select(score => new
|
||||||
|
{
|
||||||
|
score.ExperimentGradeItemId,
|
||||||
|
score.ExperimentGradeItem!.Name,
|
||||||
|
score.Score,
|
||||||
|
score.Comment
|
||||||
|
}),
|
||||||
|
x.UpdatedAt
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var task = await db.TeachingTasks.AsNoTracking()
|
||||||
|
.Include(x => x.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.SingleAsync(
|
||||||
|
x => x.Id == sheet.TeachingTaskId,
|
||||||
|
cancellationToken);
|
||||||
|
var canReview = CanReviewCollege(sheet.CourseCollegeId) &&
|
||||||
|
sheet.Status ==
|
||||||
|
ExperimentGradeSheetStatus.Submitted;
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Sheet = new
|
||||||
|
{
|
||||||
|
sheet.Id,
|
||||||
|
sheet.ExperimentProjectId,
|
||||||
|
sheet.ProjectCode,
|
||||||
|
sheet.ProjectName,
|
||||||
|
sheet.ArrangementMode,
|
||||||
|
sheet.TeachingTaskId,
|
||||||
|
sheet.TaskNumber,
|
||||||
|
sheet.TaskName,
|
||||||
|
sheet.AcademicTermId,
|
||||||
|
sheet.TermName,
|
||||||
|
sheet.CourseCollegeName,
|
||||||
|
sheet.CourseCode,
|
||||||
|
sheet.CourseName,
|
||||||
|
sheet.TeacherNames,
|
||||||
|
sheet.ContributionWeight,
|
||||||
|
sheet.PassScore,
|
||||||
|
sheet.Items,
|
||||||
|
sheet.Status,
|
||||||
|
sheet.ReviewComment,
|
||||||
|
sheet.SubmittedAt,
|
||||||
|
sheet.ReviewedAt,
|
||||||
|
sheet.PublishedAt,
|
||||||
|
RecordTotal = recordTotal,
|
||||||
|
RecordPage = recordPage,
|
||||||
|
RecordPageSize = recordPageSize,
|
||||||
|
Records = records,
|
||||||
|
sheet.CreatedAt,
|
||||||
|
sheet.UpdatedAt
|
||||||
|
},
|
||||||
|
CanEdit = CanEdit(task) &&
|
||||||
|
sheet.Status is
|
||||||
|
ExperimentGradeSheetStatus.Draft or
|
||||||
|
ExperimentGradeSheetStatus.Returned,
|
||||||
|
CanReview = canReview,
|
||||||
|
CanPublish = IsPublisher() &&
|
||||||
|
sheet.Status == ExperimentGradeSheetStatus.Approved
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("sheets/{id:guid}/settings")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> UpdateSettings(
|
||||||
|
Guid id,
|
||||||
|
ExperimentGradeSettingsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var replacementItems = BuildItems(request.Items);
|
||||||
|
if (!ExperimentGradeCalculator.AreWeightsValid(
|
||||||
|
request.ContributionWeight,
|
||||||
|
request.PassScore,
|
||||||
|
replacementItems))
|
||||||
|
return ValidationProblem(
|
||||||
|
"实验评分项至少设置一项、权重合计 100%,项目贡献权重应大于 0。");
|
||||||
|
|
||||||
|
var sheet = await ScopedSheets()
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||||
|
if (sheet.Status is not (
|
||||||
|
ExperimentGradeSheetStatus.Draft or
|
||||||
|
ExperimentGradeSheetStatus.Returned))
|
||||||
|
return ConflictProblem("当前状态不能修改实验评分方案。");
|
||||||
|
|
||||||
|
sheet.ContributionWeight = request.ContributionWeight;
|
||||||
|
sheet.PassScore = request.PassScore;
|
||||||
|
db.ExperimentGradeItems.RemoveRange(sheet.Items);
|
||||||
|
foreach (var record in sheet.Records)
|
||||||
|
{
|
||||||
|
record.ItemScores.Clear();
|
||||||
|
record.TotalScore = null;
|
||||||
|
record.IsPassed = null;
|
||||||
|
}
|
||||||
|
sheet.Items = replacementItems;
|
||||||
|
foreach (var record in sheet.Records)
|
||||||
|
foreach (var item in replacementItems)
|
||||||
|
record.ItemScores.Add(new ExperimentGradeItemScore
|
||||||
|
{
|
||||||
|
ExperimentGradeItemId = item.Id
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("sheets/{id:guid}/records")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> UpdateRecords(
|
||||||
|
Guid id,
|
||||||
|
ExperimentGradeRecordsRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await ScopedSheets()
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||||
|
if (sheet.Status is not (
|
||||||
|
ExperimentGradeSheetStatus.Draft or
|
||||||
|
ExperimentGradeSheetStatus.Returned))
|
||||||
|
return ConflictProblem("成绩单提交后不能继续修改。");
|
||||||
|
|
||||||
|
var records = sheet.Records.ToDictionary(x => x.Id);
|
||||||
|
if (request.Records.Select(x => x.Id).Distinct().Count() !=
|
||||||
|
request.Records.Count ||
|
||||||
|
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
||||||
|
return ValidationProblem("包含无效或重复的实验成绩记录。");
|
||||||
|
var itemIds = sheet.Items.Select(x => x.Id).ToHashSet();
|
||||||
|
foreach (var item in request.Records)
|
||||||
|
{
|
||||||
|
if (item.AttemptNumber is < 1 or > 20)
|
||||||
|
return ValidationProblem("实验尝试次数应在 1—20 次之间。");
|
||||||
|
if (item.ItemScores.Select(x => x.ExperimentGradeItemId)
|
||||||
|
.Distinct().Count() != item.ItemScores.Count ||
|
||||||
|
item.ItemScores.Any(x =>
|
||||||
|
!itemIds.Contains(x.ExperimentGradeItemId) ||
|
||||||
|
!ValidScore(x.Score)))
|
||||||
|
return ValidationProblem("包含无效、重复或超出 0—100 分的评分项。");
|
||||||
|
|
||||||
|
var record = records[item.Id];
|
||||||
|
record.ParticipationStatus = item.ParticipationStatus;
|
||||||
|
record.SafetyViolation = item.SafetyViolation;
|
||||||
|
record.AttemptNumber = item.AttemptNumber;
|
||||||
|
record.SubmissionReference =
|
||||||
|
Normalize(item.SubmissionReference);
|
||||||
|
record.SubmittedAt = item.SubmittedAt;
|
||||||
|
record.IsLate = item.IsLate;
|
||||||
|
record.TeacherComment = Normalize(item.TeacherComment);
|
||||||
|
var scores = record.ItemScores.ToDictionary(
|
||||||
|
x => x.ExperimentGradeItemId);
|
||||||
|
foreach (var score in item.ItemScores)
|
||||||
|
{
|
||||||
|
var target = scores[score.ExperimentGradeItemId];
|
||||||
|
target.Score = score.Score;
|
||||||
|
target.Comment = Normalize(score.Comment);
|
||||||
|
}
|
||||||
|
Recalculate(sheet, record);
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/sync-participants")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> SyncParticipants(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await ScopedSheets()
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||||
|
if (sheet.Status is not (
|
||||||
|
ExperimentGradeSheetStatus.Draft or
|
||||||
|
ExperimentGradeSheetStatus.Returned))
|
||||||
|
return ConflictProblem("成绩单提交后不能同步参与学生。");
|
||||||
|
|
||||||
|
var expected = await LoadParticipantsAsync(
|
||||||
|
sheet.ExperimentProject,
|
||||||
|
cancellationToken);
|
||||||
|
var expectedByStudent = expected.ToDictionary(x => x.StudentId);
|
||||||
|
var existingByStudent = sheet.Records.ToDictionary(x => x.StudentId);
|
||||||
|
var added = 0;
|
||||||
|
var removed = 0;
|
||||||
|
var retained = 0;
|
||||||
|
var recordsToRemove = new List<ExperimentGradeRecord>();
|
||||||
|
var recordsToAdd = new List<ExperimentGradeRecord>();
|
||||||
|
|
||||||
|
foreach (var participant in expected)
|
||||||
|
{
|
||||||
|
if (existingByStudent.TryGetValue(
|
||||||
|
participant.StudentId,
|
||||||
|
out var record))
|
||||||
|
{
|
||||||
|
if (record.ParticipationStatus ==
|
||||||
|
ExperimentParticipationStatus.Pending &&
|
||||||
|
record.ItemScores.All(x => !x.Score.HasValue))
|
||||||
|
record.ExperimentSessionId = participant.SessionId;
|
||||||
|
retained++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newRecord = new ExperimentGradeRecord
|
||||||
|
{
|
||||||
|
ExperimentGradeSheetId = sheet.Id,
|
||||||
|
StudentId = participant.StudentId,
|
||||||
|
ExperimentSessionId = participant.SessionId
|
||||||
|
};
|
||||||
|
db.ExperimentGradeRecords.Add(newRecord);
|
||||||
|
recordsToAdd.Add(newRecord);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var record in sheet.Records
|
||||||
|
.Where(x => !expectedByStudent.ContainsKey(x.StudentId))
|
||||||
|
.ToList())
|
||||||
|
{
|
||||||
|
if (record.ParticipationStatus !=
|
||||||
|
ExperimentParticipationStatus.Pending ||
|
||||||
|
record.ItemScores.Any(x => x.Score.HasValue))
|
||||||
|
{
|
||||||
|
retained++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.ExperimentGradeItemScores.RemoveRange(record.ItemScores);
|
||||||
|
recordsToRemove.Add(record);
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordsToRemove.Count > 0 || recordsToAdd.Count > 0)
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
foreach (var record in recordsToRemove)
|
||||||
|
sheet.Records.Remove(record);
|
||||||
|
db.ExperimentGradeRecords.RemoveRange(recordsToRemove);
|
||||||
|
}
|
||||||
|
foreach (var record in recordsToAdd)
|
||||||
|
foreach (var item in sheet.Items)
|
||||||
|
record.ItemScores.Add(new ExperimentGradeItemScore
|
||||||
|
{
|
||||||
|
ExperimentGradeItemId = item.Id
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
Added = added,
|
||||||
|
Removed = removed,
|
||||||
|
Retained = retained,
|
||||||
|
Total = await db.ExperimentGradeRecords.CountAsync(
|
||||||
|
x => x.ExperimentGradeSheetId == sheet.Id,
|
||||||
|
cancellationToken)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/submit")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> Submit(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEdit(sheet.ExperimentProject!.TeachingTask!)) return Forbid();
|
||||||
|
if (sheet.Status is not (
|
||||||
|
ExperimentGradeSheetStatus.Draft or
|
||||||
|
ExperimentGradeSheetStatus.Returned))
|
||||||
|
return ConflictProblem("只有草稿或已退回实验成绩单可以提交。");
|
||||||
|
if (sheet.Records.Count == 0)
|
||||||
|
return ConflictProblem("实验成绩单没有学生记录。");
|
||||||
|
|
||||||
|
foreach (var record in sheet.Records) Recalculate(sheet, record);
|
||||||
|
if (sheet.Records.Any(x =>
|
||||||
|
x.ParticipationStatus ==
|
||||||
|
ExperimentParticipationStatus.Pending))
|
||||||
|
return ConflictProblem("仍有学生未登记实验参与状态。");
|
||||||
|
if (sheet.Records.Any(x =>
|
||||||
|
x.ParticipationStatus is
|
||||||
|
ExperimentParticipationStatus.Completed or
|
||||||
|
ExperimentParticipationStatus.Makeup &&
|
||||||
|
!x.TotalScore.HasValue))
|
||||||
|
return ConflictProblem("仍有已完成或补做学生缺少必填评分项。");
|
||||||
|
|
||||||
|
sheet.Status = ExperimentGradeSheetStatus.Submitted;
|
||||||
|
sheet.SubmittedAt = DateTime.UtcNow;
|
||||||
|
sheet.ReviewComment = null;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotificationService.SendToRoleAsync(
|
||||||
|
db,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
"实验成绩待审核",
|
||||||
|
$"“{sheet.ExperimentProject!.Name}”实验成绩已提交,请及时审核。",
|
||||||
|
sheet.ExperimentProject.TeachingTask!.Course!.CollegeId,
|
||||||
|
"/experiment-grades",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Grade);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/approve")]
|
||||||
|
[Authorize(Roles = Reviewers)]
|
||||||
|
public async Task<ActionResult> Approve(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
|
||||||
|
if (!CanReviewCollege(collegeId)) return Forbid();
|
||||||
|
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
|
||||||
|
return ConflictProblem("只有待审核实验成绩单可以通过审核。");
|
||||||
|
|
||||||
|
sheet.Status = ExperimentGradeSheetStatus.Approved;
|
||||||
|
sheet.ReviewedAt = DateTime.UtcNow;
|
||||||
|
sheet.ReviewComment = null;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotifyTeachersAsync(
|
||||||
|
sheet.ExperimentProject.TeachingTaskId,
|
||||||
|
"实验成绩审核通过",
|
||||||
|
$"“{sheet.ExperimentProject.Name}”实验成绩已通过学院审核。",
|
||||||
|
cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/return")]
|
||||||
|
[Authorize(Roles = Reviewers)]
|
||||||
|
public async Task<ActionResult> Return(
|
||||||
|
Guid id,
|
||||||
|
ExperimentGradeReviewRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
var collegeId = sheet.ExperimentProject!.TeachingTask!.Course!.CollegeId;
|
||||||
|
if (!CanReviewCollege(collegeId)) return Forbid();
|
||||||
|
if (sheet.Status != ExperimentGradeSheetStatus.Submitted)
|
||||||
|
return ConflictProblem("只有待审核实验成绩单可以退回。");
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||||
|
return ValidationProblem("退回时必须填写修改意见。");
|
||||||
|
|
||||||
|
sheet.Status = ExperimentGradeSheetStatus.Returned;
|
||||||
|
sheet.ReviewedAt = DateTime.UtcNow;
|
||||||
|
sheet.ReviewComment = request.Comment.Trim();
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
await NotifyTeachersAsync(
|
||||||
|
sheet.ExperimentProject.TeachingTaskId,
|
||||||
|
"实验成绩被退回",
|
||||||
|
$"“{sheet.ExperimentProject.Name}”实验成绩被退回:{sheet.ReviewComment}",
|
||||||
|
cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/publish")]
|
||||||
|
[Authorize(Roles = Publishers)]
|
||||||
|
public async Task<ActionResult> Publish(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await LoadForWorkflowAsync(id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!IsPublisher()) return Forbid();
|
||||||
|
if (sheet.Status != ExperimentGradeSheetStatus.Approved)
|
||||||
|
return ConflictProblem("只有审核通过的实验成绩单可以发布。");
|
||||||
|
|
||||||
|
sheet.Status = ExperimentGradeSheetStatus.Published;
|
||||||
|
sheet.PublishedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
var userIds = await db.ExperimentGradeRecords
|
||||||
|
.Where(x => x.ExperimentGradeSheetId == sheet.Id)
|
||||||
|
.Select(x => x.Student!.UserId)
|
||||||
|
.Where(x => x != null)
|
||||||
|
.Select(x => x!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
await NotificationService.SendToUserIdsAsync(
|
||||||
|
db,
|
||||||
|
userIds,
|
||||||
|
"实验成绩已发布",
|
||||||
|
$"“{sheet.ExperimentProject!.Name}”实验成绩已发布。",
|
||||||
|
"/experiment-grades",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Grade);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ExperimentGradeSheet?> LoadForWorkflowAsync(
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!await ScopedSheets().AnyAsync(
|
||||||
|
x => x.Id == id,
|
||||||
|
cancellationToken))
|
||||||
|
return null;
|
||||||
|
return await db.ExperimentGradeSheets
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.ExperimentProject)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Course)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<ExperimentParticipantSeed>> LoadParticipantsAsync(
|
||||||
|
ExperimentProject project,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (project.ArrangementMode ==
|
||||||
|
ExperimentArrangementMode.Centralized)
|
||||||
|
{
|
||||||
|
return await TeachingTaskRosterQuery
|
||||||
|
.ForTask(db, project.TeachingTaskId)
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderBy(x => x.StudentNumber)
|
||||||
|
.Select(x => new ExperimentParticipantSeed(x.Id, null))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await db.ExperimentBookings.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.ExperimentProjectId == project.Id &&
|
||||||
|
x.Status == ExperimentBookingStatus.Booked &&
|
||||||
|
x.Student!.Status == StudentStatus.Active)
|
||||||
|
.OrderBy(x => x.Student!.StudentNumber)
|
||||||
|
.Select(x => new ExperimentParticipantSeed(
|
||||||
|
x.StudentId,
|
||||||
|
x.ExperimentSessionId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ExperimentGradeItem> BuildItems(
|
||||||
|
IReadOnlyCollection<ExperimentGradeItemRequest>? requests) =>
|
||||||
|
requests?.Select((item, index) => new ExperimentGradeItem
|
||||||
|
{
|
||||||
|
Name = item.Name.Trim(),
|
||||||
|
Kind = item.Kind,
|
||||||
|
Weight = item.Weight,
|
||||||
|
SortOrder = index
|
||||||
|
}).ToList() ?? [];
|
||||||
|
|
||||||
|
private static void Recalculate(
|
||||||
|
ExperimentGradeSheet sheet,
|
||||||
|
ExperimentGradeRecord record)
|
||||||
|
{
|
||||||
|
record.TotalScore = ExperimentGradeCalculator.CalculateTotal(
|
||||||
|
record,
|
||||||
|
sheet.Items.ToList());
|
||||||
|
record.IsPassed = record.TotalScore.HasValue
|
||||||
|
? record.TotalScore.Value >= sheet.PassScore &&
|
||||||
|
!record.SafetyViolation
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<TeachingTask> AccessibleTeachingTasks()
|
||||||
|
{
|
||||||
|
var source = db.TeachingTasks.AsQueryable();
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (scope.Scope == DataScope.All) return source;
|
||||||
|
if (scope.Scope == DataScope.College)
|
||||||
|
return source.Where(x =>
|
||||||
|
x.Course!.CollegeId == scope.RestrictedCollegeId);
|
||||||
|
if (scope.IsInRole(SystemRoles.Teacher))
|
||||||
|
return source.Where(x => x.Teachers.Any(item =>
|
||||||
|
item.Teacher!.UserId == scope.UserId));
|
||||||
|
return source.Where(_ => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<ExperimentProject> ScopedProjects()
|
||||||
|
{
|
||||||
|
var taskIds = AccessibleTeachingTasks().Select(x => x.Id);
|
||||||
|
return db.ExperimentProjects.Where(x =>
|
||||||
|
taskIds.Contains(x.TeachingTaskId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<ExperimentGradeSheet> ScopedSheets()
|
||||||
|
{
|
||||||
|
var projectIds = ScopedProjects().Select(x => x.Id);
|
||||||
|
return db.ExperimentGradeSheets.Where(x =>
|
||||||
|
projectIds.Contains(x.ExperimentProjectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanInitialize(TeachingTask task) =>
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin) ||
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.CollegeAdmin) ||
|
||||||
|
IsAssignedTeacher(task);
|
||||||
|
|
||||||
|
private bool CanEdit(TeachingTask task) =>
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
IsAssignedTeacher(task);
|
||||||
|
|
||||||
|
private bool IsAssignedTeacher(TeachingTask task) =>
|
||||||
|
task.Teachers.Any(x =>
|
||||||
|
x.Teacher?.UserId == currentUserDataScope.Current.UserId);
|
||||||
|
|
||||||
|
private bool CanReviewCollege(Guid collegeId)
|
||||||
|
{
|
||||||
|
var scope = currentUserDataScope.Current;
|
||||||
|
if (!scope.IsInRole(SystemRoles.SuperAdmin) &&
|
||||||
|
!scope.IsInRole(SystemRoles.AcademicAdmin) &&
|
||||||
|
!scope.IsInRole(SystemRoles.CollegeAdmin))
|
||||||
|
return false;
|
||||||
|
return scope.Scope == DataScope.All ||
|
||||||
|
scope.RestrictedCollegeId == collegeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPublisher() =>
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
|
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||||
|
|
||||||
|
private Task<Student?> CurrentStudentAsync(
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.Students.FirstOrDefaultAsync(x =>
|
||||||
|
x.UserId == currentUserDataScope.Current.UserId &&
|
||||||
|
x.Status == StudentStatus.Active,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
private async Task NotifyTeachersAsync(
|
||||||
|
Guid teachingTaskId,
|
||||||
|
string title,
|
||||||
|
string content,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userIds = await db.TeachingTaskTeachers
|
||||||
|
.Where(x => x.TeachingTaskId == teachingTaskId)
|
||||||
|
.Select(x => x.Teacher!.UserId)
|
||||||
|
.Where(x => x != null)
|
||||||
|
.Select(x => x!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
await NotificationService.SendToUserIdsAsync(
|
||||||
|
db,
|
||||||
|
userIds,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
"/experiment-grades",
|
||||||
|
cancellationToken,
|
||||||
|
NotificationCategory.Grade);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ValidScore(decimal? score) =>
|
||||||
|
!score.HasValue || score.Value is >= 0 and <= 100;
|
||||||
|
|
||||||
|
private static string? Normalize(string? value) =>
|
||||||
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private ActionResult ConflictProblem(string detail) =>
|
||||||
|
Conflict(new ProblemDetails
|
||||||
|
{
|
||||||
|
Title = "无法完成实验成绩操作",
|
||||||
|
Detail = detail,
|
||||||
|
Status = StatusCodes.Status409Conflict
|
||||||
|
});
|
||||||
|
|
||||||
|
private sealed record ExperimentParticipantSeed(
|
||||||
|
Guid StudentId,
|
||||||
|
Guid? SessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExperimentGradeSheetRequest(
|
||||||
|
Guid ExperimentProjectId,
|
||||||
|
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
|
||||||
|
[Range(typeof(decimal), "0", "100")] decimal PassScore,
|
||||||
|
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
|
||||||
|
|
||||||
|
public sealed record ExperimentGradeSettingsRequest(
|
||||||
|
[Range(typeof(decimal), "0.1", "100")] decimal ContributionWeight,
|
||||||
|
[Range(typeof(decimal), "0", "100")] decimal PassScore,
|
||||||
|
IReadOnlyCollection<ExperimentGradeItemRequest> Items);
|
||||||
|
|
||||||
|
public sealed record ExperimentGradeItemRequest(
|
||||||
|
[Required, MaxLength(60)] string Name,
|
||||||
|
ExperimentGradeItemKind Kind,
|
||||||
|
[Range(typeof(decimal), "0.1", "100")] decimal Weight);
|
||||||
|
|
||||||
|
public sealed record ExperimentGradeRecordsRequest(
|
||||||
|
IReadOnlyCollection<ExperimentGradeRecordRequest> Records);
|
||||||
|
|
||||||
|
public sealed record ExperimentGradeRecordRequest(
|
||||||
|
Guid Id,
|
||||||
|
ExperimentParticipationStatus ParticipationStatus,
|
||||||
|
bool SafetyViolation,
|
||||||
|
[Range(1, 20)] int AttemptNumber,
|
||||||
|
[MaxLength(500)] string? SubmissionReference,
|
||||||
|
DateTime? SubmittedAt,
|
||||||
|
bool IsLate,
|
||||||
|
[MaxLength(500)] string? TeacherComment,
|
||||||
|
IReadOnlyCollection<ExperimentGradeItemScoreRequest> ItemScores);
|
||||||
|
|
||||||
|
public sealed record ExperimentGradeItemScoreRequest(
|
||||||
|
Guid ExperimentGradeItemId,
|
||||||
|
decimal? Score,
|
||||||
|
[MaxLength(300)] string? Comment);
|
||||||
|
|
||||||
|
public sealed record ExperimentGradeReviewRequest(
|
||||||
|
[MaxLength(500)] string? Comment);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -101,7 +101,9 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
item.Id,
|
item.Id,
|
||||||
item.Name,
|
item.Name,
|
||||||
item.Weight
|
item.Weight,
|
||||||
|
item.SourceType,
|
||||||
|
item.SourceSnapshotAt
|
||||||
}),
|
}),
|
||||||
StudentCount = sheet.Records.Count,
|
StudentCount = sheet.Records.Count,
|
||||||
CompletedCount = sheet.Records.Count(record =>
|
CompletedCount = sheet.Records.Count(record =>
|
||||||
@@ -220,7 +222,9 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
item.Id,
|
item.Id,
|
||||||
item.Name,
|
item.Name,
|
||||||
item.Weight
|
item.Weight,
|
||||||
|
item.SourceType,
|
||||||
|
item.SourceSnapshotAt
|
||||||
}),
|
}),
|
||||||
x.Status,
|
x.Status,
|
||||||
x.ReviewComment,
|
x.ReviewComment,
|
||||||
@@ -408,7 +412,7 @@ public sealed class GradesController(
|
|||||||
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
request.Records.Any(x => !records.ContainsKey(x.Id)))
|
||||||
return ValidationProblem("包含无效或重复的成绩记录。");
|
return ValidationProblem("包含无效或重复的成绩记录。");
|
||||||
|
|
||||||
var itemIds = sheet.Items.Select(item => item.Id).ToHashSet();
|
var itemsById = sheet.Items.ToDictionary(item => item.Id);
|
||||||
foreach (var item in request.Records)
|
foreach (var item in request.Records)
|
||||||
{
|
{
|
||||||
if (!ValidScore(item.RegularScore) ||
|
if (!ValidScore(item.RegularScore) ||
|
||||||
@@ -428,8 +432,19 @@ public sealed class GradesController(
|
|||||||
{
|
{
|
||||||
if (!ValidScore(scoreEntry.Score))
|
if (!ValidScore(scoreEntry.Score))
|
||||||
return ValidationProblem("分项成绩必须在 0—100 分之间。");
|
return ValidationProblem("分项成绩必须在 0—100 分之间。");
|
||||||
if (scoreMap.TryGetValue(scoreEntry.GradeItemId, out var existing))
|
if (!itemsById.TryGetValue(
|
||||||
existing.Score = scoreEntry.Score;
|
scoreEntry.GradeItemId,
|
||||||
|
out var gradeItem) ||
|
||||||
|
!scoreMap.TryGetValue(
|
||||||
|
scoreEntry.GradeItemId,
|
||||||
|
out var existing))
|
||||||
|
continue;
|
||||||
|
if (gradeItem.SourceType ==
|
||||||
|
GradeItemSourceType.ExperimentSummary &&
|
||||||
|
existing.Score != scoreEntry.Score)
|
||||||
|
return ConflictProblem(
|
||||||
|
$"“{gradeItem.Name}”来自实验成绩快照,不能手工修改;请重新导入实验成绩。");
|
||||||
|
existing.Score = scoreEntry.Score;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +453,66 @@ public sealed class GradesController(
|
|||||||
return await SaveAsync(id, false, cancellationToken);
|
return await SaveAsync(id, false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("sheets/{id:guid}/import-experiment-scores")]
|
||||||
|
[Authorize(Roles = SheetUsers)]
|
||||||
|
public async Task<ActionResult> ImportExperimentScores(
|
||||||
|
Guid id,
|
||||||
|
ImportExperimentScoresRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheet = await AccessibleSheets()
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.Include(x => x.TeachingTask)
|
||||||
|
.ThenInclude(x => x!.Teachers)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
||||||
|
if (sheet is null) return NotFound();
|
||||||
|
if (!CanEditScores(sheet.TeachingTask!)) return Forbid();
|
||||||
|
if (sheet.Status is not (
|
||||||
|
GradeSheetStatus.Draft or
|
||||||
|
GradeSheetStatus.Returned))
|
||||||
|
return ConflictProblem("成绩单提交后不能导入实验成绩。");
|
||||||
|
|
||||||
|
var targetItem = sheet.Items.FirstOrDefault(x =>
|
||||||
|
x.Id == request.GradeItemId);
|
||||||
|
if (targetItem is null)
|
||||||
|
return ValidationProblem("请选择当前成绩单中的有效分项。");
|
||||||
|
|
||||||
|
var aggregate = await ExperimentGradeAggregationService.CalculateAsync(
|
||||||
|
db,
|
||||||
|
sheet.TeachingTaskId,
|
||||||
|
sheet.Records.Select(x => x.StudentId).ToArray(),
|
||||||
|
cancellationToken);
|
||||||
|
if (aggregate.PublishedProjectCount == 0)
|
||||||
|
return ConflictProblem(
|
||||||
|
"该教学任务尚无已发布的实验项目成绩,无法导入。");
|
||||||
|
|
||||||
|
var imported = 0;
|
||||||
|
var missing = 0;
|
||||||
|
foreach (var record in sheet.Records)
|
||||||
|
{
|
||||||
|
var targetScore = record.ItemScores.First(x =>
|
||||||
|
x.GradeItemId == targetItem.Id);
|
||||||
|
targetScore.Score = aggregate.Scores[record.StudentId];
|
||||||
|
if (targetScore.Score.HasValue) imported++;
|
||||||
|
else missing++;
|
||||||
|
Recalculate(sheet, record);
|
||||||
|
}
|
||||||
|
targetItem.SourceType = GradeItemSourceType.ExperimentSummary;
|
||||||
|
targetItem.SourceSnapshotAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
aggregate.PublishedProjectCount,
|
||||||
|
aggregate.Projects,
|
||||||
|
ImportedCount = imported,
|
||||||
|
MissingCount = missing,
|
||||||
|
targetItem.SourceSnapshotAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("sheets/{id:guid}/submit")]
|
[HttpPost("sheets/{id:guid}/submit")]
|
||||||
[Authorize(Roles = SheetUsers)]
|
[Authorize(Roles = SheetUsers)]
|
||||||
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Submit(Guid id, CancellationToken cancellationToken)
|
||||||
@@ -998,5 +1073,7 @@ public sealed record GradeItemScoreRequest(
|
|||||||
Guid GradeItemId,
|
Guid GradeItemId,
|
||||||
decimal? Score);
|
decimal? Score);
|
||||||
|
|
||||||
|
public sealed record ImportExperimentScoresRequest(Guid GradeItemId);
|
||||||
|
|
||||||
public sealed record GradeReviewRequest(
|
public sealed record GradeReviewRequest(
|
||||||
[MaxLength(500)] string? Comment);
|
[MaxLength(500)] string? Comment);
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ namespace Jiaowu.Api.Controllers;
|
|||||||
public sealed class MakeupExamsController(
|
public sealed class MakeupExamsController(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
ICurrentUserDataScope currentUserDataScope,
|
ICurrentUserDataScope currentUserDataScope,
|
||||||
MakeupExamEligibilityService eligibilityService,
|
MakeupExamEligibilityService eligibilityService) : ControllerBase
|
||||||
MakeupExamArrangementService arrangementService) : ControllerBase
|
|
||||||
{
|
{
|
||||||
private const string Managers =
|
private const string Managers =
|
||||||
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin;
|
||||||
@@ -111,6 +110,7 @@ public sealed class MakeupExamsController(
|
|||||||
item.StartsAt,
|
item.StartsAt,
|
||||||
item.EndsAt,
|
item.EndsAt,
|
||||||
item.RequiredBuildingId,
|
item.RequiredBuildingId,
|
||||||
|
item.RequiredBuildingIds,
|
||||||
RequiredBuildingName = item.RequiredBuilding != null
|
RequiredBuildingName = item.RequiredBuilding != null
|
||||||
? item.RequiredBuilding.Name : null,
|
? item.RequiredBuilding.Name : null,
|
||||||
item.RequiredInvigilatorCount,
|
item.RequiredInvigilatorCount,
|
||||||
@@ -138,38 +138,90 @@ public sealed class MakeupExamsController(
|
|||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Publish(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var plan = await db.MakeupExamPlans
|
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||||
.Include(x => x.Sessions)
|
return ConflictProblem("补考计划正在后台编排,完成后才能发布。");
|
||||||
.ThenInclude(x => x.Invigilators)
|
if (await FindActivePublishJobAsync(id, cancellationToken) is not null)
|
||||||
.Include(x => x.Sessions)
|
return ConflictProblem("补考计划正在后台发布,请等待任务完成。");
|
||||||
.ThenInclude(x => x.Enrollments)
|
|
||||||
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
|
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||||
|
.Where(x => x.Id == id)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Status,
|
||||||
|
HasSessions = x.Sessions.Any()
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
return ConflictProblem("只有草稿补考计划可以发布。");
|
return ConflictProblem("只有草稿补考计划可以发布。");
|
||||||
if (plan.Sessions.Count == 0)
|
if (!plan.HasSessions)
|
||||||
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
return ConflictProblem("至少安排一个考试场次后才能发布。");
|
||||||
|
|
||||||
var unassigned = plan.Sessions.Count(x =>
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
!x.ClassroomId.HasValue || x.Invigilators.Count == 0);
|
var job = new ExamPublishJob
|
||||||
if (unassigned > 0)
|
{
|
||||||
return ConflictProblem(
|
Kind = ExamPublishJobKind.MakeupExam,
|
||||||
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
PlanId = id,
|
||||||
|
ActivePlanId = id,
|
||||||
|
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||||
|
CurrentStep = "等待后台校验"
|
||||||
|
};
|
||||||
|
db.ExamPublishJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(
|
||||||
|
BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.ExamPublish,
|
||||||
|
job.Id));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var existing = await FindActivePublishJobAsync(id, cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
return AcceptedPublishJob(existing, "该计划已有正在执行的发布任务。");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
var empty = plan.Sessions.Count(x => x.Enrollments.Count == 0);
|
return AcceptedPublishJob(job, "补考发布任务已提交。");
|
||||||
if (empty > 0)
|
}
|
||||||
return ConflictProblem(
|
|
||||||
$"还有 {empty} 个场次没有登记补考学生。");
|
|
||||||
|
|
||||||
plan.Status = MakeupExamPlanStatus.Published;
|
[HttpGet("publish-jobs/{jobId:guid}")]
|
||||||
plan.PublishedAt = DateTime.UtcNow;
|
[Authorize(Roles = Managers)]
|
||||||
return await SaveAsync(id, false, cancellationToken);
|
public async Task<ActionResult> GetPublishJob(
|
||||||
|
Guid jobId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId &&
|
||||||
|
x.Kind == ExamPublishJobKind.MakeupExam,
|
||||||
|
cancellationToken);
|
||||||
|
return job is null ? NotFound() : Ok(ToPublishJobResponse(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("plans/{planId:guid}/publish-job")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetLatestPublishJob(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.ExamPublishJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.PlanId == planId &&
|
||||||
|
x.Kind == ExamPublishJobKind.MakeupExam)
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return job is null ? NoContent() : Ok(ToPublishJobResponse(job));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("plans/{id:guid}/archive")]
|
[HttpPost("plans/{id:guid}/archive")]
|
||||||
[Authorize(Roles = Managers)]
|
[Authorize(Roles = Managers)]
|
||||||
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
|
public async Task<ActionResult> Archive(Guid id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await FindActiveArrangementJobAsync(id, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("补考计划正在后台编排,暂时不能归档。");
|
||||||
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
|
var plan = await db.MakeupExamPlans.FindAsync([id], cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != MakeupExamPlanStatus.Published)
|
if (plan.Status != MakeupExamPlanStatus.Published)
|
||||||
@@ -189,6 +241,8 @@ public sealed class MakeupExamsController(
|
|||||||
CreateMakeupExamSessionRequest request,
|
CreateMakeupExamSessionRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||||
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
@@ -215,6 +269,7 @@ public sealed class MakeupExamsController(
|
|||||||
StartsAt = startsAt,
|
StartsAt = startsAt,
|
||||||
EndsAt = endsAt,
|
EndsAt = endsAt,
|
||||||
RequiredBuildingId = request.RequiredBuildingId,
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
Notes = Normalize(request.Notes),
|
Notes = Normalize(request.Notes),
|
||||||
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
Invigilators = (request.InvigilatorIds ?? []).Distinct().Select(id =>
|
||||||
@@ -231,6 +286,8 @@ public sealed class MakeupExamsController(
|
|||||||
CreateMakeupExamSessionsBatchRequest request,
|
CreateMakeupExamSessionsBatchRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||||
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
var plan = await db.MakeupExamPlans.FindAsync([planId], cancellationToken);
|
||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
@@ -281,6 +338,7 @@ public sealed class MakeupExamsController(
|
|||||||
StartsAt = startsAt,
|
StartsAt = startsAt,
|
||||||
EndsAt = endsAt,
|
EndsAt = endsAt,
|
||||||
RequiredBuildingId = request.RequiredBuildingId,
|
RequiredBuildingId = request.RequiredBuildingId,
|
||||||
|
RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds),
|
||||||
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
RequiredInvigilatorCount = request.RequiredInvigilatorCount,
|
||||||
Notes = Normalize(request.Notes)
|
Notes = Normalize(request.Notes)
|
||||||
}));
|
}));
|
||||||
@@ -304,6 +362,8 @@ public sealed class MakeupExamsController(
|
|||||||
CreateMakeupExamSessionRequest request,
|
CreateMakeupExamSessionRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||||
var session = await db.MakeupExamSessions
|
var session = await db.MakeupExamSessions
|
||||||
.Include(x => x.MakeupExamPlan)
|
.Include(x => x.MakeupExamPlan)
|
||||||
.Include(x => x.Invigilators)
|
.Include(x => x.Invigilators)
|
||||||
@@ -330,6 +390,7 @@ public sealed class MakeupExamsController(
|
|||||||
session.StartsAt = startsAt;
|
session.StartsAt = startsAt;
|
||||||
session.EndsAt = endsAt;
|
session.EndsAt = endsAt;
|
||||||
session.RequiredBuildingId = request.RequiredBuildingId;
|
session.RequiredBuildingId = request.RequiredBuildingId;
|
||||||
|
session.RequiredBuildingIds = SerializeBuildingIds(request.RequiredBuildingIds);
|
||||||
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
session.RequiredInvigilatorCount = request.RequiredInvigilatorCount;
|
||||||
session.Notes = Normalize(request.Notes);
|
session.Notes = Normalize(request.Notes);
|
||||||
|
|
||||||
@@ -347,6 +408,8 @@ public sealed class MakeupExamsController(
|
|||||||
Guid id,
|
Guid id,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("补考计划正在后台编排,暂时不能调整场次。");
|
||||||
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
|
var session = await db.MakeupExamSessions.Include(x => x.MakeupExamPlan)
|
||||||
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == id && x.MakeupExamPlanId == planId, cancellationToken);
|
||||||
if (session is null) return NotFound();
|
if (session is null) return NotFound();
|
||||||
@@ -368,15 +431,117 @@ public sealed class MakeupExamsController(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
request ??= new ExamAutoArrangeRequest();
|
request ??= new ExamAutoArrangeRequest();
|
||||||
var result = await arrangementService.ArrangeAsync(
|
if (!request.AssignClassrooms && !request.AssignInvigilators)
|
||||||
|
return ValidationProblem("请至少选择分配考场或分配监考教师。");
|
||||||
|
|
||||||
|
var sessionIds = (request.SessionIds ?? []).Distinct().ToArray();
|
||||||
|
if (sessionIds.Length > 100)
|
||||||
|
return ValidationProblem("一次最多处理100个补考场次。");
|
||||||
|
|
||||||
|
var plan = await db.MakeupExamPlans.AsNoTracking()
|
||||||
|
.Where(x => x.Id == planId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Status,
|
||||||
|
TotalSessions = x.Sessions.Count
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (plan is null)
|
||||||
|
return NotFound();
|
||||||
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
return ConflictProblem("只有草稿状态的补考计划可以自动编排。");
|
||||||
|
if (sessionIds.Length > 0)
|
||||||
|
{
|
||||||
|
var selectedSessionCount = await db.MakeupExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamPlanId == planId)
|
||||||
|
.WhereIn(sessionIds, x => x.Id)
|
||||||
|
.CountAsync(cancellationToken);
|
||||||
|
if (selectedSessionCount != sessionIds.Length)
|
||||||
|
return ConflictProblem("所选场次不存在或不属于当前补考计划。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var autoCreateActive = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||||
|
.AnyAsync(
|
||||||
|
x => x.MakeupExamPlanId == planId &&
|
||||||
|
(x.Status == MakeupExamAutoJobStatus.Queued ||
|
||||||
|
x.Status == MakeupExamAutoJobStatus.Running),
|
||||||
|
cancellationToken);
|
||||||
|
if (autoCreateActive)
|
||||||
|
return ConflictProblem("该计划正在自动生成补考场次,请完成后再编排。");
|
||||||
|
|
||||||
|
var existing = await FindActiveArrangementJobAsync(
|
||||||
planId,
|
planId,
|
||||||
request.SessionIds,
|
|
||||||
request.AssignClassrooms,
|
|
||||||
request.AssignInvigilators,
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (!result.Success)
|
if (existing is not null)
|
||||||
return ConflictProblem(result.Message);
|
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
|
||||||
return Ok(new { message = result.Message });
|
|
||||||
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
|
var job = new ExamArrangementJob
|
||||||
|
{
|
||||||
|
Kind = ExamArrangementKind.MakeupExam,
|
||||||
|
PlanId = planId,
|
||||||
|
ActivePlanId = planId,
|
||||||
|
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||||
|
SessionIdsJson = sessionIds.Length == 0
|
||||||
|
? null
|
||||||
|
: System.Text.Json.JsonSerializer.Serialize(sessionIds),
|
||||||
|
AssignClassrooms = request.AssignClassrooms,
|
||||||
|
AssignInvigilators = request.AssignInvigilators,
|
||||||
|
TotalSessions = sessionIds.Length == 0
|
||||||
|
? plan.TotalSessions
|
||||||
|
: sessionIds.Length,
|
||||||
|
CurrentStep = "等待后台编排"
|
||||||
|
};
|
||||||
|
db.ExamArrangementJobs.Add(job);
|
||||||
|
db.BackgroundJobOutboxMessages.Add(
|
||||||
|
BackgroundJobOutboxMessage.Create(
|
||||||
|
BackgroundJobKind.ExamArrangement,
|
||||||
|
job.Id));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
existing = await FindActiveArrangementJobAsync(
|
||||||
|
planId,
|
||||||
|
cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
return AcceptedArrangementJob(existing, "该计划已有正在执行的编排任务。");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return AcceptedArrangementJob(job, "补考编排任务已提交。");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("arrangement-jobs/{jobId:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetArrangementJob(
|
||||||
|
Guid jobId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.ExamArrangementJobs.AsNoTracking()
|
||||||
|
.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId &&
|
||||||
|
x.Kind == ExamArrangementKind.MakeupExam,
|
||||||
|
cancellationToken);
|
||||||
|
return job is null ? NotFound() : Ok(ToArrangementJobResponse(job));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("plans/{planId:guid}/arrangement-job")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> GetLatestArrangementJob(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await db.ExamArrangementJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.PlanId == planId &&
|
||||||
|
x.Kind == ExamArrangementKind.MakeupExam)
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
return job is null ? NoContent() : Ok(ToArrangementJobResponse(job));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -394,6 +559,8 @@ public sealed class MakeupExamsController(
|
|||||||
if (plan is null) return NotFound();
|
if (plan is null) return NotFound();
|
||||||
if (plan.Status != MakeupExamPlanStatus.Draft)
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
|
return ConflictProblem("只有草稿状态的补考计划可以自动生成。");
|
||||||
|
if (await FindActiveArrangementJobAsync(planId, cancellationToken) is not null)
|
||||||
|
return ConflictProblem("该计划正在自动编排,请完成后再自动生成场次。");
|
||||||
|
|
||||||
// Check for existing active job
|
// Check for existing active job
|
||||||
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
|
var existing = await db.MakeupExamAutoJobs.AsNoTracking()
|
||||||
@@ -1042,6 +1209,87 @@ public sealed class MakeupExamsController(
|
|||||||
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
currentUserDataScope.Current.IsInRole(SystemRoles.SuperAdmin) ||
|
||||||
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
currentUserDataScope.Current.IsInRole(SystemRoles.AcademicAdmin);
|
||||||
|
|
||||||
|
private Task<ExamArrangementJob?> FindActiveArrangementJobAsync(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.ExamArrangementJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Kind == ExamArrangementKind.MakeupExam &&
|
||||||
|
x.ActivePlanId == planId &&
|
||||||
|
(x.Status == ExamArrangementJobStatus.Queued ||
|
||||||
|
x.Status == ExamArrangementJobStatus.Running))
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
private ActionResult AcceptedArrangementJob(
|
||||||
|
ExamArrangementJob job,
|
||||||
|
string message) =>
|
||||||
|
AcceptedAtAction(
|
||||||
|
nameof(GetArrangementJob),
|
||||||
|
new { jobId = job.Id },
|
||||||
|
new
|
||||||
|
{
|
||||||
|
jobId = job.Id,
|
||||||
|
status = job.Status.ToString(),
|
||||||
|
message
|
||||||
|
});
|
||||||
|
|
||||||
|
private static object ToArrangementJobResponse(ExamArrangementJob job) => new
|
||||||
|
{
|
||||||
|
job.Id,
|
||||||
|
job.PlanId,
|
||||||
|
Kind = job.Kind.ToString(),
|
||||||
|
Status = job.Status.ToString(),
|
||||||
|
job.TotalSessions,
|
||||||
|
job.ProcessedSessions,
|
||||||
|
job.CurrentStep,
|
||||||
|
job.ResultMessage,
|
||||||
|
job.ErrorMessage,
|
||||||
|
job.AssignClassrooms,
|
||||||
|
job.AssignInvigilators,
|
||||||
|
job.CreatedAt,
|
||||||
|
job.StartedAt,
|
||||||
|
job.CompletedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
private Task<ExamPublishJob?> FindActivePublishJobAsync(
|
||||||
|
Guid planId,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
db.ExamPublishJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Kind == ExamPublishJobKind.MakeupExam &&
|
||||||
|
x.ActivePlanId == planId &&
|
||||||
|
(x.Status == ExamPublishJobStatus.Queued ||
|
||||||
|
x.Status == ExamPublishJobStatus.Running))
|
||||||
|
.OrderByDescending(x => x.CreatedAt)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
private ActionResult AcceptedPublishJob(
|
||||||
|
ExamPublishJob job,
|
||||||
|
string message) =>
|
||||||
|
AcceptedAtAction(
|
||||||
|
nameof(GetPublishJob),
|
||||||
|
new { jobId = job.Id },
|
||||||
|
new
|
||||||
|
{
|
||||||
|
jobId = job.Id,
|
||||||
|
status = job.Status.ToString(),
|
||||||
|
message
|
||||||
|
});
|
||||||
|
|
||||||
|
private static object ToPublishJobResponse(ExamPublishJob job) => new
|
||||||
|
{
|
||||||
|
job.Id,
|
||||||
|
job.PlanId,
|
||||||
|
Kind = job.Kind.ToString(),
|
||||||
|
Status = job.Status.ToString(),
|
||||||
|
job.CurrentStep,
|
||||||
|
job.ErrorMessage,
|
||||||
|
job.CreatedAt,
|
||||||
|
job.StartedAt,
|
||||||
|
job.CompletedAt
|
||||||
|
};
|
||||||
|
|
||||||
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
private async Task<ActionResult> SaveAsync(Guid id, bool created,
|
||||||
CancellationToken token)
|
CancellationToken token)
|
||||||
{
|
{
|
||||||
@@ -1064,6 +1312,10 @@ public sealed class MakeupExamsController(
|
|||||||
});
|
});
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
private static string? SerializeBuildingIds(IReadOnlyCollection<Guid>? ids) =>
|
||||||
|
ids is { Count: > 0 }
|
||||||
|
? System.Text.Json.JsonSerializer.Serialize(ids)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -1082,6 +1334,7 @@ public sealed record CreateMakeupExamSessionRequest(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 6)] int PeriodCount,
|
[Range(1, 6)] int PeriodCount,
|
||||||
Guid? RequiredBuildingId,
|
Guid? RequiredBuildingId,
|
||||||
|
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
IReadOnlyCollection<Guid>? InvigilatorIds,
|
IReadOnlyCollection<Guid>? InvigilatorIds,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
@@ -1092,6 +1345,7 @@ public sealed record CreateMakeupExamSessionsBatchRequest(
|
|||||||
[Range(1, 30)] int StartPeriod,
|
[Range(1, 30)] int StartPeriod,
|
||||||
[Range(1, 6)] int PeriodCount,
|
[Range(1, 6)] int PeriodCount,
|
||||||
Guid? RequiredBuildingId,
|
Guid? RequiredBuildingId,
|
||||||
|
IReadOnlyCollection<Guid>? RequiredBuildingIds,
|
||||||
[Range(1, 10)] int RequiredInvigilatorCount,
|
[Range(1, 10)] int RequiredInvigilatorCount,
|
||||||
[MaxLength(500)] string? Notes);
|
[MaxLength(500)] string? Notes);
|
||||||
|
|
||||||
|
|||||||
@@ -213,6 +213,62 @@ public sealed class OperationsController(
|
|||||||
.ToListAsync(cancellationToken));
|
.ToListAsync(cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (normalizedKind is null or "ExamArrangement")
|
||||||
|
{
|
||||||
|
var query = db.ExamArrangementJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Status == ExamArrangementJobStatus.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,
|
||||||
|
"ExamArrangement",
|
||||||
|
x.Kind == ExamArrangementKind.FormalExam
|
||||||
|
? "正式考试编排"
|
||||||
|
: "补考编排",
|
||||||
|
x.Kind == ExamArrangementKind.FormalExam
|
||||||
|
? "正式考试计划"
|
||||||
|
: "补考计划",
|
||||||
|
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||||
|
x.CreatedAt,
|
||||||
|
x.StartedAt,
|
||||||
|
x.CompletedAt,
|
||||||
|
0))
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedKind is null or "ExamPublish")
|
||||||
|
{
|
||||||
|
var query = db.ExamPublishJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Status == ExamPublishJobStatus.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,
|
||||||
|
"ExamPublish",
|
||||||
|
x.Kind == ExamPublishJobKind.FormalExam
|
||||||
|
? "正式考试发布"
|
||||||
|
: "补考发布",
|
||||||
|
x.Kind == ExamPublishJobKind.FormalExam
|
||||||
|
? "正式考试计划"
|
||||||
|
: "补考计划",
|
||||||
|
x.ErrorMessage ?? "任务失败但未记录错误详情。",
|
||||||
|
x.CreatedAt,
|
||||||
|
x.StartedAt,
|
||||||
|
x.CompletedAt,
|
||||||
|
0))
|
||||||
|
.ToListAsync(cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
var pageItems = rows
|
var pageItems = rows
|
||||||
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
|
.OrderByDescending(x => x.CompletedAt ?? x.CreatedAt)
|
||||||
.Skip((page - 1) * pageSize)
|
.Skip((page - 1) * pageSize)
|
||||||
@@ -435,6 +491,16 @@ public sealed class OperationsController(
|
|||||||
.CountAsync(
|
.CountAsync(
|
||||||
x => x.Status == MakeupExamAutoJobStatus.Failed &&
|
x => x.Status == MakeupExamAutoJobStatus.Failed &&
|
||||||
x.CreatedAt >= from,
|
x.CreatedAt >= from,
|
||||||
|
cancellationToken) +
|
||||||
|
await db.ExamArrangementJobs.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.Status == ExamArrangementJobStatus.Failed &&
|
||||||
|
x.CreatedAt >= from,
|
||||||
|
cancellationToken) +
|
||||||
|
await db.ExamPublishJobs.AsNoTracking()
|
||||||
|
.CountAsync(
|
||||||
|
x => x.Status == ExamPublishJobStatus.Failed &&
|
||||||
|
x.CreatedAt >= from,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
private ActionResult? ValidatePaging(int page, int pageSize)
|
private ActionResult? ValidatePaging(int page, int pageSize)
|
||||||
@@ -462,6 +528,7 @@ public sealed class OperationsController(
|
|||||||
"AutomaticSchedule" => "AutomaticSchedule",
|
"AutomaticSchedule" => "AutomaticSchedule",
|
||||||
"SchedulePublish" => "SchedulePublish",
|
"SchedulePublish" => "SchedulePublish",
|
||||||
"MakeupExamAuto" => "MakeupExamAuto",
|
"MakeupExamAuto" => "MakeupExamAuto",
|
||||||
|
"ExamArrangement" => "ExamArrangement",
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Jiaowu.Api.Domain.Academic;
|
|||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -96,6 +97,8 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
x.StartWeek,
|
x.StartWeek,
|
||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
|
CourseTotalHours = x.Course.TotalHours,
|
||||||
|
CoursePracticeHours = x.Course.PracticeHours,
|
||||||
x.SchedulingMode
|
x.SchedulingMode
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
@@ -107,6 +110,15 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
return Ok(tasks.Select(task =>
|
return Ok(tasks.Select(task =>
|
||||||
{
|
{
|
||||||
constraints.TryGetValue(task.Id, out var constraint);
|
constraints.TryGetValue(task.Id, out var constraint);
|
||||||
|
var weeklyHours = task.WeeklyHours;
|
||||||
|
if (task.SchedulingMode == TeachingTaskSchedulingMode.Standard &&
|
||||||
|
TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||||
|
task.CourseTotalHours,
|
||||||
|
task.CoursePracticeHours,
|
||||||
|
task.StartWeek,
|
||||||
|
task.EndWeek,
|
||||||
|
out var regularWeeklyHours))
|
||||||
|
weeklyHours = regularWeeklyHours;
|
||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
task.Id,
|
task.Id,
|
||||||
@@ -120,7 +132,9 @@ public sealed class ScheduleSettingsController(AppDbContext db, IAppCache cache)
|
|||||||
task.Capacity,
|
task.Capacity,
|
||||||
task.StartWeek,
|
task.StartWeek,
|
||||||
task.EndWeek,
|
task.EndWeek,
|
||||||
task.WeeklyHours,
|
WeeklyHours = weeklyHours,
|
||||||
|
task.CourseTotalHours,
|
||||||
|
task.CoursePracticeHours,
|
||||||
task.SchedulingMode,
|
task.SchedulingMode,
|
||||||
HasCustomConstraint = constraint is not null,
|
HasCustomConstraint = constraint is not null,
|
||||||
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
|
RequiresClassroom = task.SchedulingMode == TeachingTaskSchedulingMode.Flexible
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Jiaowu.Api.Domain.Identity;
|
|||||||
using Jiaowu.Api.Domain.System;
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Scheduling;
|
using Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -497,6 +498,7 @@ public sealed class SchedulesController(
|
|||||||
.Include(x => x.Classes)
|
.Include(x => x.Classes)
|
||||||
.ThenInclude(x => x.AdministrativeClass)
|
.ThenInclude(x => x.AdministrativeClass)
|
||||||
.ThenInclude(x => x!.Students)
|
.ThenInclude(x => x!.Students)
|
||||||
|
.Include(x => x.Course)
|
||||||
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == request.TeachingTaskId, cancellationToken);
|
||||||
if (task is null ||
|
if (task is null ||
|
||||||
task.Status != TeachingTaskStatus.Published ||
|
task.Status != TeachingTaskStatus.Published ||
|
||||||
@@ -506,6 +508,27 @@ public sealed class SchedulesController(
|
|||||||
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
return ValidationProblem("非排时课程不进入正常课表,无需设置星期、节次或教室。");
|
||||||
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
if (request.StartWeek < task.StartWeek || request.EndWeek > task.EndWeek)
|
||||||
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
return ValidationProblem("排课周次必须位于教学任务的授课周次内。");
|
||||||
|
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||||
|
task.Course!,
|
||||||
|
task.StartWeek,
|
||||||
|
task.EndWeek,
|
||||||
|
out var requiredWeeklyHours))
|
||||||
|
return ValidationProblem(
|
||||||
|
"该课程的普通排课学时不能按授课周次整除,请先调整教学任务周次。");
|
||||||
|
if (requiredWeeklyHours == 0)
|
||||||
|
return ValidationProblem(
|
||||||
|
"该课程全部为实践学时,无需进入普通课表,请在实验管理中安排。");
|
||||||
|
var existingHours = await db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.SchedulePlanId == plan.Id &&
|
||||||
|
x.TeachingTaskId == request.TeachingTaskId &&
|
||||||
|
x.Id != entryId)
|
||||||
|
.SumAsync(x => x.PeriodCount, cancellationToken);
|
||||||
|
if (existingHours + request.PeriodCount > requiredWeeklyHours)
|
||||||
|
return ValidationProblem(
|
||||||
|
$"该教学任务普通课表每周只需 {requiredWeeklyHours} 学时;" +
|
||||||
|
$"当前操作后将达到 {existingHours + request.PeriodCount} 学时," +
|
||||||
|
"实践学时请在实验管理中安排。");
|
||||||
|
|
||||||
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
var constraint = await db.TeachingTaskScheduleConstraints.AsNoTracking()
|
||||||
.Include(x => x.AllowedClassrooms)
|
.Include(x => x.AllowedClassrooms)
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ public sealed class StatisticsController(
|
|||||||
SystemRoles.CollegeAdmin + "," +
|
SystemRoles.CollegeAdmin + "," +
|
||||||
SystemRoles.Leader;
|
SystemRoles.Leader;
|
||||||
|
|
||||||
private Guid? RestrictedCollegeId => currentUserDataScope.Current.RestrictedCollegeId;
|
// HybridCache may execute its source factory without the request's ambient
|
||||||
|
// HttpContext. Keep the authorization scope stable for the whole controller
|
||||||
|
// invocation instead of resolving it again inside that background factory.
|
||||||
|
private readonly CurrentUserScope currentUser = currentUserDataScope.Current;
|
||||||
|
|
||||||
|
private Guid? RestrictedCollegeId => currentUser.RestrictedCollegeId;
|
||||||
|
|
||||||
private Guid? ResolveCollegeId(Guid? requestedCollegeId)
|
private Guid? ResolveCollegeId(Guid? requestedCollegeId)
|
||||||
{
|
{
|
||||||
@@ -957,13 +962,14 @@ public sealed class StatisticsController(
|
|||||||
async token =>
|
async token =>
|
||||||
{
|
{
|
||||||
var source = await factory(token);
|
var source = await factory(token);
|
||||||
if (source.Result is not null || source.Value is null)
|
var data = source.Result is ObjectResult { Value: not null } objectResult
|
||||||
|
? objectResult.Value
|
||||||
|
: source.Value;
|
||||||
|
if (data is null)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Statistics cache source did not return a successful value.");
|
"Statistics cache source did not return a successful value.");
|
||||||
|
|
||||||
return JsonSerializer.SerializeToElement(
|
return JsonSerializer.SerializeToElement(data, data.GetType());
|
||||||
source.Value,
|
|
||||||
source.Value.GetType());
|
|
||||||
},
|
},
|
||||||
AppCacheProfile.Analytics,
|
AppCacheProfile.Analytics,
|
||||||
[
|
[
|
||||||
@@ -981,7 +987,7 @@ public sealed class StatisticsController(
|
|||||||
params string?[] filters) =>
|
params string?[] filters) =>
|
||||||
AppCacheKeys.Statistics(
|
AppCacheKeys.Statistics(
|
||||||
area,
|
area,
|
||||||
currentUserDataScope.Current.Scope.ToString(),
|
currentUser.Scope.ToString(),
|
||||||
effectiveCollegeId,
|
effectiveCollegeId,
|
||||||
filters);
|
filters);
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ public sealed class TeachingTasksController(
|
|||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
x.SchedulingMode,
|
x.SchedulingMode,
|
||||||
CourseTotalHours = x.Course.TotalHours,
|
CourseTotalHours = x.Course.TotalHours,
|
||||||
|
CoursePracticeHours = x.Course.PracticeHours,
|
||||||
|
CourseRegularScheduleHours =
|
||||||
|
x.Course.TotalHours - x.Course.PracticeHours,
|
||||||
x.GenerationBatchCode,
|
x.GenerationBatchCode,
|
||||||
x.Status,
|
x.Status,
|
||||||
TeacherNames = x.Teachers
|
TeacherNames = x.Teachers
|
||||||
@@ -127,6 +130,10 @@ public sealed class TeachingTasksController(
|
|||||||
x.EndWeek,
|
x.EndWeek,
|
||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
x.SchedulingMode,
|
x.SchedulingMode,
|
||||||
|
CourseTotalHours = x.Course!.TotalHours,
|
||||||
|
CoursePracticeHours = x.Course.PracticeHours,
|
||||||
|
CourseRegularScheduleHours =
|
||||||
|
x.Course.TotalHours - x.Course.PracticeHours,
|
||||||
TeacherNames = x.Teachers
|
TeacherNames = x.Teachers
|
||||||
.OrderByDescending(item => item.IsPrimary)
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
.Select(item => item.Teacher!.Name),
|
.Select(item => item.Teacher!.Name),
|
||||||
@@ -163,6 +170,9 @@ public sealed class TeachingTasksController(
|
|||||||
x.WeeklyHours,
|
x.WeeklyHours,
|
||||||
x.SchedulingMode,
|
x.SchedulingMode,
|
||||||
CourseTotalHours = x.Course.TotalHours,
|
CourseTotalHours = x.Course.TotalHours,
|
||||||
|
CoursePracticeHours = x.Course.PracticeHours,
|
||||||
|
CourseRegularScheduleHours =
|
||||||
|
x.Course.TotalHours - x.Course.PracticeHours,
|
||||||
x.GenerationBatchCode,
|
x.GenerationBatchCode,
|
||||||
x.Status,
|
x.Status,
|
||||||
x.Notes,
|
x.Notes,
|
||||||
@@ -423,7 +433,8 @@ public sealed class TeachingTasksController(
|
|||||||
course,
|
course,
|
||||||
request.StartWeek,
|
request.StartWeek,
|
||||||
request.EndWeek,
|
request.EndWeek,
|
||||||
request.WeeklyHours);
|
request.WeeklyHours,
|
||||||
|
TeachingTaskSchedulingMode.Standard);
|
||||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||||
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
|
if (course.Nature is not (CourseNature.GeneralRequired or CourseNature.GeneralElective))
|
||||||
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
|
return ValidationProblem("批量合班生成仅用于公共必修课或公共选修课。");
|
||||||
@@ -598,7 +609,8 @@ public sealed class TeachingTasksController(
|
|||||||
course,
|
course,
|
||||||
request.StartWeek,
|
request.StartWeek,
|
||||||
request.EndWeek,
|
request.EndWeek,
|
||||||
request.WeeklyHours);
|
request.WeeklyHours,
|
||||||
|
request.SchedulingMode);
|
||||||
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
if (hoursProblem is not null) return ValidationProblem(hoursProblem);
|
||||||
var collegeId = ScopedCollegeId();
|
var collegeId = ScopedCollegeId();
|
||||||
if (!await db.AcademicTerms.AnyAsync(
|
if (!await db.AcademicTerms.AnyAsync(
|
||||||
|
|||||||
@@ -367,7 +367,10 @@ public sealed class TimetablesController(
|
|||||||
db.TeachingTasks.Any(task =>
|
db.TeachingTasks.Any(task =>
|
||||||
task.AcademicTermId == x.Id &&
|
task.AcademicTermId == x.Id &&
|
||||||
task.Status == TeachingTaskStatus.Published &&
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible)))
|
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible) ||
|
||||||
|
db.ExamPlans.Any(plan =>
|
||||||
|
plan.AcademicTermId == x.Id &&
|
||||||
|
plan.Status == ExamPlanStatus.Published)))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
var defaultTermId = terms.FirstOrDefault(x => x.IsCurrent)?.Id
|
||||||
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
?? terms.FirstOrDefault(x => x.HasPublishedTimetable)?.Id;
|
||||||
@@ -394,7 +397,12 @@ public sealed class TimetablesController(
|
|||||||
task.AcademicTermId == defaultTermId.Value &&
|
task.AcademicTermId == defaultTermId.Value &&
|
||||||
task.Status == TeachingTaskStatus.Published &&
|
task.Status == TeachingTaskStatus.Published &&
|
||||||
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
task.SchedulingMode == TeachingTaskSchedulingMode.Flexible &&
|
||||||
task.Classes.Any(item => item.AdministrativeClassId == x.Id)))))
|
task.Classes.Any(item => item.AdministrativeClassId == x.Id)) ||
|
||||||
|
db.ExamSessions.Any(session =>
|
||||||
|
session.ExamPlan!.AcademicTermId == defaultTermId.Value &&
|
||||||
|
session.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||||
|
session.TeachingTask!.Classes.Any(item =>
|
||||||
|
item.AdministrativeClassId == x.Id)))))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var colleges = await db.Colleges.AsNoTracking()
|
var colleges = await db.Colleges.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled)
|
.Where(x => x.IsEnabled)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class AttendanceSheet : EntityBase
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public DateTime? SubmittedAt { get; set; }
|
public DateTime? SubmittedAt { get; set; }
|
||||||
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
public ICollection<AttendanceRecord> Records { get; set; } = [];
|
||||||
|
public ICollection<AttendanceCheckInAttempt> CheckInAttempts { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class AttendanceRecord
|
public sealed class AttendanceRecord
|
||||||
@@ -43,6 +44,26 @@ public sealed class AttendanceRecord
|
|||||||
public DateTime? AppealReviewedAt { get; set; }
|
public DateTime? AppealReviewedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class AttendanceCheckInAttempt : EntityBase
|
||||||
|
{
|
||||||
|
public Guid AttendanceSheetId { get; set; }
|
||||||
|
public AttendanceSheet? AttendanceSheet { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public AttendanceCheckInMethod CheckInMethod { get; set; }
|
||||||
|
public bool IsSuccessful { get; set; }
|
||||||
|
public string? FailureCode { get; set; }
|
||||||
|
public string? DeviceIdentifierHash { get; set; }
|
||||||
|
public string? DevicePlatform { get; set; }
|
||||||
|
public string? IpAddress { get; set; }
|
||||||
|
public string? UserAgent { get; set; }
|
||||||
|
public string? RiskFlags { get; set; }
|
||||||
|
public decimal? Latitude { get; set; }
|
||||||
|
public decimal? Longitude { get; set; }
|
||||||
|
public double? AccuracyMeters { get; set; }
|
||||||
|
public double? DistanceMeters { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public enum AttendanceSheetStatus
|
public enum AttendanceSheetStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
|
|||||||
@@ -11,6 +11,41 @@ public sealed class ExamPlan : EntityBase
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public DateTime? PublishedAt { get; set; }
|
public DateTime? PublishedAt { get; set; }
|
||||||
public ICollection<ExamSession> Sessions { get; set; } = [];
|
public ICollection<ExamSession> Sessions { get; set; } = [];
|
||||||
|
public ICollection<ExamRoomAssignment> Rooms { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamArrangementJob : EntityBase
|
||||||
|
{
|
||||||
|
public ExamArrangementKind Kind { get; set; }
|
||||||
|
public Guid PlanId { get; set; }
|
||||||
|
public Guid? ActivePlanId { get; set; }
|
||||||
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? SessionIdsJson { get; set; }
|
||||||
|
public bool AssignClassrooms { get; set; }
|
||||||
|
public bool AssignInvigilators { get; set; }
|
||||||
|
public ExamArrangementJobStatus Status { get; set; } =
|
||||||
|
ExamArrangementJobStatus.Queued;
|
||||||
|
public int TotalSessions { get; set; }
|
||||||
|
public int ProcessedSessions { get; set; }
|
||||||
|
public string? CurrentStep { get; set; }
|
||||||
|
public string? ResultMessage { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamArrangementKind
|
||||||
|
{
|
||||||
|
FormalExam = 1,
|
||||||
|
MakeupExam = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamArrangementJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ExamSession : EntityBase
|
public sealed class ExamSession : EntityBase
|
||||||
@@ -28,9 +63,12 @@ public sealed class ExamSession : EntityBase
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public Guid? RequiredBuildingId { get; set; }
|
public Guid? RequiredBuildingId { get; set; }
|
||||||
public Building? RequiredBuilding { get; set; }
|
public Building? RequiredBuilding { get; set; }
|
||||||
|
public string? RequiredBuildingIds { get; set; }
|
||||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
public ICollection<ExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||||
|
public ICollection<ExamRoomSession> RoomLinks { get; set; } = [];
|
||||||
|
public ICollection<ExamSeatAssignment> SeatAssignments { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ExamSessionInvigilator
|
public sealed class ExamSessionInvigilator
|
||||||
@@ -41,9 +79,82 @@ public sealed class ExamSessionInvigilator
|
|||||||
public Teacher? Teacher { get; set; }
|
public Teacher? Teacher { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ExamRoomAssignment : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExamPlanId { get; set; }
|
||||||
|
public ExamPlan? ExamPlan { get; set; }
|
||||||
|
public Guid CourseId { get; set; }
|
||||||
|
public Course? Course { get; set; }
|
||||||
|
public Guid ClassroomId { get; set; }
|
||||||
|
public Classroom? Classroom { get; set; }
|
||||||
|
public DateOnly ExamDate { get; set; }
|
||||||
|
public int StartPeriod { get; set; }
|
||||||
|
public int PeriodCount { get; set; }
|
||||||
|
public DateTime StartsAt { get; set; }
|
||||||
|
public DateTime EndsAt { get; set; }
|
||||||
|
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||||
|
public ICollection<ExamRoomSession> SessionLinks { get; set; } = [];
|
||||||
|
public ICollection<ExamSeatAssignment> Seats { get; set; } = [];
|
||||||
|
public ICollection<ExamRoomInvigilator> Invigilators { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamRoomSession
|
||||||
|
{
|
||||||
|
public Guid ExamRoomId { get; set; }
|
||||||
|
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||||
|
public Guid ExamSessionId { get; set; }
|
||||||
|
public ExamSession? ExamSession { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamSeatAssignment
|
||||||
|
{
|
||||||
|
public Guid ExamRoomId { get; set; }
|
||||||
|
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||||
|
public Guid ExamSessionId { get; set; }
|
||||||
|
public ExamSession? ExamSession { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public int SeatNumber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamRoomInvigilator
|
||||||
|
{
|
||||||
|
public Guid ExamRoomId { get; set; }
|
||||||
|
public ExamRoomAssignment? ExamRoom { get; set; }
|
||||||
|
public Guid TeacherId { get; set; }
|
||||||
|
public Teacher? Teacher { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public enum ExamPlanStatus
|
public enum ExamPlanStatus
|
||||||
{
|
{
|
||||||
Draft = 1,
|
Draft = 1,
|
||||||
Published = 2,
|
Published = 2,
|
||||||
Archived = 3
|
Archived = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ExamPublishJob : EntityBase
|
||||||
|
{
|
||||||
|
public ExamPublishJobKind Kind { get; set; }
|
||||||
|
public Guid PlanId { get; set; }
|
||||||
|
public Guid? ActivePlanId { get; set; }
|
||||||
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
||||||
|
public string? CurrentStep { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamPublishJobKind
|
||||||
|
{
|
||||||
|
FormalExam = 1,
|
||||||
|
MakeupExam = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamPublishJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class ExamSignInExportJob : EntityBase
|
||||||
|
{
|
||||||
|
public Guid PlanId { get; set; }
|
||||||
|
public ExamSignInExportJobStatus Status { get; set; } =
|
||||||
|
ExamSignInExportJobStatus.Queued;
|
||||||
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? FileName { get; set; }
|
||||||
|
public byte[]? FileBytes { get; set; }
|
||||||
|
public int FileSize { get; set; }
|
||||||
|
public string? CurrentStep { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public DateTime? StartedAt { get; set; }
|
||||||
|
public DateTime? CompletedAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExamSignInExportJobStatus
|
||||||
|
{
|
||||||
|
Queued = 1,
|
||||||
|
Running = 2,
|
||||||
|
Succeeded = 3,
|
||||||
|
Failed = 4
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class ExperimentProject : EntityBase
|
||||||
|
{
|
||||||
|
public Guid TeachingTaskId { get; set; }
|
||||||
|
public TeachingTask? TeachingTask { get; set; }
|
||||||
|
public required string Code { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public ExperimentArrangementMode ArrangementMode { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string? Requirements { get; set; }
|
||||||
|
public DateOnly StartDate { get; set; }
|
||||||
|
public DateOnly EndDate { get; set; }
|
||||||
|
public ExperimentProjectStatus Status { get; set; } =
|
||||||
|
ExperimentProjectStatus.Draft;
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public DateTime? ClosedAt { get; set; }
|
||||||
|
public ICollection<ExperimentSession> Sessions { get; set; } = [];
|
||||||
|
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||||
|
public ExperimentGradeSheet? GradeSheet { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentSession : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentProjectId { get; set; }
|
||||||
|
public ExperimentProject? ExperimentProject { get; set; }
|
||||||
|
public Guid ClassroomId { get; set; }
|
||||||
|
public Classroom? Classroom { get; set; }
|
||||||
|
public DateOnly SessionDate { get; set; }
|
||||||
|
public int StartPeriod { get; set; }
|
||||||
|
public int PeriodCount { get; set; }
|
||||||
|
public int Capacity { get; set; }
|
||||||
|
public int ReservedCount { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public ExperimentSessionStatus Status { get; set; } =
|
||||||
|
ExperimentSessionStatus.Scheduled;
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
public ICollection<ExperimentBooking> Bookings { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentBooking : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentProjectId { get; set; }
|
||||||
|
public ExperimentProject? ExperimentProject { get; set; }
|
||||||
|
public Guid ExperimentSessionId { get; set; }
|
||||||
|
public ExperimentSession? ExperimentSession { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public ExperimentBookingStatus Status { get; set; } =
|
||||||
|
ExperimentBookingStatus.Booked;
|
||||||
|
public DateTime BookedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? CancelledAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentArrangementMode
|
||||||
|
{
|
||||||
|
Centralized = 1,
|
||||||
|
SelfScheduled = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentProjectStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Published = 2,
|
||||||
|
Closed = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentSessionStatus
|
||||||
|
{
|
||||||
|
Scheduled = 1,
|
||||||
|
Cancelled = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentBookingStatus
|
||||||
|
{
|
||||||
|
Booked = 1,
|
||||||
|
Cancelled = 2
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
public sealed class ExperimentGradeSheet : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentProjectId { get; set; }
|
||||||
|
public ExperimentProject? ExperimentProject { get; set; }
|
||||||
|
public decimal ContributionWeight { get; set; } = 1;
|
||||||
|
public decimal PassScore { get; set; } = 60;
|
||||||
|
public ExperimentGradeSheetStatus Status { get; set; } =
|
||||||
|
ExperimentGradeSheetStatus.Draft;
|
||||||
|
public string? ReviewComment { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
public DateTime? ReviewedAt { get; set; }
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
public ICollection<ExperimentGradeItem> Items { get; set; } = [];
|
||||||
|
public ICollection<ExperimentGradeRecord> Records { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentGradeItem : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentGradeSheetId { get; set; }
|
||||||
|
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
|
||||||
|
public required string Name { get; set; }
|
||||||
|
public ExperimentGradeItemKind Kind { get; set; } =
|
||||||
|
ExperimentGradeItemKind.Other;
|
||||||
|
public decimal Weight { get; set; }
|
||||||
|
public int SortOrder { get; set; }
|
||||||
|
public ICollection<ExperimentGradeItemScore> Scores { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentGradeRecord : EntityBase
|
||||||
|
{
|
||||||
|
public Guid ExperimentGradeSheetId { get; set; }
|
||||||
|
public ExperimentGradeSheet? ExperimentGradeSheet { get; set; }
|
||||||
|
public Guid StudentId { get; set; }
|
||||||
|
public Student? Student { get; set; }
|
||||||
|
public Guid? ExperimentSessionId { get; set; }
|
||||||
|
public ExperimentSession? ExperimentSession { get; set; }
|
||||||
|
public ExperimentParticipationStatus ParticipationStatus { get; set; } =
|
||||||
|
ExperimentParticipationStatus.Pending;
|
||||||
|
public decimal? TotalScore { get; set; }
|
||||||
|
public bool? IsPassed { get; set; }
|
||||||
|
public bool SafetyViolation { get; set; }
|
||||||
|
public int AttemptNumber { get; set; } = 1;
|
||||||
|
public string? SubmissionReference { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
public bool IsLate { get; set; }
|
||||||
|
public string? TeacherComment { get; set; }
|
||||||
|
public ICollection<ExperimentGradeItemScore> ItemScores { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExperimentGradeItemScore
|
||||||
|
{
|
||||||
|
public Guid ExperimentGradeRecordId { get; set; }
|
||||||
|
public ExperimentGradeRecord? ExperimentGradeRecord { get; set; }
|
||||||
|
public Guid ExperimentGradeItemId { get; set; }
|
||||||
|
public ExperimentGradeItem? ExperimentGradeItem { get; set; }
|
||||||
|
public decimal? Score { get; set; }
|
||||||
|
public string? Comment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentGradeSheetStatus
|
||||||
|
{
|
||||||
|
Draft = 1,
|
||||||
|
Submitted = 2,
|
||||||
|
Approved = 3,
|
||||||
|
Published = 4,
|
||||||
|
Returned = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentGradeItemKind
|
||||||
|
{
|
||||||
|
Operation = 1,
|
||||||
|
Report = 2,
|
||||||
|
Result = 3,
|
||||||
|
Defense = 4,
|
||||||
|
Attendance = 5,
|
||||||
|
Safety = 6,
|
||||||
|
Other = 7
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ExperimentParticipationStatus
|
||||||
|
{
|
||||||
|
Pending = 1,
|
||||||
|
Completed = 2,
|
||||||
|
Absent = 3,
|
||||||
|
Excused = 4,
|
||||||
|
Makeup = 5,
|
||||||
|
Exempt = 6
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ public sealed class GradeItem : EntityBase
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public decimal Weight { get; set; }
|
public decimal Weight { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
public GradeItemSourceType SourceType { get; set; } =
|
||||||
|
GradeItemSourceType.Manual;
|
||||||
|
public DateTime? SourceSnapshotAt { get; set; }
|
||||||
public ICollection<GradeItemScore> Scores { get; set; } = [];
|
public ICollection<GradeItemScore> Scores { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,3 +71,9 @@ public enum GradeExamStatus
|
|||||||
Exempt = 4,
|
Exempt = 4,
|
||||||
Makeup = 5
|
Makeup = 5
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum GradeItemSourceType
|
||||||
|
{
|
||||||
|
Manual = 1,
|
||||||
|
ExperimentSummary = 2
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed class MakeupExamSession : EntityBase
|
|||||||
public DateTime EndsAt { get; set; }
|
public DateTime EndsAt { get; set; }
|
||||||
public Guid? RequiredBuildingId { get; set; }
|
public Guid? RequiredBuildingId { get; set; }
|
||||||
public Building? RequiredBuilding { get; set; }
|
public Building? RequiredBuilding { get; set; }
|
||||||
|
public string? RequiredBuildingIds { get; set; }
|
||||||
public int RequiredInvigilatorCount { get; set; } = 2;
|
public int RequiredInvigilatorCount { get; set; } = 2;
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
|
public ICollection<MakeupExamSessionInvigilator> Invigilators { get; set; } = [];
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Jiaowu.Api.Domain.Common;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Domain.System;
|
||||||
|
|
||||||
|
public enum AppUpdatePlatform
|
||||||
|
{
|
||||||
|
Android,
|
||||||
|
Ios
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AppUpdateChannel
|
||||||
|
{
|
||||||
|
Production,
|
||||||
|
Staging
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AppUpdateReleaseStatus
|
||||||
|
{
|
||||||
|
Draft,
|
||||||
|
Published,
|
||||||
|
Archived
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class AppUpdateRelease : EntityBase
|
||||||
|
{
|
||||||
|
public AppUpdatePlatform Platform { get; set; }
|
||||||
|
public AppUpdateChannel Channel { get; set; }
|
||||||
|
public required string Version { get; set; }
|
||||||
|
public required string NativeVersion { get; set; }
|
||||||
|
public AppUpdateReleaseStatus Status { get; set; } =
|
||||||
|
AppUpdateReleaseStatus.Draft;
|
||||||
|
public string? ReleaseNotes { get; set; }
|
||||||
|
public required string FileName { get; set; }
|
||||||
|
public long FileSize { get; set; }
|
||||||
|
public required string Sha256 { get; set; }
|
||||||
|
public required byte[] BundleContent { get; set; }
|
||||||
|
public required string CreatedByUserName { get; set; }
|
||||||
|
public string? PublishedByUserName { get; set; }
|
||||||
|
public DateTime? PublishedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -30,7 +30,10 @@ public enum BackgroundJobKind
|
|||||||
{
|
{
|
||||||
AutomaticSchedule = 1,
|
AutomaticSchedule = 1,
|
||||||
SchedulePublish = 2,
|
SchedulePublish = 2,
|
||||||
MakeupExamAuto = 3
|
MakeupExamAuto = 3,
|
||||||
|
ExamArrangement = 4,
|
||||||
|
ExamSignInExport = 5,
|
||||||
|
ExamPublish = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum BackgroundJobOutboxState
|
public enum BackgroundJobOutboxState
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ public sealed class BackgroundJobOptions
|
|||||||
public int AutomaticScheduleConcurrency { get; set; } = 1;
|
public int AutomaticScheduleConcurrency { get; set; } = 1;
|
||||||
public int SchedulePublishConcurrency { get; set; } = 1;
|
public int SchedulePublishConcurrency { get; set; } = 1;
|
||||||
public int MakeupExamAutoConcurrency { get; set; } = 1;
|
public int MakeupExamAutoConcurrency { get; set; } = 1;
|
||||||
|
public int ExamArrangementConcurrency { get; set; } = 1;
|
||||||
|
public int ExamSignInExportConcurrency { get; set; } = 1;
|
||||||
|
public int ExamPublishConcurrency { get; set; } = 1;
|
||||||
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
public string Exchange { get; set; } = "jiaowu.background-jobs";
|
||||||
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
public string QueuePrefix { get; set; } = "jiaowu.background-jobs";
|
||||||
public bool UseQuorumQueues { get; set; } = true;
|
public bool UseQuorumQueues { get; set; } = true;
|
||||||
@@ -29,6 +32,9 @@ public sealed class BackgroundJobOptions
|
|||||||
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
|
BackgroundJobKind.AutomaticSchedule => AutomaticScheduleConcurrency,
|
||||||
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
|
BackgroundJobKind.SchedulePublish => SchedulePublishConcurrency,
|
||||||
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
|
BackgroundJobKind.MakeupExamAuto => MakeupExamAutoConcurrency,
|
||||||
|
BackgroundJobKind.ExamArrangement => ExamArrangementConcurrency,
|
||||||
|
BackgroundJobKind.ExamSignInExport => ExamSignInExportConcurrency,
|
||||||
|
BackgroundJobKind.ExamPublish => ExamPublishConcurrency,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,33 @@ public sealed class BackgroundJobOutboxPublisher(
|
|||||||
message.JobId == x.Id))
|
message.JobId == x.Id))
|
||||||
.Select(x => x.Id)
|
.Select(x => x.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
var arrangementJobs = await db.ExamArrangementJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
(x.Status == ExamArrangementJobStatus.Queued ||
|
||||||
|
x.Status == ExamArrangementJobStatus.Running) &&
|
||||||
|
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||||
|
message.JobKind == BackgroundJobKind.ExamArrangement &&
|
||||||
|
message.JobId == x.Id))
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var exportJobs = await db.ExamSignInExportJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
(x.Status == ExamSignInExportJobStatus.Queued ||
|
||||||
|
x.Status == ExamSignInExportJobStatus.Running) &&
|
||||||
|
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||||
|
message.JobKind == BackgroundJobKind.ExamSignInExport &&
|
||||||
|
message.JobId == x.Id))
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var publishJobs2 = await db.ExamPublishJobs.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
(x.Status == ExamPublishJobStatus.Queued ||
|
||||||
|
x.Status == ExamPublishJobStatus.Running) &&
|
||||||
|
!db.BackgroundJobOutboxMessages.Any(message =>
|
||||||
|
message.JobKind == BackgroundJobKind.ExamPublish &&
|
||||||
|
message.JobId == x.Id))
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var missingKeys = automaticJobs
|
var missingKeys = automaticJobs
|
||||||
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
.Select(id => (BackgroundJobKind.AutomaticSchedule, id))
|
||||||
@@ -110,6 +137,12 @@ public sealed class BackgroundJobOutboxPublisher(
|
|||||||
(BackgroundJobKind.SchedulePublish, id)))
|
(BackgroundJobKind.SchedulePublish, id)))
|
||||||
.Concat(makeupJobs.Select(id =>
|
.Concat(makeupJobs.Select(id =>
|
||||||
(BackgroundJobKind.MakeupExamAuto, id)))
|
(BackgroundJobKind.MakeupExamAuto, id)))
|
||||||
|
.Concat(arrangementJobs.Select(id =>
|
||||||
|
(BackgroundJobKind.ExamArrangement, id)))
|
||||||
|
.Concat(exportJobs.Select(id =>
|
||||||
|
(BackgroundJobKind.ExamSignInExport, id)))
|
||||||
|
.Concat(publishJobs2.Select(id =>
|
||||||
|
(BackgroundJobKind.ExamPublish, id)))
|
||||||
.ToList();
|
.ToList();
|
||||||
foreach (var (kind, jobId) in missingKeys)
|
foreach (var (kind, jobId) in missingKeys)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -85,6 +85,21 @@ public sealed class BackgroundJobRunner(
|
|||||||
.GetRequiredService<MakeupExamAutoJobProcessor>()
|
.GetRequiredService<MakeupExamAutoJobProcessor>()
|
||||||
.ProcessAsync(message.JobId, cancellationToken);
|
.ProcessAsync(message.JobId, cancellationToken);
|
||||||
break;
|
break;
|
||||||
|
case BackgroundJobKind.ExamArrangement:
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<ExamArrangementJobProcessor>()
|
||||||
|
.ProcessAsync(message.JobId, cancellationToken);
|
||||||
|
break;
|
||||||
|
case BackgroundJobKind.ExamSignInExport:
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<ExamSignInExportJobProcessor>()
|
||||||
|
.ProcessAsync(message.JobId, cancellationToken);
|
||||||
|
break;
|
||||||
|
case BackgroundJobKind.ExamPublish:
|
||||||
|
await scope.ServiceProvider
|
||||||
|
.GetRequiredService<ExamPublishJobProcessor>()
|
||||||
|
.ProcessAsync(message.JobId, cancellationToken);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Unsupported background job kind '{message.JobKind}'.");
|
$"Unsupported background job kind '{message.JobKind}'.");
|
||||||
@@ -259,6 +274,40 @@ public sealed class BackgroundJobRunner(
|
|||||||
.SetProperty(x => x.CompletedAt, completedAt),
|
.SetProperty(x => x.CompletedAt, completedAt),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
break;
|
break;
|
||||||
|
case BackgroundJobKind.ExamArrangement:
|
||||||
|
await db.ExamArrangementJobs
|
||||||
|
.Where(x =>
|
||||||
|
x.Id == message.JobId &&
|
||||||
|
x.Status != ExamArrangementJobStatus.Succeeded &&
|
||||||
|
x.Status != ExamArrangementJobStatus.Failed)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters
|
||||||
|
.SetProperty(
|
||||||
|
x => x.Status,
|
||||||
|
ExamArrangementJobStatus.Failed)
|
||||||
|
.SetProperty(x => x.ActivePlanId, (Guid?)null)
|
||||||
|
.SetProperty(x => x.CurrentStep, "后台处理已停止")
|
||||||
|
.SetProperty(x => x.ErrorMessage, error)
|
||||||
|
.SetProperty(x => x.CompletedAt, completedAt),
|
||||||
|
cancellationToken);
|
||||||
|
break;
|
||||||
|
case BackgroundJobKind.ExamPublish:
|
||||||
|
await db.ExamPublishJobs
|
||||||
|
.Where(x =>
|
||||||
|
x.Id == message.JobId &&
|
||||||
|
x.Status != ExamPublishJobStatus.Succeeded &&
|
||||||
|
x.Status != ExamPublishJobStatus.Failed)
|
||||||
|
.ExecuteUpdateAsync(
|
||||||
|
setters => setters
|
||||||
|
.SetProperty(
|
||||||
|
x => x.Status,
|
||||||
|
ExamPublishJobStatus.Failed)
|
||||||
|
.SetProperty(x => x.ActivePlanId, (Guid?)null)
|
||||||
|
.SetProperty(x => x.CurrentStep, "后台处理已停止")
|
||||||
|
.SetProperty(x => x.ErrorMessage, error)
|
||||||
|
.SetProperty(x => x.CompletedAt, completedAt),
|
||||||
|
cancellationToken);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException(
|
throw new ArgumentOutOfRangeException(
|
||||||
nameof(message.JobKind),
|
nameof(message.JobKind),
|
||||||
|
|||||||
@@ -316,7 +316,10 @@ internal static class RabbitMqBackgroundJobTopology
|
|||||||
[
|
[
|
||||||
BackgroundJobKind.AutomaticSchedule,
|
BackgroundJobKind.AutomaticSchedule,
|
||||||
BackgroundJobKind.SchedulePublish,
|
BackgroundJobKind.SchedulePublish,
|
||||||
BackgroundJobKind.MakeupExamAuto
|
BackgroundJobKind.MakeupExamAuto,
|
||||||
|
BackgroundJobKind.ExamArrangement,
|
||||||
|
BackgroundJobKind.ExamSignInExport,
|
||||||
|
BackgroundJobKind.ExamPublish
|
||||||
];
|
];
|
||||||
|
|
||||||
public static async Task<IConnection> CreateConnectionAsync(
|
public static async Task<IConnection> CreateConnectionAsync(
|
||||||
@@ -412,6 +415,9 @@ internal static class RabbitMqBackgroundJobTopology
|
|||||||
BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
|
BackgroundJobKind.AutomaticSchedule => "schedule.automatic",
|
||||||
BackgroundJobKind.SchedulePublish => "schedule.publish",
|
BackgroundJobKind.SchedulePublish => "schedule.publish",
|
||||||
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
|
BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic",
|
||||||
|
BackgroundJobKind.ExamArrangement => "exam.arrangement",
|
||||||
|
BackgroundJobKind.ExamSignInExport => "exam.sign-in-export",
|
||||||
|
BackgroundJobKind.ExamPublish => "exam.publish",
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public sealed class ExamArrangementJobProcessor(
|
||||||
|
AppDbContext db,
|
||||||
|
ExamArrangementService examArrangementService,
|
||||||
|
MakeupExamArrangementService makeupExamArrangementService,
|
||||||
|
IAppCache cache,
|
||||||
|
ILogger<ExamArrangementJobProcessor> logger)
|
||||||
|
{
|
||||||
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var job = await db.ExamArrangementJobs
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||||
|
if (job is null ||
|
||||||
|
job.Status is ExamArrangementJobStatus.Succeeded
|
||||||
|
or ExamArrangementJobStatus.Failed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = ExamArrangementJobStatus.Running;
|
||||||
|
job.StartedAt ??= DateTime.UtcNow;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
job.ResultMessage = null;
|
||||||
|
job.CurrentStep = job.Kind == ExamArrangementKind.FormalExam
|
||||||
|
? "正在编排正式考试"
|
||||||
|
: "正在编排补考";
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
var sessionIds = DeserializeSessionIds(job.SessionIdsJson);
|
||||||
|
var result = job.Kind switch
|
||||||
|
{
|
||||||
|
ExamArrangementKind.FormalExam =>
|
||||||
|
await examArrangementService.ArrangeAsync(
|
||||||
|
job.PlanId,
|
||||||
|
sessionIds,
|
||||||
|
job.AssignClassrooms,
|
||||||
|
job.AssignInvigilators,
|
||||||
|
stoppingToken),
|
||||||
|
ExamArrangementKind.MakeupExam =>
|
||||||
|
await makeupExamArrangementService.ArrangeAsync(
|
||||||
|
job.PlanId,
|
||||||
|
sessionIds,
|
||||||
|
job.AssignClassrooms,
|
||||||
|
job.AssignInvigilators,
|
||||||
|
stoppingToken),
|
||||||
|
_ => throw new InvalidOperationException(
|
||||||
|
$"不支持的考试编排类型:{job.Kind}。")
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!result.Success)
|
||||||
|
{
|
||||||
|
await MarkFailedAsync(jobId, result.Message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = ExamArrangementJobStatus.Succeeded;
|
||||||
|
job.ProcessedSessions = job.TotalSessions;
|
||||||
|
job.CurrentStep = "编排完成";
|
||||||
|
job.ResultMessage = result.Message;
|
||||||
|
job.ActivePlanId = null;
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (job.Kind == ExamArrangementKind.FormalExam)
|
||||||
|
{
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Timetables,
|
||||||
|
stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Exam arrangement job {JobId} for {Kind}/{PlanId} completed.",
|
||||||
|
job.Id,
|
||||||
|
job.Kind,
|
||||||
|
job.PlanId);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Exam arrangement job {JobId} was interrupted by application shutdown.",
|
||||||
|
jobId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Exam arrangement job {JobId} failed.", jobId);
|
||||||
|
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyCollection<Guid>? DeserializeSessionIds(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var sessionIds = JsonSerializer.Deserialize<Guid[]>(json);
|
||||||
|
return sessionIds is { Length: > 0 } ? sessionIds : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var job = await db.ExamArrangementJobs.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId,
|
||||||
|
CancellationToken.None);
|
||||||
|
if (job is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
job.Status = ExamArrangementJobStatus.Failed;
|
||||||
|
job.ActivePlanId = null;
|
||||||
|
job.CurrentStep = "编排失败";
|
||||||
|
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
public sealed class ExamArrangementService(AppDbContext db)
|
public sealed class ExamArrangementService(AppDbContext db)
|
||||||
{
|
{
|
||||||
private sealed record RoomOccupancy(Guid ClassroomId, DateTime StartsAt, DateTime EndsAt);
|
private sealed record RoomGroupKey(
|
||||||
private sealed record InvigilatorOccupancy(Guid TeacherId, DateTime StartsAt, DateTime EndsAt);
|
Guid CourseId,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
int StartPeriod,
|
||||||
|
int PeriodCount,
|
||||||
|
string RequiredBuildingIdsKey);
|
||||||
|
|
||||||
|
private sealed record RoomOccupancy(
|
||||||
|
Guid ClassroomId,
|
||||||
|
DateTime StartsAt,
|
||||||
|
DateTime EndsAt);
|
||||||
|
|
||||||
|
private sealed record InvigilatorOccupancy(
|
||||||
|
Guid TeacherId,
|
||||||
|
DateTime StartsAt,
|
||||||
|
DateTime EndsAt);
|
||||||
|
|
||||||
|
private sealed record CandidateSeat(
|
||||||
|
Guid ExamSessionId,
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentNumber);
|
||||||
|
|
||||||
public async Task<ExamArrangementResult> ArrangeAsync(
|
public async Task<ExamArrangementResult> ArrangeAsync(
|
||||||
Guid planId,
|
Guid planId,
|
||||||
@@ -29,6 +49,13 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
.ThenInclude(x => x.TeachingTask)
|
.ThenInclude(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
|
.Include(x => x.Rooms)
|
||||||
|
.ThenInclude(x => x.SessionLinks)
|
||||||
|
.Include(x => x.Rooms)
|
||||||
|
.ThenInclude(x => x.Seats)
|
||||||
|
.Include(x => x.Rooms)
|
||||||
|
.ThenInclude(x => x.Invigilators)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||||
|
|
||||||
if (plan is null)
|
if (plan is null)
|
||||||
@@ -45,201 +72,549 @@ public sealed class ExamArrangementService(AppDbContext db)
|
|||||||
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
|
plan.Sessions.Count(x => requestedIds.Contains(x.Id)) != requestedIds.Count)
|
||||||
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
|
return ExamArrangementResult.Fail("所选场次不存在或不属于当前考试计划。");
|
||||||
|
|
||||||
var sessions = plan.Sessions
|
|
||||||
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
|
|
||||||
.OrderBy(x => x.ExamDate)
|
|
||||||
.ThenBy(x => x.StartPeriod)
|
|
||||||
.ThenByDescending(x => x.RequiredInvigilatorCount)
|
|
||||||
.ToList();
|
|
||||||
if (sessions.Count == 0)
|
|
||||||
return ExamArrangementResult.Fail("没有可处理的考试场次。");
|
|
||||||
|
|
||||||
var termId = plan.AcademicTermId;
|
|
||||||
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
var timeSlots = await db.ScheduleTimeSlots.AsNoTracking()
|
||||||
.Where(x => x.AcademicTermId == termId && x.IsEnabled)
|
.Where(x => x.AcademicTermId == plan.AcademicTermId && x.IsEnabled)
|
||||||
.OrderBy(x => x.PeriodNumber)
|
.OrderBy(x => x.PeriodNumber)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (timeSlots.Count == 0)
|
if (timeSlots.Count == 0)
|
||||||
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
|
return ExamArrangementResult.Fail("当前学期未配置上课时间表,无法计算考试时间段。");
|
||||||
|
|
||||||
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
|
var timeSlotLookup = timeSlots.ToDictionary(x => x.PeriodNumber);
|
||||||
|
foreach (var session in plan.Sessions)
|
||||||
int assignedRooms = 0;
|
|
||||||
int assignedInvigilators = 0;
|
|
||||||
int unavailableRooms = 0;
|
|
||||||
int unavailableInvigilators = 0;
|
|
||||||
var messages = new List<string>();
|
|
||||||
|
|
||||||
// Track occupied time slots to avoid conflicts
|
|
||||||
var occupiedRooms = sessions
|
|
||||||
.Where(x => x.ClassroomId.HasValue)
|
|
||||||
.Select(x => new RoomOccupancy(x.ClassroomId!.Value, x.StartsAt, x.EndsAt))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var occupiedInvigilators = sessions
|
|
||||||
.SelectMany(x => x.Invigilators.Select(i =>
|
|
||||||
new InvigilatorOccupancy(i.TeacherId, x.StartsAt, x.EndsAt)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var session in sessions)
|
|
||||||
{
|
|
||||||
// Compute StartsAt/EndsAt from time slots
|
|
||||||
ComputeTimesFromSlots(session, timeSlotLookup);
|
ComputeTimesFromSlots(session, timeSlotLookup);
|
||||||
var studentCount = await db.CourseEnrollments.CountAsync(
|
|
||||||
x => x.Status == CourseEnrollmentStatus.Enrolled &&
|
|
||||||
x.CourseSelectionOffering!.TeachingTaskId == session.TeachingTaskId,
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
// ── Auto-assign classroom ──
|
var explicitlySelected = plan.Sessions
|
||||||
if (assignClassrooms && !session.ClassroomId.HasValue)
|
.Where(x => requestedIds.Count == 0 || requestedIds.Contains(x.Id))
|
||||||
|
.ToList();
|
||||||
|
if (explicitlySelected.Count == 0)
|
||||||
|
return ExamArrangementResult.Fail("没有可处理的考试场次。");
|
||||||
|
|
||||||
|
var selectedKeys = explicitlySelected
|
||||||
|
.Select(GroupKey)
|
||||||
|
.ToHashSet();
|
||||||
|
var sessions = plan.Sessions
|
||||||
|
.Where(x => selectedKeys.Contains(GroupKey(x)))
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ThenBy(x => x.TeachingTask!.Course!.Code)
|
||||||
|
.ThenBy(x => x.TeachingTask!.TaskNumber)
|
||||||
|
.ToList();
|
||||||
|
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
|
||||||
|
|
||||||
|
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||||
|
db,
|
||||||
|
sessions.Select(x => x.TeachingTaskId),
|
||||||
|
cancellationToken);
|
||||||
|
var sessionByTaskId = sessions
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.ToDictionary(x => x.Key, x => x.First());
|
||||||
|
|
||||||
|
var replacedRooms = assignClassrooms
|
||||||
|
? plan.Rooms
|
||||||
|
.Where(room => room.SessionLinks.Any(link =>
|
||||||
|
sessionIds.Contains(link.ExamSessionId)))
|
||||||
|
.ToList()
|
||||||
|
: [];
|
||||||
|
var replacedRoomIds = replacedRooms.Select(x => x.Id).ToHashSet();
|
||||||
|
if (replacedRooms.Count > 0)
|
||||||
|
db.ExamRooms.RemoveRange(replacedRooms);
|
||||||
|
|
||||||
|
var occupiedRooms = await LoadOccupiedRoomsAsync(
|
||||||
|
plan,
|
||||||
|
sessionIds,
|
||||||
|
replacedRoomIds,
|
||||||
|
cancellationToken);
|
||||||
|
var rooms = await db.Classrooms.AsNoTracking()
|
||||||
|
.Include(x => x.Building)
|
||||||
|
.Where(x => x.IsEnabled)
|
||||||
|
.OrderBy(x => x.Building!.Name)
|
||||||
|
.ThenBy(x => x.Name)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var targetRooms = new List<ExamRoomAssignment>();
|
||||||
|
var messages = new List<string>();
|
||||||
|
var seatedStudents = 0;
|
||||||
|
var unavailableStudents = 0;
|
||||||
|
|
||||||
|
if (assignClassrooms)
|
||||||
|
{
|
||||||
|
foreach (var session in sessions)
|
||||||
{
|
{
|
||||||
var room = await FindBestClassroomAsync(
|
session.ClassroomId = null;
|
||||||
session, studentCount, occupiedRooms, cancellationToken);
|
if (session.Invigilators.Count > 0)
|
||||||
if (room is not null)
|
|
||||||
{
|
{
|
||||||
session.ClassroomId = room.Id;
|
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
session.Invigilators.Clear();
|
||||||
assignedRooms++;
|
|
||||||
messages.Add(
|
|
||||||
$"“{session.TeachingTask!.Course!.Name}”→{room.Name}({room.Capacity}座)");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
unavailableRooms++;
|
|
||||||
messages.Add(
|
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:无可用考场(需≥{studentCount}座)");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ── Auto-assign invigilators ──
|
|
||||||
var currentInvigilatorCount = session.Invigilators.Count;
|
foreach (var group in sessions
|
||||||
var needed = session.RequiredInvigilatorCount - currentInvigilatorCount;
|
.GroupBy(GroupKey)
|
||||||
if (assignInvigilators && needed > 0)
|
.OrderBy(x => x.Key.ExamDate)
|
||||||
|
.ThenBy(x => x.Key.StartPeriod)
|
||||||
|
.ThenBy(x => x.First().TeachingTask!.Course!.Code))
|
||||||
{
|
{
|
||||||
var courseTeacherIds = session.TeachingTask!.Teachers
|
var groupSessions = group.ToList();
|
||||||
.Select(x => x.TeacherId).ToHashSet();
|
var candidates = InterleaveCandidates(
|
||||||
var newlyAssigned = await FindInvigilatorsAsync(
|
groupSessions,
|
||||||
session, needed, courseTeacherIds,
|
roster,
|
||||||
occupiedInvigilators, cancellationToken);
|
sessionByTaskId);
|
||||||
foreach (var teacher in newlyAssigned)
|
if (candidates.Count == 0)
|
||||||
{
|
{
|
||||||
session.Invigilators.Add(new ExamSessionInvigilator
|
messages.Add(
|
||||||
|
$"“{groupSessions[0].TeachingTask!.Course!.Name}”没有有效考生。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var availableRooms = rooms
|
||||||
|
.Where(room =>
|
||||||
|
(group.Key.RequiredBuildingIdsKey.Length == 0 ||
|
||||||
|
group.Key.RequiredBuildingIdsKey
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Contains(room.BuildingId.ToString())) &&
|
||||||
|
occupiedRooms.All(occupied =>
|
||||||
|
occupied.ClassroomId != room.Id ||
|
||||||
|
!ExamConflictRules.TimeOverlaps(
|
||||||
|
occupied.StartsAt,
|
||||||
|
occupied.EndsAt,
|
||||||
|
groupSessions[0].StartsAt,
|
||||||
|
groupSessions[0].EndsAt)))
|
||||||
|
.ToList();
|
||||||
|
var selectedRooms = SelectRooms(
|
||||||
|
availableRooms,
|
||||||
|
candidates.Count);
|
||||||
|
if (selectedRooms.Sum(x => x.Capacity / 2) < candidates.Count)
|
||||||
|
{
|
||||||
|
unavailableStudents += candidates.Count;
|
||||||
|
messages.Add(
|
||||||
|
$"“{groupSessions[0].TeachingTask!.Course!.Name}”缺少足够考场容量," +
|
||||||
|
$"需 {candidates.Count} 座、可用 {selectedRooms.Sum(x => x.Capacity / 2)} 座。");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 0;
|
||||||
|
foreach (var classroom in selectedRooms)
|
||||||
|
{
|
||||||
|
var roomCandidates = candidates
|
||||||
|
.Skip(offset)
|
||||||
|
.Take(classroom.Capacity / 2)
|
||||||
|
.ToList();
|
||||||
|
if (roomCandidates.Count == 0) break;
|
||||||
|
offset += roomCandidates.Count;
|
||||||
|
|
||||||
|
var room = new ExamRoomAssignment
|
||||||
|
{
|
||||||
|
ExamPlanId = plan.Id,
|
||||||
|
CourseId = group.Key.CourseId,
|
||||||
|
ClassroomId = classroom.Id,
|
||||||
|
ExamDate = group.Key.ExamDate,
|
||||||
|
StartPeriod = group.Key.StartPeriod,
|
||||||
|
PeriodCount = group.Key.PeriodCount,
|
||||||
|
StartsAt = groupSessions[0].StartsAt,
|
||||||
|
EndsAt = groupSessions[0].EndsAt,
|
||||||
|
RequiredInvigilatorCount =
|
||||||
|
groupSessions.Max(x => x.RequiredInvigilatorCount),
|
||||||
|
SessionLinks = roomCandidates
|
||||||
|
.Select(x => x.ExamSessionId)
|
||||||
|
.Distinct()
|
||||||
|
.Select(sessionId => new ExamRoomSession
|
||||||
|
{
|
||||||
|
ExamSessionId = sessionId
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
Seats = roomCandidates
|
||||||
|
.Select((candidate, index) =>
|
||||||
|
new ExamSeatAssignment
|
||||||
|
{
|
||||||
|
ExamSessionId = candidate.ExamSessionId,
|
||||||
|
StudentId = candidate.StudentId,
|
||||||
|
SeatNumber = index + 1
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
};
|
||||||
|
db.ExamRooms.Add(room);
|
||||||
|
targetRooms.Add(room);
|
||||||
|
occupiedRooms.Add(new RoomOccupancy(
|
||||||
|
classroom.Id,
|
||||||
|
room.StartsAt,
|
||||||
|
room.EndsAt));
|
||||||
|
seatedStudents += roomCandidates.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.Add(
|
||||||
|
$"“{groupSessions[0].TeachingTask!.Course!.Name}”" +
|
||||||
|
$"{groupSessions.Count}个教学班混排至{selectedRooms.Count}个考场。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
targetRooms.AddRange(plan.Rooms.Where(room =>
|
||||||
|
room.SessionLinks.Any(link =>
|
||||||
|
sessionIds.Contains(link.ExamSessionId))));
|
||||||
|
var linkedSessionIds = targetRooms
|
||||||
|
.SelectMany(x => x.SessionLinks)
|
||||||
|
.Select(x => x.ExamSessionId)
|
||||||
|
.ToHashSet();
|
||||||
|
foreach (var session in sessions.Where(x =>
|
||||||
|
!linkedSessionIds.Contains(x.Id) &&
|
||||||
|
x.ClassroomId.HasValue))
|
||||||
|
{
|
||||||
|
var legacyRoom = CreateLegacyRoom(
|
||||||
|
plan,
|
||||||
|
session,
|
||||||
|
roster.Where(x =>
|
||||||
|
x.TeachingTaskId == session.TeachingTaskId));
|
||||||
|
db.ExamRooms.Add(legacyRoom);
|
||||||
|
targetRooms.Add(legacyRoom);
|
||||||
|
session.ClassroomId = null;
|
||||||
|
if (session.Invigilators.Count > 0)
|
||||||
|
{
|
||||||
|
db.ExamSessionInvigilators.RemoveRange(session.Invigilators);
|
||||||
|
session.Invigilators.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seatedStudents = targetRooms.Sum(x => x.Seats.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
var assignedInvigilators = 0;
|
||||||
|
var unavailableInvigilators = 0;
|
||||||
|
if (assignInvigilators)
|
||||||
|
{
|
||||||
|
if (targetRooms.Count == 0)
|
||||||
|
return ExamArrangementResult.Fail("尚未生成实际考场,请先分配考场。");
|
||||||
|
|
||||||
|
var occupiedInvigilators = plan.Rooms
|
||||||
|
.Where(x => !replacedRoomIds.Contains(x.Id) &&
|
||||||
|
!targetRooms.Any(target => target.Id == x.Id))
|
||||||
|
.SelectMany(room => room.Invigilators.Select(item =>
|
||||||
|
new InvigilatorOccupancy(
|
||||||
|
item.TeacherId,
|
||||||
|
room.StartsAt,
|
||||||
|
room.EndsAt)))
|
||||||
|
.Concat(plan.Sessions
|
||||||
|
.Where(x => !sessionIds.Contains(x.Id))
|
||||||
|
.SelectMany(session => session.Invigilators.Select(item =>
|
||||||
|
new InvigilatorOccupancy(
|
||||||
|
item.TeacherId,
|
||||||
|
session.StartsAt,
|
||||||
|
session.EndsAt))))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var room in targetRooms
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ThenBy(x => x.ClassroomId))
|
||||||
|
{
|
||||||
|
var needed = room.RequiredInvigilatorCount -
|
||||||
|
room.Invigilators.Count;
|
||||||
|
if (needed <= 0) continue;
|
||||||
|
|
||||||
|
var linkedSessionIds = room.SessionLinks
|
||||||
|
.Select(x => x.ExamSessionId)
|
||||||
|
.ToHashSet();
|
||||||
|
var excludedTeacherIds = sessions
|
||||||
|
.Where(x => linkedSessionIds.Contains(x.Id))
|
||||||
|
.SelectMany(x => x.TeachingTask!.Teachers)
|
||||||
|
.Select(x => x.TeacherId)
|
||||||
|
.ToHashSet();
|
||||||
|
var teachers = await FindInvigilatorsAsync(
|
||||||
|
room,
|
||||||
|
needed,
|
||||||
|
excludedTeacherIds,
|
||||||
|
occupiedInvigilators,
|
||||||
|
cancellationToken);
|
||||||
|
foreach (var teacher in teachers)
|
||||||
|
{
|
||||||
|
room.Invigilators.Add(new ExamRoomInvigilator
|
||||||
{
|
{
|
||||||
ExamSessionId = session.Id,
|
|
||||||
TeacherId = teacher.Id
|
TeacherId = teacher.Id
|
||||||
});
|
});
|
||||||
occupiedInvigilators.Add(new InvigilatorOccupancy(
|
occupiedInvigilators.Add(new InvigilatorOccupancy(
|
||||||
teacher.Id, session.StartsAt, session.EndsAt));
|
teacher.Id,
|
||||||
|
room.StartsAt,
|
||||||
|
room.EndsAt));
|
||||||
assignedInvigilators++;
|
assignedInvigilators++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newlyAssigned.Count < needed)
|
if (teachers.Count < needed)
|
||||||
{
|
unavailableInvigilators += needed - teachers.Count;
|
||||||
unavailableInvigilators += needed - newlyAssigned.Count;
|
|
||||||
messages.Add(
|
|
||||||
$"“{session.TeachingTask!.Course!.Name}”:仅找到{newlyAssigned.Count}/{needed}名监考教师");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var expandedCount = sessions.Count - explicitlySelected.Count;
|
||||||
|
var detail = messages.Count > 0
|
||||||
|
? $" 详情:{string.Join(";", messages.Take(10))}"
|
||||||
|
: "";
|
||||||
return new ExamArrangementResult(
|
return new ExamArrangementResult(
|
||||||
true,
|
true,
|
||||||
$"{sessions.Count}个场次处理完成,分配{assignedRooms}个考场、{assignedInvigilators}名监考教师。" +
|
$"{sessions.Count}个教学班场次处理完成,生成{targetRooms.Count}个实际考场," +
|
||||||
(unavailableRooms > 0 ? $" {unavailableRooms}个场次暂无可用考场。" : "") +
|
$"安排{seatedStudents}名考生、{assignedInvigilators}名监考教师。" +
|
||||||
(unavailableInvigilators > 0 ? $" 仍缺{unavailableInvigilators}名监考教师。" : "") +
|
(expandedCount > 0
|
||||||
(messages.Count > 0 ? $" 详情:{string.Join(";", messages.Take(10))}" : ""));
|
? $" 为保持混排完整性,自动包含同组{expandedCount}个场次。"
|
||||||
|
: "") +
|
||||||
|
(unavailableStudents > 0
|
||||||
|
? $" {unavailableStudents}名考生尚未安排考场。"
|
||||||
|
: "") +
|
||||||
|
(unavailableInvigilators > 0
|
||||||
|
? $" 仍缺{unavailableInvigilators}名监考教师。"
|
||||||
|
: "") +
|
||||||
|
detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<RoomOccupancy>> LoadOccupiedRoomsAsync(
|
||||||
|
ExamPlan plan,
|
||||||
|
IReadOnlySet<Guid> targetSessionIds,
|
||||||
|
IReadOnlySet<Guid> replacedRoomIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var occupied = plan.Rooms
|
||||||
|
.Where(x => !replacedRoomIds.Contains(x.Id))
|
||||||
|
.Select(x => new RoomOccupancy(
|
||||||
|
x.ClassroomId,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt))
|
||||||
|
.ToList();
|
||||||
|
occupied.AddRange(plan.Sessions
|
||||||
|
.Where(x =>
|
||||||
|
!targetSessionIds.Contains(x.Id) &&
|
||||||
|
x.ClassroomId.HasValue)
|
||||||
|
.Select(x => new RoomOccupancy(
|
||||||
|
x.ClassroomId!.Value,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt)));
|
||||||
|
|
||||||
|
var externalRooms = await db.ExamRooms.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.ExamPlanId != plan.Id &&
|
||||||
|
x.ExamPlan!.Status != ExamPlanStatus.Archived)
|
||||||
|
.Select(x => new RoomOccupancy(
|
||||||
|
x.ClassroomId,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupied.AddRange(externalRooms);
|
||||||
|
|
||||||
|
var externalLegacyRooms = await db.ExamSessions.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.ExamPlanId != plan.Id &&
|
||||||
|
x.ExamPlan!.Status != ExamPlanStatus.Archived &&
|
||||||
|
x.ClassroomId != null)
|
||||||
|
.Select(x => new RoomOccupancy(
|
||||||
|
x.ClassroomId!.Value,
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupied.AddRange(externalLegacyRooms);
|
||||||
|
return occupied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<Teacher>> FindInvigilatorsAsync(
|
||||||
|
ExamRoomAssignment room,
|
||||||
|
int needed,
|
||||||
|
HashSet<Guid> excludedTeacherIds,
|
||||||
|
List<InvigilatorOccupancy> occupied,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var busyTeacherIds = occupied
|
||||||
|
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||||
|
x.StartsAt,
|
||||||
|
x.EndsAt,
|
||||||
|
room.StartsAt,
|
||||||
|
room.EndsAt))
|
||||||
|
.Select(x => x.TeacherId)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var databaseBusyIds = await db.ExamRoomInvigilators.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.ExamRoomId != room.Id &&
|
||||||
|
x.ExamRoom!.ExamPlan!.Status != ExamPlanStatus.Archived &&
|
||||||
|
x.ExamRoom.StartsAt < room.EndsAt &&
|
||||||
|
room.StartsAt < x.ExamRoom.EndsAt)
|
||||||
|
.Select(x => x.TeacherId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var teacherId in databaseBusyIds)
|
||||||
|
busyTeacherIds.Add(teacherId);
|
||||||
|
|
||||||
|
var legacyBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.ExamSession!.ExamPlan!.Status != ExamPlanStatus.Archived &&
|
||||||
|
x.ExamSession.StartsAt < room.EndsAt &&
|
||||||
|
room.StartsAt < x.ExamSession.EndsAt)
|
||||||
|
.Select(x => x.TeacherId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var teacherId in legacyBusyIds)
|
||||||
|
busyTeacherIds.Add(teacherId);
|
||||||
|
foreach (var teacherId in excludedTeacherIds)
|
||||||
|
busyTeacherIds.Add(teacherId);
|
||||||
|
|
||||||
|
var candidates = await InvigilatorCandidateQuery
|
||||||
|
.Create(db, busyTeacherIds)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
return candidates
|
||||||
|
.OrderBy(_ => Random.Shared.Next())
|
||||||
|
.Take(needed)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Classroom> SelectRooms(
|
||||||
|
IReadOnlyCollection<Classroom> availableRooms,
|
||||||
|
int candidateCount)
|
||||||
|
{
|
||||||
|
var remainingRooms = availableRooms.ToList();
|
||||||
|
var selected = new List<Classroom>();
|
||||||
|
var remainingSeats = candidateCount;
|
||||||
|
while (remainingSeats > 0 && remainingRooms.Count > 0)
|
||||||
|
{
|
||||||
|
var room = remainingRooms
|
||||||
|
.Where(x => x.Capacity / 2 >= remainingSeats)
|
||||||
|
.OrderBy(x => x.Capacity / 2)
|
||||||
|
.ThenBy(x => x.Name)
|
||||||
|
.FirstOrDefault()
|
||||||
|
?? remainingRooms
|
||||||
|
.OrderByDescending(x => x.Capacity / 2)
|
||||||
|
.ThenBy(x => x.Name)
|
||||||
|
.First();
|
||||||
|
selected.Add(room);
|
||||||
|
remainingRooms.Remove(room);
|
||||||
|
remainingSeats -= room.Capacity / 2;
|
||||||
|
}
|
||||||
|
return selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CandidateSeat> InterleaveCandidates(
|
||||||
|
IReadOnlyCollection<ExamSession> sessions,
|
||||||
|
IReadOnlyCollection<TeachingTaskRosterEntry> roster,
|
||||||
|
IReadOnlyDictionary<Guid, ExamSession> sessionByTaskId)
|
||||||
|
{
|
||||||
|
var sessionIds = sessions.Select(x => x.Id).ToHashSet();
|
||||||
|
var queues = roster
|
||||||
|
.Where(x =>
|
||||||
|
sessionByTaskId.TryGetValue(
|
||||||
|
x.TeachingTaskId,
|
||||||
|
out var session) &&
|
||||||
|
sessionIds.Contains(session.Id))
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.OrderBy(x => sessionByTaskId[x.Key].TeachingTask!.TaskNumber)
|
||||||
|
.Select(group => new Queue<CandidateSeat>(
|
||||||
|
group.OrderBy(x => x.StudentNumber)
|
||||||
|
.Select(x => new CandidateSeat(
|
||||||
|
sessionByTaskId[x.TeachingTaskId].Id,
|
||||||
|
x.StudentId,
|
||||||
|
x.StudentNumber))))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var result = new List<CandidateSeat>();
|
||||||
|
var assignedStudentIds = new HashSet<Guid>();
|
||||||
|
while (queues.Any(x => x.Count > 0))
|
||||||
|
{
|
||||||
|
foreach (var queue in queues)
|
||||||
|
{
|
||||||
|
while (queue.Count > 0)
|
||||||
|
{
|
||||||
|
var candidate = queue.Dequeue();
|
||||||
|
if (!assignedStudentIds.Add(candidate.StudentId))
|
||||||
|
continue;
|
||||||
|
result.Add(candidate);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ExamRoomAssignment CreateLegacyRoom(
|
||||||
|
ExamPlan plan,
|
||||||
|
ExamSession session,
|
||||||
|
IEnumerable<TeachingTaskRosterEntry> roster)
|
||||||
|
{
|
||||||
|
var students = roster
|
||||||
|
.OrderBy(x => x.StudentNumber)
|
||||||
|
.ToList();
|
||||||
|
return new ExamRoomAssignment
|
||||||
|
{
|
||||||
|
ExamPlanId = plan.Id,
|
||||||
|
CourseId = session.TeachingTask!.CourseId,
|
||||||
|
ClassroomId = session.ClassroomId!.Value,
|
||||||
|
ExamDate = session.ExamDate,
|
||||||
|
StartPeriod = session.StartPeriod,
|
||||||
|
PeriodCount = session.PeriodCount,
|
||||||
|
StartsAt = session.StartsAt,
|
||||||
|
EndsAt = session.EndsAt,
|
||||||
|
RequiredInvigilatorCount = session.RequiredInvigilatorCount,
|
||||||
|
SessionLinks =
|
||||||
|
[
|
||||||
|
new ExamRoomSession
|
||||||
|
{
|
||||||
|
ExamSessionId = session.Id
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Seats = students.Select((student, index) =>
|
||||||
|
new ExamSeatAssignment
|
||||||
|
{
|
||||||
|
ExamSessionId = session.Id,
|
||||||
|
StudentId = student.StudentId,
|
||||||
|
SeatNumber = index + 1
|
||||||
|
}).ToList(),
|
||||||
|
Invigilators = session.Invigilators.Select(x =>
|
||||||
|
new ExamRoomInvigilator
|
||||||
|
{
|
||||||
|
TeacherId = x.TeacherId
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RoomGroupKey GroupKey(ExamSession session)
|
||||||
|
{
|
||||||
|
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
|
||||||
|
if (session.RequiredBuildingId.HasValue)
|
||||||
|
buildingIds.Add(session.RequiredBuildingId.Value);
|
||||||
|
var key = buildingIds.Count == 0
|
||||||
|
? string.Empty
|
||||||
|
: string.Join(",", buildingIds.OrderBy(x => x));
|
||||||
|
return new(
|
||||||
|
session.TeachingTask!.CourseId,
|
||||||
|
session.ExamDate,
|
||||||
|
session.StartPeriod,
|
||||||
|
session.PeriodCount,
|
||||||
|
key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<Guid> ParseBuildingIds(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ComputeTimesFromSlots(
|
private static void ComputeTimesFromSlots(
|
||||||
ExamSession session,
|
ExamSession session,
|
||||||
Dictionary<int, ScheduleTimeSlot> timeSlotLookup)
|
IReadOnlyDictionary<int, ScheduleTimeSlot> timeSlotLookup)
|
||||||
{
|
{
|
||||||
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
|
var startSlot = timeSlotLookup.GetValueOrDefault(session.StartPeriod);
|
||||||
var endSlot = timeSlotLookup.GetValueOrDefault(
|
var endSlot = timeSlotLookup.GetValueOrDefault(
|
||||||
session.StartPeriod + session.PeriodCount - 1);
|
session.StartPeriod + session.PeriodCount - 1);
|
||||||
if (startSlot is null || endSlot is null) return;
|
if (startSlot is null || endSlot is null) return;
|
||||||
|
|
||||||
var examDate = session.ExamDate;
|
session.StartsAt = session.ExamDate.ToDateTime(
|
||||||
session.StartsAt = examDate.ToDateTime(startSlot.StartsAt, DateTimeKind.Utc);
|
startSlot.StartsAt,
|
||||||
session.EndsAt = examDate.ToDateTime(endSlot.EndsAt, DateTimeKind.Utc);
|
DateTimeKind.Utc);
|
||||||
}
|
session.EndsAt = session.ExamDate.ToDateTime(
|
||||||
|
endSlot.EndsAt,
|
||||||
private async Task<Classroom?> FindBestClassroomAsync(
|
DateTimeKind.Utc);
|
||||||
ExamSession session,
|
|
||||||
int studentCount,
|
|
||||||
List<RoomOccupancy> occupied,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var query = db.Classrooms.AsNoTracking()
|
|
||||||
.Where(x => x.IsEnabled && x.Capacity >= studentCount);
|
|
||||||
|
|
||||||
if (session.RequiredBuildingId.HasValue)
|
|
||||||
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
|
||||||
|
|
||||||
// Exclude classrooms already occupied in-memory
|
|
||||||
var occupiedRoomIds = occupied
|
|
||||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
|
||||||
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
|
|
||||||
.Select(x => x.ClassroomId)
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
if (occupiedRoomIds.Count > 0)
|
|
||||||
query = query.WhereNotIn(occupiedRoomIds, x => x.Id);
|
|
||||||
|
|
||||||
// Exclude classrooms occupied by DB sessions not yet tracked in memory
|
|
||||||
var dbOccupiedRooms = await db.ExamSessions.AsNoTracking()
|
|
||||||
.Where(x => x.ExamPlanId == session.ExamPlanId &&
|
|
||||||
x.Id != session.Id &&
|
|
||||||
x.ClassroomId != null &&
|
|
||||||
x.StartsAt < session.EndsAt &&
|
|
||||||
session.StartsAt < x.EndsAt)
|
|
||||||
.Select(x => x.ClassroomId!.Value)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (dbOccupiedRooms.Count > 0)
|
|
||||||
query = query.WhereNotIn(dbOccupiedRooms, x => x.Id);
|
|
||||||
|
|
||||||
return await query
|
|
||||||
.OrderBy(x => x.Capacity)
|
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<List<Teacher>> FindInvigilatorsAsync(
|
|
||||||
ExamSession session,
|
|
||||||
int needed,
|
|
||||||
HashSet<Guid> excludeTeacherIds,
|
|
||||||
List<InvigilatorOccupancy> occupied,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var busyTeacherIds = occupied
|
|
||||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
|
||||||
x.StartsAt, x.EndsAt, session.StartsAt, session.EndsAt))
|
|
||||||
.Select(x => x.TeacherId)
|
|
||||||
.ToHashSet();
|
|
||||||
|
|
||||||
var dbBusyIds = await db.ExamSessionInvigilators.AsNoTracking()
|
|
||||||
.Where(x => x.ExamSession!.ExamPlanId == session.ExamPlanId &&
|
|
||||||
x.ExamSessionId != session.Id &&
|
|
||||||
x.ExamSession!.StartsAt < session.EndsAt &&
|
|
||||||
session.StartsAt < x.ExamSession.EndsAt)
|
|
||||||
.Select(x => x.TeacherId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
|
||||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
|
||||||
|
|
||||||
return await db.Teachers.AsNoTracking()
|
|
||||||
.Where(x => x.Status == TeacherStatus.Active)
|
|
||||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
|
||||||
.OrderBy(x => Guid.NewGuid())
|
|
||||||
.Take(needed)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ExamArrangementResult(bool Success, string Message)
|
public sealed record ExamArrangementResult(bool Success, string Message)
|
||||||
{
|
{
|
||||||
public static ExamArrangementResult Fail(string message) => new(false, message);
|
public static ExamArrangementResult Fail(string message) =>
|
||||||
|
new(false, message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public sealed class ExamPublishJobProcessor(
|
||||||
|
AppDbContext db,
|
||||||
|
IAppCache cache,
|
||||||
|
ILogger<ExamPublishJobProcessor> logger)
|
||||||
|
{
|
||||||
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var job = await db.ExamPublishJobs
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||||
|
if (job is null ||
|
||||||
|
job.Status is ExamPublishJobStatus.Succeeded
|
||||||
|
or ExamPublishJobStatus.Failed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = ExamPublishJobStatus.Running;
|
||||||
|
job.StartedAt ??= DateTime.UtcNow;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
job.CurrentStep = "正在校验考试计划";
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
switch (job.Kind)
|
||||||
|
{
|
||||||
|
case ExamPublishJobKind.FormalExam:
|
||||||
|
await PublishFormalExamAsync(job, stoppingToken);
|
||||||
|
break;
|
||||||
|
case ExamPublishJobKind.MakeupExam:
|
||||||
|
await PublishMakeupExamAsync(job, stoppingToken);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"不支持的考试发布类型:{job.Kind}。");
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = ExamPublishJobStatus.Succeeded;
|
||||||
|
job.ActivePlanId = null;
|
||||||
|
job.CurrentStep = "发布完成";
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
await cache.RemoveByTagAsync(
|
||||||
|
AppCacheTags.Timetables,
|
||||||
|
stoppingToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Exam publish job {JobId} for {Kind}/{PlanId} completed.",
|
||||||
|
job.Id,
|
||||||
|
job.Kind,
|
||||||
|
job.PlanId);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Exam publish job {JobId} was interrupted by application shutdown.",
|
||||||
|
jobId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (ExamPublishValidationException validationException)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
validationException,
|
||||||
|
"Exam publish job {JobId} validation failed.",
|
||||||
|
jobId);
|
||||||
|
await MarkFailedAsync(jobId, validationException.Message);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Exam publish job {JobId} failed.", jobId);
|
||||||
|
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishFormalExamAsync(
|
||||||
|
ExamPublishJob job,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var plan = await db.ExamPlans
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||||
|
if (plan is null)
|
||||||
|
throw new ExamPublishValidationException("考试计划不存在。");
|
||||||
|
if (plan.Status != ExamPlanStatus.Draft)
|
||||||
|
throw new ExamPublishValidationException("只有草稿考试计划可以发布。");
|
||||||
|
|
||||||
|
// Step 1: check sessions exist — simple count, no JOIN
|
||||||
|
var sessionCount = await db.ExamSessions
|
||||||
|
.CountAsync(x => x.ExamPlanId == job.PlanId, ct);
|
||||||
|
if (sessionCount == 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
"至少安排一个考试场次后才能发布。");
|
||||||
|
|
||||||
|
// Step 2: load session summaries — only necessary columns, no Include chains
|
||||||
|
var sessions = await db.ExamSessions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.ExamPlanId == job.PlanId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.ClassroomId,
|
||||||
|
InvigilatorCount = x.Invigilators.Count,
|
||||||
|
RoomLinkCount = x.RoomLinks.Count
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Step 3: load roster counts — independent query
|
||||||
|
var taskIds = sessions.Select(x => x.TeachingTaskId).ToArray();
|
||||||
|
var rosterCounts = (await TeachingTaskRosterQuery
|
||||||
|
.LoadForTasksAsync(db, taskIds, ct))
|
||||||
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
|
.ToDictionary(x => x.Key, x => x.Count());
|
||||||
|
|
||||||
|
// Step 4: validate each session's assignment completeness
|
||||||
|
var unassignedSessionIds = new List<Guid>();
|
||||||
|
foreach (var session in sessions)
|
||||||
|
{
|
||||||
|
var rosterCount = rosterCounts.GetValueOrDefault(session.TeachingTaskId);
|
||||||
|
if (session.RoomLinkCount > 0)
|
||||||
|
{
|
||||||
|
// Has room links — check seat assignment via separate query
|
||||||
|
var assignedSeatCount = await db.ExamSeats
|
||||||
|
.CountAsync(seat =>
|
||||||
|
seat.ExamSessionId == session.Id,
|
||||||
|
ct);
|
||||||
|
if (assignedSeatCount != rosterCount)
|
||||||
|
unassignedSessionIds.Add(session.Id);
|
||||||
|
}
|
||||||
|
else if (!session.ClassroomId.HasValue ||
|
||||||
|
session.InvigilatorCount == 0)
|
||||||
|
{
|
||||||
|
unassignedSessionIds.Add(session.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unassignedSessionIds.Count > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"还有 {unassignedSessionIds.Count} 个教学班未完成考场座位或监考安排," +
|
||||||
|
"请先完成自动编排。");
|
||||||
|
|
||||||
|
// Step 5: check room invigilator sufficiency — separate query
|
||||||
|
var insufficientInvigilatorCount = await db.ExamRoomSessions
|
||||||
|
.Where(link =>
|
||||||
|
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||||
|
link.ExamRoom.Invigilators.Count <
|
||||||
|
link.ExamRoom.RequiredInvigilatorCount)
|
||||||
|
.Select(link => link.ExamSessionId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(ct);
|
||||||
|
if (insufficientInvigilatorCount > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"还有 {insufficientInvigilatorCount} 个场次的混排考场监考教师不足," +
|
||||||
|
"请先完成自动编排。");
|
||||||
|
|
||||||
|
// Step 6: validate mixed rooms — split into two independent queries
|
||||||
|
// to avoid Seats × SessionLinks Cartesian product
|
||||||
|
|
||||||
|
// 6a: capacity overflow
|
||||||
|
var overCapacityCount = await db.ExamRooms
|
||||||
|
.Where(r => r.ExamPlanId == job.PlanId)
|
||||||
|
.Select(r => new
|
||||||
|
{
|
||||||
|
r.Id,
|
||||||
|
SeatCount = r.Seats.Count,
|
||||||
|
Capacity = r.Classroom!.Capacity
|
||||||
|
})
|
||||||
|
.CountAsync(x => x.SeatCount > x.Capacity, ct);
|
||||||
|
if (overCapacityCount > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"发现 {overCapacityCount} 个考场容量超限,请重新编排。");
|
||||||
|
|
||||||
|
// 6b: course mismatch
|
||||||
|
var courseMismatchCount = await db.ExamRoomSessions
|
||||||
|
.Where(link =>
|
||||||
|
link.ExamRoom!.ExamPlanId == job.PlanId &&
|
||||||
|
link.ExamSession!.TeachingTask!.CourseId !=
|
||||||
|
link.ExamRoom.CourseId)
|
||||||
|
.Select(link => link.ExamRoomId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(ct);
|
||||||
|
if (courseMismatchCount > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"发现 {courseMismatchCount} 个考场混入不同课程,请重新编排。");
|
||||||
|
|
||||||
|
// Step 7: publish
|
||||||
|
plan.Status = ExamPlanStatus.Published;
|
||||||
|
plan.PublishedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishMakeupExamAsync(
|
||||||
|
ExamPublishJob job,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var plan = await db.MakeupExamPlans
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == job.PlanId, ct);
|
||||||
|
if (plan is null)
|
||||||
|
throw new ExamPublishValidationException("补考计划不存在。");
|
||||||
|
if (plan.Status != MakeupExamPlanStatus.Draft)
|
||||||
|
throw new ExamPublishValidationException("只有草稿补考计划可以发布。");
|
||||||
|
|
||||||
|
// Step 1: check sessions exist — simple count
|
||||||
|
var sessionCount = await db.MakeupExamSessions
|
||||||
|
.CountAsync(x => x.MakeupExamPlanId == job.PlanId, ct);
|
||||||
|
if (sessionCount == 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
"至少安排一个考试场次后才能发布。");
|
||||||
|
|
||||||
|
// Step 2: load session summaries — only necessary columns
|
||||||
|
var sessions = await db.MakeupExamSessions
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.MakeupExamPlanId == job.PlanId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.ClassroomId,
|
||||||
|
InvigilatorCount = x.Invigilators.Count,
|
||||||
|
EnrollmentCount = x.Enrollments.Count
|
||||||
|
})
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// Step 3: validate completeness — no Include chain needed
|
||||||
|
var unassigned = sessions.Count(x =>
|
||||||
|
!x.ClassroomId.HasValue || x.InvigilatorCount == 0);
|
||||||
|
if (unassigned > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"还有 {unassigned} 个场次未分配考场或监考教师,请先完成自动编排。");
|
||||||
|
|
||||||
|
// Step 4: validate enrollments
|
||||||
|
var empty = sessions.Count(x => x.EnrollmentCount == 0);
|
||||||
|
if (empty > 0)
|
||||||
|
throw new ExamPublishValidationException(
|
||||||
|
$"还有 {empty} 个场次没有登记补考学生。");
|
||||||
|
|
||||||
|
// Step 5: publish
|
||||||
|
plan.Status = MakeupExamPlanStatus.Published;
|
||||||
|
plan.PublishedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var job = await db.ExamPublishJobs.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId,
|
||||||
|
CancellationToken.None);
|
||||||
|
if (job is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
job.Status = ExamPublishJobStatus.Failed;
|
||||||
|
job.ActivePlanId = null;
|
||||||
|
job.CurrentStep = "发布失败";
|
||||||
|
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ExamPublishValidationException(string message)
|
||||||
|
: InvalidOperationException(message);
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public sealed class ExamSignInExportJobProcessor(
|
||||||
|
AppDbContext db,
|
||||||
|
ILogger<ExamSignInExportJobProcessor> logger)
|
||||||
|
{
|
||||||
|
public async Task ProcessAsync(Guid jobId, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var job = await db.ExamSignInExportJobs
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == jobId, stoppingToken);
|
||||||
|
if (job is null ||
|
||||||
|
job.Status is ExamSignInExportJobStatus.Succeeded
|
||||||
|
or ExamSignInExportJobStatus.Failed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
job.Status = ExamSignInExportJobStatus.Running;
|
||||||
|
job.StartedAt ??= DateTime.UtcNow;
|
||||||
|
job.CompletedAt = null;
|
||||||
|
job.ErrorMessage = null;
|
||||||
|
job.CurrentStep = "正在生成考场签名单";
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
var plan = await db.ExamPlans.AsNoTracking()
|
||||||
|
.Where(x => x.Id == job.PlanId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Name,
|
||||||
|
TermName = x.AcademicTerm!.Name
|
||||||
|
})
|
||||||
|
.FirstOrDefaultAsync(stoppingToken);
|
||||||
|
if (plan is null)
|
||||||
|
{
|
||||||
|
await MarkFailedAsync(jobId, "考试计划不存在。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rooms = await db.ExamRooms.AsNoTracking()
|
||||||
|
.Include(x => x.Course)
|
||||||
|
.Include(x => x.Classroom)
|
||||||
|
.ThenInclude(x => x!.Building)
|
||||||
|
.Include(x => x.Invigilators)
|
||||||
|
.ThenInclude(x => x.Teacher)
|
||||||
|
.Include(x => x.SessionLinks)
|
||||||
|
.ThenInclude(x => x.ExamSession)
|
||||||
|
.ThenInclude(x => x!.TeachingTask)
|
||||||
|
.Include(x => x.Seats)
|
||||||
|
.ThenInclude(x => x.Student)
|
||||||
|
.ThenInclude(x => x!.AdministrativeClass)
|
||||||
|
.Where(x => x.ExamPlanId == job.PlanId)
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ThenBy(x => x.Classroom!.Building!.Name)
|
||||||
|
.ThenBy(x => x.Classroom!.Name)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
var legacySessions = await db.ExamSessions.AsNoTracking()
|
||||||
|
.Where(x => x.ExamPlanId == job.PlanId)
|
||||||
|
.Where(x => !x.RoomLinks.Any())
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.Select(session => new
|
||||||
|
{
|
||||||
|
session.Id,
|
||||||
|
session.TeachingTaskId,
|
||||||
|
session.ExamDate,
|
||||||
|
session.StartsAt,
|
||||||
|
session.EndsAt,
|
||||||
|
CourseCode = session.TeachingTask!.Course!.Code,
|
||||||
|
CourseName = session.TeachingTask.Course.Name,
|
||||||
|
session.TeachingTask.TaskNumber,
|
||||||
|
BuildingName = session.Classroom != null
|
||||||
|
? session.Classroom.Building!.Name
|
||||||
|
: null,
|
||||||
|
ClassroomName = session.Classroom != null
|
||||||
|
? session.Classroom.Name
|
||||||
|
: null,
|
||||||
|
InvigilatorNames = session.Invigilators
|
||||||
|
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||||
|
.Select(item => item.Teacher!.Name)
|
||||||
|
})
|
||||||
|
.ToListAsync(stoppingToken);
|
||||||
|
|
||||||
|
if (rooms.Count == 0 && legacySessions.Count == 0)
|
||||||
|
{
|
||||||
|
await MarkFailedAsync(jobId, "当前考试计划没有可导出的考试场次。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
|
||||||
|
db,
|
||||||
|
legacySessions.Select(x => x.TeachingTaskId),
|
||||||
|
stoppingToken);
|
||||||
|
var studentsByTask = roster.ToLookup(x => x.TeachingTaskId);
|
||||||
|
|
||||||
|
var sheets = rooms.Select(room => new ExamSignInSessionData(
|
||||||
|
room.Id,
|
||||||
|
room.ExamDate,
|
||||||
|
room.StartsAt,
|
||||||
|
room.EndsAt,
|
||||||
|
room.Course!.Code,
|
||||||
|
room.Course.Name,
|
||||||
|
string.Join(
|
||||||
|
"、",
|
||||||
|
room.SessionLinks
|
||||||
|
.Select(link =>
|
||||||
|
link.ExamSession!.TeachingTask!.TaskNumber)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(x => x)),
|
||||||
|
room.Classroom!.Building!.Name,
|
||||||
|
room.Classroom.Name,
|
||||||
|
room.Invigilators
|
||||||
|
.OrderBy(item => item.Teacher!.TeacherNumber)
|
||||||
|
.Select(item => item.Teacher!.Name)
|
||||||
|
.ToList(),
|
||||||
|
room.Seats
|
||||||
|
.OrderBy(seat => seat.SeatNumber)
|
||||||
|
.Select(seat => new ExamSignInStudentData(
|
||||||
|
seat.StudentId,
|
||||||
|
seat.Student!.StudentNumber,
|
||||||
|
seat.Student.Name,
|
||||||
|
seat.Student.AdministrativeClass!.Name,
|
||||||
|
seat.SeatNumber))
|
||||||
|
.ToList()))
|
||||||
|
.Concat(legacySessions.Select(session =>
|
||||||
|
new ExamSignInSessionData(
|
||||||
|
session.Id,
|
||||||
|
session.ExamDate,
|
||||||
|
session.StartsAt,
|
||||||
|
session.EndsAt,
|
||||||
|
session.CourseCode,
|
||||||
|
session.CourseName,
|
||||||
|
session.TaskNumber,
|
||||||
|
session.BuildingName,
|
||||||
|
session.ClassroomName,
|
||||||
|
session.InvigilatorNames.ToList(),
|
||||||
|
studentsByTask[session.TeachingTaskId]
|
||||||
|
.Select((student, index) =>
|
||||||
|
new ExamSignInStudentData(
|
||||||
|
student.StudentId,
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
student.ClassName,
|
||||||
|
index + 1))
|
||||||
|
.ToList())))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var data = new ExamSignInWorkbookData(plan.Name, plan.TermName, sheets);
|
||||||
|
var bytes = ExamSignInWorkbookExporter.Create(data);
|
||||||
|
|
||||||
|
job.Status = ExamSignInExportJobStatus.Succeeded;
|
||||||
|
job.FileName = $"考场签名单-{SanitizeFileName(plan.Name)}.xlsx";
|
||||||
|
job.FileBytes = bytes;
|
||||||
|
job.FileSize = bytes.Length;
|
||||||
|
job.CurrentStep = "生成完成";
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(stoppingToken);
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"Sign-in export job {JobId} for plan {PlanId} completed, {Size:N0} bytes.",
|
||||||
|
job.Id,
|
||||||
|
job.PlanId,
|
||||||
|
bytes.Length);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Sign-in export job {JobId} was interrupted by application shutdown.",
|
||||||
|
jobId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
logger.LogError(exception, "Sign-in export job {JobId} failed.", jobId);
|
||||||
|
await MarkFailedAsync(jobId, exception.GetBaseException().Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||||
|
{
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var job = await db.ExamSignInExportJobs.FirstOrDefaultAsync(
|
||||||
|
x => x.Id == jobId,
|
||||||
|
CancellationToken.None);
|
||||||
|
if (job is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
job.Status = ExamSignInExportJobStatus.Failed;
|
||||||
|
job.CurrentStep = "生成失败";
|
||||||
|
job.ErrorMessage = message.Length <= 2000 ? message : message[..2000];
|
||||||
|
job.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeFileName(string value)
|
||||||
|
{
|
||||||
|
var invalid = System.IO.Path.GetInvalidFileNameChars().ToHashSet();
|
||||||
|
var normalized = new string(value
|
||||||
|
.Select(c => invalid.Contains(c) ? '_' : c)
|
||||||
|
.ToArray()).Trim();
|
||||||
|
return normalized.Length > 100 ? normalized[..100] : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
public static class ExamSignInWorkbookExporter
|
||||||
|
{
|
||||||
|
private static readonly XLColor Navy = XLColor.FromHtml("#173E72");
|
||||||
|
private static readonly XLColor Teal = XLColor.FromHtml("#24706A");
|
||||||
|
private static readonly XLColor Pale = XLColor.FromHtml("#EFF3F6");
|
||||||
|
private static readonly XLColor Rule = XLColor.FromHtml("#C8D3DA");
|
||||||
|
|
||||||
|
public static byte[] Create(ExamSignInWorkbookData data)
|
||||||
|
{
|
||||||
|
using var workbook = new XLWorkbook();
|
||||||
|
AddSummarySheet(workbook, data);
|
||||||
|
|
||||||
|
var usedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"考场汇总"
|
||||||
|
};
|
||||||
|
foreach (var session in data.Sessions
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartsAt)
|
||||||
|
.ThenBy(x => x.BuildingName)
|
||||||
|
.ThenBy(x => x.ClassroomName)
|
||||||
|
.ThenBy(x => x.CourseCode))
|
||||||
|
{
|
||||||
|
AddSignInSheet(
|
||||||
|
workbook,
|
||||||
|
data,
|
||||||
|
session,
|
||||||
|
UniqueSheetName(session, usedNames));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
workbook.SaveAs(stream);
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSummarySheet(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
ExamSignInWorkbookData data)
|
||||||
|
{
|
||||||
|
var sheet = workbook.Worksheets.Add("考场汇总");
|
||||||
|
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||||
|
sheet.ShowGridLines = false;
|
||||||
|
|
||||||
|
sheet.Range("A1:G1").Merge();
|
||||||
|
sheet.Cell("A1").Value = $"{data.PlanName} · 考场签名单";
|
||||||
|
StyleTitle(sheet.Range("A1:G1"));
|
||||||
|
sheet.Row(1).Height = 34;
|
||||||
|
|
||||||
|
sheet.Range("A2:G2").Merge();
|
||||||
|
sheet.Cell("A2").Value =
|
||||||
|
$"{data.TermName} · 共 {data.Sessions.Count} 个场次 · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
|
||||||
|
StyleSubTitle(sheet.Range("A2:G2"));
|
||||||
|
sheet.Row(2).Height = 24;
|
||||||
|
|
||||||
|
var headers = new[] { "日期", "时间", "考场", "课程", "教学班", "考生人数", "监考教师" };
|
||||||
|
for (var column = 1; column <= headers.Length; column++)
|
||||||
|
sheet.Cell(4, column).Value = headers[column - 1];
|
||||||
|
StyleHeader(sheet.Range(4, 1, 4, headers.Length));
|
||||||
|
|
||||||
|
var row = 5;
|
||||||
|
foreach (var session in data.Sessions
|
||||||
|
.OrderBy(x => x.ExamDate)
|
||||||
|
.ThenBy(x => x.StartsAt)
|
||||||
|
.ThenBy(x => x.BuildingName)
|
||||||
|
.ThenBy(x => x.ClassroomName))
|
||||||
|
{
|
||||||
|
sheet.Cell(row, 1).Value = session.ExamDate.ToDateTime(TimeOnly.MinValue);
|
||||||
|
sheet.Cell(row, 1).Style.DateFormat.Format = "yyyy-mm-dd";
|
||||||
|
sheet.Cell(row, 2).Value = $"{session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
|
||||||
|
sheet.Cell(row, 3).Value = Location(session);
|
||||||
|
sheet.Cell(row, 4).Value = $"{session.CourseCode} {session.CourseName}";
|
||||||
|
sheet.Cell(row, 5).Value = session.TaskNumber;
|
||||||
|
sheet.Cell(row, 6).Value = session.Students.Count;
|
||||||
|
sheet.Cell(row, 7).Value = Invigilators(session);
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row > 5)
|
||||||
|
{
|
||||||
|
var table = sheet.Range(4, 1, row - 1, headers.Length);
|
||||||
|
table.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
||||||
|
table.Style.Border.InsideBorderColor = Rule;
|
||||||
|
table.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
|
||||||
|
table.Style.Border.OutsideBorderColor = Rule;
|
||||||
|
table.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||||
|
sheet.Rows(5, row - 1).Height = 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
sheet.Column(1).Width = 13;
|
||||||
|
sheet.Column(2).Width = 15;
|
||||||
|
sheet.Column(3).Width = 24;
|
||||||
|
sheet.Column(4).Width = 30;
|
||||||
|
sheet.Column(5).Width = 22;
|
||||||
|
sheet.Column(6).Width = 11;
|
||||||
|
sheet.Column(7).Width = 24;
|
||||||
|
sheet.Columns(3, 7).Style.Alignment.WrapText = true;
|
||||||
|
sheet.SheetView.FreezeRows(4);
|
||||||
|
sheet.PageSetup.PageOrientation = XLPageOrientation.Landscape;
|
||||||
|
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||||
|
sheet.PageSetup.FitToPages(1, 0);
|
||||||
|
sheet.PageSetup.Margins
|
||||||
|
.SetLeft(0.25).SetRight(0.25).SetTop(0.35).SetBottom(0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddSignInSheet(
|
||||||
|
XLWorkbook workbook,
|
||||||
|
ExamSignInWorkbookData data,
|
||||||
|
ExamSignInSessionData session,
|
||||||
|
string sheetName)
|
||||||
|
{
|
||||||
|
var sheet = workbook.Worksheets.Add(sheetName);
|
||||||
|
sheet.Style.Font.FontName = "Microsoft YaHei";
|
||||||
|
sheet.ShowGridLines = false;
|
||||||
|
|
||||||
|
sheet.Range("A1:G1").Merge();
|
||||||
|
sheet.Cell("A1").Value = "考试考场签名单";
|
||||||
|
StyleTitle(sheet.Range("A1:G1"));
|
||||||
|
sheet.Cell("A1").Style.Alignment.Horizontal =
|
||||||
|
XLAlignmentHorizontalValues.Center;
|
||||||
|
sheet.Row(1).Height = 36;
|
||||||
|
|
||||||
|
sheet.Range("A2:G2").Merge();
|
||||||
|
sheet.Cell("A2").Value = $"{data.PlanName} · {data.TermName}";
|
||||||
|
StyleSubTitle(sheet.Range("A2:G2"));
|
||||||
|
sheet.Cell("A2").Style.Alignment.Horizontal =
|
||||||
|
XLAlignmentHorizontalValues.Center;
|
||||||
|
sheet.Row(2).Height = 23;
|
||||||
|
|
||||||
|
sheet.Range("A3:D3").Merge();
|
||||||
|
sheet.Cell("A3").Value =
|
||||||
|
$"课程:{session.CourseCode} {session.CourseName}({session.TaskNumber})";
|
||||||
|
sheet.Range("E3:G3").Merge();
|
||||||
|
sheet.Cell("E3").Value = $"考生人数:{session.Students.Count} 人";
|
||||||
|
|
||||||
|
sheet.Range("A4:D4").Merge();
|
||||||
|
sheet.Cell("A4").Value =
|
||||||
|
$"考试时间:{session.ExamDate:yyyy-MM-dd} {session.StartsAt:HH:mm}-{session.EndsAt:HH:mm}";
|
||||||
|
sheet.Range("E4:G4").Merge();
|
||||||
|
sheet.Cell("E4").Value = $"考场:{Location(session)}";
|
||||||
|
|
||||||
|
sheet.Range("A5:D5").Merge();
|
||||||
|
sheet.Cell("A5").Value = $"监考教师:{Invigilators(session)}";
|
||||||
|
sheet.Range("E5:G5").Merge();
|
||||||
|
sheet.Cell("E5").Value = "应到:______ 实到:______ 缺考:______";
|
||||||
|
|
||||||
|
var metadata = sheet.Range("A3:G5");
|
||||||
|
metadata.Style.Fill.BackgroundColor = XLColor.White;
|
||||||
|
metadata.Style.Font.FontColor = XLColor.FromHtml("#304B60");
|
||||||
|
metadata.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||||
|
metadata.Style.Alignment.WrapText = true;
|
||||||
|
metadata.Style.Border.BottomBorder = XLBorderStyleValues.Thin;
|
||||||
|
metadata.Style.Border.BottomBorderColor = Rule;
|
||||||
|
sheet.Rows(3, 5).Height = 24;
|
||||||
|
|
||||||
|
var headers = new[] { "序号", "座位号", "学号", "姓名", "行政班", "考生签名", "备注" };
|
||||||
|
for (var column = 1; column <= headers.Length; column++)
|
||||||
|
sheet.Cell(7, column).Value = headers[column - 1];
|
||||||
|
StyleHeader(sheet.Range(7, 1, 7, headers.Length));
|
||||||
|
sheet.Row(7).Height = 27;
|
||||||
|
|
||||||
|
var row = 8;
|
||||||
|
foreach (var student in session.Students
|
||||||
|
.OrderBy(x => x.SeatNumber)
|
||||||
|
.ThenBy(x => x.StudentNumber))
|
||||||
|
{
|
||||||
|
var index = row - 7;
|
||||||
|
sheet.Cell(row, 1).Value = index;
|
||||||
|
sheet.Cell(row, 2).Value =
|
||||||
|
(student.SeatNumber > 0 ? student.SeatNumber : index)
|
||||||
|
.ToString("D3");
|
||||||
|
sheet.Cell(row, 3).Value = student.StudentNumber;
|
||||||
|
sheet.Cell(row, 4).Value = student.Name;
|
||||||
|
sheet.Cell(row, 5).Value = student.ClassName;
|
||||||
|
sheet.Row(row).Height = 28;
|
||||||
|
row++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.Students.Count == 0)
|
||||||
|
{
|
||||||
|
sheet.Range("A8:G8").Merge();
|
||||||
|
sheet.Cell("A8").Value = "本场次暂无考生";
|
||||||
|
sheet.Cell("A8").Style
|
||||||
|
.Font.SetFontColor(XLColor.FromHtml("#8A5A20"))
|
||||||
|
.Fill.SetBackgroundColor(XLColor.FromHtml("#FFF8EA"))
|
||||||
|
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center);
|
||||||
|
sheet.Row(8).Height = 28;
|
||||||
|
row = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
var roster = sheet.Range(7, 1, row - 1, headers.Length);
|
||||||
|
roster.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
|
||||||
|
roster.Style.Border.InsideBorderColor = Rule;
|
||||||
|
roster.Style.Border.OutsideBorder = XLBorderStyleValues.Medium;
|
||||||
|
roster.Style.Border.OutsideBorderColor = XLColor.FromHtml("#9CADB7");
|
||||||
|
roster.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
|
||||||
|
sheet.Range(8, 1, row - 1, 4).Style.Alignment.Horizontal =
|
||||||
|
XLAlignmentHorizontalValues.Center;
|
||||||
|
sheet.Range(8, 5, row - 1, 7).Style.Alignment.WrapText = true;
|
||||||
|
|
||||||
|
sheet.Column(1).Width = 7;
|
||||||
|
sheet.Column(2).Width = 10;
|
||||||
|
sheet.Column(3).Width = 16;
|
||||||
|
sheet.Column(4).Width = 12;
|
||||||
|
sheet.Column(5).Width = 20;
|
||||||
|
sheet.Column(6).Width = 22;
|
||||||
|
sheet.Column(7).Width = 17;
|
||||||
|
sheet.SheetView.FreezeRows(7);
|
||||||
|
sheet.PageSetup.PageOrientation = XLPageOrientation.Portrait;
|
||||||
|
sheet.PageSetup.PaperSize = XLPaperSize.A4Paper;
|
||||||
|
sheet.PageSetup.FitToPages(1, 0);
|
||||||
|
sheet.PageSetup.Margins
|
||||||
|
.SetLeft(0.25).SetRight(0.25).SetTop(0.3).SetBottom(0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void StyleTitle(IXLRange range) =>
|
||||||
|
range.Style
|
||||||
|
.Font.SetBold()
|
||||||
|
.Font.SetFontSize(18)
|
||||||
|
.Font.SetFontColor(XLColor.White)
|
||||||
|
.Fill.SetBackgroundColor(Navy)
|
||||||
|
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||||
|
|
||||||
|
private static void StyleSubTitle(IXLRange range) =>
|
||||||
|
range.Style
|
||||||
|
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
|
||||||
|
.Fill.SetBackgroundColor(Pale)
|
||||||
|
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||||
|
|
||||||
|
private static void StyleHeader(IXLRange range) =>
|
||||||
|
range.Style
|
||||||
|
.Font.SetBold()
|
||||||
|
.Font.SetFontColor(XLColor.White)
|
||||||
|
.Fill.SetBackgroundColor(Teal)
|
||||||
|
.Alignment.SetHorizontal(XLAlignmentHorizontalValues.Center)
|
||||||
|
.Alignment.SetVertical(XLAlignmentVerticalValues.Center);
|
||||||
|
|
||||||
|
private static string Location(ExamSignInSessionData session) =>
|
||||||
|
string.Join(" · ", new[]
|
||||||
|
{
|
||||||
|
session.BuildingName,
|
||||||
|
session.ClassroomName
|
||||||
|
}.Where(x => !string.IsNullOrWhiteSpace(x))) is { Length: > 0 } value
|
||||||
|
? value
|
||||||
|
: "待分配考场";
|
||||||
|
|
||||||
|
private static string Invigilators(ExamSignInSessionData session) =>
|
||||||
|
session.InvigilatorNames.Count > 0
|
||||||
|
? string.Join('、', session.InvigilatorNames)
|
||||||
|
: "待分配";
|
||||||
|
|
||||||
|
private static string UniqueSheetName(
|
||||||
|
ExamSignInSessionData session,
|
||||||
|
ISet<string> usedNames)
|
||||||
|
{
|
||||||
|
var location = string.IsNullOrWhiteSpace(session.ClassroomName)
|
||||||
|
? "待分配"
|
||||||
|
: session.ClassroomName;
|
||||||
|
var raw = $"{session.ExamDate:MMdd}-{location}-{session.CourseCode}";
|
||||||
|
var invalidCharacters = new HashSet<char>(":\\/?*[]");
|
||||||
|
var sanitized = new string(raw
|
||||||
|
.Select(character => invalidCharacters.Contains(character) ? '-' : character)
|
||||||
|
.ToArray()).Trim(' ', '\'');
|
||||||
|
if (sanitized.Length == 0) sanitized = "考场";
|
||||||
|
if (sanitized.Length > 31) sanitized = sanitized[..31];
|
||||||
|
|
||||||
|
var candidate = sanitized;
|
||||||
|
var suffix = 2;
|
||||||
|
while (!usedNames.Add(candidate))
|
||||||
|
{
|
||||||
|
var marker = $"-{suffix++}";
|
||||||
|
var prefixLength = Math.Min(sanitized.Length, 31 - marker.Length);
|
||||||
|
candidate = sanitized[..prefixLength] + marker;
|
||||||
|
}
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExamSignInWorkbookData(
|
||||||
|
string PlanName,
|
||||||
|
string TermName,
|
||||||
|
IReadOnlyList<ExamSignInSessionData> Sessions);
|
||||||
|
|
||||||
|
public sealed record ExamSignInSessionData(
|
||||||
|
Guid SessionId,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
DateTime StartsAt,
|
||||||
|
DateTime EndsAt,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
string TaskNumber,
|
||||||
|
string? BuildingName,
|
||||||
|
string? ClassroomName,
|
||||||
|
IReadOnlyList<string> InvigilatorNames,
|
||||||
|
IReadOnlyList<ExamSignInStudentData> Students);
|
||||||
|
|
||||||
|
public sealed record ExamSignInStudentData(
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentNumber,
|
||||||
|
string Name,
|
||||||
|
string ClassName,
|
||||||
|
int SeatNumber = 0);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
internal static class InvigilatorCandidateQuery
|
||||||
|
{
|
||||||
|
public static IQueryable<Teacher> Create(
|
||||||
|
AppDbContext db,
|
||||||
|
IEnumerable<Guid> excludedTeacherIds) =>
|
||||||
|
db.Teachers.AsNoTracking()
|
||||||
|
.Where(x => x.Status == TeacherStatus.Active)
|
||||||
|
.WhereNotIn(excludedTeacherIds, x => x.Id);
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
.Include(x => x.Sessions)
|
.Include(x => x.Sessions)
|
||||||
.ThenInclude(x => x.TeachingTask)
|
.ThenInclude(x => x.TeachingTask)
|
||||||
.ThenInclude(x => x!.Course)
|
.ThenInclude(x => x!.Course)
|
||||||
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
.FirstOrDefaultAsync(x => x.Id == planId, cancellationToken);
|
||||||
|
|
||||||
if (plan is null)
|
if (plan is null)
|
||||||
@@ -99,7 +100,7 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
occupiedRooms.Add(new RoomOccupancy(room.Id, session.StartsAt, session.EndsAt));
|
||||||
assignedRooms++;
|
assignedRooms++;
|
||||||
messages.Add(
|
messages.Add(
|
||||||
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity}座)");
|
$"\"{session.TeachingTask!.Course!.Name}\"→{room.Name}({room.Capacity / 2}座)");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -170,10 +171,13 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var query = db.Classrooms.AsNoTracking()
|
var query = db.Classrooms.AsNoTracking()
|
||||||
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount);
|
.Where(x => x.IsEnabled && x.Capacity >= enrolledCount * 2);
|
||||||
|
|
||||||
|
var buildingIds = ParseBuildingIds(session.RequiredBuildingIds);
|
||||||
if (session.RequiredBuildingId.HasValue)
|
if (session.RequiredBuildingId.HasValue)
|
||||||
query = query.Where(x => x.BuildingId == session.RequiredBuildingId.Value);
|
buildingIds.Add(session.RequiredBuildingId.Value);
|
||||||
|
if (buildingIds.Count > 0)
|
||||||
|
query = query.Where(x => buildingIds.Contains(x.BuildingId));
|
||||||
|
|
||||||
var occupiedRoomIds = occupied
|
var occupiedRoomIds = occupied
|
||||||
.Where(x => ExamConflictRules.TimeOverlaps(
|
.Where(x => ExamConflictRules.TimeOverlaps(
|
||||||
@@ -225,11 +229,27 @@ public sealed class MakeupExamArrangementService(AppDbContext db)
|
|||||||
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
foreach (var id in dbBusyIds) busyTeacherIds.Add(id);
|
||||||
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
foreach (var id in excludeTeacherIds) busyTeacherIds.Add(id);
|
||||||
|
|
||||||
return await db.Teachers.AsNoTracking()
|
var candidates = await InvigilatorCandidateQuery
|
||||||
.Where(x => x.Status == TeacherStatus.Active)
|
.Create(db, busyTeacherIds)
|
||||||
.WhereNotIn(busyTeacherIds, x => x.Id)
|
|
||||||
.OrderBy(x => Guid.NewGuid())
|
|
||||||
.Take(needed)
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
.OrderBy(_ => Random.Shared.Next())
|
||||||
|
.Take(needed)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<Guid> ParseBuildingIds(string? json)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
|
return [];
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return System.Text.Json.JsonSerializer.Deserialize<HashSet<Guid>>(json) ?? [];
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Experiments;
|
||||||
|
|
||||||
|
internal static class CentralizedExperimentConflictQuery
|
||||||
|
{
|
||||||
|
public static IQueryable<Guid> TaskIdsForTeachers(
|
||||||
|
AppDbContext db,
|
||||||
|
IReadOnlyCollection<Guid> teacherIds) =>
|
||||||
|
db.TeachingTaskTeachers.AsNoTracking()
|
||||||
|
.WhereIn(teacherIds, x => x.TeacherId)
|
||||||
|
.Select(x => x.TeachingTaskId);
|
||||||
|
|
||||||
|
public static IQueryable<Guid> TaskIdsForClasses(
|
||||||
|
AppDbContext db,
|
||||||
|
IReadOnlyCollection<Guid> classIds) =>
|
||||||
|
db.TeachingTaskClasses.AsNoTracking()
|
||||||
|
.WhereIn(classIds, x => x.AdministrativeClassId)
|
||||||
|
.Select(x => x.TeachingTaskId);
|
||||||
|
|
||||||
|
public static IQueryable<ScheduleEntry> ScheduleEntries(
|
||||||
|
AppDbContext db,
|
||||||
|
IReadOnlyCollection<Guid> relatedTaskIds,
|
||||||
|
Guid academicTermId,
|
||||||
|
int dayOfWeek,
|
||||||
|
int week,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount) =>
|
||||||
|
db.ScheduleEntries.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.SchedulePlan!.Status == SchedulePlanStatus.Published &&
|
||||||
|
x.SchedulePlan.AcademicTermId == academicTermId &&
|
||||||
|
x.DayOfWeek == dayOfWeek &&
|
||||||
|
x.StartWeek <= week &&
|
||||||
|
x.EndWeek >= week &&
|
||||||
|
x.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < x.StartPeriod + x.PeriodCount)
|
||||||
|
.WhereIn(relatedTaskIds, x => x.TeachingTaskId);
|
||||||
|
|
||||||
|
public static IQueryable<ExperimentSession> ExperimentSessions(
|
||||||
|
AppDbContext db,
|
||||||
|
IReadOnlyCollection<Guid> relatedTaskIds,
|
||||||
|
DateOnly date,
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount) =>
|
||||||
|
db.ExperimentSessions.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
x.SessionDate == date &&
|
||||||
|
x.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < x.StartPeriod + x.PeriodCount &&
|
||||||
|
x.ExperimentProject!.ArrangementMode ==
|
||||||
|
ExperimentArrangementMode.Centralized)
|
||||||
|
.WhereIn(
|
||||||
|
relatedTaskIds,
|
||||||
|
x => x.ExperimentProject!.TeachingTaskId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
|
||||||
|
public static class ExperimentGradeAggregationService
|
||||||
|
{
|
||||||
|
public static async Task<ExperimentGradeAggregateResult> CalculateAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
Guid teachingTaskId,
|
||||||
|
IReadOnlyCollection<Guid> studentIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var sheets = await db.ExperimentGradeSheets.AsNoTracking()
|
||||||
|
.Where(x =>
|
||||||
|
x.Status ==
|
||||||
|
Domain.Academic.ExperimentGradeSheetStatus.Published &&
|
||||||
|
x.ExperimentProject!.TeachingTaskId == teachingTaskId)
|
||||||
|
.OrderBy(x => x.ExperimentProject!.Code)
|
||||||
|
.Select(x => new PublishedExperimentSheet(
|
||||||
|
x.Id,
|
||||||
|
x.ExperimentProject!.Code,
|
||||||
|
x.ExperimentProject.Name,
|
||||||
|
x.ContributionWeight,
|
||||||
|
x.Records.Select(record => new PublishedExperimentScore(
|
||||||
|
record.StudentId,
|
||||||
|
record.TotalScore)).ToList()))
|
||||||
|
.AsSplitQuery()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var scores = new Dictionary<Guid, decimal?>();
|
||||||
|
foreach (var studentId in studentIds.Distinct())
|
||||||
|
{
|
||||||
|
decimal weightedTotal = 0;
|
||||||
|
decimal totalWeight = 0;
|
||||||
|
var complete = sheets.Count > 0;
|
||||||
|
foreach (var sheet in sheets)
|
||||||
|
{
|
||||||
|
var score = sheet.Scores.FirstOrDefault(x =>
|
||||||
|
x.StudentId == studentId);
|
||||||
|
if (score?.TotalScore is not decimal totalScore)
|
||||||
|
{
|
||||||
|
complete = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
weightedTotal += totalScore * sheet.ContributionWeight;
|
||||||
|
totalWeight += sheet.ContributionWeight;
|
||||||
|
}
|
||||||
|
scores[studentId] = complete && totalWeight > 0
|
||||||
|
? Math.Round(
|
||||||
|
weightedTotal / totalWeight,
|
||||||
|
1,
|
||||||
|
MidpointRounding.AwayFromZero)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ExperimentGradeAggregateResult(
|
||||||
|
sheets.Count,
|
||||||
|
sheets.Select(x => new ExperimentGradeAggregateProject(
|
||||||
|
x.Id,
|
||||||
|
x.Code,
|
||||||
|
x.Name,
|
||||||
|
x.ContributionWeight)).ToList(),
|
||||||
|
scores);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record PublishedExperimentSheet(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
decimal ContributionWeight,
|
||||||
|
List<PublishedExperimentScore> Scores);
|
||||||
|
|
||||||
|
private sealed record PublishedExperimentScore(
|
||||||
|
Guid StudentId,
|
||||||
|
decimal? TotalScore);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record ExperimentGradeAggregateResult(
|
||||||
|
int PublishedProjectCount,
|
||||||
|
IReadOnlyList<ExperimentGradeAggregateProject> Projects,
|
||||||
|
IReadOnlyDictionary<Guid, decimal?> Scores);
|
||||||
|
|
||||||
|
public sealed record ExperimentGradeAggregateProject(
|
||||||
|
Guid Id,
|
||||||
|
string Code,
|
||||||
|
string Name,
|
||||||
|
decimal ContributionWeight);
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Grades;
|
||||||
|
|
||||||
|
public static class ExperimentGradeCalculator
|
||||||
|
{
|
||||||
|
public static bool AreWeightsValid(
|
||||||
|
decimal contributionWeight,
|
||||||
|
decimal passScore,
|
||||||
|
IEnumerable<ExperimentGradeItem> items)
|
||||||
|
{
|
||||||
|
var itemList = items.ToList();
|
||||||
|
return contributionWeight is > 0 and <= 100 &&
|
||||||
|
passScore is >= 0 and <= 100 &&
|
||||||
|
itemList.Count > 0 &&
|
||||||
|
itemList.All(item => item.Weight is > 0 and <= 100) &&
|
||||||
|
itemList.Sum(item => item.Weight) == 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decimal? CalculateTotal(
|
||||||
|
ExperimentGradeRecord record,
|
||||||
|
IReadOnlyCollection<ExperimentGradeItem> items)
|
||||||
|
{
|
||||||
|
if (record.SafetyViolation ||
|
||||||
|
record.ParticipationStatus == ExperimentParticipationStatus.Absent)
|
||||||
|
return 0;
|
||||||
|
if (record.ParticipationStatus is
|
||||||
|
ExperimentParticipationStatus.Pending or
|
||||||
|
ExperimentParticipationStatus.Excused or
|
||||||
|
ExperimentParticipationStatus.Exempt)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var scores = record.ItemScores.ToDictionary(
|
||||||
|
x => x.ExperimentGradeItemId);
|
||||||
|
decimal total = 0;
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (!scores.TryGetValue(item.Id, out var score) ||
|
||||||
|
!score.Score.HasValue)
|
||||||
|
return null;
|
||||||
|
total += score.Score.Value * item.Weight / 100;
|
||||||
|
}
|
||||||
|
return Math.Round(total, 1, MidpointRounding.AwayFromZero);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,17 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
Set<SchedulePublishJob>();
|
Set<SchedulePublishJob>();
|
||||||
public DbSet<ClassroomReservation> ClassroomReservations =>
|
public DbSet<ClassroomReservation> ClassroomReservations =>
|
||||||
Set<ClassroomReservation>();
|
Set<ClassroomReservation>();
|
||||||
|
public DbSet<ExperimentProject> ExperimentProjects => Set<ExperimentProject>();
|
||||||
|
public DbSet<ExperimentSession> ExperimentSessions => Set<ExperimentSession>();
|
||||||
|
public DbSet<ExperimentBooking> ExperimentBookings => Set<ExperimentBooking>();
|
||||||
|
public DbSet<ExperimentGradeSheet> ExperimentGradeSheets =>
|
||||||
|
Set<ExperimentGradeSheet>();
|
||||||
|
public DbSet<ExperimentGradeItem> ExperimentGradeItems =>
|
||||||
|
Set<ExperimentGradeItem>();
|
||||||
|
public DbSet<ExperimentGradeRecord> ExperimentGradeRecords =>
|
||||||
|
Set<ExperimentGradeRecord>();
|
||||||
|
public DbSet<ExperimentGradeItemScore> ExperimentGradeItemScores =>
|
||||||
|
Set<ExperimentGradeItemScore>();
|
||||||
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
public DbSet<CourseSelectionRound> CourseSelectionRounds =>
|
||||||
Set<CourseSelectionRound>();
|
Set<CourseSelectionRound>();
|
||||||
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
public DbSet<CourseSelectionRoundGrade> CourseSelectionRoundGrades =>
|
||||||
@@ -57,10 +68,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
public DbSet<GradeItemScore> GradeItemScores => Set<GradeItemScore>();
|
||||||
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
public DbSet<AttendanceSheet> AttendanceSheets => Set<AttendanceSheet>();
|
||||||
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
public DbSet<AttendanceRecord> AttendanceRecords => Set<AttendanceRecord>();
|
||||||
|
public DbSet<AttendanceCheckInAttempt> AttendanceCheckInAttempts =>
|
||||||
|
Set<AttendanceCheckInAttempt>();
|
||||||
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
public DbSet<ExamPlan> ExamPlans => Set<ExamPlan>();
|
||||||
|
public DbSet<ExamArrangementJob> ExamArrangementJobs =>
|
||||||
|
Set<ExamArrangementJob>();
|
||||||
|
public DbSet<ExamSignInExportJob> ExamSignInExportJobs =>
|
||||||
|
Set<ExamSignInExportJob>();
|
||||||
|
public DbSet<ExamPublishJob> ExamPublishJobs =>
|
||||||
|
Set<ExamPublishJob>();
|
||||||
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
public DbSet<ExamSession> ExamSessions => Set<ExamSession>();
|
||||||
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
public DbSet<ExamSessionInvigilator> ExamSessionInvigilators =>
|
||||||
Set<ExamSessionInvigilator>();
|
Set<ExamSessionInvigilator>();
|
||||||
|
public DbSet<ExamRoomAssignment> ExamRooms => Set<ExamRoomAssignment>();
|
||||||
|
public DbSet<ExamRoomSession> ExamRoomSessions => Set<ExamRoomSession>();
|
||||||
|
public DbSet<ExamSeatAssignment> ExamSeats => Set<ExamSeatAssignment>();
|
||||||
|
public DbSet<ExamRoomInvigilator> ExamRoomInvigilators =>
|
||||||
|
Set<ExamRoomInvigilator>();
|
||||||
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
|
public DbSet<MakeupExamPlan> MakeupExamPlans => Set<MakeupExamPlan>();
|
||||||
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
|
public DbSet<MakeupExamSession> MakeupExamSessions => Set<MakeupExamSession>();
|
||||||
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
|
public DbSet<MakeupExamSessionInvigilator> MakeupExamSessionInvigilators =>
|
||||||
@@ -101,6 +125,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||||
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
|
public DbSet<BackgroundJobOutboxMessage> BackgroundJobOutboxMessages =>
|
||||||
Set<BackgroundJobOutboxMessage>();
|
Set<BackgroundJobOutboxMessage>();
|
||||||
|
public DbSet<AppUpdateRelease> AppUpdateReleases =>
|
||||||
|
Set<AppUpdateRelease>();
|
||||||
|
|
||||||
protected override void ConfigureConventions(
|
protected override void ConfigureConventions(
|
||||||
ModelConfigurationBuilder configurationBuilder)
|
ModelConfigurationBuilder configurationBuilder)
|
||||||
@@ -534,6 +560,133 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentProject>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Code).HasMaxLength(40);
|
||||||
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
|
entity.Property(x => x.Description).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.Requirements).HasMaxLength(1000);
|
||||||
|
entity.HasIndex(x => new { x.TeachingTaskId, x.Code }).IsUnique();
|
||||||
|
entity.HasIndex(x => new { x.Status, x.StartDate, x.EndDate });
|
||||||
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
|
.HasForeignKey(x => x.TeachingTaskId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentSession>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Notes).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ExperimentProjectId,
|
||||||
|
x.SessionDate,
|
||||||
|
x.StartPeriod
|
||||||
|
});
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ClassroomId,
|
||||||
|
x.SessionDate,
|
||||||
|
x.Status,
|
||||||
|
x.StartPeriod
|
||||||
|
});
|
||||||
|
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Sessions)
|
||||||
|
.HasForeignKey(x => x.ExperimentProjectId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Classroom).WithMany()
|
||||||
|
.HasForeignKey(x => x.ClassroomId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentBooking>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasIndex(x => new { x.ExperimentProjectId, x.StudentId })
|
||||||
|
.IsUnique();
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ExperimentSessionId,
|
||||||
|
x.Status,
|
||||||
|
x.BookedAt
|
||||||
|
});
|
||||||
|
entity.HasOne(x => x.ExperimentProject).WithMany(x => x.Bookings)
|
||||||
|
.HasForeignKey(x => x.ExperimentProjectId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.ExperimentSession).WithMany(x => x.Bookings)
|
||||||
|
.HasForeignKey(x => x.ExperimentSessionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentGradeSheet>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.ContributionWeight).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.PassScore).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.ReviewComment).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => x.ExperimentProjectId).IsUnique();
|
||||||
|
entity.HasOne(x => x.ExperimentProject)
|
||||||
|
.WithOne(x => x.GradeSheet)
|
||||||
|
.HasForeignKey<ExperimentGradeSheet>(
|
||||||
|
x => x.ExperimentProjectId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentGradeItem>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Name).HasMaxLength(60);
|
||||||
|
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ExperimentGradeSheetId,
|
||||||
|
x.SortOrder
|
||||||
|
});
|
||||||
|
entity.HasOne(x => x.ExperimentGradeSheet)
|
||||||
|
.WithMany(x => x.Items)
|
||||||
|
.HasForeignKey(x => x.ExperimentGradeSheetId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentGradeRecord>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.TotalScore).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.SubmissionReference).HasMaxLength(500);
|
||||||
|
entity.Property(x => x.TeacherComment).HasMaxLength(500);
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ExperimentGradeSheetId,
|
||||||
|
x.StudentId
|
||||||
|
}).IsUnique();
|
||||||
|
entity.HasOne(x => x.ExperimentGradeSheet)
|
||||||
|
.WithMany(x => x.Records)
|
||||||
|
.HasForeignKey(x => x.ExperimentGradeSheetId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.ExperimentSession).WithMany()
|
||||||
|
.HasForeignKey(x => x.ExperimentSessionId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Entity<ExperimentGradeItemScore>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(x => new
|
||||||
|
{
|
||||||
|
x.ExperimentGradeRecordId,
|
||||||
|
x.ExperimentGradeItemId
|
||||||
|
});
|
||||||
|
entity.Property(x => x.Score).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.Comment).HasMaxLength(300);
|
||||||
|
entity.HasOne(x => x.ExperimentGradeRecord)
|
||||||
|
.WithMany(x => x.ItemScores)
|
||||||
|
.HasForeignKey(x => x.ExperimentGradeRecordId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.ExperimentGradeItem)
|
||||||
|
.WithMany(x => x.Scores)
|
||||||
|
.HasForeignKey(x => x.ExperimentGradeItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<CourseSelectionRound>(entity =>
|
builder.Entity<CourseSelectionRound>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
@@ -613,6 +766,8 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(60);
|
entity.Property(x => x.Name).HasMaxLength(60);
|
||||||
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
entity.Property(x => x.Weight).HasPrecision(5, 1);
|
||||||
|
entity.Property(x => x.SourceType)
|
||||||
|
.HasDefaultValue(GradeItemSourceType.Manual);
|
||||||
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
|
entity.HasIndex(x => new { x.GradeSheetId, x.SortOrder });
|
||||||
entity.HasOne(x => x.GradeSheet)
|
entity.HasOne(x => x.GradeSheet)
|
||||||
.WithMany(x => x.Items)
|
.WithMany(x => x.Items)
|
||||||
@@ -687,6 +842,29 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
.OnDelete(DeleteBehavior.Restrict);
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<AttendanceCheckInAttempt>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.FailureCode).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.DeviceIdentifierHash).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.DevicePlatform).HasMaxLength(32);
|
||||||
|
entity.Property(x => x.IpAddress).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.UserAgent).HasMaxLength(500);
|
||||||
|
entity.Property(x => x.RiskFlags).HasMaxLength(300);
|
||||||
|
entity.Property(x => x.Latitude).HasPrecision(10, 7);
|
||||||
|
entity.Property(x => x.Longitude).HasPrecision(10, 7);
|
||||||
|
entity.HasIndex(x => new { x.AttendanceSheetId, x.StudentId, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.DeviceIdentifierHash, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.IpAddress, x.CreatedAt });
|
||||||
|
entity.HasOne(x => x.AttendanceSheet)
|
||||||
|
.WithMany(x => x.CheckInAttempts)
|
||||||
|
.HasForeignKey(x => x.AttendanceSheetId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Student)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<ExamPlan>(entity =>
|
builder.Entity<ExamPlan>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
@@ -702,6 +880,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => x.TeachingTaskId);
|
entity.HasIndex(x => x.TeachingTaskId);
|
||||||
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
|
entity.HasIndex(x => new { x.ExamPlanId, x.ExamDate });
|
||||||
entity.HasIndex(x => x.RequiredBuildingId);
|
entity.HasIndex(x => x.RequiredBuildingId);
|
||||||
|
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||||
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
|
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Sessions)
|
||||||
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
@@ -719,6 +898,87 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasOne(x => x.Teacher).WithMany()
|
entity.HasOne(x => x.Teacher).WithMany()
|
||||||
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
});
|
});
|
||||||
|
builder.Entity<ExamRoomAssignment>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("ExamRooms");
|
||||||
|
entity.HasIndex(x => new { x.ExamPlanId, x.StartsAt })
|
||||||
|
.HasDatabaseName("IX_ExamRooms_Plan_Time");
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.ExamPlanId,
|
||||||
|
x.ClassroomId,
|
||||||
|
x.StartsAt
|
||||||
|
})
|
||||||
|
.HasDatabaseName("IX_ExamRooms_Plan_Room_Time");
|
||||||
|
entity.HasIndex(x => x.CourseId)
|
||||||
|
.HasDatabaseName("IX_ExamRooms_CourseId");
|
||||||
|
entity.HasOne(x => x.ExamPlan).WithMany(x => x.Rooms)
|
||||||
|
.HasForeignKey(x => x.ExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Course).WithMany()
|
||||||
|
.HasForeignKey(x => x.CourseId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Classroom).WithMany()
|
||||||
|
.HasForeignKey(x => x.ClassroomId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamRoomSession>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("ExamRoomSessions");
|
||||||
|
entity.HasKey(x => new { x.ExamRoomId, x.ExamSessionId });
|
||||||
|
entity.HasIndex(x => x.ExamSessionId)
|
||||||
|
.HasDatabaseName("IX_ExamRoomSessions_SessionId");
|
||||||
|
entity.HasOne(x => x.ExamRoom).WithMany(x => x.SessionLinks)
|
||||||
|
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.ExamSession).WithMany(x => x.RoomLinks)
|
||||||
|
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamSeatAssignment>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("ExamSeats");
|
||||||
|
entity.HasKey(x => new { x.ExamRoomId, x.StudentId });
|
||||||
|
entity.HasIndex(x => new { x.ExamSessionId, x.StudentId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UX_ExamSeats_Session_Student");
|
||||||
|
entity.HasIndex(x => x.StudentId)
|
||||||
|
.HasDatabaseName("IX_ExamSeats_StudentId");
|
||||||
|
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Seats)
|
||||||
|
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.ExamSession).WithMany(x => x.SeatAssignments)
|
||||||
|
.HasForeignKey(x => x.ExamSessionId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
entity.HasOne(x => x.Student).WithMany()
|
||||||
|
.HasForeignKey(x => x.StudentId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamRoomInvigilator>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("ExamRoomInvigilators");
|
||||||
|
entity.HasKey(x => new { x.ExamRoomId, x.TeacherId });
|
||||||
|
entity.HasIndex(x => x.TeacherId)
|
||||||
|
.HasDatabaseName("IX_ExamRoomInvigilators_TeacherId");
|
||||||
|
entity.HasOne(x => x.ExamRoom).WithMany(x => x.Invigilators)
|
||||||
|
.HasForeignKey(x => x.ExamRoomId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
entity.HasOne(x => x.Teacher).WithMany()
|
||||||
|
.HasForeignKey(x => x.TeacherId).OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamArrangementJob>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.ResultMessage).HasMaxLength(2000);
|
||||||
|
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||||
|
entity.HasIndex(x => new { x.Kind, x.ActivePlanId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UX_ExamArrangementJobs_Kind_ActivePlan");
|
||||||
|
entity.HasIndex(x => new { x.Kind, x.PlanId, x.CreatedAt })
|
||||||
|
.HasDatabaseName("IX_ExamArrangementJobs_Kind_Plan_CreatedAt");
|
||||||
|
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => x.RequestedByUserId);
|
||||||
|
});
|
||||||
|
builder.Entity<ExamSignInExportJob>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.CurrentStep).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.FileName).HasMaxLength(200);
|
||||||
|
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
|
||||||
|
entity.HasIndex(x => new { x.PlanId, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => new { x.Status, x.CreatedAt });
|
||||||
|
entity.HasIndex(x => x.RequestedByUserId);
|
||||||
|
});
|
||||||
builder.Entity<MakeupExamPlan>(entity =>
|
builder.Entity<MakeupExamPlan>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.Name).HasMaxLength(120);
|
entity.Property(x => x.Name).HasMaxLength(120);
|
||||||
@@ -734,6 +994,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => x.TeachingTaskId);
|
entity.HasIndex(x => x.TeachingTaskId);
|
||||||
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
|
entity.HasIndex(x => new { x.MakeupExamPlanId, x.ExamDate });
|
||||||
entity.HasIndex(x => x.RequiredBuildingId);
|
entity.HasIndex(x => x.RequiredBuildingId);
|
||||||
|
entity.Property(x => x.RequiredBuildingIds).HasColumnType("longtext");
|
||||||
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
|
entity.HasOne(x => x.MakeupExamPlan).WithMany(x => x.Sessions)
|
||||||
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(x => x.MakeupExamPlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
entity.HasOne(x => x.TeachingTask).WithMany()
|
entity.HasOne(x => x.TeachingTask).WithMany()
|
||||||
@@ -1020,6 +1281,42 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
entity.HasIndex(x => x.LeaseExpiresAt);
|
entity.HasIndex(x => x.LeaseExpiresAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Entity<AppUpdateRelease>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(x => x.Platform)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(20);
|
||||||
|
entity.Property(x => x.Channel)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(20);
|
||||||
|
entity.Property(x => x.Status)
|
||||||
|
.HasConversion<string>()
|
||||||
|
.HasMaxLength(20);
|
||||||
|
entity.Property(x => x.Version).HasMaxLength(40);
|
||||||
|
entity.Property(x => x.NativeVersion).HasMaxLength(40);
|
||||||
|
entity.Property(x => x.ReleaseNotes).HasMaxLength(1000);
|
||||||
|
entity.Property(x => x.FileName).HasMaxLength(180);
|
||||||
|
entity.Property(x => x.Sha256).HasMaxLength(64);
|
||||||
|
entity.Property(x => x.BundleContent).HasColumnType("longblob");
|
||||||
|
entity.Property(x => x.CreatedByUserName).HasMaxLength(100);
|
||||||
|
entity.Property(x => x.PublishedByUserName).HasMaxLength(100);
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.Platform,
|
||||||
|
x.Channel,
|
||||||
|
x.NativeVersion,
|
||||||
|
x.Version
|
||||||
|
}).IsUnique();
|
||||||
|
entity.HasIndex(x => new
|
||||||
|
{
|
||||||
|
x.Platform,
|
||||||
|
x.Channel,
|
||||||
|
x.NativeVersion,
|
||||||
|
x.Status
|
||||||
|
});
|
||||||
|
entity.HasIndex(x => x.CreatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
builder.Entity<OfficialDocument>(entity =>
|
builder.Entity<OfficialDocument>(entity =>
|
||||||
{
|
{
|
||||||
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
entity.Property(x => x.DocumentNumber).HasMaxLength(50);
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"20260727_34_course_adjustment_occurrences";
|
"20260727_34_course_adjustment_occurrences";
|
||||||
private const string AcademicPlanningPrerequisitesMigration =
|
private const string AcademicPlanningPrerequisitesMigration =
|
||||||
"20260727_35_academic_planning_prerequisites";
|
"20260727_35_academic_planning_prerequisites";
|
||||||
|
private const string ExamRoomMixingMigration =
|
||||||
|
"20260727_36_exam_room_mixing";
|
||||||
|
private const string AttendanceCheckInAuditMigration =
|
||||||
|
"20260728_37_attendance_check_in_audit";
|
||||||
|
private const string ExamPublishJobsMigration =
|
||||||
|
"20260728_38_exam_publish_jobs";
|
||||||
|
private const string ExperimentManagementMigration =
|
||||||
|
"20260728_39_experiment_management";
|
||||||
|
private const string ExperimentGradeManagementMigration =
|
||||||
|
"20260728_40_experiment_grade_management";
|
||||||
|
private const string AppUpdateReleasesMigration =
|
||||||
|
"20260729_41_app_update_releases";
|
||||||
|
|
||||||
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
public async Task MigrateAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -322,6 +334,18 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
AttendanceCheckInMigration,
|
AttendanceCheckInMigration,
|
||||||
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
|
attendanceCheckInExists ? [] : AttendanceCheckInStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
var attendanceCheckInAttemptsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'AttendanceCheckInAttempts'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
AttendanceCheckInAuditMigration,
|
||||||
|
attendanceCheckInAttemptsExist ? [] : AttendanceCheckInAuditStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var approvalTablesExist = await db.Database
|
var approvalTablesExist = await db.Database
|
||||||
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
|
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'CourseExemptions'")
|
||||||
@@ -363,6 +387,22 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
|
makeupAutoJobsExist ? [] : MakeupExamAutoJobStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
var examArrangementJobsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExamArrangementJobs'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExamArrangementJobsMigration,
|
||||||
|
examArrangementJobsExist ? [] : ExamArrangementJobStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var exportJobsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>("SELECT COUNT(*) AS \"Value\" FROM sqlite_master WHERE type = 'table' AND name = 'ExamSignInExportJobs'")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExamSignInExportJobsMigration,
|
||||||
|
exportJobsExist ? [] : ExamSignInExportJobStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
var academicTermArchivingExists = await db.Database
|
var academicTermArchivingExists = await db.Database
|
||||||
.SqlQueryRaw<int>(
|
.SqlQueryRaw<int>(
|
||||||
"""
|
"""
|
||||||
@@ -468,6 +508,75 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
AcademicPlanningPrerequisitesMigration,
|
AcademicPlanningPrerequisitesMigration,
|
||||||
coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements,
|
coursePrerequisitesExist ? [] : AcademicPlanningPrerequisiteStatements,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
var examRoomsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'ExamRooms'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExamRoomMixingMigration,
|
||||||
|
examRoomsExist ? [] : ExamRoomMixingStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var examPublishJobsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'ExamPublishJobs'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExamPublishJobsMigration,
|
||||||
|
examPublishJobsExist ? [] : ExamPublishJobStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var experimentProjectsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'ExperimentProjects'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExperimentManagementMigration,
|
||||||
|
experimentProjectsExist ? [] : ExperimentManagementStatements,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
var gradeItemSourceExists = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM pragma_table_info('GradeItems')
|
||||||
|
WHERE name = 'SourceType'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
var experimentGradeSheetsExist = await db.Database
|
||||||
|
.SqlQueryRaw<int>(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS "Value"
|
||||||
|
FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND name = 'ExperimentGradeSheets'
|
||||||
|
""")
|
||||||
|
.AnyAsync(value => value > 0, cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
ExperimentGradeManagementMigration,
|
||||||
|
(gradeItemSourceExists
|
||||||
|
? []
|
||||||
|
: ExperimentGradeItemSourceStatements)
|
||||||
|
.Concat(experimentGradeSheetsExist
|
||||||
|
? []
|
||||||
|
: ExperimentGradeManagementStatements),
|
||||||
|
cancellationToken);
|
||||||
|
await ApplyMigrationAsync(
|
||||||
|
AppUpdateReleasesMigration,
|
||||||
|
AppUpdateReleasesStatements,
|
||||||
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ApplyMigrationAsync(
|
private async Task ApplyMigrationAsync(
|
||||||
@@ -1643,6 +1752,9 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingId" TEXT NULL;
|
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingId" TEXT NULL;
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
|
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredBuildingIds" TEXT NULL;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredInvigilatorCount" INTEGER NOT NULL DEFAULT 2;
|
ALTER TABLE "ExamSessions" ADD COLUMN "RequiredInvigilatorCount" INTEGER NOT NULL DEFAULT 2;
|
||||||
""",
|
""",
|
||||||
"""
|
"""
|
||||||
@@ -1657,6 +1769,7 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"StartsAt" TEXT NOT NULL,
|
"StartsAt" TEXT NOT NULL,
|
||||||
"EndsAt" TEXT NOT NULL,
|
"EndsAt" TEXT NOT NULL,
|
||||||
"RequiredBuildingId" TEXT NULL,
|
"RequiredBuildingId" TEXT NULL,
|
||||||
|
"RequiredBuildingIds" TEXT NULL,
|
||||||
"RequiredInvigilatorCount" INTEGER NOT NULL,
|
"RequiredInvigilatorCount" INTEGER NOT NULL,
|
||||||
"Notes" TEXT NULL,
|
"Notes" TEXT NULL,
|
||||||
"CreatedAt" TEXT NOT NULL,
|
"CreatedAt" TEXT NOT NULL,
|
||||||
@@ -1894,6 +2007,87 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
|
"""ALTER TABLE "AttendanceRecords" ADD COLUMN "CheckInDistanceMeters" REAL NULL;"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] AttendanceCheckInAuditStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "AttendanceCheckInAttempts" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_AttendanceCheckInAttempts" PRIMARY KEY,
|
||||||
|
"AttendanceSheetId" TEXT NOT NULL,
|
||||||
|
"StudentId" TEXT NOT NULL,
|
||||||
|
"CheckInMethod" INTEGER NOT NULL,
|
||||||
|
"IsSuccessful" INTEGER NOT NULL,
|
||||||
|
"FailureCode" TEXT NULL,
|
||||||
|
"DeviceIdentifierHash" TEXT NULL,
|
||||||
|
"DevicePlatform" TEXT NULL,
|
||||||
|
"IpAddress" TEXT NULL,
|
||||||
|
"UserAgent" TEXT NULL,
|
||||||
|
"RiskFlags" TEXT NULL,
|
||||||
|
"Latitude" TEXT NULL,
|
||||||
|
"Longitude" TEXT NULL,
|
||||||
|
"AccuracyMeters" REAL NULL,
|
||||||
|
"DistanceMeters" REAL NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId"
|
||||||
|
FOREIGN KEY ("AttendanceSheetId") REFERENCES "AttendanceSheets" ("Id") ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_AttendanceCheckInAttempts_Students_StudentId"
|
||||||
|
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_CreatedAt"
|
||||||
|
ON "AttendanceCheckInAttempts" ("AttendanceSheetId", "StudentId", "CreatedAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt"
|
||||||
|
ON "AttendanceCheckInAttempts" ("DeviceIdentifierHash", "CreatedAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt"
|
||||||
|
ON "AttendanceCheckInAttempts" ("IpAddress", "CreatedAt");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] AppUpdateReleasesStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS "AppUpdateReleases" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_AppUpdateReleases" PRIMARY KEY,
|
||||||
|
"Platform" TEXT NOT NULL,
|
||||||
|
"Channel" TEXT NOT NULL,
|
||||||
|
"Version" TEXT NOT NULL,
|
||||||
|
"NativeVersion" TEXT NOT NULL,
|
||||||
|
"Status" TEXT NOT NULL,
|
||||||
|
"ReleaseNotes" TEXT NULL,
|
||||||
|
"FileName" TEXT NOT NULL,
|
||||||
|
"FileSize" INTEGER NOT NULL,
|
||||||
|
"Sha256" TEXT NOT NULL,
|
||||||
|
"BundleContent" BLOB NOT NULL,
|
||||||
|
"CreatedByUserName" TEXT NOT NULL,
|
||||||
|
"PublishedByUserName" TEXT NULL,
|
||||||
|
"PublishedAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS
|
||||||
|
"IX_AppUpdateReleases_Platform_Channel_NativeVersion_Version"
|
||||||
|
ON "AppUpdateReleases"
|
||||||
|
("Platform", "Channel", "NativeVersion", "Version");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS
|
||||||
|
"IX_AppUpdateReleases_Platform_Channel_NativeVersion_Status"
|
||||||
|
ON "AppUpdateReleases"
|
||||||
|
("Platform", "Channel", "NativeVersion", "Status");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_AppUpdateReleases_CreatedAt"
|
||||||
|
ON "AppUpdateReleases" ("CreatedAt");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] ApprovalTableStatements =
|
private static readonly string[] ApprovalTableStatements =
|
||||||
[
|
[
|
||||||
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
"""CREATE TABLE "CourseExemptions" ("Id" TEXT NOT NULL CONSTRAINT "PK_CourseExemptions" PRIMARY KEY, "StudentId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "Reason" TEXT NOT NULL, "Status" INTEGER NOT NULL, "ReviewComment" TEXT NULL, "SubmittedAt" TEXT NOT NULL, "ReviewedAt" TEXT NULL, "ReviewedByUserId" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "FK_CourseExemptions_Students" FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_CourseExemptions_TeachingTasks" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT);""",
|
||||||
@@ -1952,7 +2146,7 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
[
|
[
|
||||||
"""CREATE TABLE "MakeupExamPlans" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamPlans" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamPlans_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
"""CREATE TABLE "MakeupExamPlans" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "AcademicTermId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Status" INTEGER NOT NULL, "Notes" TEXT NULL, "PublishedAt" TEXT NULL, CONSTRAINT "PK_MakeupExamPlans" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamPlans_AcademicTerms_AcademicTermId" FOREIGN KEY ("AcademicTermId") REFERENCES "AcademicTerms" ("Id") ON DELETE RESTRICT);""",
|
||||||
"""CREATE INDEX "IX_MakeupExamPlans_AcademicTermId_Status" ON "MakeupExamPlans" ("AcademicTermId", "Status");""",
|
"""CREATE INDEX "IX_MakeupExamPlans_AcademicTermId_Status" ON "MakeupExamPlans" ("AcademicTermId", "Status");""",
|
||||||
"""CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "ClassroomId" TEXT NULL, "ExamDate" TEXT NOT NULL, "StartPeriod" INTEGER NOT NULL, "PeriodCount" INTEGER NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, "RequiredBuildingId" TEXT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL, "Notes" TEXT NULL, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""",
|
"""CREATE TABLE "MakeupExamSessions" ("Id" TEXT NOT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, "MakeupExamPlanId" TEXT NOT NULL, "TeachingTaskId" TEXT NOT NULL, "ClassroomId" TEXT NULL, "ExamDate" TEXT NOT NULL, "StartPeriod" INTEGER NOT NULL, "PeriodCount" INTEGER NOT NULL, "StartsAt" TEXT NOT NULL, "EndsAt" TEXT NOT NULL, "RequiredBuildingId" TEXT NULL, "RequiredBuildingIds" TEXT NULL, "RequiredInvigilatorCount" INTEGER NOT NULL, "Notes" TEXT NULL, CONSTRAINT "PK_MakeupExamSessions" PRIMARY KEY ("Id"), CONSTRAINT "FK_MakeupExamSessions_MakeupExamPlans_MakeupExamPlanId" FOREIGN KEY ("MakeupExamPlanId") REFERENCES "MakeupExamPlans" ("Id") ON DELETE CASCADE, CONSTRAINT "FK_MakeupExamSessions_TeachingTasks_TeachingTaskId" FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id") ON DELETE RESTRICT, CONSTRAINT "FK_MakeupExamSessions_Classrooms_ClassroomId" FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id") ON DELETE SET NULL, CONSTRAINT "FK_MakeupExamSessions_Buildings_RequiredBuildingId" FOREIGN KEY ("RequiredBuildingId") REFERENCES "Buildings" ("Id") ON DELETE SET NULL);""",
|
||||||
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_StartsAt" ON "MakeupExamSessions" ("MakeupExamPlanId", "StartsAt");""",
|
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_StartsAt" ON "MakeupExamSessions" ("MakeupExamPlanId", "StartsAt");""",
|
||||||
"""CREATE INDEX "IX_MakeupExamSessions_TeachingTaskId" ON "MakeupExamSessions" ("TeachingTaskId");""",
|
"""CREATE INDEX "IX_MakeupExamSessions_TeachingTaskId" ON "MakeupExamSessions" ("TeachingTaskId");""",
|
||||||
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_ExamDate" ON "MakeupExamSessions" ("MakeupExamPlanId", "ExamDate");""",
|
"""CREATE INDEX "IX_MakeupExamSessions_MakeupExamPlanId_ExamDate" ON "MakeupExamSessions" ("MakeupExamPlanId", "ExamDate");""",
|
||||||
@@ -1971,6 +2165,27 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
|
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private const string ExamArrangementJobsMigration = "ExamArrangementJobs";
|
||||||
|
|
||||||
|
private static readonly string[] ExamArrangementJobStatements =
|
||||||
|
[
|
||||||
|
"""CREATE TABLE "ExamArrangementJobs" ("Id" TEXT NOT NULL, "Kind" INTEGER NOT NULL, "PlanId" TEXT NOT NULL, "ActivePlanId" TEXT NULL, "RequestedByUserId" TEXT NULL, "SessionIdsJson" TEXT NULL, "AssignClassrooms" INTEGER NOT NULL, "AssignInvigilators" INTEGER NOT NULL, "Status" INTEGER NOT NULL, "TotalSessions" INTEGER NOT NULL, "ProcessedSessions" INTEGER NOT NULL, "CurrentStep" TEXT NULL, "ResultMessage" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "PK_ExamArrangementJobs" PRIMARY KEY ("Id"));""",
|
||||||
|
"""CREATE INDEX "IX_ExamArrangementJobs_Kind_Plan_CreatedAt" ON "ExamArrangementJobs" ("Kind", "PlanId", "CreatedAt");""",
|
||||||
|
"""CREATE INDEX "IX_ExamArrangementJobs_RequestedByUserId" ON "ExamArrangementJobs" ("RequestedByUserId");""",
|
||||||
|
"""CREATE INDEX "IX_ExamArrangementJobs_Status_CreatedAt" ON "ExamArrangementJobs" ("Status", "CreatedAt");""",
|
||||||
|
"""CREATE UNIQUE INDEX "UX_ExamArrangementJobs_Kind_ActivePlan" ON "ExamArrangementJobs" ("Kind", "ActivePlanId");""",
|
||||||
|
];
|
||||||
|
|
||||||
|
private const string ExamSignInExportJobsMigration = "ExamSignInExportJobs";
|
||||||
|
|
||||||
|
private static readonly string[] ExamSignInExportJobStatements =
|
||||||
|
[
|
||||||
|
"""CREATE TABLE "ExamSignInExportJobs" ("Id" TEXT NOT NULL, "PlanId" TEXT NOT NULL, "Status" INTEGER NOT NULL, "RequestedByUserId" TEXT NULL, "FileName" TEXT NULL, "FileBytes" BLOB NULL, "FileSize" INTEGER NOT NULL, "CurrentStep" TEXT NULL, "ErrorMessage" TEXT NULL, "StartedAt" TEXT NULL, "CompletedAt" TEXT NULL, "CreatedAt" TEXT NOT NULL, "UpdatedAt" TEXT NOT NULL, CONSTRAINT "PK_ExamSignInExportJobs" PRIMARY KEY ("Id"));""",
|
||||||
|
"""CREATE INDEX "IX_ExamSignInExportJobs_PlanId_CreatedAt" ON "ExamSignInExportJobs" ("PlanId", "CreatedAt");""",
|
||||||
|
"""CREATE INDEX "IX_ExamSignInExportJobs_Status_CreatedAt" ON "ExamSignInExportJobs" ("Status", "CreatedAt");""",
|
||||||
|
"""CREATE INDEX "IX_ExamSignInExportJobs_RequestedByUserId" ON "ExamSignInExportJobs" ("RequestedByUserId");""",
|
||||||
|
];
|
||||||
|
|
||||||
private static readonly string[] UnifiedMessageCenterStatements =
|
private static readonly string[] UnifiedMessageCenterStatements =
|
||||||
[
|
[
|
||||||
"""
|
"""
|
||||||
@@ -2132,4 +2347,371 @@ public sealed class DevelopmentSqliteMigrator(
|
|||||||
ON "CoursePrerequisites" ("PrerequisiteCourseId");
|
ON "CoursePrerequisites" ("PrerequisiteCourseId");
|
||||||
"""
|
"""
|
||||||
];
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ExamRoomMixingStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExamRooms" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamRooms" PRIMARY KEY,
|
||||||
|
"ExamPlanId" TEXT NOT NULL,
|
||||||
|
"CourseId" TEXT NOT NULL,
|
||||||
|
"ClassroomId" TEXT NOT NULL,
|
||||||
|
"ExamDate" TEXT NOT NULL,
|
||||||
|
"StartPeriod" INTEGER NOT NULL,
|
||||||
|
"PeriodCount" INTEGER NOT NULL,
|
||||||
|
"StartsAt" TEXT NOT NULL,
|
||||||
|
"EndsAt" TEXT NOT NULL,
|
||||||
|
"RequiredInvigilatorCount" INTEGER NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExamRooms_ExamPlans_ExamPlanId"
|
||||||
|
FOREIGN KEY ("ExamPlanId") REFERENCES "ExamPlans" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExamRooms_Courses_CourseId"
|
||||||
|
FOREIGN KEY ("CourseId") REFERENCES "Courses" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ExamRooms_Classrooms_ClassroomId"
|
||||||
|
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExamRooms_Plan_Time"
|
||||||
|
ON "ExamRooms" ("ExamPlanId", "StartsAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExamRooms_Plan_Room_Time"
|
||||||
|
ON "ExamRooms" ("ExamPlanId", "ClassroomId", "StartsAt");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExamRooms_CourseId"
|
||||||
|
ON "ExamRooms" ("CourseId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExamRooms_ClassroomId"
|
||||||
|
ON "ExamRooms" ("ClassroomId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExamRoomSessions" (
|
||||||
|
"ExamRoomId" TEXT NOT NULL,
|
||||||
|
"ExamSessionId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "PK_ExamRoomSessions"
|
||||||
|
PRIMARY KEY ("ExamRoomId", "ExamSessionId"),
|
||||||
|
CONSTRAINT "FK_ExamRoomSessions_ExamRooms_ExamRoomId"
|
||||||
|
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExamRoomSessions_ExamSessions_ExamSessionId"
|
||||||
|
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExamRoomSessions_SessionId"
|
||||||
|
ON "ExamRoomSessions" ("ExamSessionId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExamSeats" (
|
||||||
|
"ExamRoomId" TEXT NOT NULL,
|
||||||
|
"ExamSessionId" TEXT NOT NULL,
|
||||||
|
"StudentId" TEXT NOT NULL,
|
||||||
|
"SeatNumber" INTEGER NOT NULL,
|
||||||
|
CONSTRAINT "PK_ExamSeats"
|
||||||
|
PRIMARY KEY ("ExamRoomId", "StudentId"),
|
||||||
|
CONSTRAINT "FK_ExamSeats_ExamRooms_ExamRoomId"
|
||||||
|
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExamSeats_ExamSessions_ExamSessionId"
|
||||||
|
FOREIGN KEY ("ExamSessionId") REFERENCES "ExamSessions" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ExamSeats_Students_StudentId"
|
||||||
|
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "UX_ExamSeats_Session_Student"
|
||||||
|
ON "ExamSeats" ("ExamSessionId", "StudentId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExamSeats_StudentId"
|
||||||
|
ON "ExamSeats" ("StudentId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExamRoomInvigilators" (
|
||||||
|
"ExamRoomId" TEXT NOT NULL,
|
||||||
|
"TeacherId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "PK_ExamRoomInvigilators"
|
||||||
|
PRIMARY KEY ("ExamRoomId", "TeacherId"),
|
||||||
|
CONSTRAINT "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId"
|
||||||
|
FOREIGN KEY ("ExamRoomId") REFERENCES "ExamRooms" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExamRoomInvigilators_Teachers_TeacherId"
|
||||||
|
FOREIGN KEY ("TeacherId") REFERENCES "Teachers" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExamRoomInvigilators_TeacherId"
|
||||||
|
ON "ExamRoomInvigilators" ("TeacherId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ExamPublishJobStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExamPublishJobs" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExamPublishJobs" PRIMARY KEY,
|
||||||
|
"Kind" INTEGER NOT NULL,
|
||||||
|
"PlanId" TEXT NOT NULL,
|
||||||
|
"ActivePlanId" TEXT NULL,
|
||||||
|
"RequestedByUserId" TEXT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"CurrentStep" TEXT NULL,
|
||||||
|
"ErrorMessage" TEXT NULL,
|
||||||
|
"StartedAt" TEXT NULL,
|
||||||
|
"CompletedAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ExperimentManagementStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentProjects" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentProjects" PRIMARY KEY,
|
||||||
|
"TeachingTaskId" TEXT NOT NULL,
|
||||||
|
"Code" TEXT NOT NULL,
|
||||||
|
"Name" TEXT NOT NULL,
|
||||||
|
"ArrangementMode" INTEGER NOT NULL,
|
||||||
|
"Description" TEXT NULL,
|
||||||
|
"Requirements" TEXT NULL,
|
||||||
|
"StartDate" TEXT NOT NULL,
|
||||||
|
"EndDate" TEXT NOT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"PublishedAt" TEXT NULL,
|
||||||
|
"ClosedAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentProjects_TeachingTasks_TeachingTaskId"
|
||||||
|
FOREIGN KEY ("TeachingTaskId") REFERENCES "TeachingTasks" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_ExperimentProjects_TeachingTaskId_Code"
|
||||||
|
ON "ExperimentProjects" ("TeachingTaskId", "Code");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentProjects_Status_StartDate_EndDate"
|
||||||
|
ON "ExperimentProjects" ("Status", "StartDate", "EndDate");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentSessions" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentSessions" PRIMARY KEY,
|
||||||
|
"ExperimentProjectId" TEXT NOT NULL,
|
||||||
|
"ClassroomId" TEXT NOT NULL,
|
||||||
|
"SessionDate" TEXT NOT NULL,
|
||||||
|
"StartPeriod" INTEGER NOT NULL,
|
||||||
|
"PeriodCount" INTEGER NOT NULL,
|
||||||
|
"Capacity" INTEGER NOT NULL,
|
||||||
|
"ReservedCount" INTEGER NOT NULL,
|
||||||
|
"Notes" TEXT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"CancelledAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentSessions_ExperimentProjects_ExperimentProjectId"
|
||||||
|
FOREIGN KEY ("ExperimentProjectId")
|
||||||
|
REFERENCES "ExperimentProjects" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExperimentSessions_Classrooms_ClassroomId"
|
||||||
|
FOREIGN KEY ("ClassroomId") REFERENCES "Classrooms" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentSessions_ExperimentProjectId_SessionDate_StartPeriod"
|
||||||
|
ON "ExperimentSessions" (
|
||||||
|
"ExperimentProjectId",
|
||||||
|
"SessionDate",
|
||||||
|
"StartPeriod"
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentSessions_ClassroomId_SessionDate_Status_StartPeriod"
|
||||||
|
ON "ExperimentSessions" (
|
||||||
|
"ClassroomId",
|
||||||
|
"SessionDate",
|
||||||
|
"Status",
|
||||||
|
"StartPeriod"
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentBookings" (
|
||||||
|
"Id" TEXT NOT NULL CONSTRAINT "PK_ExperimentBookings" PRIMARY KEY,
|
||||||
|
"ExperimentProjectId" TEXT NOT NULL,
|
||||||
|
"ExperimentSessionId" TEXT NOT NULL,
|
||||||
|
"StudentId" TEXT NOT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"BookedAt" TEXT NOT NULL,
|
||||||
|
"CancelledAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentBookings_ExperimentProjects_ExperimentProjectId"
|
||||||
|
FOREIGN KEY ("ExperimentProjectId")
|
||||||
|
REFERENCES "ExperimentProjects" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExperimentBookings_ExperimentSessions_ExperimentSessionId"
|
||||||
|
FOREIGN KEY ("ExperimentSessionId")
|
||||||
|
REFERENCES "ExperimentSessions" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ExperimentBookings_Students_StudentId"
|
||||||
|
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_ExperimentBookings_ExperimentProjectId_StudentId"
|
||||||
|
ON "ExperimentBookings" ("ExperimentProjectId", "StudentId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentBookings_ExperimentSessionId_Status_BookedAt"
|
||||||
|
ON "ExperimentBookings" (
|
||||||
|
"ExperimentSessionId",
|
||||||
|
"Status",
|
||||||
|
"BookedAt"
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ExperimentGradeItemSourceStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
ALTER TABLE "GradeItems"
|
||||||
|
ADD "SourceType" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
ALTER TABLE "GradeItems"
|
||||||
|
ADD "SourceSnapshotAt" TEXT NULL;
|
||||||
|
"""
|
||||||
|
];
|
||||||
|
|
||||||
|
private static readonly string[] ExperimentGradeManagementStatements =
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentGradeSheets" (
|
||||||
|
"Id" TEXT NOT NULL
|
||||||
|
CONSTRAINT "PK_ExperimentGradeSheets" PRIMARY KEY,
|
||||||
|
"ExperimentProjectId" TEXT NOT NULL,
|
||||||
|
"ContributionWeight" TEXT NOT NULL,
|
||||||
|
"PassScore" TEXT NOT NULL,
|
||||||
|
"Status" INTEGER NOT NULL,
|
||||||
|
"ReviewComment" TEXT NULL,
|
||||||
|
"SubmittedAt" TEXT NULL,
|
||||||
|
"ReviewedAt" TEXT NULL,
|
||||||
|
"PublishedAt" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentGradeSheets_ExperimentProjects_ExperimentProjectId"
|
||||||
|
FOREIGN KEY ("ExperimentProjectId")
|
||||||
|
REFERENCES "ExperimentProjects" ("Id")
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_ExperimentGradeSheets_ExperimentProjectId"
|
||||||
|
ON "ExperimentGradeSheets" ("ExperimentProjectId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentGradeItems" (
|
||||||
|
"Id" TEXT NOT NULL
|
||||||
|
CONSTRAINT "PK_ExperimentGradeItems" PRIMARY KEY,
|
||||||
|
"ExperimentGradeSheetId" TEXT NOT NULL,
|
||||||
|
"Name" TEXT NOT NULL,
|
||||||
|
"Kind" INTEGER NOT NULL,
|
||||||
|
"Weight" TEXT NOT NULL,
|
||||||
|
"SortOrder" INTEGER NOT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentGradeItems_ExperimentGradeSheets_ExperimentGradeSheetId"
|
||||||
|
FOREIGN KEY ("ExperimentGradeSheetId")
|
||||||
|
REFERENCES "ExperimentGradeSheets" ("Id")
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentGradeItems_ExperimentGradeSheetId_SortOrder"
|
||||||
|
ON "ExperimentGradeItems" ("ExperimentGradeSheetId", "SortOrder");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentGradeRecords" (
|
||||||
|
"Id" TEXT NOT NULL
|
||||||
|
CONSTRAINT "PK_ExperimentGradeRecords" PRIMARY KEY,
|
||||||
|
"ExperimentGradeSheetId" TEXT NOT NULL,
|
||||||
|
"StudentId" TEXT NOT NULL,
|
||||||
|
"ExperimentSessionId" TEXT NULL,
|
||||||
|
"ParticipationStatus" INTEGER NOT NULL,
|
||||||
|
"TotalScore" TEXT NULL,
|
||||||
|
"IsPassed" INTEGER NULL,
|
||||||
|
"SafetyViolation" INTEGER NOT NULL,
|
||||||
|
"AttemptNumber" INTEGER NOT NULL,
|
||||||
|
"SubmissionReference" TEXT NULL,
|
||||||
|
"SubmittedAt" TEXT NULL,
|
||||||
|
"IsLate" INTEGER NOT NULL,
|
||||||
|
"TeacherComment" TEXT NULL,
|
||||||
|
"CreatedAt" TEXT NOT NULL,
|
||||||
|
"UpdatedAt" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "FK_ExperimentGradeRecords_ExperimentGradeSheets_ExperimentGradeSheetId"
|
||||||
|
FOREIGN KEY ("ExperimentGradeSheetId")
|
||||||
|
REFERENCES "ExperimentGradeSheets" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExperimentGradeRecords_Students_StudentId"
|
||||||
|
FOREIGN KEY ("StudentId") REFERENCES "Students" ("Id")
|
||||||
|
ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT "FK_ExperimentGradeRecords_ExperimentSessions_ExperimentSessionId"
|
||||||
|
FOREIGN KEY ("ExperimentSessionId")
|
||||||
|
REFERENCES "ExperimentSessions" ("Id")
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX "IX_ExperimentGradeRecords_ExperimentGradeSheetId_StudentId"
|
||||||
|
ON "ExperimentGradeRecords" ("ExperimentGradeSheetId", "StudentId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentGradeRecords_StudentId"
|
||||||
|
ON "ExperimentGradeRecords" ("StudentId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentGradeRecords_ExperimentSessionId"
|
||||||
|
ON "ExperimentGradeRecords" ("ExperimentSessionId");
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE TABLE "ExperimentGradeItemScores" (
|
||||||
|
"ExperimentGradeRecordId" TEXT NOT NULL,
|
||||||
|
"ExperimentGradeItemId" TEXT NOT NULL,
|
||||||
|
"Score" TEXT NULL,
|
||||||
|
"Comment" TEXT NULL,
|
||||||
|
CONSTRAINT "PK_ExperimentGradeItemScores"
|
||||||
|
PRIMARY KEY (
|
||||||
|
"ExperimentGradeRecordId",
|
||||||
|
"ExperimentGradeItemId"
|
||||||
|
),
|
||||||
|
CONSTRAINT "FK_ExperimentGradeItemScores_ExperimentGradeRecords_ExperimentGradeRecordId"
|
||||||
|
FOREIGN KEY ("ExperimentGradeRecordId")
|
||||||
|
REFERENCES "ExperimentGradeRecords" ("Id")
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT "FK_ExperimentGradeItemScores_ExperimentGradeItems_ExperimentGradeItemId"
|
||||||
|
FOREIGN KEY ("ExperimentGradeItemId")
|
||||||
|
REFERENCES "ExperimentGradeItems" ("Id")
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
""",
|
||||||
|
"""
|
||||||
|
CREATE INDEX "IX_ExperimentGradeItemScores_ExperimentGradeItemId"
|
||||||
|
ON "ExperimentGradeItemScores" ("ExperimentGradeItemId");
|
||||||
|
"""
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260727105716_ExamRoomMixing.Designer.cs
Generated
+5139
File diff suppressed because it is too large
Load Diff
+196
@@ -0,0 +1,196 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExamRoomMixing : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExamRooms",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExamPlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CourseId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExamDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||||
|
StartPeriod = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PeriodCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
StartsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
EndsAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
RequiredInvigilatorCount = table.Column<int>(type: "int", 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_ExamRooms", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamRooms_Classrooms_ClassroomId",
|
||||||
|
column: x => x.ClassroomId,
|
||||||
|
principalTable: "Classrooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamRooms_Courses_CourseId",
|
||||||
|
column: x => x.CourseId,
|
||||||
|
principalTable: "Courses",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamRooms_ExamPlans_ExamPlanId",
|
||||||
|
column: x => x.ExamPlanId,
|
||||||
|
principalTable: "ExamPlans",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExamRoomInvigilators",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
TeacherId = table.Column<Guid>(type: "char(36)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExamRoomInvigilators", x => new { x.ExamRoomId, x.TeacherId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamRoomInvigilators_ExamRooms_ExamRoomId",
|
||||||
|
column: x => x.ExamRoomId,
|
||||||
|
principalTable: "ExamRooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamRoomInvigilators_Teachers_TeacherId",
|
||||||
|
column: x => x.TeacherId,
|
||||||
|
principalTable: "Teachers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExamRoomSessions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExamRoomSessions", x => new { x.ExamRoomId, x.ExamSessionId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamRoomSessions_ExamRooms_ExamRoomId",
|
||||||
|
column: x => x.ExamRoomId,
|
||||||
|
principalTable: "ExamRooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamRoomSessions_ExamSessions_ExamSessionId",
|
||||||
|
column: x => x.ExamSessionId,
|
||||||
|
principalTable: "ExamSessions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExamSeats",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExamRoomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExamSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
SeatNumber = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExamSeats", x => new { x.ExamRoomId, x.StudentId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamSeats_ExamRooms_ExamRoomId",
|
||||||
|
column: x => x.ExamRoomId,
|
||||||
|
principalTable: "ExamRooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamSeats_ExamSessions_ExamSessionId",
|
||||||
|
column: x => x.ExamSessionId,
|
||||||
|
principalTable: "ExamSessions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExamSeats_Students_StudentId",
|
||||||
|
column: x => x.StudentId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamRoomInvigilators_TeacherId",
|
||||||
|
table: "ExamRoomInvigilators",
|
||||||
|
column: "TeacherId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamRooms_ClassroomId",
|
||||||
|
table: "ExamRooms",
|
||||||
|
column: "ClassroomId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamRooms_CourseId",
|
||||||
|
table: "ExamRooms",
|
||||||
|
column: "CourseId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamRooms_Plan_Room_Time",
|
||||||
|
table: "ExamRooms",
|
||||||
|
columns: new[] { "ExamPlanId", "ClassroomId", "StartsAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamRooms_Plan_Time",
|
||||||
|
table: "ExamRooms",
|
||||||
|
columns: new[] { "ExamPlanId", "StartsAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamRoomSessions_SessionId",
|
||||||
|
table: "ExamRoomSessions",
|
||||||
|
column: "ExamSessionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamSeats_StudentId",
|
||||||
|
table: "ExamSeats",
|
||||||
|
column: "StudentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_ExamSeats_Session_Student",
|
||||||
|
table: "ExamSeats",
|
||||||
|
columns: new[] { "ExamSessionId", "StudentId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExamRoomInvigilators");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExamRoomSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExamSeats");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExamRooms");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5215
File diff suppressed because it is too large
Load Diff
+72
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExamArrangementJobs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExamArrangementJobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Kind = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ActivePlanId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
SessionIdsJson = table.Column<string>(type: "longtext", nullable: true),
|
||||||
|
AssignClassrooms = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
AssignInvigilators = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TotalSessions = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ProcessedSessions = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||||
|
ResultMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExamArrangementJobs", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamArrangementJobs_Kind_Plan_CreatedAt",
|
||||||
|
table: "ExamArrangementJobs",
|
||||||
|
columns: new[] { "Kind", "PlanId", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamArrangementJobs_RequestedByUserId",
|
||||||
|
table: "ExamArrangementJobs",
|
||||||
|
column: "RequestedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamArrangementJobs_Status_CreatedAt",
|
||||||
|
table: "ExamArrangementJobs",
|
||||||
|
columns: new[] { "Status", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "UX_ExamArrangementJobs_Kind_ActivePlan",
|
||||||
|
table: "ExamArrangementJobs",
|
||||||
|
columns: new[] { "Kind", "ActivePlanId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExamArrangementJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5221
File diff suppressed because it is too large
Load Diff
+38
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRequiredBuildingIds : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RequiredBuildingIds",
|
||||||
|
table: "MakeupExamSessions",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "RequiredBuildingIds",
|
||||||
|
table: "ExamSessions",
|
||||||
|
type: "longtext",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RequiredBuildingIds",
|
||||||
|
table: "MakeupExamSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RequiredBuildingIds",
|
||||||
|
table: "ExamSessions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5277
File diff suppressed because it is too large
Load Diff
+61
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExamSignInExportJobs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExamSignInExportJobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
FileName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||||
|
FileBytes = table.Column<byte[]>(type: "longblob", nullable: true),
|
||||||
|
FileSize = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CurrentStep = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: true),
|
||||||
|
ErrorMessage = table.Column<string>(type: "varchar(2000)", maxLength: 2000, nullable: true),
|
||||||
|
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExamSignInExportJobs", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamSignInExportJobs_PlanId_CreatedAt",
|
||||||
|
table: "ExamSignInExportJobs",
|
||||||
|
columns: new[] { "PlanId", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamSignInExportJobs_RequestedByUserId",
|
||||||
|
table: "ExamSignInExportJobs",
|
||||||
|
column: "RequestedByUserId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExamSignInExportJobs_Status_CreatedAt",
|
||||||
|
table: "ExamSignInExportJobs",
|
||||||
|
columns: new[] { "Status", "CreatedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExamSignInExportJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5321
File diff suppressed because it is too large
Load Diff
+45
@@ -0,0 +1,45 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExamPublishJobs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExamPublishJobs",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Kind = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PlanId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ActivePlanId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
RequestedByUserId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CurrentStep = table.Column<string>(type: "longtext", nullable: true),
|
||||||
|
ErrorMessage = table.Column<string>(type: "longtext", nullable: true),
|
||||||
|
StartedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExamPublishJobs", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExamPublishJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5417
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AttendanceCheckInAudit : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AttendanceCheckInAttempts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
AttendanceSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
CheckInMethod = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsSuccessful = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
FailureCode = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||||
|
DeviceIdentifierHash = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||||
|
DevicePlatform = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: true),
|
||||||
|
IpAddress = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: true),
|
||||||
|
UserAgent = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
RiskFlags = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
|
||||||
|
Latitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
|
||||||
|
Longitude = table.Column<decimal>(type: "decimal(10,7)", precision: 10, scale: 7, nullable: true),
|
||||||
|
AccuracyMeters = table.Column<double>(type: "double", nullable: true),
|
||||||
|
DistanceMeters = table.Column<double>(type: "double", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AttendanceCheckInAttempts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AttendanceCheckInAttempts_AttendanceSheets_AttendanceSheetId",
|
||||||
|
column: x => x.AttendanceSheetId,
|
||||||
|
principalTable: "AttendanceSheets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AttendanceCheckInAttempts_Students_StudentId",
|
||||||
|
column: x => x.StudentId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AttendanceCheckInAttempts_AttendanceSheetId_StudentId_Create~",
|
||||||
|
table: "AttendanceCheckInAttempts",
|
||||||
|
columns: new[] { "AttendanceSheetId", "StudentId", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AttendanceCheckInAttempts_DeviceIdentifierHash_CreatedAt",
|
||||||
|
table: "AttendanceCheckInAttempts",
|
||||||
|
columns: new[] { "DeviceIdentifierHash", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AttendanceCheckInAttempts_IpAddress_CreatedAt",
|
||||||
|
table: "AttendanceCheckInAttempts",
|
||||||
|
columns: new[] { "IpAddress", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AttendanceCheckInAttempts_StudentId",
|
||||||
|
table: "AttendanceCheckInAttempts",
|
||||||
|
column: "StudentId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AttendanceCheckInAttempts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5641
File diff suppressed because it is too large
Load Diff
+170
@@ -0,0 +1,170 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExperimentManagement : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentProjects",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
TeachingTaskId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Code = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||||
|
Name = table.Column<string>(type: "varchar(120)", maxLength: 120, nullable: false),
|
||||||
|
ArrangementMode = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
Requirements = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
StartDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||||
|
EndDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentProjects", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentProjects_TeachingTasks_TeachingTaskId",
|
||||||
|
column: x => x.TeachingTaskId,
|
||||||
|
principalTable: "TeachingTasks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentSessions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ClassroomId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
SessionDate = table.Column<DateTime>(type: "date", nullable: false),
|
||||||
|
StartPeriod = table.Column<int>(type: "int", nullable: false),
|
||||||
|
PeriodCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Capacity = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ReservedCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentSessions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentSessions_Classrooms_ClassroomId",
|
||||||
|
column: x => x.ClassroomId,
|
||||||
|
principalTable: "Classrooms",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentSessions_ExperimentProjects_ExperimentProjectId",
|
||||||
|
column: x => x.ExperimentProjectId,
|
||||||
|
principalTable: "ExperimentProjects",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentBookings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
BookedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
CancelledAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentBookings", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentBookings_ExperimentProjects_ExperimentProjectId",
|
||||||
|
column: x => x.ExperimentProjectId,
|
||||||
|
principalTable: "ExperimentProjects",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentBookings_ExperimentSessions_ExperimentSessionId",
|
||||||
|
column: x => x.ExperimentSessionId,
|
||||||
|
principalTable: "ExperimentSessions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentBookings_Students_StudentId",
|
||||||
|
column: x => x.StudentId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentBookings_ExperimentProjectId_StudentId",
|
||||||
|
table: "ExperimentBookings",
|
||||||
|
columns: new[] { "ExperimentProjectId", "StudentId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentBookings_ExperimentSessionId_Status_BookedAt",
|
||||||
|
table: "ExperimentBookings",
|
||||||
|
columns: new[] { "ExperimentSessionId", "Status", "BookedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentBookings_StudentId",
|
||||||
|
table: "ExperimentBookings",
|
||||||
|
column: "StudentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_Status_StartDate_EndDate",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "Status", "StartDate", "EndDate" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentProjects_TeachingTaskId_Code",
|
||||||
|
table: "ExperimentProjects",
|
||||||
|
columns: new[] { "TeachingTaskId", "Code" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentSessions_ClassroomId_SessionDate_Status_StartPeriod",
|
||||||
|
table: "ExperimentSessions",
|
||||||
|
columns: new[] { "ClassroomId", "SessionDate", "Status", "StartPeriod" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentSessions_ExperimentProjectId_SessionDate_StartPeri~",
|
||||||
|
table: "ExperimentSessions",
|
||||||
|
columns: new[] { "ExperimentProjectId", "SessionDate", "StartPeriod" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentBookings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentProjects");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5905
File diff suppressed because it is too large
Load Diff
+208
@@ -0,0 +1,208 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class ExperimentGradeManagement : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "SourceSnapshotAt",
|
||||||
|
table: "GradeItems",
|
||||||
|
type: "datetime(6)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SourceType",
|
||||||
|
table: "GradeItems",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 1);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentGradeSheets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentProjectId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ContributionWeight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||||
|
PassScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ReviewComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
ReviewedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentGradeSheets", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentGradeSheets_ExperimentProjects_ExperimentProjectId",
|
||||||
|
column: x => x.ExperimentProjectId,
|
||||||
|
principalTable: "ExperimentProjects",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentGradeItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentGradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "varchar(60)", maxLength: 60, nullable: false),
|
||||||
|
Kind = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Weight = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: false),
|
||||||
|
SortOrder = table.Column<int>(type: "int", 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_ExperimentGradeItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentGradeItems_ExperimentGradeSheets_ExperimentGradeSh~",
|
||||||
|
column: x => x.ExperimentGradeSheetId,
|
||||||
|
principalTable: "ExperimentGradeSheets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentGradeRecords",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentGradeSheetId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
StudentId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentSessionId = table.Column<Guid>(type: "char(36)", nullable: true),
|
||||||
|
ParticipationStatus = table.Column<int>(type: "int", nullable: false),
|
||||||
|
TotalScore = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||||
|
IsPassed = table.Column<bool>(type: "tinyint(1)", nullable: true),
|
||||||
|
SafetyViolation = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
AttemptNumber = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SubmissionReference = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
SubmittedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
IsLate = table.Column<bool>(type: "tinyint(1)", nullable: false),
|
||||||
|
TeacherComment = table.Column<string>(type: "varchar(500)", maxLength: 500, nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentGradeRecords", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentGradeRecords_ExperimentGradeSheets_ExperimentGrade~",
|
||||||
|
column: x => x.ExperimentGradeSheetId,
|
||||||
|
principalTable: "ExperimentGradeSheets",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentGradeRecords_ExperimentSessions_ExperimentSessionId",
|
||||||
|
column: x => x.ExperimentSessionId,
|
||||||
|
principalTable: "ExperimentSessions",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentGradeRecords_Students_StudentId",
|
||||||
|
column: x => x.StudentId,
|
||||||
|
principalTable: "Students",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ExperimentGradeItemScores",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
ExperimentGradeRecordId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
ExperimentGradeItemId = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Score = table.Column<decimal>(type: "decimal(5,1)", precision: 5, scale: 1, nullable: true),
|
||||||
|
Comment = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ExperimentGradeItemScores", x => new { x.ExperimentGradeRecordId, x.ExperimentGradeItemId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentGradeItemScores_ExperimentGradeItems_ExperimentGra~",
|
||||||
|
column: x => x.ExperimentGradeItemId,
|
||||||
|
principalTable: "ExperimentGradeItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_ExperimentGradeItemScores_ExperimentGradeRecords_ExperimentG~",
|
||||||
|
column: x => x.ExperimentGradeRecordId,
|
||||||
|
principalTable: "ExperimentGradeRecords",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentGradeItems_ExperimentGradeSheetId_SortOrder",
|
||||||
|
table: "ExperimentGradeItems",
|
||||||
|
columns: new[] { "ExperimentGradeSheetId", "SortOrder" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentGradeItemScores_ExperimentGradeItemId",
|
||||||
|
table: "ExperimentGradeItemScores",
|
||||||
|
column: "ExperimentGradeItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentGradeRecords_ExperimentGradeSheetId_StudentId",
|
||||||
|
table: "ExperimentGradeRecords",
|
||||||
|
columns: new[] { "ExperimentGradeSheetId", "StudentId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentGradeRecords_ExperimentSessionId",
|
||||||
|
table: "ExperimentGradeRecords",
|
||||||
|
column: "ExperimentSessionId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentGradeRecords_StudentId",
|
||||||
|
table: "ExperimentGradeRecords",
|
||||||
|
column: "StudentId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ExperimentGradeSheets_ExperimentProjectId",
|
||||||
|
table: "ExperimentGradeSheets",
|
||||||
|
column: "ExperimentProjectId",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentGradeItemScores");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentGradeItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentGradeRecords");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ExperimentGradeSheets");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourceSnapshotAt",
|
||||||
|
table: "GradeItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourceType",
|
||||||
|
table: "GradeItems");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5987
File diff suppressed because it is too large
Load Diff
+65
@@ -0,0 +1,65 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AppUpdateReleases : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AppUpdateReleases",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "char(36)", nullable: false),
|
||||||
|
Platform = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||||
|
Channel = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||||
|
Version = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||||
|
NativeVersion = table.Column<string>(type: "varchar(40)", maxLength: 40, nullable: false),
|
||||||
|
Status = table.Column<string>(type: "varchar(20)", maxLength: 20, nullable: false),
|
||||||
|
ReleaseNotes = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: true),
|
||||||
|
FileName = table.Column<string>(type: "varchar(180)", maxLength: 180, nullable: false),
|
||||||
|
FileSize = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
Sha256 = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false),
|
||||||
|
BundleContent = table.Column<byte[]>(type: "longblob", nullable: false),
|
||||||
|
CreatedByUserName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
|
||||||
|
PublishedByUserName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: true),
|
||||||
|
PublishedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AppUpdateReleases", x => x.Id);
|
||||||
|
})
|
||||||
|
.Annotation("MySQL:Charset", "utf8mb4");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppUpdateReleases_CreatedAt",
|
||||||
|
table: "AppUpdateReleases",
|
||||||
|
column: "CreatedAt");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppUpdateReleases_Platform_Channel_NativeVersion_Status",
|
||||||
|
table: "AppUpdateReleases",
|
||||||
|
columns: new[] { "Platform", "Channel", "NativeVersion", "Status" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AppUpdateReleases_Platform_Channel_NativeVersion_Version",
|
||||||
|
table: "AppUpdateReleases",
|
||||||
|
columns: new[] { "Platform", "Channel", "NativeVersion", "Version" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AppUpdateReleases");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1068
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
@@ -34,6 +35,7 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
.Include(x => x.Classes)
|
.Include(x => x.Classes)
|
||||||
.ThenInclude(x => x.AdministrativeClass)
|
.ThenInclude(x => x.AdministrativeClass)
|
||||||
.ThenInclude(x => x!.Students)
|
.ThenInclude(x => x!.Students)
|
||||||
|
.Include(x => x.Course)
|
||||||
.OrderByDescending(x => x.Classes.Count + x.Teachers.Count)
|
.OrderByDescending(x => x.Classes.Count + x.Teachers.Count)
|
||||||
.ThenByDescending(x => x.Capacity)
|
.ThenByDescending(x => x.Capacity)
|
||||||
.ThenBy(x => x.TaskNumber)
|
.ThenBy(x => x.TaskNumber)
|
||||||
@@ -72,10 +74,43 @@ public sealed class AutomaticScheduleGenerator(AppDbContext db)
|
|||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
constraints.TryGetValue(task.Id, out var constraint);
|
constraints.TryGetValue(task.Id, out var constraint);
|
||||||
|
if (!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||||
|
task.Course!,
|
||||||
|
task.StartWeek,
|
||||||
|
task.EndWeek,
|
||||||
|
out var requiredWeeklyHours))
|
||||||
|
{
|
||||||
|
messages.Add(
|
||||||
|
$"{task.TaskNumber} · {task.Name} 的普通排课学时不能按授课周次整除,请调整教学任务周次。");
|
||||||
|
processedTasks++;
|
||||||
|
if (reportProgress is not null)
|
||||||
|
{
|
||||||
|
await reportProgress(
|
||||||
|
new(tasks.Count, processedTasks, created, completedTasks),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var scheduledHours = entries
|
var scheduledHours = entries
|
||||||
.Where(x => x.TeachingTaskId == task.Id)
|
.Where(x => x.TeachingTaskId == task.Id)
|
||||||
.Sum(x => x.PeriodCount);
|
.Sum(x => x.PeriodCount);
|
||||||
var remainingHours = Math.Max(0, task.WeeklyHours - scheduledHours);
|
if (scheduledHours > requiredWeeklyHours)
|
||||||
|
{
|
||||||
|
messages.Add(
|
||||||
|
$"{task.TaskNumber} · {task.Name} 已安排每周 {scheduledHours} 学时," +
|
||||||
|
$"普通课表只需 {requiredWeeklyHours} 学时;请删除已包含的实践学时。");
|
||||||
|
processedTasks++;
|
||||||
|
if (reportProgress is not null)
|
||||||
|
{
|
||||||
|
await reportProgress(
|
||||||
|
new(tasks.Count, processedTasks, created, completedTasks),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainingHours = requiredWeeklyHours - scheduledHours;
|
||||||
if (remainingHours == 0)
|
if (remainingHours == 0)
|
||||||
{
|
{
|
||||||
completedTasks++;
|
completedTasks++;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
namespace Jiaowu.Api.Infrastructure.Scheduling;
|
||||||
@@ -179,19 +180,68 @@ public sealed class SchedulePlanPublisher(AppDbContext db)
|
|||||||
x.AcademicTermId == plan.AcademicTermId &&
|
x.AcademicTermId == plan.AcademicTermId &&
|
||||||
x.Status == TeachingTaskStatus.Published &&
|
x.Status == TeachingTaskStatus.Published &&
|
||||||
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
|
x.SchedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||||
.Select(x => new { x.Id, x.TaskNumber, x.Name, x.WeeklyHours })
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.Id,
|
||||||
|
x.TaskNumber,
|
||||||
|
x.Name,
|
||||||
|
x.StartWeek,
|
||||||
|
x.EndWeek,
|
||||||
|
CourseTotalHours = x.Course!.TotalHours,
|
||||||
|
CoursePracticeHours = x.Course.PracticeHours
|
||||||
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
var invalidHours = requiredTasks.FirstOrDefault(task =>
|
||||||
|
!TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||||
|
task.CourseTotalHours,
|
||||||
|
task.CoursePracticeHours,
|
||||||
|
task.StartWeek,
|
||||||
|
task.EndWeek,
|
||||||
|
out _));
|
||||||
|
if (invalidHours is not null)
|
||||||
|
{
|
||||||
|
throw new SchedulePublishValidationException(
|
||||||
|
$"{invalidHours.TaskNumber} · {invalidHours.Name} 的普通排课学时" +
|
||||||
|
"不能按授课周次整除,请先调整教学任务周次。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var requiredWeeklyHours = requiredTasks
|
||||||
|
.Select(task =>
|
||||||
|
{
|
||||||
|
TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||||
|
task.CourseTotalHours,
|
||||||
|
task.CoursePracticeHours,
|
||||||
|
task.StartWeek,
|
||||||
|
task.EndWeek,
|
||||||
|
out var hours);
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
Task = task,
|
||||||
|
Hours = hours
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
var scheduledHours = plan.Entries
|
var scheduledHours = plan.Entries
|
||||||
.GroupBy(x => x.TeachingTaskId)
|
.GroupBy(x => x.TeachingTaskId)
|
||||||
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
|
.ToDictionary(group => group.Key, group => group.Sum(x => x.PeriodCount));
|
||||||
var incomplete = requiredTasks.FirstOrDefault(task =>
|
var incomplete = requiredWeeklyHours.FirstOrDefault(item =>
|
||||||
!scheduledHours.TryGetValue(task.Id, out var hours) ||
|
scheduledHours.GetValueOrDefault(item.Task.Id) < item.Hours);
|
||||||
hours < task.WeeklyHours);
|
|
||||||
if (incomplete is not null)
|
if (incomplete is not null)
|
||||||
{
|
{
|
||||||
throw new SchedulePublishValidationException(
|
throw new SchedulePublishValidationException(
|
||||||
$"{incomplete.TaskNumber} · {incomplete.Name} 尚未达到每周 " +
|
$"{incomplete.Task.TaskNumber} · {incomplete.Task.Name} 尚未达到每周 " +
|
||||||
$"{incomplete.WeeklyHours} 学时,不能发布。");
|
$"{incomplete.Hours} 个普通排课学时,不能发布。");
|
||||||
|
}
|
||||||
|
|
||||||
|
var excessive = requiredWeeklyHours.FirstOrDefault(item =>
|
||||||
|
scheduledHours.GetValueOrDefault(item.Task.Id) > item.Hours);
|
||||||
|
if (excessive is not null)
|
||||||
|
{
|
||||||
|
var actualHours = scheduledHours.GetValueOrDefault(excessive.Task.Id);
|
||||||
|
throw new SchedulePublishValidationException(
|
||||||
|
$"{excessive.Task.TaskNumber} · {excessive.Task.Name} 已安排每周 " +
|
||||||
|
$"{actualHours} 学时,普通课表应为 {excessive.Hours} 学时;" +
|
||||||
|
"请删除已包含的实践学时后再发布。");
|
||||||
}
|
}
|
||||||
|
|
||||||
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
await reportProgress(3, "检查教师、行政班和教室冲突", cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
|
||||||
|
public static class AttendanceCheckInChallenge
|
||||||
|
{
|
||||||
|
public const int LifetimeSeconds = 20;
|
||||||
|
public const int RefreshSeconds = 10;
|
||||||
|
private const int MaximumClockSkewSeconds = 2;
|
||||||
|
private const string Base36Digits = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||||
|
|
||||||
|
public static AttendanceCheckInChallengeResult Create(
|
||||||
|
Guid attendanceSheetId,
|
||||||
|
string secret,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var issuedAt = new DateTimeOffset(
|
||||||
|
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc));
|
||||||
|
var payload = CreatePayload(
|
||||||
|
attendanceSheetId,
|
||||||
|
ToBase36(issuedAt.ToUnixTimeSeconds()));
|
||||||
|
var signature = CreateSignature(payload, secret);
|
||||||
|
return new AttendanceCheckInChallengeResult(
|
||||||
|
$"{payload}.{signature}",
|
||||||
|
issuedAt.UtcDateTime,
|
||||||
|
issuedAt.AddSeconds(LifetimeSeconds).UtcDateTime,
|
||||||
|
issuedAt.AddSeconds(RefreshSeconds).UtcDateTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryReadSheetId(string? token, out Guid attendanceSheetId)
|
||||||
|
{
|
||||||
|
attendanceSheetId = Guid.Empty;
|
||||||
|
if (!TryParse(token, out var sheetIdText, out _, out _))
|
||||||
|
return false;
|
||||||
|
return Guid.TryParseExact(sheetIdText, "N", out attendanceSheetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsValid(
|
||||||
|
string? token,
|
||||||
|
Guid attendanceSheetId,
|
||||||
|
string? secret,
|
||||||
|
DateTime nowUtc)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(secret) ||
|
||||||
|
!TryParse(token, out var sheetIdText, out var issuedAtText, out var signature) ||
|
||||||
|
!Guid.TryParseExact(sheetIdText, "N", out var tokenSheetId) ||
|
||||||
|
tokenSheetId != attendanceSheetId ||
|
||||||
|
!TryParseBase36(issuedAtText, out var issuedAtUnixSeconds))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nowUnixSeconds = new DateTimeOffset(
|
||||||
|
DateTime.SpecifyKind(nowUtc, DateTimeKind.Utc)).ToUnixTimeSeconds();
|
||||||
|
var ageSeconds = nowUnixSeconds - issuedAtUnixSeconds;
|
||||||
|
if (ageSeconds < -MaximumClockSkewSeconds || ageSeconds > LifetimeSeconds)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
byte[] providedSignature;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
providedSignature = WebEncoders.Base64UrlDecode(signature);
|
||||||
|
}
|
||||||
|
catch (FormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var expectedSignature = WebEncoders.Base64UrlDecode(
|
||||||
|
CreateSignature(
|
||||||
|
CreatePayload(attendanceSheetId, issuedAtText),
|
||||||
|
secret));
|
||||||
|
return CryptographicOperations.FixedTimeEquals(
|
||||||
|
providedSignature,
|
||||||
|
expectedSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreatePayload(Guid attendanceSheetId, string issuedAtText) =>
|
||||||
|
$"{attendanceSheetId:N}.{issuedAtText}";
|
||||||
|
|
||||||
|
private static string CreateSignature(string payload, string secret)
|
||||||
|
{
|
||||||
|
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||||
|
var digest = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
|
||||||
|
return WebEncoders.Base64UrlEncode(digest[..16]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParse(
|
||||||
|
string? token,
|
||||||
|
out string sheetIdText,
|
||||||
|
out string issuedAtText,
|
||||||
|
out string signature)
|
||||||
|
{
|
||||||
|
sheetIdText = string.Empty;
|
||||||
|
issuedAtText = string.Empty;
|
||||||
|
signature = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(token) || token.Length > 64)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var parts = token.Split('.');
|
||||||
|
if (parts.Length != 3 ||
|
||||||
|
parts[0].Length != 32 ||
|
||||||
|
parts[1].Length is < 1 or > 12 ||
|
||||||
|
parts[2].Length != 22)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sheetIdText = parts[0];
|
||||||
|
issuedAtText = parts[1];
|
||||||
|
signature = parts[2];
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToBase36(long value)
|
||||||
|
{
|
||||||
|
if (value == 0) return "0";
|
||||||
|
Span<char> buffer = stackalloc char[13];
|
||||||
|
var index = buffer.Length;
|
||||||
|
while (value > 0)
|
||||||
|
{
|
||||||
|
buffer[--index] = Base36Digits[(int)(value % 36)];
|
||||||
|
value /= 36;
|
||||||
|
}
|
||||||
|
return new string(buffer[index..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseBase36(string value, out long result)
|
||||||
|
{
|
||||||
|
result = 0;
|
||||||
|
foreach (var character in value)
|
||||||
|
{
|
||||||
|
var digit = Base36Digits.IndexOf(
|
||||||
|
char.ToLower(character, CultureInfo.InvariantCulture));
|
||||||
|
if (digit < 0) return false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = checked(result * 36 + digit);
|
||||||
|
}
|
||||||
|
catch (OverflowException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record AttendanceCheckInChallengeResult(
|
||||||
|
string Token,
|
||||||
|
DateTime IssuedAt,
|
||||||
|
DateTime ExpiresAt,
|
||||||
|
DateTime RefreshAt);
|
||||||
@@ -7,15 +7,75 @@ public static class TeachingTaskHours
|
|||||||
public static int Calculate(int startWeek, int endWeek, int weeklyHours) =>
|
public static int Calculate(int startWeek, int endWeek, int weeklyHours) =>
|
||||||
endWeek < startWeek ? 0 : (endWeek - startWeek + 1) * weeklyHours;
|
endWeek < startWeek ? 0 : (endWeek - startWeek + 1) * weeklyHours;
|
||||||
|
|
||||||
|
public static int RegularScheduleHours(Course course) =>
|
||||||
|
RegularScheduleHours(course.TotalHours, course.PracticeHours);
|
||||||
|
|
||||||
|
public static int RegularScheduleHours(int totalHours, int practiceHours) =>
|
||||||
|
Math.Max(0, totalHours - practiceHours);
|
||||||
|
|
||||||
|
public static int TargetHours(
|
||||||
|
Course course,
|
||||||
|
TeachingTaskSchedulingMode schedulingMode) =>
|
||||||
|
schedulingMode == TeachingTaskSchedulingMode.Flexible
|
||||||
|
? course.TotalHours
|
||||||
|
: RegularScheduleHours(course);
|
||||||
|
|
||||||
|
public static bool TryResolveRegularWeeklyHours(
|
||||||
|
Course course,
|
||||||
|
int startWeek,
|
||||||
|
int endWeek,
|
||||||
|
out int weeklyHours) =>
|
||||||
|
TryResolveRegularWeeklyHours(
|
||||||
|
course.TotalHours,
|
||||||
|
course.PracticeHours,
|
||||||
|
startWeek,
|
||||||
|
endWeek,
|
||||||
|
out weeklyHours);
|
||||||
|
|
||||||
|
public static bool TryResolveRegularWeeklyHours(
|
||||||
|
int totalHours,
|
||||||
|
int practiceHours,
|
||||||
|
int startWeek,
|
||||||
|
int endWeek,
|
||||||
|
out int weeklyHours)
|
||||||
|
{
|
||||||
|
weeklyHours = 0;
|
||||||
|
var weekCount = endWeek - startWeek + 1;
|
||||||
|
var regularHours = RegularScheduleHours(totalHours, practiceHours);
|
||||||
|
if (weekCount <= 0 || regularHours % weekCount != 0) return false;
|
||||||
|
|
||||||
|
weeklyHours = regularHours / weekCount;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static string? Validate(
|
public static string? Validate(
|
||||||
Course course,
|
Course course,
|
||||||
int startWeek,
|
int startWeek,
|
||||||
int endWeek,
|
int endWeek,
|
||||||
int weeklyHours)
|
int weeklyHours,
|
||||||
|
TeachingTaskSchedulingMode schedulingMode =
|
||||||
|
TeachingTaskSchedulingMode.Standard)
|
||||||
{
|
{
|
||||||
var plannedHours = Calculate(startWeek, endWeek, weeklyHours);
|
var plannedHours = Calculate(startWeek, endWeek, weeklyHours);
|
||||||
return plannedHours == course.TotalHours
|
var targetHours = TargetHours(course, schedulingMode);
|
||||||
? null
|
if (plannedHours == targetHours) return null;
|
||||||
: $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 {startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 {plannedHours} 学时。请调整授课周次或周学时。";
|
|
||||||
|
if (schedulingMode == TeachingTaskSchedulingMode.Standard)
|
||||||
|
{
|
||||||
|
if (targetHours == 0)
|
||||||
|
{
|
||||||
|
return $"课程“{course.Name}”的 {course.TotalHours} 学时均为实践学时," +
|
||||||
|
"无需进入普通课表;请将授课方式设为“非排时课程”,并在实验管理中安排。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"课程“{course.Name}”总学时为 {course.TotalHours},其中实践学时 " +
|
||||||
|
$"{course.PracticeHours},普通课表应安排 {targetHours} 学时;当前第 " +
|
||||||
|
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
|
||||||
|
$"{plannedHours} 学时。请调整授课周次或周学时。";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"课程“{course.Name}”总学时为 {course.TotalHours};当前第 " +
|
||||||
|
$"{startWeek}—{endWeek} 周、每周 {weeklyHours} 学时,共 " +
|
||||||
|
$"{plannedHours} 学时。请调整授课周次或周学时。";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Teaching;
|
namespace Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
|
||||||
@@ -15,4 +16,107 @@ public static class TeachingTaskRosterQuery
|
|||||||
enrollment.StudentId == student.Id &&
|
enrollment.StudentId == student.Id &&
|
||||||
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
enrollment.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId));
|
enrollment.CourseSelectionOffering!.TeachingTaskId == teachingTaskId));
|
||||||
|
|
||||||
|
public static IQueryable<Guid> TaskIdsForStudent(AppDbContext db, Guid studentId)
|
||||||
|
{
|
||||||
|
var classTaskIds = db.Students
|
||||||
|
.Where(student =>
|
||||||
|
student.Id == studentId &&
|
||||||
|
student.Status == StudentStatus.Active)
|
||||||
|
.SelectMany(student => db.TeachingTaskClasses
|
||||||
|
.Where(assignment =>
|
||||||
|
assignment.AdministrativeClassId ==
|
||||||
|
student.AdministrativeClassId)
|
||||||
|
.Select(assignment => assignment.TeachingTaskId));
|
||||||
|
var enrolledTaskIds = db.CourseEnrollments
|
||||||
|
.Where(enrollment =>
|
||||||
|
enrollment.StudentId == studentId &&
|
||||||
|
enrollment.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
|
.Select(enrollment =>
|
||||||
|
enrollment.CourseSelectionOffering!.TeachingTaskId);
|
||||||
|
return classTaskIds.Concat(enrolledTaskIds).Distinct();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<IReadOnlyList<TeachingTaskRosterEntry>> LoadForTasksAsync(
|
||||||
|
AppDbContext db,
|
||||||
|
IEnumerable<Guid> teachingTaskIds,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var taskIds = teachingTaskIds.Distinct().ToArray();
|
||||||
|
if (taskIds.Length == 0) return [];
|
||||||
|
|
||||||
|
var assignments = await db.TeachingTaskClasses.AsNoTracking()
|
||||||
|
.WhereIn(taskIds, x => x.TeachingTaskId)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.AdministrativeClassId
|
||||||
|
})
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var classIds = assignments
|
||||||
|
.Select(x => x.AdministrativeClassId)
|
||||||
|
.Distinct()
|
||||||
|
.ToArray();
|
||||||
|
var classStudents = classIds.Length == 0
|
||||||
|
? []
|
||||||
|
: await db.Students.AsNoTracking()
|
||||||
|
.Where(x => x.Status == StudentStatus.Active)
|
||||||
|
.WhereIn(classIds, x => x.AdministrativeClassId)
|
||||||
|
.Select(x => new RosterStudent(
|
||||||
|
x.Id,
|
||||||
|
x.StudentNumber,
|
||||||
|
x.Name,
|
||||||
|
x.AdministrativeClassId,
|
||||||
|
x.AdministrativeClass!.Name))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var enrolledStudents = await db.CourseEnrollments.AsNoTracking()
|
||||||
|
.Where(x => x.Status == CourseEnrollmentStatus.Enrolled)
|
||||||
|
.WhereIn(taskIds, x => x.CourseSelectionOffering!.TeachingTaskId)
|
||||||
|
.Select(x => new TeachingTaskRosterEntry(
|
||||||
|
x.CourseSelectionOffering!.TeachingTaskId,
|
||||||
|
x.StudentId,
|
||||||
|
x.Student!.StudentNumber,
|
||||||
|
x.Student.Name,
|
||||||
|
x.Student.AdministrativeClass!.Name))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var result = enrolledStudents.ToDictionary(
|
||||||
|
x => (x.TeachingTaskId, x.StudentId));
|
||||||
|
var studentsByClass = classStudents.ToLookup(x => x.AdministrativeClassId);
|
||||||
|
foreach (var assignment in assignments)
|
||||||
|
{
|
||||||
|
foreach (var student in studentsByClass[assignment.AdministrativeClassId])
|
||||||
|
{
|
||||||
|
result.TryAdd(
|
||||||
|
(assignment.TeachingTaskId, student.StudentId),
|
||||||
|
new TeachingTaskRosterEntry(
|
||||||
|
assignment.TeachingTaskId,
|
||||||
|
student.StudentId,
|
||||||
|
student.StudentNumber,
|
||||||
|
student.Name,
|
||||||
|
student.ClassName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Values
|
||||||
|
.OrderBy(x => x.TeachingTaskId)
|
||||||
|
.ThenBy(x => x.StudentNumber)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record RosterStudent(
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentNumber,
|
||||||
|
string Name,
|
||||||
|
Guid AdministrativeClassId,
|
||||||
|
string ClassName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record TeachingTaskRosterEntry(
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentNumber,
|
||||||
|
string Name,
|
||||||
|
string ClassName);
|
||||||
|
|||||||
@@ -58,6 +58,17 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
occupiedIds.UnionWith(examRoomIds);
|
occupiedIds.UnionWith(examRoomIds);
|
||||||
|
|
||||||
|
var mixedExamRoomIds = await db.ExamRooms.AsNoTracking()
|
||||||
|
.Where(room =>
|
||||||
|
room.ExamPlan!.AcademicTermId == term.Id &&
|
||||||
|
room.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||||
|
room.ExamDate == reservationDate &&
|
||||||
|
room.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < room.StartPeriod + room.PeriodCount)
|
||||||
|
.Select(room => room.ClassroomId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(mixedExamRoomIds);
|
||||||
|
|
||||||
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
|
var makeupExamRoomIds = await db.MakeupExamSessions.AsNoTracking()
|
||||||
.Where(session =>
|
.Where(session =>
|
||||||
session.ClassroomId.HasValue &&
|
session.ClassroomId.HasValue &&
|
||||||
@@ -70,6 +81,20 @@ public sealed class ClassroomReservationAvailabilityService(AppDbContext db)
|
|||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
occupiedIds.UnionWith(makeupExamRoomIds);
|
occupiedIds.UnionWith(makeupExamRoomIds);
|
||||||
|
|
||||||
|
var experimentRoomIds = await db.ExperimentSessions.AsNoTracking()
|
||||||
|
.Where(session =>
|
||||||
|
session.ExperimentProject!.TeachingTask!.AcademicTermId ==
|
||||||
|
term.Id &&
|
||||||
|
session.ExperimentProject.Status !=
|
||||||
|
ExperimentProjectStatus.Closed &&
|
||||||
|
session.Status == ExperimentSessionStatus.Scheduled &&
|
||||||
|
session.SessionDate == reservationDate &&
|
||||||
|
session.StartPeriod < startPeriod + periodCount &&
|
||||||
|
startPeriod < session.StartPeriod + session.PeriodCount)
|
||||||
|
.Select(session => session.ClassroomId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
occupiedIds.UnionWith(experimentRoomIds);
|
||||||
|
|
||||||
var reservationQuery = db.ClassroomReservations.AsNoTracking()
|
var reservationQuery = db.ClassroomReservations.AsNoTracking()
|
||||||
.Where(reservation =>
|
.Where(reservation =>
|
||||||
reservation.AcademicTermId == term.Id &&
|
reservation.AcademicTermId == term.Id &&
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ public sealed class PersonalCalendarService(
|
|||||||
|
|
||||||
AddScheduledCourses(builder, timetable);
|
AddScheduledCourses(builder, timetable);
|
||||||
AddExamEntries(builder, timetable, teacher is not null);
|
AddExamEntries(builder, timetable, teacher is not null);
|
||||||
|
AddExperimentEntries(builder, timetable);
|
||||||
AddFlexibleCourseReminders(builder, timetable);
|
AddFlexibleCourseReminders(builder, timetable);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +247,46 @@ public sealed class PersonalCalendarService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AddExperimentEntries(
|
||||||
|
IcsBuilder builder,
|
||||||
|
TimetableData timetable)
|
||||||
|
{
|
||||||
|
var slots = timetable.Slots.ToDictionary(x => x.PeriodNumber);
|
||||||
|
foreach (var entry in timetable.ExperimentEntries)
|
||||||
|
{
|
||||||
|
if (entry.ExperimentDate is not DateOnly experimentDate ||
|
||||||
|
!slots.TryGetValue(entry.StartPeriod, out var startSlot) ||
|
||||||
|
!slots.TryGetValue(
|
||||||
|
entry.StartPeriod + entry.PeriodCount - 1,
|
||||||
|
out var endSlot))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var arrangement = entry.ExperimentArrangementMode switch
|
||||||
|
{
|
||||||
|
ExperimentArrangementMode.Centralized => "集中安排",
|
||||||
|
ExperimentArrangementMode.SelfScheduled => "自主预约",
|
||||||
|
_ => "实验安排"
|
||||||
|
};
|
||||||
|
builder.AddTimedEvent(
|
||||||
|
$"experiment-{entry.Id:N}@jiaowu",
|
||||||
|
$"[实验] {entry.ExperimentProjectName ?? entry.CourseName}",
|
||||||
|
ToUtc(experimentDate, startSlot.StartsAt),
|
||||||
|
ToUtc(experimentDate, endSlot.EndsAt),
|
||||||
|
JoinLocation(
|
||||||
|
entry.CampusName,
|
||||||
|
entry.BuildingName,
|
||||||
|
entry.ClassroomName),
|
||||||
|
JoinDescription(
|
||||||
|
$"{entry.ExperimentProjectCode} · {arrangement}",
|
||||||
|
entry.CourseName,
|
||||||
|
entry.TaskName,
|
||||||
|
$"教师:{string.Join('、', entry.TeacherNames)}",
|
||||||
|
entry.Notes),
|
||||||
|
"实验",
|
||||||
|
entry.UpdatedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
|
private async Task<List<PersonalMakeupSession>> LoadMakeupSessionsAsync(
|
||||||
Guid? studentId,
|
Guid? studentId,
|
||||||
Guid? teacherId,
|
Guid? teacherId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Timetables;
|
namespace Jiaowu.Api.Infrastructure.Timetables;
|
||||||
@@ -117,6 +118,8 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
// Load exam sessions for student/teacher timetables
|
// Load exam sessions for student/teacher timetables
|
||||||
var examEntries = await LoadExamEntriesAsync(
|
var examEntries = await LoadExamEntriesAsync(
|
||||||
resourceType, resourceId, term.Id, studentId, slots, cancellationToken);
|
resourceType, resourceId, term.Id, studentId, slots, cancellationToken);
|
||||||
|
var experimentEntries = await LoadExperimentEntriesAsync(
|
||||||
|
resourceType, term, studentId, cancellationToken);
|
||||||
|
|
||||||
return new TimetableData(
|
return new TimetableData(
|
||||||
term,
|
term,
|
||||||
@@ -127,7 +130,10 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
slots,
|
slots,
|
||||||
entries,
|
entries,
|
||||||
flexibleCourses,
|
flexibleCourses,
|
||||||
examEntries);
|
examEntries)
|
||||||
|
{
|
||||||
|
ExperimentEntries = experimentEntries
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<TimetableSubjectDto?> LoadSubjectAsync(
|
private async Task<TimetableSubjectDto?> LoadSubjectAsync(
|
||||||
@@ -307,69 +313,188 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
IReadOnlyList<TimetableSlotDto> slots,
|
IReadOnlyList<TimetableSlotDto> slots,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (resourceType == TimetableResourceType.Classroom) return [];
|
|
||||||
|
|
||||||
var slotLookup = slots.ToDictionary(x => x.PeriodNumber);
|
var slotLookup = slots.ToDictionary(x => x.PeriodNumber);
|
||||||
|
var result = new List<TimetableEntryDto>();
|
||||||
|
|
||||||
IQueryable<ExamSession> source = db.ExamSessions.AsNoTracking()
|
// ── Legacy sessions (ExamSessions with direct ClassroomId) ──
|
||||||
|
var legacyQuery = db.ExamSessions.AsNoTracking()
|
||||||
.Where(x => x.ExamPlan!.AcademicTermId == academicTermId &&
|
.Where(x => x.ExamPlan!.AcademicTermId == academicTermId &&
|
||||||
x.ExamPlan.Status == ExamPlanStatus.Published &&
|
x.ExamPlan.Status == ExamPlanStatus.Published &&
|
||||||
x.ClassroomId != null);
|
x.ClassroomId != null);
|
||||||
|
|
||||||
if (resourceType == TimetableResourceType.Teacher)
|
legacyQuery = ApplyLegacyResourceFilter(
|
||||||
{
|
legacyQuery, resourceType, resourceId, studentId);
|
||||||
source = source.Where(x =>
|
|
||||||
x.Invigilators.Any(i => i.TeacherId == resourceId));
|
|
||||||
}
|
|
||||||
else if (studentId.HasValue)
|
|
||||||
{
|
|
||||||
source = source.Where(x =>
|
|
||||||
db.CourseEnrollments.Any(e =>
|
|
||||||
e.StudentId == studentId.Value &&
|
|
||||||
e.Status == CourseEnrollmentStatus.Enrolled &&
|
|
||||||
e.CourseSelectionOffering!.TeachingTaskId == x.TeachingTaskId));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Class timetable: exams for the class's teaching tasks
|
|
||||||
source = source.Where(x =>
|
|
||||||
x.TeachingTask!.Classes.Any(c =>
|
|
||||||
c.AdministrativeClassId == resourceId));
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessions = await source
|
var legacySessions = await legacyQuery
|
||||||
.OrderBy(x => x.ExamDate)
|
.OrderBy(x => x.ExamDate)
|
||||||
.ThenBy(x => x.StartPeriod)
|
.ThenBy(x => x.StartPeriod)
|
||||||
.Select(x => new
|
.Select(x => new ExamSessionProjection(
|
||||||
{
|
|
||||||
x.Id,
|
x.Id,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
x.TeachingTask!.TaskNumber,
|
x.TeachingTask!.TaskNumber,
|
||||||
TaskName = x.TeachingTask.Name,
|
x.TeachingTask.Name,
|
||||||
CourseCode = x.TeachingTask.Course!.Code,
|
x.TeachingTask.Course!.Code,
|
||||||
CourseName = x.TeachingTask.Course.Name,
|
x.TeachingTask.Course.Name,
|
||||||
TeacherNames = x.TeachingTask.Teachers
|
x.TeachingTask.Teachers
|
||||||
.OrderByDescending(t => t.IsPrimary)
|
.OrderByDescending(t => t.IsPrimary)
|
||||||
.Select(t => t.Teacher!.Name),
|
.Select(t => t.Teacher!.Name).ToList(),
|
||||||
ClassNames = x.TeachingTask.Classes
|
x.TeachingTask.Classes
|
||||||
.Select(c => c.AdministrativeClass!.Name),
|
.Select(c => c.AdministrativeClass!.Name).ToList(),
|
||||||
ClassroomName = x.Classroom!.Name,
|
x.Classroom!.Name,
|
||||||
BuildingName = x.Classroom.Building!.Name,
|
x.Classroom.Building!.Name,
|
||||||
CampusName = x.Classroom.Building.Campus!.Name,
|
x.Classroom.Building.Campus!.Name,
|
||||||
x.ExamDate,
|
x.ExamDate,
|
||||||
x.StartPeriod,
|
x.StartPeriod,
|
||||||
x.PeriodCount,
|
x.PeriodCount,
|
||||||
x.ExamPlan!.Name,
|
x.ExamPlan!.Name,
|
||||||
InvigilatorNames = x.Invigilators
|
x.Invigilators.Select(i => i.Teacher!.Name).ToList(),
|
||||||
.Select(i => i.Teacher!.Name),
|
|
||||||
x.Notes,
|
x.Notes,
|
||||||
x.UpdatedAt
|
x.UpdatedAt))
|
||||||
})
|
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
result.AddRange(legacySessions.Select(x =>
|
||||||
|
MapToEntryDto(x, slotLookup)));
|
||||||
|
|
||||||
|
// ── Mixed-room sessions (ExamRoomAssignment) ──
|
||||||
|
// Load all mixed rooms for this term into memory, then filter
|
||||||
|
var allMixedRooms = await db.ExamRooms.AsNoTracking()
|
||||||
|
.Where(room =>
|
||||||
|
room.ExamPlan!.AcademicTermId == academicTermId &&
|
||||||
|
room.ExamPlan.Status == ExamPlanStatus.Published)
|
||||||
|
.Include(room => room.ExamPlan)
|
||||||
|
.Include(room => room.Classroom)
|
||||||
|
.ThenInclude(c => c!.Building)
|
||||||
|
.ThenInclude(b => b!.Campus)
|
||||||
|
.Include(room => room.Invigilators)
|
||||||
|
.ThenInclude(i => i.Teacher)
|
||||||
|
.Include(room => room.SessionLinks)
|
||||||
|
.ThenInclude(link => link.ExamSession)
|
||||||
|
.ThenInclude(s => s!.TeachingTask)
|
||||||
|
.ThenInclude(t => t!.Course)
|
||||||
|
.Include(room => room.SessionLinks)
|
||||||
|
.ThenInclude(link => link.ExamSession)
|
||||||
|
.ThenInclude(s => s!.TeachingTask)
|
||||||
|
.ThenInclude(t => t!.Teachers)
|
||||||
|
.ThenInclude(tt => tt.Teacher)
|
||||||
|
.Include(room => room.SessionLinks)
|
||||||
|
.ThenInclude(link => link.ExamSession)
|
||||||
|
.ThenInclude(s => s!.TeachingTask)
|
||||||
|
.ThenInclude(t => t!.Classes)
|
||||||
|
.ThenInclude(tc => tc.AdministrativeClass)
|
||||||
|
.Include(room => room.Seats)
|
||||||
|
.AsSplitQuery()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var room in allMixedRooms)
|
||||||
|
{
|
||||||
|
foreach (var link in room.SessionLinks)
|
||||||
|
{
|
||||||
|
var session = link.ExamSession;
|
||||||
|
if (session == null || session.TeachingTask == null) continue;
|
||||||
|
|
||||||
|
// Apply resource filter in memory
|
||||||
|
if (!MatchesMixedResource(
|
||||||
|
room, session, resourceType, resourceId, studentId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
result.Add(MapToEntryDto(new ExamSessionProjection(
|
||||||
|
room.Id,
|
||||||
|
session.TeachingTaskId,
|
||||||
|
session.TeachingTask.TaskNumber,
|
||||||
|
session.TeachingTask.Name,
|
||||||
|
session.TeachingTask.Course!.Code,
|
||||||
|
session.TeachingTask.Course.Name,
|
||||||
|
session.TeachingTask.Teachers
|
||||||
|
.OrderByDescending(t => t.IsPrimary)
|
||||||
|
.Select(t => t.Teacher!.Name).ToList(),
|
||||||
|
session.TeachingTask.Classes
|
||||||
|
.Select(c => c.AdministrativeClass!.Name).ToList(),
|
||||||
|
room.Classroom!.Name,
|
||||||
|
room.Classroom.Building!.Name,
|
||||||
|
room.Classroom.Building.Campus!.Name,
|
||||||
|
room.ExamDate,
|
||||||
|
room.StartPeriod,
|
||||||
|
room.PeriodCount,
|
||||||
|
room.ExamPlan!.Name,
|
||||||
|
room.Invigilators
|
||||||
|
.Select(i => i.Teacher!.Name).ToList(),
|
||||||
|
null,
|
||||||
|
room.UpdatedAt), slotLookup));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<TimetableEntryDto>> LoadExperimentEntriesAsync(
|
||||||
|
TimetableResourceType resourceType,
|
||||||
|
TimetableTermDto term,
|
||||||
|
Guid? studentId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (resourceType != TimetableResourceType.Class || !studentId.HasValue)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var rosterTaskIds = TeachingTaskRosterQuery.TaskIdsForStudent(
|
||||||
|
db,
|
||||||
|
studentId.Value);
|
||||||
|
var sessions = await db.ExperimentSessions.AsNoTracking()
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Where(x =>
|
||||||
|
x.ExperimentProject!.TeachingTask!.AcademicTermId == term.Id &&
|
||||||
|
(x.ExperimentProject.Status ==
|
||||||
|
ExperimentProjectStatus.Published ||
|
||||||
|
x.ExperimentProject.Status ==
|
||||||
|
ExperimentProjectStatus.Closed) &&
|
||||||
|
x.Status == ExperimentSessionStatus.Scheduled)
|
||||||
|
.Where(x =>
|
||||||
|
(x.ExperimentProject!.ArrangementMode ==
|
||||||
|
ExperimentArrangementMode.Centralized &&
|
||||||
|
rosterTaskIds.Contains(
|
||||||
|
x.ExperimentProject.TeachingTaskId)) ||
|
||||||
|
(x.ExperimentProject.ArrangementMode ==
|
||||||
|
ExperimentArrangementMode.SelfScheduled &&
|
||||||
|
x.Bookings.Any(booking =>
|
||||||
|
booking.StudentId == studentId.Value &&
|
||||||
|
booking.Status == ExperimentBookingStatus.Booked)))
|
||||||
|
.OrderBy(x => x.SessionDate)
|
||||||
|
.ThenBy(x => x.StartPeriod)
|
||||||
|
.ThenBy(x => x.ExperimentProject!.Code)
|
||||||
|
.Select(x => new ExperimentSessionProjection(
|
||||||
|
x.Id,
|
||||||
|
x.ExperimentProjectId,
|
||||||
|
x.ExperimentProject!.Code,
|
||||||
|
x.ExperimentProject.Name,
|
||||||
|
x.ExperimentProject.ArrangementMode,
|
||||||
|
x.ExperimentProject.TeachingTaskId,
|
||||||
|
x.ExperimentProject.TeachingTask!.TaskNumber,
|
||||||
|
x.ExperimentProject.TeachingTask.Name,
|
||||||
|
x.ExperimentProject.TeachingTask.Course!.Code,
|
||||||
|
x.ExperimentProject.TeachingTask.Course.Name,
|
||||||
|
x.ExperimentProject.TeachingTask.Teachers
|
||||||
|
.OrderByDescending(item => item.IsPrimary)
|
||||||
|
.Select(item => item.Teacher!.Name)
|
||||||
|
.ToList(),
|
||||||
|
x.ExperimentProject.TeachingTask.Classes
|
||||||
|
.Select(item => item.AdministrativeClass!.Name)
|
||||||
|
.ToList(),
|
||||||
|
x.Classroom!.Name,
|
||||||
|
x.Classroom.Building!.Name,
|
||||||
|
x.Classroom.Building.Campus!.Name,
|
||||||
|
x.SessionDate,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
x.Notes,
|
||||||
|
x.UpdatedAt))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var termMonday = StartOfWeek(term.StartDate);
|
||||||
return sessions.Select(x =>
|
return sessions.Select(x =>
|
||||||
{
|
{
|
||||||
var dayOfWeek = x.ExamDate.DayOfWeek == 0 ? 7 : (int)x.ExamDate.DayOfWeek;
|
var week = (x.SessionDate.DayNumber - termMonday.DayNumber) / 7 + 1;
|
||||||
|
var dayOfWeek = x.SessionDate.DayOfWeek == DayOfWeek.Sunday
|
||||||
|
? 7
|
||||||
|
: (int)x.SessionDate.DayOfWeek;
|
||||||
return new TimetableEntryDto(
|
return new TimetableEntryDto(
|
||||||
x.Id,
|
x.Id,
|
||||||
x.TeachingTaskId,
|
x.TeachingTaskId,
|
||||||
@@ -377,8 +502,7 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
x.TaskName,
|
x.TaskName,
|
||||||
x.CourseCode,
|
x.CourseCode,
|
||||||
x.CourseName,
|
x.CourseName,
|
||||||
x.TeacherNames.Concat(
|
x.TeacherNames,
|
||||||
new[] { "监考:" + string.Join("、", x.InvigilatorNames) }),
|
|
||||||
x.ClassNames,
|
x.ClassNames,
|
||||||
x.ClassroomName,
|
x.ClassroomName,
|
||||||
x.BuildingName,
|
x.BuildingName,
|
||||||
@@ -386,15 +510,168 @@ public sealed class TimetableDataService(AppDbContext db)
|
|||||||
dayOfWeek,
|
dayOfWeek,
|
||||||
x.StartPeriod,
|
x.StartPeriod,
|
||||||
x.PeriodCount,
|
x.PeriodCount,
|
||||||
1, 1,
|
week,
|
||||||
|
week,
|
||||||
WeekPattern.All,
|
WeekPattern.All,
|
||||||
x.Notes,
|
x.Notes,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
x.UpdatedAt,
|
||||||
true,
|
true,
|
||||||
x.Name,
|
x.ExperimentProjectId,
|
||||||
x.ExamDate,
|
x.ProjectCode,
|
||||||
x.UpdatedAt);
|
x.ProjectName,
|
||||||
|
x.SessionDate,
|
||||||
|
x.ArrangementMode);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DateOnly StartOfWeek(DateOnly date)
|
||||||
|
{
|
||||||
|
var dayOfWeek = date.DayOfWeek == DayOfWeek.Sunday
|
||||||
|
? 7
|
||||||
|
: (int)date.DayOfWeek;
|
||||||
|
return date.AddDays(1 - dayOfWeek);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool MatchesMixedResource(
|
||||||
|
ExamRoomAssignment room,
|
||||||
|
ExamSession session,
|
||||||
|
TimetableResourceType resourceType,
|
||||||
|
Guid resourceId,
|
||||||
|
Guid? studentId)
|
||||||
|
{
|
||||||
|
switch (resourceType)
|
||||||
|
{
|
||||||
|
case TimetableResourceType.Classroom:
|
||||||
|
return room.ClassroomId == resourceId;
|
||||||
|
case TimetableResourceType.Teacher:
|
||||||
|
return room.Invigilators.Any(i => i.TeacherId == resourceId) ||
|
||||||
|
session.TeachingTask!.Teachers.Any(
|
||||||
|
t => t.TeacherId == resourceId);
|
||||||
|
case TimetableResourceType.Class:
|
||||||
|
if (studentId.HasValue)
|
||||||
|
return session.TeachingTask!.Classes.Any(c =>
|
||||||
|
c.AdministrativeClassId == resourceId) ||
|
||||||
|
room.Seats.Any(s => s.StudentId == studentId.Value);
|
||||||
|
return session.TeachingTask!.Classes.Any(c =>
|
||||||
|
c.AdministrativeClassId == resourceId);
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IQueryable<ExamSession> ApplyLegacyResourceFilter(
|
||||||
|
IQueryable<ExamSession> source,
|
||||||
|
TimetableResourceType resourceType,
|
||||||
|
Guid resourceId,
|
||||||
|
Guid? studentId)
|
||||||
|
{
|
||||||
|
switch (resourceType)
|
||||||
|
{
|
||||||
|
case TimetableResourceType.Classroom:
|
||||||
|
return source.Where(x => x.ClassroomId == resourceId);
|
||||||
|
case TimetableResourceType.Teacher:
|
||||||
|
return source.Where(x =>
|
||||||
|
x.Invigilators.Any(i => i.TeacherId == resourceId) ||
|
||||||
|
x.TeachingTask!.Teachers.Any(t => t.TeacherId == resourceId));
|
||||||
|
case TimetableResourceType.Class:
|
||||||
|
if (studentId.HasValue)
|
||||||
|
{
|
||||||
|
return source.Where(x =>
|
||||||
|
x.TeachingTask!.Classes.Any(c =>
|
||||||
|
c.AdministrativeClassId == resourceId) ||
|
||||||
|
db.CourseEnrollments.Any(e =>
|
||||||
|
e.StudentId == studentId.Value &&
|
||||||
|
e.Status == CourseEnrollmentStatus.Enrolled &&
|
||||||
|
e.CourseSelectionOffering!.TeachingTaskId ==
|
||||||
|
x.TeachingTaskId));
|
||||||
|
}
|
||||||
|
return source.Where(x =>
|
||||||
|
x.TeachingTask!.Classes.Any(c =>
|
||||||
|
c.AdministrativeClassId == resourceId));
|
||||||
|
default:
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TimetableEntryDto MapToEntryDto(
|
||||||
|
ExamSessionProjection x,
|
||||||
|
IReadOnlyDictionary<int, TimetableSlotDto> slotLookup)
|
||||||
|
{
|
||||||
|
var dayOfWeek = x.ExamDate.DayOfWeek == 0
|
||||||
|
? 7
|
||||||
|
: (int)x.ExamDate.DayOfWeek;
|
||||||
|
var teacherNames = x.InvigilatorNames.Count > 0
|
||||||
|
? x.TeacherNames.Concat(
|
||||||
|
new[] { "监考:" + string.Join("、", x.InvigilatorNames) })
|
||||||
|
: x.TeacherNames;
|
||||||
|
return new TimetableEntryDto(
|
||||||
|
x.Id,
|
||||||
|
x.TeachingTaskId,
|
||||||
|
x.TaskNumber,
|
||||||
|
x.TaskName,
|
||||||
|
x.CourseCode,
|
||||||
|
x.CourseName,
|
||||||
|
teacherNames,
|
||||||
|
x.ClassNames,
|
||||||
|
x.ClassroomName,
|
||||||
|
x.BuildingName,
|
||||||
|
x.CampusName,
|
||||||
|
dayOfWeek,
|
||||||
|
x.StartPeriod,
|
||||||
|
x.PeriodCount,
|
||||||
|
1, 1,
|
||||||
|
WeekPattern.All,
|
||||||
|
x.Notes,
|
||||||
|
true,
|
||||||
|
x.PlanName,
|
||||||
|
x.ExamDate,
|
||||||
|
x.UpdatedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record ExamSessionProjection(
|
||||||
|
Guid Id,
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
string TaskNumber,
|
||||||
|
string TaskName,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
List<string> TeacherNames,
|
||||||
|
List<string> ClassNames,
|
||||||
|
string ClassroomName,
|
||||||
|
string BuildingName,
|
||||||
|
string CampusName,
|
||||||
|
DateOnly ExamDate,
|
||||||
|
int StartPeriod,
|
||||||
|
int PeriodCount,
|
||||||
|
string PlanName,
|
||||||
|
List<string> InvigilatorNames,
|
||||||
|
string? Notes,
|
||||||
|
DateTime UpdatedAt);
|
||||||
|
|
||||||
|
private sealed record ExperimentSessionProjection(
|
||||||
|
Guid Id,
|
||||||
|
Guid ExperimentProjectId,
|
||||||
|
string ProjectCode,
|
||||||
|
string ProjectName,
|
||||||
|
ExperimentArrangementMode ArrangementMode,
|
||||||
|
Guid TeachingTaskId,
|
||||||
|
string TaskNumber,
|
||||||
|
string TaskName,
|
||||||
|
string CourseCode,
|
||||||
|
string CourseName,
|
||||||
|
List<string> TeacherNames,
|
||||||
|
List<string> ClassNames,
|
||||||
|
string ClassroomName,
|
||||||
|
string BuildingName,
|
||||||
|
string CampusName,
|
||||||
|
DateOnly SessionDate,
|
||||||
|
int StartPeriod,
|
||||||
|
int PeriodCount,
|
||||||
|
string? Notes,
|
||||||
|
DateTime UpdatedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum TimetableResourceType
|
public enum TimetableResourceType
|
||||||
@@ -413,7 +690,10 @@ public sealed record TimetableData(
|
|||||||
IReadOnlyList<TimetableSlotDto> Slots,
|
IReadOnlyList<TimetableSlotDto> Slots,
|
||||||
IReadOnlyList<TimetableEntryDto> Entries,
|
IReadOnlyList<TimetableEntryDto> Entries,
|
||||||
IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
|
IReadOnlyList<FlexibleCourseDto> FlexibleCourses,
|
||||||
IReadOnlyList<TimetableEntryDto> ExamEntries);
|
IReadOnlyList<TimetableEntryDto> ExamEntries)
|
||||||
|
{
|
||||||
|
public IReadOnlyList<TimetableEntryDto> ExperimentEntries { get; init; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
public sealed record TimetableTermDto(
|
public sealed record TimetableTermDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -476,7 +756,13 @@ public sealed record TimetableEntryDto(
|
|||||||
bool IsExam = false,
|
bool IsExam = false,
|
||||||
string? ExamPlanName = null,
|
string? ExamPlanName = null,
|
||||||
DateOnly? ExamDate = null,
|
DateOnly? ExamDate = null,
|
||||||
DateTime UpdatedAt = default);
|
DateTime UpdatedAt = default,
|
||||||
|
bool IsExperiment = false,
|
||||||
|
Guid? ExperimentProjectId = null,
|
||||||
|
string? ExperimentProjectCode = null,
|
||||||
|
string? ExperimentProjectName = null,
|
||||||
|
DateOnly? ExperimentDate = null,
|
||||||
|
ExperimentArrangementMode? ExperimentArrangementMode = null);
|
||||||
|
|
||||||
public sealed record FlexibleCourseDto(
|
public sealed record FlexibleCourseDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -25,9 +25,21 @@ public static class TimetableExcelExporter
|
|||||||
sheet.Row(1).Height = 34;
|
sheet.Row(1).Height = 34;
|
||||||
|
|
||||||
sheet.Range("A2:H2").Merge();
|
sheet.Range("A2:H2").Merge();
|
||||||
|
var activitySummary = string.Concat(
|
||||||
|
timetable.ExamEntries.Count > 0
|
||||||
|
? $" · 已融入 {timetable.ExamEntries.Count} 场考试"
|
||||||
|
: "",
|
||||||
|
timetable.ExperimentEntries.Count > 0
|
||||||
|
? $" · 已融入 {timetable.ExperimentEntries.Count} 场实验"
|
||||||
|
: "");
|
||||||
|
var hasOneOffActivities =
|
||||||
|
timetable.ExamEntries.Count > 0 ||
|
||||||
|
timetable.ExperimentEntries.Count > 0;
|
||||||
sheet.Cell("A2").Value = timetable.Plan is null
|
sheet.Cell("A2").Value = timetable.Plan is null
|
||||||
? "本学期暂无可用课表版本"
|
? hasOneOffActivities
|
||||||
: $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
|
? $"固定课表尚未发布{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}"
|
||||||
|
: "本学期暂无可用课表版本"
|
||||||
|
: $"版本:{timetable.Plan.Version} · 状态:{PlanStatus(timetable.Plan.Status)}{activitySummary} · 导出时间:{DateTime.Now:yyyy-MM-dd HH:mm}";
|
||||||
sheet.Cell("A2").Style
|
sheet.Cell("A2").Style
|
||||||
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
|
.Font.SetFontColor(XLColor.FromHtml("#52677A"))
|
||||||
.Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6"));
|
.Fill.SetBackgroundColor(XLColor.FromHtml("#EFF3F6"));
|
||||||
@@ -48,8 +60,12 @@ public static class TimetableExcelExporter
|
|||||||
}
|
}
|
||||||
sheet.Row(headerRow).Height = 26;
|
sheet.Row(headerRow).Height = 26;
|
||||||
|
|
||||||
|
var timedEntries = timetable.Entries
|
||||||
|
.Concat(timetable.ExamEntries)
|
||||||
|
.Concat(timetable.ExperimentEntries)
|
||||||
|
.ToArray();
|
||||||
var periods = timetable.Slots.Select(x => x.PeriodNumber)
|
var periods = timetable.Slots.Select(x => x.PeriodNumber)
|
||||||
.Concat(timetable.Entries.SelectMany(x =>
|
.Concat(timedEntries.SelectMany(x =>
|
||||||
Enumerable.Range(x.StartPeriod, x.PeriodCount)))
|
Enumerable.Range(x.StartPeriod, x.PeriodCount)))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Order()
|
.Order()
|
||||||
@@ -72,7 +88,7 @@ public static class TimetableExcelExporter
|
|||||||
|
|
||||||
for (var day = 1; day <= 7; day++)
|
for (var day = 1; day <= 7; day++)
|
||||||
{
|
{
|
||||||
var entries = timetable.Entries
|
var entries = timedEntries
|
||||||
.Where(x =>
|
.Where(x =>
|
||||||
x.DayOfWeek == day &&
|
x.DayOfWeek == day &&
|
||||||
x.StartPeriod <= period &&
|
x.StartPeriod <= period &&
|
||||||
@@ -86,7 +102,12 @@ public static class TimetableExcelExporter
|
|||||||
if (entries.Count > 0)
|
if (entries.Count > 0)
|
||||||
{
|
{
|
||||||
cell.Style
|
cell.Style
|
||||||
.Fill.SetBackgroundColor(XLColor.FromHtml("#E8F3F2"))
|
.Fill.SetBackgroundColor(XLColor.FromHtml(
|
||||||
|
entries.Any(x => x.IsExam)
|
||||||
|
? "#FFF1E8"
|
||||||
|
: entries.Any(x => x.IsExperiment)
|
||||||
|
? "#E7F5F1"
|
||||||
|
: "#E8F3F2"))
|
||||||
.Font.SetFontColor(XLColor.FromHtml("#173F4C"));
|
.Font.SetFontColor(XLColor.FromHtml("#173F4C"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,10 +159,32 @@ public static class TimetableExcelExporter
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string EntryText(TimetableEntryDto entry) =>
|
private static string EntryText(TimetableEntryDto entry) =>
|
||||||
$"{entry.CourseName}\n" +
|
entry.IsExperiment
|
||||||
$"{string.Join('、', entry.TeacherNames)}\n" +
|
? $"【实验】{entry.ExperimentProjectName}\n" +
|
||||||
$"{Location(entry)}\n" +
|
$"{entry.CourseName} · {ArrangementMode(entry)}\n" +
|
||||||
$"{entry.StartWeek}-{entry.EndWeek} 周";
|
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||||
|
$"{Location(entry)}\n" +
|
||||||
|
$"{entry.ExperimentDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
|
||||||
|
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
|
||||||
|
: entry.IsExam
|
||||||
|
? $"【考试】{entry.CourseName}\n" +
|
||||||
|
$"{entry.ExamPlanName}\n" +
|
||||||
|
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||||
|
$"{Location(entry)}\n" +
|
||||||
|
$"{entry.ExamDate:yyyy-MM-dd} · 第 {entry.StartPeriod}-" +
|
||||||
|
$"{entry.StartPeriod + entry.PeriodCount - 1} 节"
|
||||||
|
: $"{entry.CourseName}\n" +
|
||||||
|
$"{string.Join('、', entry.TeacherNames)}\n" +
|
||||||
|
$"{Location(entry)}\n" +
|
||||||
|
$"{entry.StartWeek}-{entry.EndWeek} 周";
|
||||||
|
|
||||||
|
private static string ArrangementMode(TimetableEntryDto entry) =>
|
||||||
|
entry.ExperimentArrangementMode switch
|
||||||
|
{
|
||||||
|
Domain.Academic.ExperimentArrangementMode.Centralized => "集中安排",
|
||||||
|
Domain.Academic.ExperimentArrangementMode.SelfScheduled => "自主预约",
|
||||||
|
_ => "实验安排"
|
||||||
|
};
|
||||||
|
|
||||||
private static string Location(TimetableEntryDto entry) =>
|
private static string Location(TimetableEntryDto entry) =>
|
||||||
string.Join(" · ", new[]
|
string.Join(" · ", new[]
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 ||
|
|||||||
backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 ||
|
backgroundJobOptions.AutomaticScheduleConcurrency is < 1 or > 16 ||
|
||||||
backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 ||
|
backgroundJobOptions.SchedulePublishConcurrency is < 1 or > 16 ||
|
||||||
backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 ||
|
backgroundJobOptions.MakeupExamAutoConcurrency is < 1 or > 16 ||
|
||||||
|
backgroundJobOptions.ExamArrangementConcurrency is < 1 or > 16 ||
|
||||||
|
backgroundJobOptions.ExamSignInExportConcurrency is < 1 or > 16 ||
|
||||||
|
backgroundJobOptions.ExamPublishConcurrency is < 1 or > 16 ||
|
||||||
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 ||
|
||||||
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
backgroundJobOptions.MaintenanceIntervalSeconds is < 10 or > 3600 ||
|
||||||
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
backgroundJobOptions.CompletedRetentionDays is < 1 or > 3650 ||
|
||||||
@@ -294,6 +297,9 @@ builder.Services.AddScoped<ExamArrangementService>();
|
|||||||
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
builder.Services.AddScoped<MakeupExamEligibilityService>();
|
||||||
builder.Services.AddScoped<MakeupExamArrangementService>();
|
builder.Services.AddScoped<MakeupExamArrangementService>();
|
||||||
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
builder.Services.AddScoped<MakeupExamAutoJobProcessor>();
|
||||||
|
builder.Services.AddScoped<ExamArrangementJobProcessor>();
|
||||||
|
builder.Services.AddScoped<ExamSignInExportJobProcessor>();
|
||||||
|
builder.Services.AddScoped<ExamPublishJobProcessor>();
|
||||||
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
builder.Services.AddSingleton<BackgroundJobTelemetry>();
|
||||||
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
builder.Services.AddScoped<BackgroundJobMonitoringService>();
|
||||||
builder.Services.AddScoped<OperationalHealthService>();
|
builder.Services.AddScoped<OperationalHealthService>();
|
||||||
@@ -358,6 +364,16 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
QueueLimit = 0,
|
QueueLimit = 0,
|
||||||
AutoReplenishment = true
|
AutoReplenishment = true
|
||||||
}));
|
}));
|
||||||
|
options.AddPolicy("app-updates", context =>
|
||||||
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
|
_ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 120,
|
||||||
|
Window = TimeSpan.FromMinutes(1),
|
||||||
|
QueueLimit = 0,
|
||||||
|
AutoReplenishment = true
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"AutomaticScheduleConcurrency": 1,
|
"AutomaticScheduleConcurrency": 1,
|
||||||
"SchedulePublishConcurrency": 1,
|
"SchedulePublishConcurrency": 1,
|
||||||
"MakeupExamAutoConcurrency": 1,
|
"MakeupExamAutoConcurrency": 1,
|
||||||
|
"ExamArrangementConcurrency": 1,
|
||||||
"Exchange": "jiaowu.background-jobs",
|
"Exchange": "jiaowu.background-jobs",
|
||||||
"QueuePrefix": "jiaowu.background-jobs",
|
"QueuePrefix": "jiaowu.background-jobs",
|
||||||
"UseQuorumQueues": true,
|
"UseQuorumQueues": true,
|
||||||
@@ -59,7 +60,11 @@
|
|||||||
"ExpireMinutes": 60
|
"ExpireMinutes": 60
|
||||||
},
|
},
|
||||||
"Cors": {
|
"Cors": {
|
||||||
"Origins": []
|
"Origins": [
|
||||||
|
"capacitor://localhost",
|
||||||
|
"https://localhost",
|
||||||
|
"http://localhost"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"OfficialDocuments": {
|
"OfficialDocuments": {
|
||||||
"InstitutionName": "明序大学",
|
"InstitutionName": "明序大学",
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using System.IO.Compression;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class AppUpdatesControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Management_actions_are_restricted_to_super_admin()
|
||||||
|
{
|
||||||
|
var method = typeof(AppUpdatesController)
|
||||||
|
.GetMethod(nameof(AppUpdatesController.UploadRelease));
|
||||||
|
var authorize = method?.GetCustomAttribute<AuthorizeAttribute>();
|
||||||
|
|
||||||
|
Assert.NotNull(authorize);
|
||||||
|
Assert.Equal(SystemRoles.SuperAdmin, authorize.Roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_publish_check_and_download_form_a_complete_flow()
|
||||||
|
{
|
||||||
|
await using var fixture = await AppUpdatesFixture.CreateAsync();
|
||||||
|
var content = CreateBundle("first");
|
||||||
|
|
||||||
|
var uploaded = await fixture.Controller.UploadRelease(
|
||||||
|
UploadRequest(content, "1.0.1"),
|
||||||
|
CancellationToken.None);
|
||||||
|
var created = Assert.IsType<CreatedAtActionResult>(uploaded.Result);
|
||||||
|
var release = Assert.IsType<AppUpdateReleaseItem>(created.Value);
|
||||||
|
Assert.Equal(AppUpdateReleaseStatus.Draft, release.Status);
|
||||||
|
Assert.Equal(64, release.Sha256.Length);
|
||||||
|
|
||||||
|
var publishedAction = await fixture.Controller.PublishRelease(
|
||||||
|
release.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var publishedResult = Assert.IsType<OkObjectResult>(
|
||||||
|
publishedAction.Result);
|
||||||
|
var published = Assert.IsType<AppUpdateReleaseItem>(
|
||||||
|
publishedResult.Value);
|
||||||
|
Assert.Equal(AppUpdateReleaseStatus.Published, published.Status);
|
||||||
|
|
||||||
|
var checkAction = await fixture.Controller.GetLatest(
|
||||||
|
"android",
|
||||||
|
"1.0",
|
||||||
|
"production",
|
||||||
|
"1.0.0",
|
||||||
|
CancellationToken.None);
|
||||||
|
var checkResult = Assert.IsType<OkObjectResult>(checkAction.Result);
|
||||||
|
var check = Assert.IsType<AppUpdateCheckResponse>(checkResult.Value);
|
||||||
|
Assert.True(check.Available);
|
||||||
|
Assert.Equal("1.0.1", check.Version);
|
||||||
|
Assert.Equal(release.Sha256, check.Sha256);
|
||||||
|
Assert.Equal($"app-updates/releases/{release.Id}/bundle", check.DownloadUrl);
|
||||||
|
|
||||||
|
var downloadAction = await fixture.Controller.DownloadBundle(
|
||||||
|
release.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var download = Assert.IsType<FileContentResult>(downloadAction);
|
||||||
|
Assert.Equal(content, download.FileContents);
|
||||||
|
Assert.True(download.EnableRangeProcessing);
|
||||||
|
|
||||||
|
var currentAction = await fixture.Controller.GetLatest(
|
||||||
|
"android",
|
||||||
|
"1.0",
|
||||||
|
"production",
|
||||||
|
"1.0.1",
|
||||||
|
CancellationToken.None);
|
||||||
|
var currentResult = Assert.IsType<OkObjectResult>(
|
||||||
|
currentAction.Result);
|
||||||
|
Assert.False(Assert.IsType<AppUpdateCheckResponse>(
|
||||||
|
currentResult.Value).Available);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publishing_an_archived_release_performs_a_rollback()
|
||||||
|
{
|
||||||
|
await using var fixture = await AppUpdatesFixture.CreateAsync();
|
||||||
|
var first = await UploadAsync(fixture, "1.0.1", "first");
|
||||||
|
await fixture.Controller.PublishRelease(first.Id, CancellationToken.None);
|
||||||
|
var second = await UploadAsync(fixture, "1.0.2", "second");
|
||||||
|
await fixture.Controller.PublishRelease(second.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
AppUpdateReleaseStatus.Archived,
|
||||||
|
(await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status);
|
||||||
|
Assert.Equal(
|
||||||
|
AppUpdateReleaseStatus.Published,
|
||||||
|
(await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status);
|
||||||
|
|
||||||
|
await fixture.Controller.PublishRelease(first.Id, CancellationToken.None);
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
Assert.Equal(
|
||||||
|
AppUpdateReleaseStatus.Published,
|
||||||
|
(await fixture.Db.AppUpdateReleases.FindAsync(first.Id))!.Status);
|
||||||
|
Assert.Equal(
|
||||||
|
AppUpdateReleaseStatus.Archived,
|
||||||
|
(await fixture.Db.AppUpdateReleases.FindAsync(second.Id))!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Invalid_or_nested_web_bundles_are_rejected()
|
||||||
|
{
|
||||||
|
await using var fixture = await AppUpdatesFixture.CreateAsync();
|
||||||
|
var invalid = await fixture.Controller.UploadRelease(
|
||||||
|
UploadRequest("not a zip"u8.ToArray(), "1.0.1"),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<ObjectResult>(invalid.Result);
|
||||||
|
|
||||||
|
var nested = CreateBundle("nested", "dist/index.html");
|
||||||
|
var nestedResult = await fixture.Controller.UploadRelease(
|
||||||
|
UploadRequest(nested, "1.0.2"),
|
||||||
|
CancellationToken.None);
|
||||||
|
var problem = Assert.IsType<ObjectResult>(nestedResult.Result);
|
||||||
|
Assert.Contains(
|
||||||
|
"根目录",
|
||||||
|
Assert.IsType<ValidationProblemDetails>(problem.Value).Detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<AppUpdateReleaseItem> UploadAsync(
|
||||||
|
AppUpdatesFixture fixture,
|
||||||
|
string version,
|
||||||
|
string marker)
|
||||||
|
{
|
||||||
|
var action = await fixture.Controller.UploadRelease(
|
||||||
|
UploadRequest(CreateBundle(marker), version),
|
||||||
|
CancellationToken.None);
|
||||||
|
return Assert.IsType<AppUpdateReleaseItem>(
|
||||||
|
Assert.IsType<CreatedAtActionResult>(action.Result).Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AppUpdateUploadRequest UploadRequest(
|
||||||
|
byte[] content,
|
||||||
|
string version)
|
||||||
|
{
|
||||||
|
var stream = new MemoryStream(content);
|
||||||
|
return new AppUpdateUploadRequest
|
||||||
|
{
|
||||||
|
Bundle = new FormFile(
|
||||||
|
stream,
|
||||||
|
0,
|
||||||
|
content.Length,
|
||||||
|
"bundle",
|
||||||
|
$"jiaowu-{version}.zip"),
|
||||||
|
Platform = "android",
|
||||||
|
Channel = "production",
|
||||||
|
Version = version,
|
||||||
|
NativeVersion = "1.0",
|
||||||
|
ReleaseNotes = $"Release {version}"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] CreateBundle(
|
||||||
|
string marker,
|
||||||
|
string indexPath = "index.html")
|
||||||
|
{
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
using (var archive = new ZipArchive(
|
||||||
|
output,
|
||||||
|
ZipArchiveMode.Create,
|
||||||
|
leaveOpen: true))
|
||||||
|
{
|
||||||
|
var index = archive.CreateEntry(indexPath);
|
||||||
|
using var writer = new StreamWriter(index.Open());
|
||||||
|
writer.Write($"<!doctype html><title>{marker}</title>");
|
||||||
|
}
|
||||||
|
return output.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class AppUpdatesFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private AppUpdatesFixture(
|
||||||
|
AppDbContext db,
|
||||||
|
AppUpdatesController controller)
|
||||||
|
{
|
||||||
|
Db = db;
|
||||||
|
Controller = controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public AppUpdatesController Controller { get; }
|
||||||
|
|
||||||
|
public static async Task<AppUpdatesFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite("Data Source=:memory:")
|
||||||
|
.Options;
|
||||||
|
var db = new AppDbContext(options);
|
||||||
|
await db.Database.OpenConnectionAsync();
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
var controller = new AppUpdatesController(db)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
User = new ClaimsPrincipal(new ClaimsIdentity(
|
||||||
|
[
|
||||||
|
new Claim(ClaimTypes.Name, "root"),
|
||||||
|
new Claim(
|
||||||
|
ClaimTypes.Role,
|
||||||
|
SystemRoles.SuperAdmin)
|
||||||
|
],
|
||||||
|
"test"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return new AppUpdatesFixture(db, controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync() => await Db.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class AttendanceCheckInChallengeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Challenge_IsBoundToSheetSecretAndTwentySecondLifetime()
|
||||||
|
{
|
||||||
|
var sheetId = Guid.NewGuid();
|
||||||
|
var issuedAt = new DateTime(2026, 7, 28, 8, 0, 0, DateTimeKind.Utc);
|
||||||
|
var challenge = AttendanceCheckInChallenge.Create(
|
||||||
|
sheetId,
|
||||||
|
"sheet-secret",
|
||||||
|
issuedAt);
|
||||||
|
|
||||||
|
Assert.True(AttendanceCheckInChallenge.TryReadSheetId(
|
||||||
|
challenge.Token,
|
||||||
|
out var parsedSheetId));
|
||||||
|
Assert.Equal(sheetId, parsedSheetId);
|
||||||
|
Assert.True(AttendanceCheckInChallenge.IsValid(
|
||||||
|
challenge.Token,
|
||||||
|
sheetId,
|
||||||
|
"sheet-secret",
|
||||||
|
issuedAt.AddSeconds(20)));
|
||||||
|
Assert.False(AttendanceCheckInChallenge.IsValid(
|
||||||
|
challenge.Token,
|
||||||
|
sheetId,
|
||||||
|
"sheet-secret",
|
||||||
|
issuedAt.AddSeconds(21)));
|
||||||
|
Assert.False(AttendanceCheckInChallenge.IsValid(
|
||||||
|
challenge.Token,
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"sheet-secret",
|
||||||
|
issuedAt));
|
||||||
|
Assert.False(AttendanceCheckInChallenge.IsValid(
|
||||||
|
challenge.Token,
|
||||||
|
sheetId,
|
||||||
|
"different-secret",
|
||||||
|
issuedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,10 +55,19 @@ public sealed class AttendanceControllerTests
|
|||||||
EnrollmentYear = 2026,
|
EnrollmentYear = 2026,
|
||||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
};
|
};
|
||||||
|
var secondStudentUserId = Guid.NewGuid();
|
||||||
|
var secondStudentUser = new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = secondStudentUserId,
|
||||||
|
UserName = "202601002",
|
||||||
|
NormalizedUserName = "202601002",
|
||||||
|
DisplayName = "吴同学"
|
||||||
|
};
|
||||||
var secondStudent = new Student
|
var secondStudent = new Student
|
||||||
{
|
{
|
||||||
StudentNumber = "202601002",
|
StudentNumber = "202601002",
|
||||||
Name = "吴同学",
|
Name = "吴同学",
|
||||||
|
UserId = secondStudentUserId,
|
||||||
AdministrativeClassId = administrativeClass.Id,
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
EnrollmentYear = 2026,
|
EnrollmentYear = 2026,
|
||||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
@@ -95,6 +104,7 @@ public sealed class AttendanceControllerTests
|
|||||||
};
|
};
|
||||||
db.AddRange(
|
db.AddRange(
|
||||||
studentUser,
|
studentUser,
|
||||||
|
secondStudentUser,
|
||||||
college,
|
college,
|
||||||
major,
|
major,
|
||||||
administrativeClass,
|
administrativeClass,
|
||||||
@@ -198,6 +208,11 @@ public sealed class AttendanceControllerTests
|
|||||||
StudentId = firstStudent.Id,
|
StudentId = firstStudent.Id,
|
||||||
Status = AttendanceStatus.Absent
|
Status = AttendanceStatus.Absent
|
||||||
};
|
};
|
||||||
|
var secondQrRecord = new AttendanceRecord
|
||||||
|
{
|
||||||
|
StudentId = secondStudent.Id,
|
||||||
|
Status = AttendanceStatus.Absent
|
||||||
|
};
|
||||||
var qrSheet = new AttendanceSheet
|
var qrSheet = new AttendanceSheet
|
||||||
{
|
{
|
||||||
TeachingTaskId = task.Id,
|
TeachingTaskId = task.Id,
|
||||||
@@ -208,10 +223,17 @@ public sealed class AttendanceControllerTests
|
|||||||
CheckInToken = "TEST-QR-TOKEN",
|
CheckInToken = "TEST-QR-TOKEN",
|
||||||
CheckInStartsAt = now.AddMinutes(-1),
|
CheckInStartsAt = now.AddMinutes(-1),
|
||||||
CheckInEndsAt = now.AddMinutes(10),
|
CheckInEndsAt = now.AddMinutes(10),
|
||||||
Records = [qrRecord]
|
Records = [qrRecord, secondQrRecord]
|
||||||
};
|
};
|
||||||
db.AttendanceSheets.Add(qrSheet);
|
db.AttendanceSheets.Add(qrSheet);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
var qrChallengeResult = await controller.GetQrChallenge(
|
||||||
|
qrSheet.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var qrChallengeOk = Assert.IsType<OkObjectResult>(qrChallengeResult);
|
||||||
|
var qrToken = Assert.IsType<string>(
|
||||||
|
qrChallengeOk.Value!.GetType().GetProperty("Token")!.GetValue(
|
||||||
|
qrChallengeOk.Value));
|
||||||
|
|
||||||
var studentController = new AttendanceController(
|
var studentController = new AttendanceController(
|
||||||
db,
|
db,
|
||||||
@@ -231,23 +253,70 @@ public sealed class AttendanceControllerTests
|
|||||||
item.GetType().GetProperty("TeachingTaskId")!.GetValue(item)));
|
item.GetType().GetProperty("TeachingTaskId")!.GetValue(item)));
|
||||||
|
|
||||||
var infoResult = await studentController.GetCheckInInfo(
|
var infoResult = await studentController.GetCheckInInfo(
|
||||||
qrSheet.CheckInToken,
|
qrToken,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
Assert.IsType<OkObjectResult>(infoResult);
|
Assert.IsType<OkObjectResult>(infoResult);
|
||||||
|
Assert.IsType<NotFoundResult>(
|
||||||
|
await studentController.GetCheckInInfo(
|
||||||
|
qrSheet.CheckInToken,
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
var qrCheckInResult = await studentController.CheckIn(
|
var qrCheckInResult = await studentController.CheckIn(
|
||||||
new AttendanceCheckInRequest(
|
new AttendanceCheckInRequest(
|
||||||
null,
|
null,
|
||||||
qrSheet.CheckInToken,
|
qrToken,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null),
|
null,
|
||||||
|
"test-device-1",
|
||||||
|
"android"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
Assert.IsType<OkObjectResult>(qrCheckInResult);
|
Assert.IsType<OkObjectResult>(qrCheckInResult);
|
||||||
Assert.Equal(AttendanceStatus.Present, qrRecord.Status);
|
Assert.Equal(AttendanceStatus.Present, qrRecord.Status);
|
||||||
Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod);
|
Assert.Equal(AttendanceCheckInMethod.QrCode, qrRecord.CheckedInMethod);
|
||||||
Assert.NotNull(qrRecord.CheckInAt);
|
Assert.NotNull(qrRecord.CheckInAt);
|
||||||
Assert.Null(qrRecord.CheckInLatitude);
|
Assert.Null(qrRecord.CheckInLatitude);
|
||||||
|
var qrAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
|
||||||
|
x => x.AttendanceSheetId == qrSheet.Id);
|
||||||
|
Assert.True(qrAttempt.IsSuccessful);
|
||||||
|
Assert.Equal("android", qrAttempt.DevicePlatform);
|
||||||
|
Assert.NotNull(qrAttempt.DeviceIdentifierHash);
|
||||||
|
|
||||||
|
var secondStudentController = new AttendanceController(
|
||||||
|
db,
|
||||||
|
new StudentDataScope(secondStudentUserId));
|
||||||
|
Assert.IsType<OkObjectResult>(
|
||||||
|
await secondStudentController.CheckIn(
|
||||||
|
new AttendanceCheckInRequest(
|
||||||
|
null,
|
||||||
|
qrToken,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"test-device-1",
|
||||||
|
"android"),
|
||||||
|
CancellationToken.None));
|
||||||
|
var sharedDeviceAttempt = await db.AttendanceCheckInAttempts.SingleAsync(
|
||||||
|
x => x.AttendanceSheetId == qrSheet.Id &&
|
||||||
|
x.StudentId == secondStudent.Id);
|
||||||
|
Assert.Contains(
|
||||||
|
"SharedDevice",
|
||||||
|
sharedDeviceAttempt.RiskFlags ?? string.Empty);
|
||||||
|
var sheetDetailResult = await controller.GetSheet(
|
||||||
|
qrSheet.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var sheetDetailOk = Assert.IsType<OkObjectResult>(sheetDetailResult);
|
||||||
|
var sheetDetail = sheetDetailOk.Value!.GetType()
|
||||||
|
.GetProperty("Sheet")!
|
||||||
|
.GetValue(sheetDetailOk.Value)!;
|
||||||
|
var riskSummary = sheetDetail.GetType()
|
||||||
|
.GetProperty("RiskSummary")!
|
||||||
|
.GetValue(sheetDetail)!;
|
||||||
|
Assert.Equal(
|
||||||
|
2,
|
||||||
|
riskSummary.GetType()
|
||||||
|
.GetProperty("SharedDeviceStudentCount")!
|
||||||
|
.GetValue(riskSummary));
|
||||||
|
|
||||||
var locationRecord = new AttendanceRecord
|
var locationRecord = new AttendanceRecord
|
||||||
{
|
{
|
||||||
@@ -282,6 +351,40 @@ public sealed class AttendanceControllerTests
|
|||||||
Assert.IsType<ConflictObjectResult>(outsideResult);
|
Assert.IsType<ConflictObjectResult>(outsideResult);
|
||||||
Assert.Null(locationRecord.CheckInAt);
|
Assert.Null(locationRecord.CheckInAt);
|
||||||
|
|
||||||
|
var inaccurateResult = await studentController.CheckIn(
|
||||||
|
new AttendanceCheckInRequest(
|
||||||
|
locationSheet.Id,
|
||||||
|
null,
|
||||||
|
39.9001m,
|
||||||
|
116.4m,
|
||||||
|
150),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<ConflictObjectResult>(inaccurateResult);
|
||||||
|
Assert.Null(locationRecord.CheckInAt);
|
||||||
|
|
||||||
|
var missingAccuracyResult = await studentController.CheckIn(
|
||||||
|
new AttendanceCheckInRequest(
|
||||||
|
locationSheet.Id,
|
||||||
|
null,
|
||||||
|
39.9001m,
|
||||||
|
116.4m,
|
||||||
|
null),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<ConflictObjectResult>(missingAccuracyResult);
|
||||||
|
Assert.Null(locationRecord.CheckInAt);
|
||||||
|
var locationFailures = await db.AttendanceCheckInAttempts
|
||||||
|
.Where(x =>
|
||||||
|
x.AttendanceSheetId == locationSheet.Id &&
|
||||||
|
!x.IsSuccessful)
|
||||||
|
.OrderBy(x => x.CreatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(3, locationFailures.Count);
|
||||||
|
Assert.Contains(
|
||||||
|
locationFailures,
|
||||||
|
x => (x.RiskFlags ?? string.Empty).Contains(
|
||||||
|
"RepeatedFailures",
|
||||||
|
StringComparison.Ordinal));
|
||||||
|
|
||||||
var nearbyResult = await studentController.CheckIn(
|
var nearbyResult = await studentController.CheckIn(
|
||||||
new AttendanceCheckInRequest(
|
new AttendanceCheckInRequest(
|
||||||
locationSheet.Id,
|
locationSheet.Id,
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
await db.Database.ExecuteSqlRawAsync(
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
"DROP TABLE \"BackgroundJobOutboxMessages\"");
|
"DROP TABLE \"BackgroundJobOutboxMessages\"");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"DROP TABLE \"ExamRoomInvigilators\"");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"DROP TABLE \"ExamSeats\"");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"DROP TABLE \"ExamRoomSessions\"");
|
||||||
|
await db.Database.ExecuteSqlRawAsync(
|
||||||
|
"DROP TABLE \"ExamRooms\"");
|
||||||
|
|
||||||
var migrator = new DevelopmentSqliteMigrator(
|
var migrator = new DevelopmentSqliteMigrator(
|
||||||
db,
|
db,
|
||||||
@@ -30,6 +38,10 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
|
Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0);
|
||||||
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
|
Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0);
|
||||||
Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0);
|
Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0);
|
||||||
|
Assert.True(await db.ExamRooms.CountAsync() == 0);
|
||||||
|
Assert.True(await db.ExamRoomSessions.CountAsync() == 0);
|
||||||
|
Assert.True(await db.ExamSeats.CountAsync() == 0);
|
||||||
|
Assert.True(await db.ExamRoomInvigilators.CountAsync() == 0);
|
||||||
Assert.True(await db.Database
|
Assert.True(await db.Database
|
||||||
.SqlQueryRaw<int>(
|
.SqlQueryRaw<int>(
|
||||||
"""
|
"""
|
||||||
@@ -124,6 +136,91 @@ public sealed class AutomaticScheduleGeneratorTests
|
|||||||
Assert.Empty(await db.ScheduleEntries.ToListAsync());
|
Assert.Empty(await db.ScheduleEntries.ToListAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Generator_excludes_practice_hours_from_regular_schedule()
|
||||||
|
{
|
||||||
|
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 term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-LAB",
|
||||||
|
Name = "2026 实验学时测试",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 1),
|
||||||
|
EndDate = new DateOnly(2027, 1, 15)
|
||||||
|
};
|
||||||
|
var college = new College { Code = "LAB", Name = "实验学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "LAB-01",
|
||||||
|
Name = "实验混合课程",
|
||||||
|
College = college,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 16,
|
||||||
|
PracticeHours = 16
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "TASK-LAB",
|
||||||
|
Name = "实验混合教学班",
|
||||||
|
AcademicTerm = term,
|
||||||
|
Course = course,
|
||||||
|
Capacity = 30,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeeklyHours = 2,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "实验学时拆分测试",
|
||||||
|
Version = "V1"
|
||||||
|
};
|
||||||
|
db.AddRange(term, college, course, task, plan);
|
||||||
|
db.ScheduleTimeSlots.AddRange(
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
PeriodNumber = 1,
|
||||||
|
Name = "第 1 节",
|
||||||
|
StartsAt = new TimeOnly(8, 0),
|
||||||
|
EndsAt = new TimeOnly(8, 45)
|
||||||
|
},
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
PeriodNumber = 2,
|
||||||
|
Name = "第 2 节",
|
||||||
|
StartsAt = new TimeOnly(8, 55),
|
||||||
|
EndsAt = new TimeOnly(9, 40)
|
||||||
|
});
|
||||||
|
db.TeachingTaskScheduleConstraints.Add(
|
||||||
|
new TeachingTaskScheduleConstraint
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
RequiresClassroom = false
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await new AutomaticScheduleGenerator(db)
|
||||||
|
.GenerateAsync(plan, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.CreatedEntries);
|
||||||
|
Assert.Equal(1, result.CompletedTasks);
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
(await db.ScheduleEntries.SingleAsync()).PeriodCount);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Generator_schedules_roomless_course_within_allowed_time()
|
public async Task Generator_schedules_roomless_course_within_allowed_time()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ExamArrangementJobControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Formal_and_makeup_arrangement_are_enqueued_with_outbox_messages()
|
||||||
|
{
|
||||||
|
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 term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-EXAM-JOB",
|
||||||
|
Name = "考试后台任务测试",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 7),
|
||||||
|
EndDate = new DateOnly(2027, 1, 17)
|
||||||
|
};
|
||||||
|
var formalPlan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "正式考试草稿"
|
||||||
|
};
|
||||||
|
var makeupPlan = new MakeupExamPlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "补考草稿"
|
||||||
|
};
|
||||||
|
db.AddRange(term, formalPlan, makeupPlan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var scope = new ManagerDataScope();
|
||||||
|
var exams = new ExamsController(db, scope, NoOpAppCache.Instance);
|
||||||
|
var makeup = new MakeupExamsController(
|
||||||
|
db,
|
||||||
|
scope,
|
||||||
|
new MakeupExamEligibilityService(db));
|
||||||
|
|
||||||
|
var formalResult = await exams.AutoArrange(
|
||||||
|
formalPlan.Id,
|
||||||
|
new ExamAutoArrangeRequest(),
|
||||||
|
CancellationToken.None);
|
||||||
|
var makeupResult = await makeup.AutoArrange(
|
||||||
|
makeupPlan.Id,
|
||||||
|
new ExamAutoArrangeRequest(),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<AcceptedAtActionResult>(formalResult);
|
||||||
|
Assert.IsType<AcceptedAtActionResult>(makeupResult);
|
||||||
|
|
||||||
|
var jobs = await db.ExamArrangementJobs
|
||||||
|
.OrderBy(x => x.Kind)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.Equal(2, jobs.Count);
|
||||||
|
Assert.Equal(ExamArrangementKind.FormalExam, jobs[0].Kind);
|
||||||
|
Assert.Equal(formalPlan.Id, jobs[0].ActivePlanId);
|
||||||
|
Assert.Equal(ExamArrangementKind.MakeupExam, jobs[1].Kind);
|
||||||
|
Assert.Equal(makeupPlan.Id, jobs[1].ActivePlanId);
|
||||||
|
Assert.All(jobs, job =>
|
||||||
|
Assert.Equal(ExamArrangementJobStatus.Queued, job.Status));
|
||||||
|
|
||||||
|
var outbox = await db.BackgroundJobOutboxMessages.ToListAsync();
|
||||||
|
Assert.Equal(2, outbox.Count);
|
||||||
|
Assert.All(outbox, message =>
|
||||||
|
{
|
||||||
|
Assert.Equal(BackgroundJobKind.ExamArrangement, message.JobKind);
|
||||||
|
Assert.Equal(BackgroundJobOutboxState.Pending, message.State);
|
||||||
|
});
|
||||||
|
Assert.Equal(
|
||||||
|
jobs.Select(x => x.Id).Order(),
|
||||||
|
outbox.Select(x => x.JobId).Order());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Repeated_formal_arrangement_returns_the_existing_active_job()
|
||||||
|
{
|
||||||
|
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 term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-EXAM-DUP",
|
||||||
|
Name = "考试任务去重测试",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 7),
|
||||||
|
EndDate = new DateOnly(2027, 1, 17)
|
||||||
|
};
|
||||||
|
var plan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "正式考试草稿"
|
||||||
|
};
|
||||||
|
db.AddRange(term, plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new ExamsController(
|
||||||
|
db,
|
||||||
|
new ManagerDataScope(),
|
||||||
|
NoOpAppCache.Instance);
|
||||||
|
|
||||||
|
var first = Assert.IsType<AcceptedAtActionResult>(
|
||||||
|
await controller.AutoArrange(
|
||||||
|
plan.Id,
|
||||||
|
new ExamAutoArrangeRequest(),
|
||||||
|
CancellationToken.None));
|
||||||
|
var second = Assert.IsType<AcceptedAtActionResult>(
|
||||||
|
await controller.AutoArrange(
|
||||||
|
plan.Id,
|
||||||
|
new ExamAutoArrangeRequest(),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(202, first.StatusCode);
|
||||||
|
Assert.Equal(202, second.StatusCode);
|
||||||
|
Assert.Equal(1, await db.ExamArrangementJobs.CountAsync());
|
||||||
|
Assert.Equal(1, await db.BackgroundJobOutboxMessages.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Processor_records_business_failure_and_releases_active_plan()
|
||||||
|
{
|
||||||
|
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 term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-EXAM-FAIL",
|
||||||
|
Name = "考试任务失败测试",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 7),
|
||||||
|
EndDate = new DateOnly(2027, 1, 17)
|
||||||
|
};
|
||||||
|
var plan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "缺少节次配置的考试计划"
|
||||||
|
};
|
||||||
|
var job = new ExamArrangementJob
|
||||||
|
{
|
||||||
|
Kind = ExamArrangementKind.FormalExam,
|
||||||
|
PlanId = plan.Id,
|
||||||
|
ActivePlanId = plan.Id,
|
||||||
|
AssignClassrooms = true,
|
||||||
|
AssignInvigilators = true
|
||||||
|
};
|
||||||
|
db.AddRange(term, plan, job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var processor = new ExamArrangementJobProcessor(
|
||||||
|
db,
|
||||||
|
new ExamArrangementService(db),
|
||||||
|
new MakeupExamArrangementService(db),
|
||||||
|
NoOpAppCache.Instance,
|
||||||
|
NullLogger<ExamArrangementJobProcessor>.Instance);
|
||||||
|
await processor.ProcessAsync(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
var failed = await db.ExamArrangementJobs.SingleAsync();
|
||||||
|
Assert.Equal(ExamArrangementJobStatus.Failed, failed.Status);
|
||||||
|
Assert.Null(failed.ActivePlanId);
|
||||||
|
Assert.Equal("编排失败", failed.CurrentStep);
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(failed.ErrorMessage));
|
||||||
|
Assert.NotNull(failed.CompletedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManagerDataScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"考试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string> { SystemRoles.AcademicAdmin });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ namespace Jiaowu.Api.Tests;
|
|||||||
public sealed class ExamArrangementServiceTests
|
public sealed class ExamArrangementServiceTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Normal_arrangement_only_updates_selected_sessions_and_requested_resource()
|
public async Task Normal_arrangement_creates_room_seats_for_selected_session()
|
||||||
{
|
{
|
||||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
await connection.OpenAsync();
|
await connection.OpenAsync();
|
||||||
@@ -37,21 +37,81 @@ public sealed class ExamArrangementServiceTests
|
|||||||
plan.Id,
|
plan.Id,
|
||||||
[selected.Id],
|
[selected.Id],
|
||||||
assignClassrooms: true,
|
assignClassrooms: true,
|
||||||
assignInvigilators: false,
|
assignInvigilators: true,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
var persistedSelected = await db.ExamSessions.FindAsync(selected.Id);
|
var room = await db.ExamRooms
|
||||||
var persistedUntouched = await db.ExamSessions.FindAsync(untouched.Id);
|
.Include(x => x.SessionLinks)
|
||||||
|
.Include(x => x.Seats)
|
||||||
|
.Include(x => x.Invigilators)
|
||||||
|
.SingleAsync();
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.NotNull(persistedSelected!.ClassroomId);
|
Assert.Equal(seed.LargeClassroom.Id, room.ClassroomId);
|
||||||
Assert.Null(persistedUntouched!.ClassroomId);
|
Assert.Equal(selected.Id, Assert.Single(room.SessionLinks).ExamSessionId);
|
||||||
Assert.Empty(persistedSelected.Invigilators);
|
Assert.Equal(3, room.Seats.Count);
|
||||||
Assert.Contains("1个场次处理完成", result.Message);
|
Assert.Single(room.Invigilators);
|
||||||
|
Assert.False(await db.ExamRoomSessions.AnyAsync(
|
||||||
|
x => x.ExamSessionId == untouched.Id));
|
||||||
|
Assert.Contains("1个教学班场次处理完成", result.Message);
|
||||||
|
Assert.Contains("1名监考教师", result.Message);
|
||||||
Assert.Contains("程序设计", result.Message);
|
Assert.Contains("程序设计", result.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Makeup_arrangement_only_updates_selected_sessions_and_requested_resource()
|
public async Task Same_course_sessions_are_interleaved_and_split_across_rooms()
|
||||||
|
{
|
||||||
|
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 seed = await SeedBaseDataAsync(db, "MIXED");
|
||||||
|
seed.LargeClassroom.Capacity = 8;
|
||||||
|
seed.SmallClassroom.Capacity = 4;
|
||||||
|
|
||||||
|
var plan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = seed.Term.Id,
|
||||||
|
Name = "期末考试"
|
||||||
|
};
|
||||||
|
var first = NewExamSession(plan.Id, seed.FirstTask.Id);
|
||||||
|
var second = NewExamSession(plan.Id, seed.SameCourseTask.Id);
|
||||||
|
plan.Sessions.Add(first);
|
||||||
|
plan.Sessions.Add(second);
|
||||||
|
db.ExamPlans.Add(plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
var result = await new ExamArrangementService(db).ArrangeAsync(
|
||||||
|
plan.Id,
|
||||||
|
[first.Id],
|
||||||
|
assignClassrooms: true,
|
||||||
|
assignInvigilators: false,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var rooms = await db.ExamRooms
|
||||||
|
.Include(x => x.SessionLinks)
|
||||||
|
.Include(x => x.Seats)
|
||||||
|
.OrderByDescending(x => x.Seats.Count)
|
||||||
|
.ToListAsync();
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.Equal(2, rooms.Count);
|
||||||
|
Assert.Equal([4, 2], rooms.Select(x => x.Seats.Count).ToArray());
|
||||||
|
Assert.All(rooms, room =>
|
||||||
|
{
|
||||||
|
Assert.Equal(2, room.SessionLinks.Count);
|
||||||
|
Assert.Equal(2, room.Seats.Select(x => x.ExamSessionId).Distinct().Count());
|
||||||
|
Assert.True(room.Seats.Count <= 4);
|
||||||
|
});
|
||||||
|
Assert.Equal(6, await db.ExamSeats.CountAsync());
|
||||||
|
Assert.Contains("2个教学班混排至2个考场", result.Message);
|
||||||
|
Assert.Contains("自动包含同组1个场次", result.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Makeup_arrangement_only_updates_selected_session()
|
||||||
{
|
{
|
||||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
await connection.OpenAsync();
|
await connection.OpenAsync();
|
||||||
@@ -79,16 +139,22 @@ public sealed class ExamArrangementServiceTests
|
|||||||
plan.Id,
|
plan.Id,
|
||||||
[selected.Id],
|
[selected.Id],
|
||||||
assignClassrooms: true,
|
assignClassrooms: true,
|
||||||
assignInvigilators: false,
|
assignInvigilators: true,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
var persistedSelected = await db.MakeupExamSessions.FindAsync(selected.Id);
|
var persistedSelected = await db.MakeupExamSessions
|
||||||
var persistedUntouched = await db.MakeupExamSessions.FindAsync(untouched.Id);
|
.Include(x => x.Invigilators)
|
||||||
|
.SingleAsync(x => x.Id == selected.Id);
|
||||||
|
var persistedUntouched = await db.MakeupExamSessions
|
||||||
|
.Include(x => x.Invigilators)
|
||||||
|
.SingleAsync(x => x.Id == untouched.Id);
|
||||||
Assert.True(result.Success);
|
Assert.True(result.Success);
|
||||||
Assert.NotNull(persistedSelected!.ClassroomId);
|
Assert.NotNull(persistedSelected.ClassroomId);
|
||||||
Assert.Null(persistedUntouched!.ClassroomId);
|
Assert.Null(persistedUntouched.ClassroomId);
|
||||||
Assert.Empty(persistedSelected.Invigilators);
|
Assert.Single(persistedSelected.Invigilators);
|
||||||
|
Assert.Empty(persistedUntouched.Invigilators);
|
||||||
Assert.Contains("1个场次处理完成", result.Message);
|
Assert.Contains("1个场次处理完成", result.Message);
|
||||||
|
Assert.Contains("1名监考教师", result.Message);
|
||||||
Assert.Contains("程序设计", result.Message);
|
Assert.Contains("程序设计", result.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +213,34 @@ public sealed class ExamArrangementServiceTests
|
|||||||
BuildingId = building.Id,
|
BuildingId = building.Id,
|
||||||
Capacity = 80
|
Capacity = 80
|
||||||
};
|
};
|
||||||
|
var smallClassroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = $"R-SMALL-{suffix}",
|
||||||
|
Name = "102",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 1
|
||||||
|
};
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = $"MAJOR-{suffix}",
|
||||||
|
Name = "计算机科学与技术",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
DegreeType = "工学学士"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = $"CLASS-{suffix}",
|
||||||
|
Name = "计科 2026-1 班",
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = 2026
|
||||||
|
};
|
||||||
|
var secondAdministrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = $"CLASS-2-{suffix}",
|
||||||
|
Name = "计科 2026-2 班",
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = 2026
|
||||||
|
};
|
||||||
var firstCourse = new Course
|
var firstCourse = new Course
|
||||||
{
|
{
|
||||||
Code = $"COURSE-1-{suffix}",
|
Code = $"COURSE-1-{suffix}",
|
||||||
@@ -165,7 +259,14 @@ public sealed class ExamArrangementServiceTests
|
|||||||
Name = "程序设计教学班",
|
Name = "程序设计教学班",
|
||||||
AcademicTermId = term.Id,
|
AcademicTermId = term.Id,
|
||||||
CourseId = firstCourse.Id,
|
CourseId = firstCourse.Id,
|
||||||
Status = TeachingTaskStatus.Published
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Classes =
|
||||||
|
[
|
||||||
|
new TeachingTaskClass
|
||||||
|
{
|
||||||
|
AdministrativeClassId = administrativeClass.Id
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
var secondTask = new TeachingTask
|
var secondTask = new TeachingTask
|
||||||
{
|
{
|
||||||
@@ -175,6 +276,28 @@ public sealed class ExamArrangementServiceTests
|
|||||||
CourseId = secondCourse.Id,
|
CourseId = secondCourse.Id,
|
||||||
Status = TeachingTaskStatus.Published
|
Status = TeachingTaskStatus.Published
|
||||||
};
|
};
|
||||||
|
var sameCourseTask = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = $"TASK-3-{suffix}",
|
||||||
|
Name = "程序设计第二教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = firstCourse.Id,
|
||||||
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Classes =
|
||||||
|
[
|
||||||
|
new TeachingTaskClass
|
||||||
|
{
|
||||||
|
AdministrativeClassId = secondAdministrativeClass.Id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var invigilator = new Teacher
|
||||||
|
{
|
||||||
|
TeacherNumber = $"INVIGILATOR-{suffix}",
|
||||||
|
Name = "监考教师",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Status = TeacherStatus.Active
|
||||||
|
};
|
||||||
|
|
||||||
db.AddRange(
|
db.AddRange(
|
||||||
term,
|
term,
|
||||||
@@ -182,10 +305,64 @@ public sealed class ExamArrangementServiceTests
|
|||||||
college,
|
college,
|
||||||
building,
|
building,
|
||||||
classroom,
|
classroom,
|
||||||
|
smallClassroom,
|
||||||
|
major,
|
||||||
|
administrativeClass,
|
||||||
|
secondAdministrativeClass,
|
||||||
|
new Student
|
||||||
|
{
|
||||||
|
StudentNumber = $"STUDENT-1-{suffix}",
|
||||||
|
Name = "学生甲",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
},
|
||||||
|
new Student
|
||||||
|
{
|
||||||
|
StudentNumber = $"STUDENT-2-{suffix}",
|
||||||
|
Name = "学生乙",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
},
|
||||||
|
new Student
|
||||||
|
{
|
||||||
|
StudentNumber = $"STUDENT-3-{suffix}",
|
||||||
|
Name = "学生丙",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
},
|
||||||
|
new Student
|
||||||
|
{
|
||||||
|
StudentNumber = $"STUDENT-4-{suffix}",
|
||||||
|
Name = "学生丁",
|
||||||
|
AdministrativeClassId = secondAdministrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
},
|
||||||
|
new Student
|
||||||
|
{
|
||||||
|
StudentNumber = $"STUDENT-5-{suffix}",
|
||||||
|
Name = "学生戊",
|
||||||
|
AdministrativeClassId = secondAdministrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
},
|
||||||
|
new Student
|
||||||
|
{
|
||||||
|
StudentNumber = $"STUDENT-6-{suffix}",
|
||||||
|
Name = "学生己",
|
||||||
|
AdministrativeClassId = secondAdministrativeClass.Id,
|
||||||
|
EnrollmentYear = 2026,
|
||||||
|
EnrollmentDate = new DateOnly(2026, 9, 1)
|
||||||
|
},
|
||||||
firstCourse,
|
firstCourse,
|
||||||
secondCourse,
|
secondCourse,
|
||||||
firstTask,
|
firstTask,
|
||||||
secondTask,
|
secondTask,
|
||||||
|
sameCourseTask,
|
||||||
|
invigilator,
|
||||||
new ScheduleTimeSlot
|
new ScheduleTimeSlot
|
||||||
{
|
{
|
||||||
AcademicTermId = term.Id,
|
AcademicTermId = term.Id,
|
||||||
@@ -203,11 +380,20 @@ public sealed class ExamArrangementServiceTests
|
|||||||
EndsAt = new TimeOnly(9, 50)
|
EndsAt = new TimeOnly(9, 50)
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return new SeedData(term, firstTask, secondTask);
|
return new SeedData(
|
||||||
|
term,
|
||||||
|
firstTask,
|
||||||
|
secondTask,
|
||||||
|
sameCourseTask,
|
||||||
|
classroom,
|
||||||
|
smallClassroom);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record SeedData(
|
private sealed record SeedData(
|
||||||
AcademicTerm Term,
|
AcademicTerm Term,
|
||||||
TeachingTask FirstTask,
|
TeachingTask FirstTask,
|
||||||
TeachingTask SecondTask);
|
TeachingTask SecondTask,
|
||||||
|
TeachingTask SameCourseTask,
|
||||||
|
Classroom LargeClassroom,
|
||||||
|
Classroom SmallClassroom);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ExamDeletionControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveSessionsBatch_ValidatesCompleteSelectionBeforeRemoving()
|
||||||
|
{
|
||||||
|
await using var fixture = await Fixture.CreateAsync();
|
||||||
|
var controller = fixture.CreateController();
|
||||||
|
var selectedIds = fixture.DraftPlan.Sessions
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToArray();
|
||||||
|
var otherPlanSessionId = fixture.PublishedPlan.Sessions.Single().Id;
|
||||||
|
|
||||||
|
var invalidResult = await controller.RemoveSessionsBatch(
|
||||||
|
fixture.DraftPlan.Id,
|
||||||
|
new RemoveExamSessionsBatchRequest(
|
||||||
|
[selectedIds[0], otherPlanSessionId]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(invalidResult);
|
||||||
|
Assert.Equal(2, await fixture.Db.ExamSessions.CountAsync(
|
||||||
|
x => x.ExamPlanId == fixture.DraftPlan.Id));
|
||||||
|
|
||||||
|
var result = Assert.IsType<OkObjectResult>(
|
||||||
|
await controller.RemoveSessionsBatch(
|
||||||
|
fixture.DraftPlan.Id,
|
||||||
|
new RemoveExamSessionsBatchRequest(selectedIds),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
Assert.Equal(2, ReadIntProperty(result.Value!, "removedCount"));
|
||||||
|
Assert.False(await fixture.Db.ExamSessions.AnyAsync(
|
||||||
|
x => x.ExamPlanId == fixture.DraftPlan.Id));
|
||||||
|
Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync(
|
||||||
|
x => selectedIds.Contains(x.ExamSessionId)));
|
||||||
|
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
|
||||||
|
x => x.Id == fixture.DraftPlan.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveSessionsBatch_RejectsPublishedPlan()
|
||||||
|
{
|
||||||
|
await using var fixture = await Fixture.CreateAsync();
|
||||||
|
var controller = fixture.CreateController();
|
||||||
|
var sessionId = fixture.PublishedPlan.Sessions.Single().Id;
|
||||||
|
|
||||||
|
var result = await controller.RemoveSessionsBatch(
|
||||||
|
fixture.PublishedPlan.Id,
|
||||||
|
new RemoveExamSessionsBatchRequest([sessionId]),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
Assert.True(await fixture.Db.ExamSessions.AnyAsync(
|
||||||
|
x => x.Id == sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeletePlan_DeletesDraftWithSessions_AndRejectsPublishedPlan()
|
||||||
|
{
|
||||||
|
await using var fixture = await Fixture.CreateAsync();
|
||||||
|
var controller = fixture.CreateController();
|
||||||
|
var draftPlanId = fixture.DraftPlan.Id;
|
||||||
|
var publishedPlanId = fixture.PublishedPlan.Id;
|
||||||
|
var draftSessionIds = fixture.DraftPlan.Sessions
|
||||||
|
.Select(x => x.Id)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var publishedResult = await controller.DeletePlan(
|
||||||
|
publishedPlanId,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(publishedResult);
|
||||||
|
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
|
||||||
|
x => x.Id == publishedPlanId));
|
||||||
|
|
||||||
|
var draftResult = await controller.DeletePlan(
|
||||||
|
draftPlanId,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(draftResult);
|
||||||
|
Assert.False(await fixture.Db.ExamPlans.AnyAsync(
|
||||||
|
x => x.Id == draftPlanId));
|
||||||
|
Assert.False(await fixture.Db.ExamSessions.AnyAsync(
|
||||||
|
x => x.ExamPlanId == draftPlanId));
|
||||||
|
Assert.False(await fixture.Db.ExamSessionInvigilators.AnyAsync(
|
||||||
|
x => draftSessionIds.Contains(x.ExamSessionId)));
|
||||||
|
Assert.True(await fixture.Db.ExamPlans.AnyAsync(
|
||||||
|
x => x.Id == publishedPlanId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int ReadIntProperty(object value, string name) =>
|
||||||
|
(int)value.GetType().GetProperty(name)!.GetValue(value)!;
|
||||||
|
|
||||||
|
private sealed class Fixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection connection;
|
||||||
|
|
||||||
|
private Fixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
AppDbContext db,
|
||||||
|
ExamPlan draftPlan,
|
||||||
|
ExamPlan publishedPlan)
|
||||||
|
{
|
||||||
|
this.connection = connection;
|
||||||
|
Db = db;
|
||||||
|
DraftPlan = draftPlan;
|
||||||
|
PublishedPlan = publishedPlan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public ExamPlan DraftPlan { get; }
|
||||||
|
public ExamPlan PublishedPlan { get; }
|
||||||
|
|
||||||
|
public static async Task<Fixture> 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 college = new College
|
||||||
|
{
|
||||||
|
Code = "CS",
|
||||||
|
Name = "计算机学院"
|
||||||
|
};
|
||||||
|
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, 20)
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS101",
|
||||||
|
Name = "程序设计基础",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Credits = 4,
|
||||||
|
TotalHours = 64,
|
||||||
|
LectureHours = 48,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
var firstTask = CreateTask(
|
||||||
|
term.Id,
|
||||||
|
course.Id,
|
||||||
|
"2026-1-CS101-01");
|
||||||
|
var secondTask = CreateTask(
|
||||||
|
term.Id,
|
||||||
|
course.Id,
|
||||||
|
"2026-1-CS101-02");
|
||||||
|
var invigilator = new Teacher
|
||||||
|
{
|
||||||
|
TeacherNumber = "T001",
|
||||||
|
Name = "张老师",
|
||||||
|
CollegeId = college.Id
|
||||||
|
};
|
||||||
|
var firstDraftSession = CreateSession(
|
||||||
|
firstTask.Id,
|
||||||
|
new DateOnly(2027, 1, 8));
|
||||||
|
firstDraftSession.Invigilators =
|
||||||
|
[
|
||||||
|
new ExamSessionInvigilator
|
||||||
|
{
|
||||||
|
TeacherId = invigilator.Id
|
||||||
|
}
|
||||||
|
];
|
||||||
|
var draftPlan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
Name = "期末考试草稿",
|
||||||
|
Sessions =
|
||||||
|
[
|
||||||
|
firstDraftSession,
|
||||||
|
CreateSession(secondTask.Id, new DateOnly(2027, 1, 9))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var publishedPlan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
Name = "已发布期末考试",
|
||||||
|
Status = ExamPlanStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow,
|
||||||
|
Sessions =
|
||||||
|
[
|
||||||
|
CreateSession(firstTask.Id, new DateOnly(2027, 1, 10))
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
db.AddRange(
|
||||||
|
college,
|
||||||
|
term,
|
||||||
|
course,
|
||||||
|
firstTask,
|
||||||
|
secondTask,
|
||||||
|
invigilator,
|
||||||
|
draftPlan,
|
||||||
|
publishedPlan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new Fixture(
|
||||||
|
connection,
|
||||||
|
db,
|
||||||
|
draftPlan,
|
||||||
|
publishedPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExamsController CreateController() => new(
|
||||||
|
Db,
|
||||||
|
new ManagerDataScope(),
|
||||||
|
NoOpAppCache.Instance);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Db.DisposeAsync();
|
||||||
|
await connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TeachingTask CreateTask(
|
||||||
|
Guid termId,
|
||||||
|
Guid courseId,
|
||||||
|
string number) => new()
|
||||||
|
{
|
||||||
|
AcademicTermId = termId,
|
||||||
|
CourseId = courseId,
|
||||||
|
TaskNumber = number,
|
||||||
|
Name = $"教学班 {number}",
|
||||||
|
Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ExamSession CreateSession(
|
||||||
|
Guid taskId,
|
||||||
|
DateOnly examDate) => new()
|
||||||
|
{
|
||||||
|
TeachingTaskId = taskId,
|
||||||
|
ExamDate = examDate,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartsAt = examDate.ToDateTime(new TimeOnly(8, 0)),
|
||||||
|
EndsAt = examDate.ToDateTime(new TimeOnly(9, 50)),
|
||||||
|
RequiredInvigilatorCount = 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManagerDataScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"考试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.AcademicAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
using Jiaowu.Api.Controllers;
|
||||||
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ExamPlanPaginationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task GetPlan_PaginatesSearchesAndFiltersSessionsOnServer()
|
||||||
|
{
|
||||||
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
await using var db = new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CS101",
|
||||||
|
Name = "程序设计基础",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Credits = 4,
|
||||||
|
TotalHours = 64,
|
||||||
|
LectureHours = 48,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.MajorRequired,
|
||||||
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
|
};
|
||||||
|
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, 20)
|
||||||
|
};
|
||||||
|
var tasks = Enumerable.Range(1, 11)
|
||||||
|
.Select(number => new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = $"2026-1-CS101-{number:00}",
|
||||||
|
Name = number == 11 ? "专项课程教学班" : $"程序设计教学班 {number}",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 60,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
var plan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
Name = "期末考试",
|
||||||
|
Status = ExamPlanStatus.Draft,
|
||||||
|
Sessions = tasks.Select((task, index) => new ExamSession
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
ExamDate = new DateOnly(2027, 1, 8).AddDays(index),
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc)
|
||||||
|
.AddDays(index),
|
||||||
|
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc)
|
||||||
|
.AddDays(index),
|
||||||
|
RequiredInvigilatorCount = 1
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
db.AddRange(college, course, term);
|
||||||
|
db.AddRange(tasks);
|
||||||
|
db.Add(plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new ExamsController(
|
||||||
|
db,
|
||||||
|
new AllScope(),
|
||||||
|
NoOpAppCache.Instance);
|
||||||
|
|
||||||
|
var secondPage = ReadPlan(await controller.GetPlan(
|
||||||
|
plan.Id,
|
||||||
|
page: 2,
|
||||||
|
pageSize: 10,
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
Assert.Equal(11, ReadInt(secondPage, "TotalSessionCount"));
|
||||||
|
Assert.Equal(11, ReadInt(secondPage, "FilteredSessionCount"));
|
||||||
|
Assert.Equal(2, ReadInt(secondPage, "SessionPage"));
|
||||||
|
Assert.Single(ReadItems(secondPage, "Sessions"));
|
||||||
|
Assert.Equal(11, ReadItems(secondPage, "ScheduledTeachingTaskIds").Count);
|
||||||
|
|
||||||
|
var searchPage = ReadPlan(await controller.GetPlan(
|
||||||
|
plan.Id,
|
||||||
|
keyword: "专项",
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
Assert.Equal(11, ReadInt(searchPage, "TotalSessionCount"));
|
||||||
|
Assert.Equal(1, ReadInt(searchPage, "FilteredSessionCount"));
|
||||||
|
Assert.Single(ReadItems(searchPage, "Sessions"));
|
||||||
|
|
||||||
|
var completedPage = ReadPlan(await controller.GetPlan(
|
||||||
|
plan.Id,
|
||||||
|
allocation: "complete",
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
Assert.Equal(0, ReadInt(completedPage, "FilteredSessionCount"));
|
||||||
|
Assert.Empty(ReadItems(completedPage, "Sessions"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object ReadPlan(ActionResult result) =>
|
||||||
|
Assert.IsType<OkObjectResult>(result).Value!;
|
||||||
|
|
||||||
|
private static int ReadInt(object value, string property) =>
|
||||||
|
(int)value.GetType().GetProperty(property)!.GetValue(value)!;
|
||||||
|
|
||||||
|
private static List<object> ReadItems(object value, string property) =>
|
||||||
|
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
|
||||||
|
value.GetType().GetProperty(property)!.GetValue(value))
|
||||||
|
.Cast<object>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
private sealed class AllScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"测试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.SuperAdmin]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ExamSignInWorkbookExporterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Create_builds_summary_and_printable_session_sheets()
|
||||||
|
{
|
||||||
|
var sessionId = Guid.NewGuid();
|
||||||
|
var session = new ExamSignInSessionData(
|
||||||
|
sessionId,
|
||||||
|
new DateOnly(2027, 1, 8),
|
||||||
|
new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
|
||||||
|
new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
|
||||||
|
"CS101",
|
||||||
|
"程序设计基础",
|
||||||
|
"2026-1-CS101-01",
|
||||||
|
"第一教学楼",
|
||||||
|
"101",
|
||||||
|
["张老师", "李老师"],
|
||||||
|
[
|
||||||
|
new ExamSignInStudentData(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"202601001",
|
||||||
|
"周同学",
|
||||||
|
"计科 2026-1 班")
|
||||||
|
]);
|
||||||
|
var data = new ExamSignInWorkbookData(
|
||||||
|
"2026—2027 学年第一学期期末考试",
|
||||||
|
"2026—2027 学年第一学期",
|
||||||
|
[
|
||||||
|
session,
|
||||||
|
session with { SessionId = Guid.NewGuid() }
|
||||||
|
]);
|
||||||
|
|
||||||
|
var bytes = ExamSignInWorkbookExporter.Create(data);
|
||||||
|
|
||||||
|
using var stream = new MemoryStream(bytes);
|
||||||
|
using var workbook = new XLWorkbook(stream);
|
||||||
|
Assert.Equal(3, workbook.Worksheets.Count);
|
||||||
|
Assert.Equal("考场汇总", workbook.Worksheet(1).Name);
|
||||||
|
Assert.Equal(1, workbook.Worksheet("考场汇总").Cell("F5").GetDouble());
|
||||||
|
Assert.Equal(1, workbook.Worksheet("考场汇总").Cell("F6").GetDouble());
|
||||||
|
Assert.Equal(
|
||||||
|
workbook.Worksheets.Count,
|
||||||
|
workbook.Worksheets.Select(x => x.Name)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Count());
|
||||||
|
|
||||||
|
var signInSheet = workbook.Worksheet(2);
|
||||||
|
Assert.Equal("考试考场签名单", signInSheet.Cell("A1").GetString());
|
||||||
|
Assert.Contains("第一教学楼", signInSheet.Cell("E4").GetString());
|
||||||
|
Assert.Equal("202601001", signInSheet.Cell("C8").GetString());
|
||||||
|
Assert.Equal("周同学", signInSheet.Cell("D8").GetString());
|
||||||
|
Assert.Equal(XLPageOrientation.Portrait, signInSheet.PageSetup.PageOrientation);
|
||||||
|
Assert.Equal(XLPaperSize.A4Paper, signInSheet.PageSetup.PaperSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
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 ExperimentGradesControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task IndependentExperimentGrade_CanPublishAndImportAsCourseSnapshot()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
fixture.Db.ExperimentProjects.Add(new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = "LAB-C",
|
||||||
|
Name = "集中实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.Centralized,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
var experimentGrades = fixture.ExperimentGrades(fixture.AdminScope);
|
||||||
|
|
||||||
|
var created = await experimentGrades.CreateSheet(
|
||||||
|
new ExperimentGradeSheetRequest(
|
||||||
|
project.Id,
|
||||||
|
1,
|
||||||
|
60,
|
||||||
|
[
|
||||||
|
new ExperimentGradeItemRequest(
|
||||||
|
"操作",
|
||||||
|
ExperimentGradeItemKind.Operation,
|
||||||
|
50),
|
||||||
|
new ExperimentGradeItemRequest(
|
||||||
|
"报告",
|
||||||
|
ExperimentGradeItemKind.Report,
|
||||||
|
50)
|
||||||
|
]),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<CreatedResult>(created);
|
||||||
|
|
||||||
|
var sheet = await fixture.Db.ExperimentGradeSheets
|
||||||
|
.Include(x => x.Items)
|
||||||
|
.Include(x => x.Records)
|
||||||
|
.ThenInclude(x => x.ItemScores)
|
||||||
|
.SingleAsync();
|
||||||
|
var record = Assert.Single(sheet.Records);
|
||||||
|
var operation = sheet.Items.Single(x => x.Name == "操作");
|
||||||
|
var report = sheet.Items.Single(x => x.Name == "报告");
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await experimentGrades.UpdateRecords(
|
||||||
|
sheet.Id,
|
||||||
|
new ExperimentGradeRecordsRequest(
|
||||||
|
[
|
||||||
|
new ExperimentGradeRecordRequest(
|
||||||
|
record.Id,
|
||||||
|
ExperimentParticipationStatus.Completed,
|
||||||
|
false,
|
||||||
|
1,
|
||||||
|
"https://example.test/report",
|
||||||
|
DateTime.UtcNow,
|
||||||
|
false,
|
||||||
|
"完成规范",
|
||||||
|
[
|
||||||
|
new ExperimentGradeItemScoreRequest(
|
||||||
|
operation.Id,
|
||||||
|
80,
|
||||||
|
"操作正确"),
|
||||||
|
new ExperimentGradeItemScoreRequest(
|
||||||
|
report.Id,
|
||||||
|
90,
|
||||||
|
"报告完整")
|
||||||
|
])
|
||||||
|
]),
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await experimentGrades.Submit(
|
||||||
|
sheet.Id,
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await experimentGrades.Approve(
|
||||||
|
sheet.Id,
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await experimentGrades.Publish(
|
||||||
|
sheet.Id,
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
var publishedRecord = await fixture.Db.ExperimentGradeRecords
|
||||||
|
.SingleAsync();
|
||||||
|
Assert.Equal(85m, publishedRecord.TotalScore);
|
||||||
|
Assert.True(publishedRecord.IsPassed);
|
||||||
|
Assert.Equal(
|
||||||
|
ExperimentGradeSheetStatus.Published,
|
||||||
|
(await fixture.Db.ExperimentGradeSheets.SingleAsync()).Status);
|
||||||
|
|
||||||
|
var studentResult = await fixture
|
||||||
|
.ExperimentGrades(fixture.StudentScope)
|
||||||
|
.GetMine(CancellationToken.None);
|
||||||
|
Assert.IsType<OkObjectResult>(studentResult);
|
||||||
|
|
||||||
|
var courseSheet = new GradeSheet
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
RegularWeight = 0,
|
||||||
|
FinalWeight = 50,
|
||||||
|
Items =
|
||||||
|
[
|
||||||
|
new GradeItem
|
||||||
|
{
|
||||||
|
Name = "实验",
|
||||||
|
Weight = 50,
|
||||||
|
SortOrder = 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Records =
|
||||||
|
[
|
||||||
|
new GradeRecord
|
||||||
|
{
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
FinalScore = 80
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
var courseItem = courseSheet.Items.Single();
|
||||||
|
courseSheet.Records.Single().ItemScores.Add(new GradeItemScore
|
||||||
|
{
|
||||||
|
GradeItemId = courseItem.Id
|
||||||
|
});
|
||||||
|
fixture.Db.GradeSheets.Add(courseSheet);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var grades = new GradesController(fixture.Db, fixture.AdminScope);
|
||||||
|
Assert.IsType<OkObjectResult>(
|
||||||
|
await grades.ImportExperimentScores(
|
||||||
|
courseSheet.Id,
|
||||||
|
new ImportExperimentScoresRequest(courseItem.Id),
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
var importedItem = await fixture.Db.GradeItems.SingleAsync();
|
||||||
|
var importedRecord = await fixture.Db.GradeRecords
|
||||||
|
.Include(x => x.ItemScores)
|
||||||
|
.SingleAsync();
|
||||||
|
Assert.Equal(
|
||||||
|
GradeItemSourceType.ExperimentSummary,
|
||||||
|
importedItem.SourceType);
|
||||||
|
Assert.NotNull(importedItem.SourceSnapshotAt);
|
||||||
|
Assert.Equal(85m, importedRecord.ItemScores.Single().Score);
|
||||||
|
Assert.Equal(82.5m, importedRecord.TotalScore);
|
||||||
|
|
||||||
|
var manualOverride = await grades.UpdateRecords(
|
||||||
|
courseSheet.Id,
|
||||||
|
new GradeRecordsRequest(
|
||||||
|
[
|
||||||
|
new GradeRecordRequest(
|
||||||
|
importedRecord.Id,
|
||||||
|
null,
|
||||||
|
80,
|
||||||
|
[new GradeItemScoreRequest(importedItem.Id, 90)],
|
||||||
|
GradeExamStatus.Normal,
|
||||||
|
null)
|
||||||
|
]),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<ConflictObjectResult>(manualOverride);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SelfScheduledSheet_SyncsOnlyActiveBookings()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentGradeFixture.CreateAsync();
|
||||||
|
var project = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
Code = "LAB-S",
|
||||||
|
Name = "自主实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.SelfScheduled,
|
||||||
|
StartDate = fixture.Term.StartDate,
|
||||||
|
EndDate = fixture.Term.EndDate,
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
var session = new ExperimentSession
|
||||||
|
{
|
||||||
|
ExperimentProjectId = project.Id,
|
||||||
|
ClassroomId = fixture.Classroom.Id,
|
||||||
|
SessionDate = fixture.Term.StartDate,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
Capacity = 10,
|
||||||
|
ReservedCount = 1
|
||||||
|
};
|
||||||
|
var booking = new ExperimentBooking
|
||||||
|
{
|
||||||
|
ExperimentProjectId = project.Id,
|
||||||
|
ExperimentSessionId = session.Id,
|
||||||
|
StudentId = fixture.Student.Id,
|
||||||
|
Status = ExperimentBookingStatus.Booked,
|
||||||
|
BookedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
fixture.Db.AddRange(project, session, booking);
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
var controller = fixture.ExperimentGrades(fixture.AdminScope);
|
||||||
|
|
||||||
|
await controller.CreateSheet(
|
||||||
|
new ExperimentGradeSheetRequest(
|
||||||
|
project.Id,
|
||||||
|
1,
|
||||||
|
60,
|
||||||
|
[
|
||||||
|
new ExperimentGradeItemRequest(
|
||||||
|
"操作",
|
||||||
|
ExperimentGradeItemKind.Operation,
|
||||||
|
100)
|
||||||
|
]),
|
||||||
|
CancellationToken.None);
|
||||||
|
var sheet = await fixture.Db.ExperimentGradeSheets.SingleAsync();
|
||||||
|
Assert.Single(fixture.Db.ExperimentGradeRecords);
|
||||||
|
|
||||||
|
booking.Status = ExperimentBookingStatus.Cancelled;
|
||||||
|
booking.CancelledAt = DateTime.UtcNow;
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
Assert.IsType<OkObjectResult>(
|
||||||
|
await controller.SyncParticipants(
|
||||||
|
sheet.Id,
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.Empty(fixture.Db.ExperimentGradeRecords);
|
||||||
|
|
||||||
|
fixture.Db.ChangeTracker.Clear();
|
||||||
|
booking = await fixture.Db.ExperimentBookings.SingleAsync();
|
||||||
|
controller = fixture.ExperimentGrades(fixture.AdminScope);
|
||||||
|
booking.Status = ExperimentBookingStatus.Booked;
|
||||||
|
booking.CancelledAt = null;
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
Assert.IsType<OkObjectResult>(
|
||||||
|
await controller.SyncParticipants(
|
||||||
|
sheet.Id,
|
||||||
|
CancellationToken.None));
|
||||||
|
Assert.Single(fixture.Db.ExperimentGradeRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ExperimentGradeFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private ExperimentGradeFixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
AppDbContext db,
|
||||||
|
AcademicTerm term,
|
||||||
|
TeachingTask task,
|
||||||
|
Student student,
|
||||||
|
Classroom classroom,
|
||||||
|
ICurrentUserDataScope adminScope,
|
||||||
|
ICurrentUserDataScope studentScope)
|
||||||
|
{
|
||||||
|
Connection = connection;
|
||||||
|
Db = db;
|
||||||
|
Term = term;
|
||||||
|
Task = task;
|
||||||
|
Student = student;
|
||||||
|
Classroom = classroom;
|
||||||
|
AdminScope = adminScope;
|
||||||
|
StudentScope = studentScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Connection { get; }
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public AcademicTerm Term { get; }
|
||||||
|
public TeachingTask Task { get; }
|
||||||
|
public Student Student { get; }
|
||||||
|
public Classroom Classroom { get; }
|
||||||
|
public ICurrentUserDataScope AdminScope { get; }
|
||||||
|
public ICurrentUserDataScope StudentScope { get; }
|
||||||
|
|
||||||
|
public static async Task<ExperimentGradeFixture> CreateAsync()
|
||||||
|
{
|
||||||
|
var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
|
await connection.OpenAsync();
|
||||||
|
var db = new AppDbContext(
|
||||||
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
|
.UseSqlite(connection)
|
||||||
|
.Options);
|
||||||
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var admin = User("lab-admin", "实验管理员");
|
||||||
|
var studentUser = User("lab-student", "实验学生");
|
||||||
|
var teacherUser = User("lab-teacher", "实验教师");
|
||||||
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
CampusId = campus.Id,
|
||||||
|
Code = "LAB",
|
||||||
|
Name = "实验楼"
|
||||||
|
};
|
||||||
|
var classroom = new Classroom
|
||||||
|
{
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Code = "LAB201",
|
||||||
|
Name = "实验室 201",
|
||||||
|
Capacity = 30
|
||||||
|
};
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Code = "CS",
|
||||||
|
Name = "计算机科学与技术",
|
||||||
|
DegreeType = "工学"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
MajorId = major.Id,
|
||||||
|
Code = "CS2099",
|
||||||
|
Name = "计科 2099",
|
||||||
|
Grade = 2099
|
||||||
|
};
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
UserId = studentUser.Id,
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
StudentNumber = "20990001",
|
||||||
|
Name = "实验学生",
|
||||||
|
EnrollmentYear = 2099,
|
||||||
|
EnrollmentDate = new DateOnly(2099, 9, 1)
|
||||||
|
};
|
||||||
|
var teacher = new Teacher
|
||||||
|
{
|
||||||
|
UserId = teacherUser.Id,
|
||||||
|
CollegeId = college.Id,
|
||||||
|
TeacherNumber = "T2099",
|
||||||
|
Name = "实验教师"
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2099-1",
|
||||||
|
Name = "2099—2100 学年第一学期",
|
||||||
|
AcademicYear = "2099-2100",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2099, 9, 7),
|
||||||
|
EndDate = new DateOnly(2100, 1, 17),
|
||||||
|
IsCurrent = true
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Code = "CSLAB",
|
||||||
|
Name = "系统实验",
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 16,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.Practice,
|
||||||
|
AssessmentMethod = AssessmentMethod.Assessment
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
TaskNumber = "2099-1-CSLAB-01",
|
||||||
|
Name = "系统实验教学班",
|
||||||
|
Capacity = 30,
|
||||||
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Teachers =
|
||||||
|
[
|
||||||
|
new TeachingTaskTeacher
|
||||||
|
{
|
||||||
|
TeacherId = teacher.Id,
|
||||||
|
IsPrimary = true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Classes =
|
||||||
|
[
|
||||||
|
new TeachingTaskClass
|
||||||
|
{
|
||||||
|
AdministrativeClassId = administrativeClass.Id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
admin,
|
||||||
|
studentUser,
|
||||||
|
teacherUser,
|
||||||
|
campus,
|
||||||
|
building,
|
||||||
|
classroom,
|
||||||
|
college,
|
||||||
|
major,
|
||||||
|
administrativeClass,
|
||||||
|
student,
|
||||||
|
teacher,
|
||||||
|
term,
|
||||||
|
course,
|
||||||
|
task);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new ExperimentGradeFixture(
|
||||||
|
connection,
|
||||||
|
db,
|
||||||
|
term,
|
||||||
|
task,
|
||||||
|
student,
|
||||||
|
classroom,
|
||||||
|
Scope(admin, SystemRoles.SuperAdmin, DataScope.All),
|
||||||
|
Scope(studentUser, SystemRoles.Student, DataScope.Self));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExperimentGradesController ExperimentGrades(
|
||||||
|
ICurrentUserDataScope scope) => new(Db, scope);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Db.DisposeAsync();
|
||||||
|
await Connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationUser User(
|
||||||
|
string userName,
|
||||||
|
string displayName) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = userName,
|
||||||
|
NormalizedUserName = userName.ToUpperInvariant(),
|
||||||
|
DisplayName = displayName,
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ICurrentUserDataScope Scope(
|
||||||
|
ApplicationUser user,
|
||||||
|
string role,
|
||||||
|
DataScope dataScope) =>
|
||||||
|
new FixedScope(new CurrentUserScope(
|
||||||
|
user.Id,
|
||||||
|
user.DisplayName,
|
||||||
|
user.CollegeId,
|
||||||
|
dataScope,
|
||||||
|
new HashSet<string>([role])));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedScope(CurrentUserScope current)
|
||||||
|
: ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
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 Jiaowu.Api.Infrastructure.Timetables;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
|
public sealed class ExperimentsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task CentralizedProject_PublishesAndUsesTeachingTaskRoster()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var controller = fixture.Controller(fixture.ManagerScope);
|
||||||
|
var created = await controller.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<CreatedResult>(created);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
|
||||||
|
var sessionResult = await controller.CreateSession(
|
||||||
|
project.Id,
|
||||||
|
fixture.SessionRequest(1, 2, 10),
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<CreatedResult>(sessionResult);
|
||||||
|
Assert.Equal(
|
||||||
|
1,
|
||||||
|
(await fixture.Db.ExperimentSessions.SingleAsync()).Capacity);
|
||||||
|
|
||||||
|
var published = await controller.PublishProject(
|
||||||
|
project.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.IsType<NoContentResult>(published);
|
||||||
|
Assert.Equal(
|
||||||
|
ExperimentProjectStatus.Published,
|
||||||
|
(await fixture.Db.ExperimentProjects.SingleAsync()).Status);
|
||||||
|
|
||||||
|
var session = await fixture.Db.ExperimentSessions.SingleAsync();
|
||||||
|
var participants = await controller.GetParticipants(
|
||||||
|
session.Id,
|
||||||
|
CancellationToken.None);
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(participants);
|
||||||
|
var rows = Assert.IsAssignableFrom<IEnumerable<object>>(ok.Value);
|
||||||
|
Assert.Single(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SelfScheduledBooking_IsSinglePerProjectAndCanBeChanged()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await manager.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
await manager.CreateSession(
|
||||||
|
project.Id,
|
||||||
|
fixture.SessionRequest(3, 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
await manager.CreateSession(
|
||||||
|
project.Id,
|
||||||
|
fixture.SessionRequest(5, 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
await manager.PublishProject(project.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var sessions = await fixture.Db.ExperimentSessions
|
||||||
|
.OrderBy(x => x.StartPeriod)
|
||||||
|
.ToListAsync();
|
||||||
|
var student = fixture.Controller(fixture.StudentScope);
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await student.Book(sessions[0].Id, CancellationToken.None));
|
||||||
|
Assert.IsType<ConflictObjectResult>(
|
||||||
|
await student.Book(sessions[1].Id, CancellationToken.None));
|
||||||
|
|
||||||
|
var booking = await fixture.Db.ExperimentBookings.SingleAsync();
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await student.CancelBooking(booking.Id, CancellationToken.None));
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await student.Book(sessions[1].Id, CancellationToken.None));
|
||||||
|
|
||||||
|
var active = await fixture.Db.ExperimentBookings.SingleAsync();
|
||||||
|
Assert.Equal(ExperimentBookingStatus.Booked, active.Status);
|
||||||
|
Assert.Equal(sessions[1].Id, active.ExperimentSessionId);
|
||||||
|
Assert.Equal(0, (await fixture.Db.ExperimentSessions.FindAsync(
|
||||||
|
sessions[0].Id))!.ReservedCount);
|
||||||
|
Assert.Equal(1, (await fixture.Db.ExperimentSessions.FindAsync(
|
||||||
|
sessions[1].Id))!.ReservedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StudentBooking_RejectsPublishedTimetableConflict()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await manager.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
await manager.CreateSession(
|
||||||
|
project.Id,
|
||||||
|
fixture.SessionRequest(1, 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
await manager.PublishProject(project.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
fixture.Db.SchedulePlans.Add(new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = fixture.Term.Id,
|
||||||
|
Name = "正式课表",
|
||||||
|
Version = "V1",
|
||||||
|
Status = SchedulePlanStatus.Published,
|
||||||
|
Entries =
|
||||||
|
[
|
||||||
|
new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ClassroomId = fixture.SecondClassroom.Id,
|
||||||
|
DayOfWeek = 1,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 18,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var session = await fixture.Db.ExperimentSessions.SingleAsync();
|
||||||
|
var result = await fixture.Controller(fixture.StudentScope)
|
||||||
|
.Book(session.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
Assert.Empty(fixture.Db.ExperimentBookings);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CentralizedSession_RejectsRelatedPublishedTimetableConflict()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
await manager.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
|
||||||
|
CancellationToken.None);
|
||||||
|
var project = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
fixture.Db.SchedulePlans.Add(new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTermId = fixture.Term.Id,
|
||||||
|
Name = "正式课表",
|
||||||
|
Version = "V1",
|
||||||
|
Status = SchedulePlanStatus.Published,
|
||||||
|
Entries =
|
||||||
|
[
|
||||||
|
new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTaskId = fixture.Task.Id,
|
||||||
|
ClassroomId = fixture.SecondClassroom.Id,
|
||||||
|
DayOfWeek = 1,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 18,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await fixture.Db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await manager.CreateSession(
|
||||||
|
project.Id,
|
||||||
|
fixture.SessionRequest(1, 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<ConflictObjectResult>(result);
|
||||||
|
Assert.Empty(fixture.Db.ExperimentSessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task StudentTimetable_IncludesCentralizedAndBookedSelfScheduledSessions()
|
||||||
|
{
|
||||||
|
await using var fixture = await ExperimentFixture.CreateAsync();
|
||||||
|
var manager = fixture.Controller(fixture.ManagerScope);
|
||||||
|
|
||||||
|
await manager.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.Centralized),
|
||||||
|
CancellationToken.None);
|
||||||
|
var centralized = await fixture.Db.ExperimentProjects.SingleAsync();
|
||||||
|
await manager.CreateSession(
|
||||||
|
centralized.Id,
|
||||||
|
fixture.SessionRequest(1, 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
await manager.PublishProject(centralized.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
await manager.CreateProject(
|
||||||
|
fixture.ProjectRequest(ExperimentArrangementMode.SelfScheduled),
|
||||||
|
CancellationToken.None);
|
||||||
|
var selfScheduled = await fixture.Db.ExperimentProjects.SingleAsync(
|
||||||
|
x => x.ArrangementMode == ExperimentArrangementMode.SelfScheduled);
|
||||||
|
await manager.CreateSession(
|
||||||
|
selfScheduled.Id,
|
||||||
|
fixture.SessionRequest(4, 2),
|
||||||
|
CancellationToken.None);
|
||||||
|
await manager.PublishProject(selfScheduled.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var selfSession = await fixture.Db.ExperimentSessions.SingleAsync(
|
||||||
|
x => x.ExperimentProjectId == selfScheduled.Id);
|
||||||
|
var studentController = fixture.Controller(fixture.StudentScope);
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await studentController.Book(
|
||||||
|
selfSession.Id,
|
||||||
|
CancellationToken.None));
|
||||||
|
|
||||||
|
var student = await fixture.Db.Students.AsNoTracking().SingleAsync();
|
||||||
|
var service = new TimetableDataService(fixture.Db);
|
||||||
|
var timetable = await service.BuildAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
student.AdministrativeClassId,
|
||||||
|
fixture.Term.Id,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
student.Id,
|
||||||
|
new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(timetable);
|
||||||
|
Assert.Equal(2, timetable.ExperimentEntries.Count);
|
||||||
|
Assert.Contains(
|
||||||
|
timetable.ExperimentEntries,
|
||||||
|
x => x.ExperimentArrangementMode ==
|
||||||
|
ExperimentArrangementMode.Centralized);
|
||||||
|
Assert.Contains(
|
||||||
|
timetable.ExperimentEntries,
|
||||||
|
x => x.Id == selfSession.Id &&
|
||||||
|
x.ExperimentArrangementMode ==
|
||||||
|
ExperimentArrangementMode.SelfScheduled);
|
||||||
|
Assert.All(
|
||||||
|
timetable.ExperimentEntries,
|
||||||
|
x =>
|
||||||
|
{
|
||||||
|
Assert.True(x.IsExperiment);
|
||||||
|
Assert.Equal(fixture.Term.StartDate, x.ExperimentDate);
|
||||||
|
Assert.Equal(1, x.StartWeek);
|
||||||
|
});
|
||||||
|
|
||||||
|
var publicClassTimetable = await service.BuildAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
student.AdministrativeClassId,
|
||||||
|
fixture.Term.Id,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.NotNull(publicClassTimetable);
|
||||||
|
Assert.Empty(publicClassTimetable.ExperimentEntries);
|
||||||
|
|
||||||
|
var booking = await fixture.Db.ExperimentBookings.SingleAsync();
|
||||||
|
Assert.IsType<NoContentResult>(
|
||||||
|
await studentController.CancelBooking(
|
||||||
|
booking.Id,
|
||||||
|
CancellationToken.None));
|
||||||
|
timetable = await service.BuildAsync(
|
||||||
|
TimetableResourceType.Class,
|
||||||
|
student.AdministrativeClassId,
|
||||||
|
fixture.Term.Id,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
student.Id,
|
||||||
|
new TimetableStudentDto(student.StudentNumber, student.Name),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(timetable);
|
||||||
|
Assert.Single(timetable.ExperimentEntries);
|
||||||
|
Assert.Equal(
|
||||||
|
ExperimentArrangementMode.Centralized,
|
||||||
|
timetable.ExperimentEntries[0].ExperimentArrangementMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ExperimentFixture : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private ExperimentFixture(
|
||||||
|
SqliteConnection connection,
|
||||||
|
AppDbContext db,
|
||||||
|
AcademicTerm term,
|
||||||
|
TeachingTask task,
|
||||||
|
Classroom classroom,
|
||||||
|
Classroom secondClassroom,
|
||||||
|
ICurrentUserDataScope managerScope,
|
||||||
|
ICurrentUserDataScope studentScope)
|
||||||
|
{
|
||||||
|
Connection = connection;
|
||||||
|
Db = db;
|
||||||
|
Term = term;
|
||||||
|
Task = task;
|
||||||
|
Classroom = classroom;
|
||||||
|
SecondClassroom = secondClassroom;
|
||||||
|
ManagerScope = managerScope;
|
||||||
|
StudentScope = studentScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SqliteConnection Connection { get; }
|
||||||
|
public AppDbContext Db { get; }
|
||||||
|
public AcademicTerm Term { get; }
|
||||||
|
public TeachingTask Task { get; }
|
||||||
|
public Classroom Classroom { get; }
|
||||||
|
public Classroom SecondClassroom { get; }
|
||||||
|
public ICurrentUserDataScope ManagerScope { get; }
|
||||||
|
public ICurrentUserDataScope StudentScope { get; }
|
||||||
|
|
||||||
|
public static async Task<ExperimentFixture> 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 manager = User("manager", "学院实验管理员");
|
||||||
|
var studentUser = User("student", "实验学生");
|
||||||
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "LAB",
|
||||||
|
Name = "实验中心",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var classroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "LAB101",
|
||||||
|
Name = "实验室 101",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 40
|
||||||
|
};
|
||||||
|
var secondClassroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "LAB102",
|
||||||
|
Name = "实验室 102",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 40
|
||||||
|
};
|
||||||
|
var college = new College { Code = "CS", Name = "计算机学院" };
|
||||||
|
manager.CollegeId = college.Id;
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = "CS",
|
||||||
|
Name = "计算机科学与技术",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
DegreeType = "工学"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "CS2099",
|
||||||
|
Name = "计科 2099",
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = 2099
|
||||||
|
};
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "20990001",
|
||||||
|
Name = "实验学生",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = 2099,
|
||||||
|
EnrollmentDate = new DateOnly(2099, 9, 1),
|
||||||
|
UserId = studentUser.Id
|
||||||
|
};
|
||||||
|
var teacher = new Teacher
|
||||||
|
{
|
||||||
|
TeacherNumber = "T2099",
|
||||||
|
Name = "实验教师",
|
||||||
|
CollegeId = college.Id
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2099-1",
|
||||||
|
Name = "2099—2100 学年第一学期",
|
||||||
|
AcademicYear = "2099-2100",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2099, 9, 7),
|
||||||
|
EndDate = new DateOnly(2100, 1, 17),
|
||||||
|
IsCurrent = true
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "CSLAB",
|
||||||
|
Name = "系统实验",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 16,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.Practice,
|
||||||
|
AssessmentMethod = AssessmentMethod.Assessment
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "2099-1-CSLAB-01",
|
||||||
|
Name = "系统实验教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 40,
|
||||||
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Teachers =
|
||||||
|
[
|
||||||
|
new TeachingTaskTeacher
|
||||||
|
{
|
||||||
|
TeacherId = teacher.Id,
|
||||||
|
IsPrimary = true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Classes =
|
||||||
|
[
|
||||||
|
new TeachingTaskClass
|
||||||
|
{
|
||||||
|
AdministrativeClassId = administrativeClass.Id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
db.AddRange(
|
||||||
|
manager,
|
||||||
|
studentUser,
|
||||||
|
campus,
|
||||||
|
building,
|
||||||
|
classroom,
|
||||||
|
secondClassroom,
|
||||||
|
college,
|
||||||
|
major,
|
||||||
|
administrativeClass,
|
||||||
|
student,
|
||||||
|
teacher,
|
||||||
|
term,
|
||||||
|
course,
|
||||||
|
task);
|
||||||
|
for (var period = 1; period <= 12; period++)
|
||||||
|
{
|
||||||
|
db.ScheduleTimeSlots.Add(new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
PeriodNumber = period,
|
||||||
|
Name = $"第 {period} 节",
|
||||||
|
StartsAt = new TimeOnly(8, 0).AddMinutes((period - 1) * 50),
|
||||||
|
EndsAt = new TimeOnly(8, 45).AddMinutes((period - 1) * 50)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return new ExperimentFixture(
|
||||||
|
connection,
|
||||||
|
db,
|
||||||
|
term,
|
||||||
|
task,
|
||||||
|
classroom,
|
||||||
|
secondClassroom,
|
||||||
|
Scope(
|
||||||
|
manager,
|
||||||
|
SystemRoles.CollegeAdmin,
|
||||||
|
DataScope.College),
|
||||||
|
Scope(studentUser, SystemRoles.Student, DataScope.Self));
|
||||||
|
}
|
||||||
|
|
||||||
|
public ExperimentsController Controller(
|
||||||
|
ICurrentUserDataScope currentScope) =>
|
||||||
|
new(
|
||||||
|
Db,
|
||||||
|
currentScope,
|
||||||
|
new ClassroomReservationAvailabilityService(Db));
|
||||||
|
|
||||||
|
public ExperimentProjectRequest ProjectRequest(
|
||||||
|
ExperimentArrangementMode mode) =>
|
||||||
|
new(
|
||||||
|
Task.Id,
|
||||||
|
mode == ExperimentArrangementMode.Centralized
|
||||||
|
? "LAB-C"
|
||||||
|
: "LAB-S",
|
||||||
|
mode == ExperimentArrangementMode.Centralized
|
||||||
|
? "集中上机实验"
|
||||||
|
: "自主上机实验",
|
||||||
|
mode,
|
||||||
|
"完成规定实验项目。",
|
||||||
|
"携带校园卡。",
|
||||||
|
Term.StartDate,
|
||||||
|
Term.StartDate.AddDays(14));
|
||||||
|
|
||||||
|
public ExperimentSessionRequest SessionRequest(
|
||||||
|
int startPeriod,
|
||||||
|
int periodCount,
|
||||||
|
int capacity = 1) =>
|
||||||
|
new(
|
||||||
|
Classroom.Id,
|
||||||
|
Term.StartDate,
|
||||||
|
startPeriod,
|
||||||
|
periodCount,
|
||||||
|
capacity,
|
||||||
|
null);
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await Db.DisposeAsync();
|
||||||
|
await Connection.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ApplicationUser User(
|
||||||
|
string userName,
|
||||||
|
string displayName) =>
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = userName,
|
||||||
|
NormalizedUserName = userName.ToUpperInvariant(),
|
||||||
|
DisplayName = displayName,
|
||||||
|
IsEnabled = true
|
||||||
|
};
|
||||||
|
|
||||||
|
private static ICurrentUserDataScope Scope(
|
||||||
|
ApplicationUser user,
|
||||||
|
string role,
|
||||||
|
DataScope dataScope) =>
|
||||||
|
new FixedScope(new CurrentUserScope(
|
||||||
|
user.Id,
|
||||||
|
user.DisplayName,
|
||||||
|
user.CollegeId,
|
||||||
|
dataScope,
|
||||||
|
new HashSet<string>([role])));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FixedScope(CurrentUserScope current)
|
||||||
|
: ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Jiaowu.Api.Infrastructure.Experiments;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Jiaowu.Api.Infrastructure.Teaching;
|
using Jiaowu.Api.Infrastructure.Teaching;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -10,7 +12,7 @@ namespace Jiaowu.Api.Tests;
|
|||||||
public sealed class MySqlMigrationTests
|
public sealed class MySqlMigrationTests
|
||||||
{
|
{
|
||||||
private const string LatestMigration =
|
private const string LatestMigration =
|
||||||
"20260726190000_PersonalCalendarSubscription";
|
"20260727133346_ExamArrangementJobs";
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Production_migration_is_discoverable_and_generates_mysql_sql()
|
public void Production_migration_is_discoverable_and_generates_mysql_sql()
|
||||||
@@ -53,6 +55,14 @@ public sealed class MySqlMigrationTests
|
|||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
"ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL",
|
"ADD `CalendarSubscriptionCreatedAt` datetime(6) NULL",
|
||||||
script);
|
script);
|
||||||
|
Assert.Contains("CREATE TABLE `ExamRooms`", script);
|
||||||
|
Assert.Contains("CREATE TABLE `ExamRoomSessions`", script);
|
||||||
|
Assert.Contains("CREATE TABLE `ExamSeats`", script);
|
||||||
|
Assert.Contains("CREATE TABLE `ExamRoomInvigilators`", script);
|
||||||
|
Assert.Contains("CREATE TABLE `ExamArrangementJobs`", script);
|
||||||
|
Assert.Contains(
|
||||||
|
"CREATE UNIQUE INDEX `UX_ExamArrangementJobs_Kind_ActivePlan`",
|
||||||
|
script);
|
||||||
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
|
Assert.Contains("SET `ExamDate` = DATE(`StartsAt`)", script);
|
||||||
Assert.Contains("DEFAULT 1", script);
|
Assert.Contains("DEFAULT 1", script);
|
||||||
Assert.DoesNotContain("0001-01-01", script);
|
Assert.DoesNotContain("0001-01-01", script);
|
||||||
@@ -99,19 +109,130 @@ public sealed class MySqlMigrationTests
|
|||||||
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains(ids[1].ToString(), sql, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MySql_attendance_device_hash_query_uses_provider_safe_predicates()
|
||||||
|
{
|
||||||
|
using var db = new AppDbContext(CreateMySqlOptions());
|
||||||
|
var deviceHashes = new[] { "device-hash-one", "device-hash-two" };
|
||||||
|
|
||||||
|
var sql = db.AttendanceCheckInAttempts
|
||||||
|
.Where(x =>
|
||||||
|
x.IsSuccessful &&
|
||||||
|
x.CreatedAt >= DateTime.UtcNow.AddHours(-24) &&
|
||||||
|
x.DeviceIdentifierHash != null)
|
||||||
|
.WhereIn(deviceHashes, x => x.DeviceIdentifierHash!)
|
||||||
|
.GroupBy(x => x.DeviceIdentifierHash!)
|
||||||
|
.Select(x => new
|
||||||
|
{
|
||||||
|
DeviceIdentifierHash = x.Key,
|
||||||
|
StudentCount = x.Select(a => a.StudentId).Distinct().Count()
|
||||||
|
})
|
||||||
|
.ToQueryString();
|
||||||
|
|
||||||
|
Assert.Contains(" IN (", sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(deviceHashes[0], sql, StringComparison.Ordinal);
|
||||||
|
Assert.Contains(deviceHashes[1], sql, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MySql_invigilator_candidate_query_is_translatable_without_uuid_ordering()
|
||||||
|
{
|
||||||
|
using var db = new AppDbContext(CreateMySqlOptions());
|
||||||
|
var excludedTeacherId =
|
||||||
|
Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
|
|
||||||
|
var sql = InvigilatorCandidateQuery
|
||||||
|
.Create(db, [excludedTeacherId])
|
||||||
|
.ToQueryString();
|
||||||
|
|
||||||
|
Assert.Contains("Teachers", sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.DoesNotContain("UUID(", sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MySql_teaching_task_roster_query_is_translatable()
|
public void MySql_teaching_task_roster_query_is_translatable()
|
||||||
{
|
{
|
||||||
using var db = new AppDbContext(CreateMySqlOptions());
|
using var db = new AppDbContext(CreateMySqlOptions());
|
||||||
|
var studentId =
|
||||||
|
Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||||
|
|
||||||
var sql = TeachingTaskRosterQuery
|
var sql = TeachingTaskRosterQuery
|
||||||
.ForTask(
|
.ForTask(
|
||||||
db,
|
db,
|
||||||
Guid.Parse("11111111-1111-1111-1111-111111111111"))
|
Guid.Parse("11111111-1111-1111-1111-111111111111"))
|
||||||
.ToQueryString();
|
.ToQueryString();
|
||||||
|
var taskIdsSql = TeachingTaskRosterQuery
|
||||||
|
.TaskIdsForStudent(db, studentId)
|
||||||
|
.ToQueryString();
|
||||||
|
|
||||||
Assert.Contains("TeachingTaskClasses", sql, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("TeachingTaskClasses", sql, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Contains("CourseEnrollments", sql, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("CourseEnrollments", sql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(
|
||||||
|
"TeachingTaskClasses",
|
||||||
|
taskIdsSql,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(
|
||||||
|
"CourseEnrollments",
|
||||||
|
taskIdsSql,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MySql_centralized_experiment_conflict_queries_are_translatable()
|
||||||
|
{
|
||||||
|
using var db = new AppDbContext(CreateMySqlOptions());
|
||||||
|
var firstId =
|
||||||
|
Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||||
|
var secondId =
|
||||||
|
Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||||
|
Guid[] relatedIds = [firstId, secondId];
|
||||||
|
|
||||||
|
var teacherTaskSql = CentralizedExperimentConflictQuery
|
||||||
|
.TaskIdsForTeachers(db, relatedIds)
|
||||||
|
.ToQueryString();
|
||||||
|
var classTaskSql = CentralizedExperimentConflictQuery
|
||||||
|
.TaskIdsForClasses(db, relatedIds)
|
||||||
|
.ToQueryString();
|
||||||
|
var scheduleSql = CentralizedExperimentConflictQuery
|
||||||
|
.ScheduleEntries(
|
||||||
|
db,
|
||||||
|
relatedIds,
|
||||||
|
firstId,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
2)
|
||||||
|
.ToQueryString();
|
||||||
|
var experimentSql = CentralizedExperimentConflictQuery
|
||||||
|
.ExperimentSessions(
|
||||||
|
db,
|
||||||
|
relatedIds,
|
||||||
|
new DateOnly(2026, 8, 1),
|
||||||
|
1,
|
||||||
|
2)
|
||||||
|
.ToQueryString();
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
"TeachingTaskTeachers",
|
||||||
|
teacherTaskSql,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(
|
||||||
|
"TeachingTaskClasses",
|
||||||
|
classTaskSql,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(
|
||||||
|
"ScheduleEntries",
|
||||||
|
scheduleSql,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(
|
||||||
|
"ExperimentSessions",
|
||||||
|
experimentSql,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(" IN (", scheduleSql, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains(
|
||||||
|
" IN (",
|
||||||
|
experimentSql,
|
||||||
|
StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -251,6 +251,198 @@ public sealed class PersonalCalendarServiceTests : IAsyncLifetime
|
|||||||
$"ICS line exceeds 75 octets: {line}"));
|
$"ICS line exceeds 75 octets: {line}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Build_creates_student_feed_with_booked_experiment()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Today);
|
||||||
|
var mondayOffset = today.DayOfWeek == DayOfWeek.Sunday
|
||||||
|
? 6
|
||||||
|
: (int)today.DayOfWeek - 1;
|
||||||
|
var termStart = today.AddDays(-mondayOffset);
|
||||||
|
var user = new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserName = "calendar-student",
|
||||||
|
NormalizedUserName = "CALENDAR-STUDENT",
|
||||||
|
DisplayName = "实验学生",
|
||||||
|
CalendarSubscriptionStamp = PersonalCalendarService.CreateStamp(),
|
||||||
|
CalendarSubscriptionCreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
var campus = new Campus { Code = "LAB-MAIN", Name = "主校区" };
|
||||||
|
var college = new College
|
||||||
|
{
|
||||||
|
Code = "LAB-CS",
|
||||||
|
Name = "计算机学院",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var major = new Major
|
||||||
|
{
|
||||||
|
Code = "LAB-SE",
|
||||||
|
Name = "软件工程",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
DegreeType = "工学"
|
||||||
|
};
|
||||||
|
var administrativeClass = new AdministrativeClass
|
||||||
|
{
|
||||||
|
Code = "LAB-CLASS",
|
||||||
|
Name = "软件工程实验班",
|
||||||
|
MajorId = major.Id,
|
||||||
|
Grade = today.Year
|
||||||
|
};
|
||||||
|
var student = new Student
|
||||||
|
{
|
||||||
|
StudentNumber = "LAB-STUDENT-01",
|
||||||
|
Name = "实验学生",
|
||||||
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
|
EnrollmentYear = today.Year,
|
||||||
|
EnrollmentDate = termStart,
|
||||||
|
UserId = user.Id,
|
||||||
|
Status = StudentStatus.Active
|
||||||
|
};
|
||||||
|
var teacher = new Teacher
|
||||||
|
{
|
||||||
|
TeacherNumber = "LAB-TEACHER-01",
|
||||||
|
Name = "实验教师",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Status = TeacherStatus.Active
|
||||||
|
};
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "LAB-CALENDAR",
|
||||||
|
Name = "嵌入式系统实验",
|
||||||
|
CollegeId = college.Id,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 16,
|
||||||
|
PracticeHours = 16,
|
||||||
|
Nature = CourseNature.Practice,
|
||||||
|
AssessmentMethod = AssessmentMethod.Assessment
|
||||||
|
};
|
||||||
|
var term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "LAB-CALENDAR-TERM",
|
||||||
|
Name = "实验日历测试学期",
|
||||||
|
AcademicYear = $"{today.Year}-{today.Year + 1}",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = termStart,
|
||||||
|
EndDate = termStart.AddDays(40),
|
||||||
|
IsCurrent = true
|
||||||
|
};
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "LAB-CENTER",
|
||||||
|
Name = "实验中心",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var classroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "LAB-CENTER-301",
|
||||||
|
Name = "301",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 30
|
||||||
|
};
|
||||||
|
_db.AddRange(
|
||||||
|
user,
|
||||||
|
campus,
|
||||||
|
college,
|
||||||
|
major,
|
||||||
|
administrativeClass,
|
||||||
|
student,
|
||||||
|
teacher,
|
||||||
|
course,
|
||||||
|
term,
|
||||||
|
building,
|
||||||
|
classroom);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "LAB-CALENDAR-TASK",
|
||||||
|
Name = "嵌入式系统实验教学班",
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
CourseId = course.Id,
|
||||||
|
Capacity = 30,
|
||||||
|
Status = TeachingTaskStatus.Published,
|
||||||
|
Teachers =
|
||||||
|
[
|
||||||
|
new TeachingTaskTeacher
|
||||||
|
{
|
||||||
|
TeacherId = teacher.Id,
|
||||||
|
IsPrimary = true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Classes =
|
||||||
|
[
|
||||||
|
new TeachingTaskClass
|
||||||
|
{
|
||||||
|
AdministrativeClassId = administrativeClass.Id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
_db.AddRange(
|
||||||
|
task,
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
PeriodNumber = 3,
|
||||||
|
Name = "第三节",
|
||||||
|
StartsAt = new TimeOnly(10, 0),
|
||||||
|
EndsAt = new TimeOnly(10, 45)
|
||||||
|
},
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
PeriodNumber = 4,
|
||||||
|
Name = "第四节",
|
||||||
|
StartsAt = new TimeOnly(10, 55),
|
||||||
|
EndsAt = new TimeOnly(11, 40)
|
||||||
|
});
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var project = new ExperimentProject
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
Code = "LAB-SELF-01",
|
||||||
|
Name = "自主硬件实验",
|
||||||
|
ArrangementMode = ExperimentArrangementMode.SelfScheduled,
|
||||||
|
StartDate = termStart,
|
||||||
|
EndDate = termStart.AddDays(14),
|
||||||
|
Status = ExperimentProjectStatus.Published,
|
||||||
|
PublishedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
var session = new ExperimentSession
|
||||||
|
{
|
||||||
|
ExperimentProject = project,
|
||||||
|
ClassroomId = classroom.Id,
|
||||||
|
SessionDate = termStart.AddDays(2),
|
||||||
|
StartPeriod = 3,
|
||||||
|
PeriodCount = 2,
|
||||||
|
Capacity = 30,
|
||||||
|
ReservedCount = 1
|
||||||
|
};
|
||||||
|
session.Bookings.Add(new ExperimentBooking
|
||||||
|
{
|
||||||
|
ExperimentProject = project,
|
||||||
|
StudentId = student.Id,
|
||||||
|
Status = ExperimentBookingStatus.Booked,
|
||||||
|
BookedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
_db.Add(session);
|
||||||
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var result = await CreateService().BuildAsync(
|
||||||
|
user,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal(1, result.EventCount);
|
||||||
|
var ics = Encoding.UTF8.GetString(result.Content);
|
||||||
|
Assert.Contains("SUMMARY:[实验] 自主硬件实验", ics);
|
||||||
|
Assert.Contains("CATEGORIES:实验", ics);
|
||||||
|
Assert.Contains("LOCATION:主校区 · 实验中心 · 301", ics);
|
||||||
|
Assert.Contains("LAB-SELF-01 · 自主预约", ics);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Access_token_is_bound_to_user_stamp_and_can_be_rotated()
|
public void Access_token_is_bound_to_user_stamp_and_can_be_rotated()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Processor_validates_and_publishes_plan_atomically()
|
public async Task Processor_validates_and_publishes_plan_atomically()
|
||||||
{
|
{
|
||||||
var result = await RunPublishAsync(weeklyHours: 2);
|
var result = await RunPublishAsync(weeklyHours: 2, scheduledHours: 2);
|
||||||
|
|
||||||
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
||||||
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
||||||
@@ -26,17 +26,46 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Processor_keeps_draft_when_validation_fails()
|
public async Task Processor_keeps_draft_when_validation_fails()
|
||||||
{
|
{
|
||||||
var result = await RunPublishAsync(weeklyHours: 4);
|
var result = await RunPublishAsync(weeklyHours: 2, scheduledHours: 1);
|
||||||
|
|
||||||
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
||||||
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||||
Assert.Null(result.ActiveAcademicTermId);
|
Assert.Null(result.ActiveAcademicTermId);
|
||||||
Assert.Equal("检查未通过", result.CurrentStep);
|
Assert.Equal("检查未通过", result.CurrentStep);
|
||||||
Assert.Contains("尚未达到每周 4 学时", result.ErrorMessage);
|
Assert.Contains("尚未达到每周 2 个普通排课学时", result.ErrorMessage);
|
||||||
Assert.Null(result.PublishedAt);
|
Assert.Null(result.PublishedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<PublishResult> RunPublishAsync(int weeklyHours)
|
[Fact]
|
||||||
|
public async Task Processor_excludes_practice_hours_for_existing_tasks()
|
||||||
|
{
|
||||||
|
var result = await RunPublishAsync(
|
||||||
|
weeklyHours: 2,
|
||||||
|
scheduledHours: 1,
|
||||||
|
practiceHours: 16);
|
||||||
|
|
||||||
|
Assert.Equal(SchedulePublishJobStatus.Succeeded, result.JobStatus);
|
||||||
|
Assert.Equal(SchedulePlanStatus.Published, result.PlanStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Processor_rejects_practice_hours_already_added_to_draft()
|
||||||
|
{
|
||||||
|
var result = await RunPublishAsync(
|
||||||
|
weeklyHours: 2,
|
||||||
|
scheduledHours: 2,
|
||||||
|
practiceHours: 16);
|
||||||
|
|
||||||
|
Assert.Equal(SchedulePublishJobStatus.Failed, result.JobStatus);
|
||||||
|
Assert.Equal(SchedulePlanStatus.Draft, result.PlanStatus);
|
||||||
|
Assert.Contains("普通课表应为 1 学时", result.ErrorMessage);
|
||||||
|
Assert.Contains("删除已包含的实践学时", result.ErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<PublishResult> RunPublishAsync(
|
||||||
|
int weeklyHours,
|
||||||
|
int scheduledHours,
|
||||||
|
int practiceHours = 0)
|
||||||
{
|
{
|
||||||
var databasePath = Path.Combine(
|
var databasePath = Path.Combine(
|
||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
@@ -75,7 +104,8 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
College = college,
|
College = college,
|
||||||
Credits = 1,
|
Credits = 1,
|
||||||
TotalHours = 32,
|
TotalHours = 32,
|
||||||
LectureHours = 32
|
LectureHours = 32 - practiceHours,
|
||||||
|
PracticeHours = practiceHours
|
||||||
};
|
};
|
||||||
var task = new TeachingTask
|
var task = new TeachingTask
|
||||||
{
|
{
|
||||||
@@ -100,7 +130,7 @@ public sealed class SchedulePublishJobProcessorTests
|
|||||||
TeachingTask = task,
|
TeachingTask = task,
|
||||||
DayOfWeek = 1,
|
DayOfWeek = 1,
|
||||||
StartPeriod = 1,
|
StartPeriod = 1,
|
||||||
PeriodCount = 2,
|
PeriodCount = scheduledHours,
|
||||||
StartWeek = 1,
|
StartWeek = 1,
|
||||||
EndWeek = 16,
|
EndWeek = 16,
|
||||||
WeekPattern = WeekPattern.All
|
WeekPattern = WeekPattern.All
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ public sealed class ScheduleSettingsControllerTests
|
|||||||
});
|
});
|
||||||
Assert.Contains("测试教师", json);
|
Assert.Contains("测试教师", json);
|
||||||
Assert.Contains(classroom.Id.ToString(), json);
|
Assert.Contains(classroom.Id.ToString(), json);
|
||||||
|
Assert.Contains("\"WeeklyHours\":3", json);
|
||||||
|
Assert.Contains("\"CoursePracticeHours\":16", json);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -288,7 +290,6 @@ public sealed class ScheduleSettingsControllerTests
|
|||||||
var examTimeSlotsResult = await new ExamsController(
|
var examTimeSlotsResult = await new ExamsController(
|
||||||
db,
|
db,
|
||||||
null!,
|
null!,
|
||||||
null!,
|
|
||||||
NoOpAppCache.Instance)
|
NoOpAppCache.Instance)
|
||||||
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
||||||
var examTimeSlotsOk =
|
var examTimeSlotsOk =
|
||||||
@@ -300,7 +301,6 @@ public sealed class ScheduleSettingsControllerTests
|
|||||||
var makeupTimeSlotsResult = await new MakeupExamsController(
|
var makeupTimeSlotsResult = await new MakeupExamsController(
|
||||||
db,
|
db,
|
||||||
null!,
|
null!,
|
||||||
null!,
|
|
||||||
null!)
|
null!)
|
||||||
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
.GetTimeSlotsForTerm(term.Id, CancellationToken.None);
|
||||||
var makeupTimeSlotsOk =
|
var makeupTimeSlotsOk =
|
||||||
|
|||||||
@@ -12,6 +12,114 @@ namespace Jiaowu.Api.Tests;
|
|||||||
|
|
||||||
public sealed class SchedulesControllerTests
|
public sealed class SchedulesControllerTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Manual_entry_rejects_hours_reserved_for_experiments()
|
||||||
|
{
|
||||||
|
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 term = new AcademicTerm
|
||||||
|
{
|
||||||
|
Code = "2026-LAB",
|
||||||
|
Name = "2026 实验学时测试",
|
||||||
|
AcademicYear = "2026-2027",
|
||||||
|
Season = TermSeason.Autumn,
|
||||||
|
StartDate = new DateOnly(2026, 9, 7),
|
||||||
|
EndDate = new DateOnly(2027, 1, 17)
|
||||||
|
};
|
||||||
|
var college = new College { Code = "LAB", Name = "实验学院" };
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "LAB-01",
|
||||||
|
Name = "实验混合课程",
|
||||||
|
College = college,
|
||||||
|
Credits = 2,
|
||||||
|
TotalHours = 32,
|
||||||
|
LectureHours = 16,
|
||||||
|
PracticeHours = 16
|
||||||
|
};
|
||||||
|
var task = new TeachingTask
|
||||||
|
{
|
||||||
|
TaskNumber = "TASK-LAB",
|
||||||
|
Name = "实验混合教学班",
|
||||||
|
AcademicTerm = term,
|
||||||
|
Course = course,
|
||||||
|
Capacity = 30,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeeklyHours = 2,
|
||||||
|
Status = TeachingTaskStatus.Published
|
||||||
|
};
|
||||||
|
var plan = new SchedulePlan
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
Name = "排课草稿",
|
||||||
|
Version = "V1"
|
||||||
|
};
|
||||||
|
plan.Entries.Add(new ScheduleEntry
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
DayOfWeek = 1,
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 1,
|
||||||
|
StartWeek = 1,
|
||||||
|
EndWeek = 16,
|
||||||
|
WeekPattern = WeekPattern.All
|
||||||
|
});
|
||||||
|
db.AddRange(term, college, course, task, plan);
|
||||||
|
db.ScheduleTimeSlots.AddRange(
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
PeriodNumber = 1,
|
||||||
|
Name = "第 1 节",
|
||||||
|
StartsAt = new TimeOnly(8, 0),
|
||||||
|
EndsAt = new TimeOnly(8, 45)
|
||||||
|
},
|
||||||
|
new ScheduleTimeSlot
|
||||||
|
{
|
||||||
|
AcademicTerm = term,
|
||||||
|
PeriodNumber = 2,
|
||||||
|
Name = "第 2 节",
|
||||||
|
StartsAt = new TimeOnly(8, 55),
|
||||||
|
EndsAt = new TimeOnly(9, 40)
|
||||||
|
});
|
||||||
|
db.TeachingTaskScheduleConstraints.Add(
|
||||||
|
new TeachingTaskScheduleConstraint
|
||||||
|
{
|
||||||
|
TeachingTask = task,
|
||||||
|
RequiresClassroom = false
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = new SchedulesController(db);
|
||||||
|
var result = await controller.CreateEntry(
|
||||||
|
plan.Id,
|
||||||
|
new ScheduleEntryRequest(
|
||||||
|
task.Id,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
16,
|
||||||
|
WeekPattern.All,
|
||||||
|
null),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
var problem = Assert.IsType<ObjectResult>(result);
|
||||||
|
var details = Assert.IsType<ValidationProblemDetails>(problem.Value);
|
||||||
|
Assert.Contains(
|
||||||
|
"实践学时请在实验管理中安排",
|
||||||
|
details.Detail);
|
||||||
|
Assert.Single(await db.ScheduleEntries.ToListAsync());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Publish_returns_accepted_before_background_validation_runs()
|
public async Task Publish_returns_accepted_before_background_validation_runs()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,6 +94,36 @@ public sealed class StatisticsControllerTests
|
|||||||
Assert.Equal(2, cache.SourceCalls);
|
Assert.Equal(2, cache.SourceCalls);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Course_summary_keeps_request_scope_when_cache_factory_loses_http_context()
|
||||||
|
{
|
||||||
|
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 college = new College { Code = "C01", Name = "第一学院" };
|
||||||
|
var category = new CourseCategory { Code = "CAT", Name = "测试分类" };
|
||||||
|
db.AddRange(college, category, CreateCourse("C001", college, category));
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var scope = new MutableDataScope(college.Id);
|
||||||
|
var cache = new RecordingCache(() => scope.LoseHttpContext());
|
||||||
|
var controller = new StatisticsController(db, scope, cache);
|
||||||
|
|
||||||
|
var result = await controller.GetCourseSummary(
|
||||||
|
college.Id,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, TotalCourses(result));
|
||||||
|
Assert.Equal(1, cache.SourceCalls);
|
||||||
|
}
|
||||||
|
|
||||||
private static int TotalCourses(ActionResult<object> result)
|
private static int TotalCourses(ActionResult<object> result)
|
||||||
{
|
{
|
||||||
var json = Assert.IsType<JsonElement>(result.Value);
|
var json = Assert.IsType<JsonElement>(result.Value);
|
||||||
@@ -117,7 +147,7 @@ public sealed class StatisticsControllerTests
|
|||||||
AssessmentMethod = AssessmentMethod.Examination
|
AssessmentMethod = AssessmentMethod.Examination
|
||||||
};
|
};
|
||||||
|
|
||||||
private sealed class RecordingCache : IAppCache
|
private sealed class RecordingCache(Action? beforeFactory = null) : IAppCache
|
||||||
{
|
{
|
||||||
private readonly Dictionary<string, object> values = [];
|
private readonly Dictionary<string, object> values = [];
|
||||||
|
|
||||||
@@ -137,6 +167,7 @@ public sealed class StatisticsControllerTests
|
|||||||
|
|
||||||
SourceCalls++;
|
SourceCalls++;
|
||||||
Tags.Add(tags.ToArray());
|
Tags.Add(tags.ToArray());
|
||||||
|
beforeFactory?.Invoke();
|
||||||
var loaded = await factory(cancellationToken);
|
var loaded = await factory(cancellationToken);
|
||||||
values[key] = loaded!;
|
values[key] = loaded!;
|
||||||
return loaded;
|
return loaded;
|
||||||
@@ -157,4 +188,29 @@ public sealed class StatisticsControllerTests
|
|||||||
DataScope.College,
|
DataScope.College,
|
||||||
new HashSet<string> { SystemRoles.CollegeAdmin });
|
new HashSet<string> { SystemRoles.CollegeAdmin });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class MutableDataScope(Guid collegeId) : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
private CurrentUserScope current = CreateCollegeScope(collegeId);
|
||||||
|
|
||||||
|
public CurrentUserScope Current => current;
|
||||||
|
|
||||||
|
public void LoseHttpContext()
|
||||||
|
{
|
||||||
|
current = new CurrentUserScope(
|
||||||
|
Guid.Empty,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
DataScope.Self,
|
||||||
|
new HashSet<string>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static CurrentUserScope CreateCollegeScope(Guid value) =>
|
||||||
|
new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"学院管理员",
|
||||||
|
value,
|
||||||
|
DataScope.College,
|
||||||
|
new HashSet<string> { SystemRoles.CollegeAdmin });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,57 @@ public sealed class TeachingTaskHoursTests
|
|||||||
Assert.Contains("总学时为 32", result);
|
Assert.Contains("总学时为 32", result);
|
||||||
Assert.Contains("共 16 学时", result);
|
Assert.Contains("共 16 学时", result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Standard_schedule_excludes_practice_hours()
|
||||||
|
{
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "TEST-LAB",
|
||||||
|
Name = "实验混合课程",
|
||||||
|
CollegeId = Guid.NewGuid(),
|
||||||
|
Credits = 4,
|
||||||
|
TotalHours = 64,
|
||||||
|
LectureHours = 48,
|
||||||
|
PracticeHours = 16
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal(48, TeachingTaskHours.RegularScheduleHours(course));
|
||||||
|
Assert.True(TeachingTaskHours.TryResolveRegularWeeklyHours(
|
||||||
|
course,
|
||||||
|
1,
|
||||||
|
16,
|
||||||
|
out var weeklyHours));
|
||||||
|
Assert.Equal(3, weeklyHours);
|
||||||
|
Assert.Null(TeachingTaskHours.Validate(course, 1, 16, 3));
|
||||||
|
|
||||||
|
var result = TeachingTaskHours.Validate(course, 1, 16, 4);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Contains("普通课表应安排 48 学时", result);
|
||||||
|
Assert.Contains("实践学时 16", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flexible_schedule_keeps_total_workload_hours()
|
||||||
|
{
|
||||||
|
var course = new Course
|
||||||
|
{
|
||||||
|
Code = "TEST-PRACTICE",
|
||||||
|
Name = "实践课程",
|
||||||
|
CollegeId = Guid.NewGuid(),
|
||||||
|
Credits = 1,
|
||||||
|
TotalHours = 16,
|
||||||
|
PracticeHours = 16
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Null(TeachingTaskHours.Validate(
|
||||||
|
course,
|
||||||
|
1,
|
||||||
|
16,
|
||||||
|
1,
|
||||||
|
TeachingTaskSchedulingMode.Flexible));
|
||||||
|
Assert.Contains(
|
||||||
|
"无需进入普通课表",
|
||||||
|
TeachingTaskHours.Validate(course, 1, 16, 1)!);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
|
using ClosedXML.Excel;
|
||||||
using Jiaowu.Api.Controllers;
|
using Jiaowu.Api.Controllers;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Infrastructure.Exams;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Tests;
|
namespace Jiaowu.Api.Tests;
|
||||||
|
|
||||||
public sealed class TeachingWorkflowRosterTests
|
public sealed class TeachingWorkflowRosterTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClassAssignedStudents_AppearAcrossTeacherWorkflows()
|
public async Task ClassAssignedStudents_AppearAcrossTeachingAndExamWorkflows()
|
||||||
{
|
{
|
||||||
await using var connection = new SqliteConnection("Data Source=:memory:");
|
await using var connection = new SqliteConnection("Data Source=:memory:");
|
||||||
await connection.OpenAsync();
|
await connection.OpenAsync();
|
||||||
@@ -23,6 +27,7 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
await db.Database.EnsureCreatedAsync();
|
await db.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
var teacherUserId = Guid.NewGuid();
|
var teacherUserId = Guid.NewGuid();
|
||||||
|
var studentUserId = Guid.NewGuid();
|
||||||
var teacherUser = new ApplicationUser
|
var teacherUser = new ApplicationUser
|
||||||
{
|
{
|
||||||
Id = teacherUserId,
|
Id = teacherUserId,
|
||||||
@@ -30,7 +35,33 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
NormalizedUserName = "T001",
|
NormalizedUserName = "T001",
|
||||||
DisplayName = "张老师"
|
DisplayName = "张老师"
|
||||||
};
|
};
|
||||||
var college = new College { Code = "CS", Name = "计算机学院" };
|
var studentUser = new ApplicationUser
|
||||||
|
{
|
||||||
|
Id = studentUserId,
|
||||||
|
UserName = "202601001",
|
||||||
|
NormalizedUserName = "202601001",
|
||||||
|
DisplayName = "周同学"
|
||||||
|
};
|
||||||
|
var campus = new Campus { Code = "MAIN", Name = "主校区" };
|
||||||
|
var college = new College
|
||||||
|
{
|
||||||
|
Code = "CS",
|
||||||
|
Name = "计算机学院",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var building = new Building
|
||||||
|
{
|
||||||
|
Code = "B01",
|
||||||
|
Name = "第一教学楼",
|
||||||
|
CampusId = campus.Id
|
||||||
|
};
|
||||||
|
var classroom = new Classroom
|
||||||
|
{
|
||||||
|
Code = "B01-101",
|
||||||
|
Name = "101",
|
||||||
|
BuildingId = building.Id,
|
||||||
|
Capacity = 60
|
||||||
|
};
|
||||||
var major = new Major
|
var major = new Major
|
||||||
{
|
{
|
||||||
Code = "080901",
|
Code = "080901",
|
||||||
@@ -51,7 +82,8 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
Name = "周同学",
|
Name = "周同学",
|
||||||
AdministrativeClassId = administrativeClass.Id,
|
AdministrativeClassId = administrativeClass.Id,
|
||||||
EnrollmentYear = 2026,
|
EnrollmentYear = 2026,
|
||||||
EnrollmentDate = new DateOnly(2026, 9, 1)
|
EnrollmentDate = new DateOnly(2026, 9, 1),
|
||||||
|
UserId = studentUserId
|
||||||
};
|
};
|
||||||
var teacher = new Teacher
|
var teacher = new Teacher
|
||||||
{
|
{
|
||||||
@@ -107,7 +139,11 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
};
|
};
|
||||||
db.AddRange(
|
db.AddRange(
|
||||||
teacherUser,
|
teacherUser,
|
||||||
|
studentUser,
|
||||||
|
campus,
|
||||||
college,
|
college,
|
||||||
|
building,
|
||||||
|
classroom,
|
||||||
major,
|
major,
|
||||||
administrativeClass,
|
administrativeClass,
|
||||||
student,
|
student,
|
||||||
@@ -117,6 +153,63 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
task);
|
task);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var examSession = new ExamSession
|
||||||
|
{
|
||||||
|
TeachingTaskId = task.Id,
|
||||||
|
ExamDate = new DateOnly(2027, 1, 8),
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
|
||||||
|
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
|
||||||
|
RequiredInvigilatorCount = 1
|
||||||
|
};
|
||||||
|
var examPlan = new ExamPlan
|
||||||
|
{
|
||||||
|
AcademicTermId = term.Id,
|
||||||
|
Name = "期末考试",
|
||||||
|
Status = ExamPlanStatus.Draft,
|
||||||
|
Sessions = [examSession],
|
||||||
|
Rooms =
|
||||||
|
[
|
||||||
|
new ExamRoomAssignment
|
||||||
|
{
|
||||||
|
CourseId = course.Id,
|
||||||
|
ClassroomId = classroom.Id,
|
||||||
|
ExamDate = new DateOnly(2027, 1, 8),
|
||||||
|
StartPeriod = 1,
|
||||||
|
PeriodCount = 2,
|
||||||
|
StartsAt = new DateTime(2027, 1, 8, 8, 0, 0, DateTimeKind.Utc),
|
||||||
|
EndsAt = new DateTime(2027, 1, 8, 9, 50, 0, DateTimeKind.Utc),
|
||||||
|
RequiredInvigilatorCount = 1,
|
||||||
|
SessionLinks =
|
||||||
|
[
|
||||||
|
new ExamRoomSession
|
||||||
|
{
|
||||||
|
ExamSessionId = examSession.Id
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Seats =
|
||||||
|
[
|
||||||
|
new ExamSeatAssignment
|
||||||
|
{
|
||||||
|
ExamSessionId = examSession.Id,
|
||||||
|
StudentId = student.Id,
|
||||||
|
SeatNumber = 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
Invigilators =
|
||||||
|
[
|
||||||
|
new ExamRoomInvigilator
|
||||||
|
{
|
||||||
|
TeacherId = teacher.Id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
db.ExamPlans.Add(examPlan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var scope = new TeacherDataScope(teacherUserId);
|
var scope = new TeacherDataScope(teacherUserId);
|
||||||
var attendance = new AttendanceController(db, scope);
|
var attendance = new AttendanceController(db, scope);
|
||||||
var attendanceResult = await attendance.CreateSheet(
|
var attendanceResult = await attendance.CreateSheet(
|
||||||
@@ -153,6 +246,71 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
Assert.Single(ReadItems(await adjustments.GetTaskOptions(
|
Assert.Single(ReadItems(await adjustments.GetTaskOptions(
|
||||||
term.Id,
|
term.Id,
|
||||||
CancellationToken.None)));
|
CancellationToken.None)));
|
||||||
|
|
||||||
|
var managerScope = new ManagerDataScope();
|
||||||
|
var exams = new ExamsController(
|
||||||
|
db,
|
||||||
|
managerScope,
|
||||||
|
NoOpAppCache.Instance);
|
||||||
|
var publishResult = await exams.Publish(examPlan.Id, CancellationToken.None);
|
||||||
|
var acceptedPublish = Assert.IsType<AcceptedAtActionResult>(publishResult);
|
||||||
|
var publishJobId = (Guid)acceptedPublish.Value!
|
||||||
|
.GetType().GetProperty("jobId")!.GetValue(acceptedPublish.Value)!;
|
||||||
|
var publishProcessor = new ExamPublishJobProcessor(
|
||||||
|
db,
|
||||||
|
NoOpAppCache.Instance,
|
||||||
|
NullLogger<ExamPublishJobProcessor>.Instance);
|
||||||
|
await publishProcessor.ProcessAsync(publishJobId, CancellationToken.None);
|
||||||
|
|
||||||
|
var planResult = Assert.IsType<OkObjectResult>(
|
||||||
|
await exams.GetPlan(
|
||||||
|
examPlan.Id,
|
||||||
|
cancellationToken: CancellationToken.None));
|
||||||
|
var planSessions = ReadEnumerableProperty(planResult.Value!, "Sessions");
|
||||||
|
Assert.Equal(1, ReadIntProperty(planSessions.Single(), "StudentCount"));
|
||||||
|
var returnedRoom = Assert.Single(
|
||||||
|
ReadEnumerableProperty(planSessions.Single(), "ExamRooms"));
|
||||||
|
Assert.Equal(
|
||||||
|
classroom.Id,
|
||||||
|
returnedRoom.GetType().GetProperty("ClassroomId")!.GetValue(returnedRoom));
|
||||||
|
Assert.Equal(1, ReadIntProperty(returnedRoom, "SeatCount"));
|
||||||
|
Assert.Equal(1, ReadIntProperty(returnedRoom, "TotalSeatCount"));
|
||||||
|
Assert.False(
|
||||||
|
(bool)returnedRoom.GetType().GetProperty("IsMixed")!
|
||||||
|
.GetValue(returnedRoom)!);
|
||||||
|
Assert.Equal(
|
||||||
|
[teacher.Id],
|
||||||
|
ReadEnumerableProperty(returnedRoom, "InvigilatorIds").Cast<Guid>());
|
||||||
|
Assert.Equal(
|
||||||
|
[teacher.Name],
|
||||||
|
ReadEnumerableProperty(returnedRoom, "InvigilatorNames").Cast<string>());
|
||||||
|
|
||||||
|
var examSessionId = examPlan.Sessions.Single().Id;
|
||||||
|
var rosterResult = Assert.IsType<OkObjectResult>(
|
||||||
|
await exams.GetRoster(examSessionId, CancellationToken.None));
|
||||||
|
Assert.Single(ReadEnumerableProperty(rosterResult.Value!, "Students"));
|
||||||
|
|
||||||
|
var studentExams = new ExamsController(
|
||||||
|
db,
|
||||||
|
new StudentDataScope(studentUserId),
|
||||||
|
NoOpAppCache.Instance);
|
||||||
|
Assert.Single(ReadItems(
|
||||||
|
await studentExams.GetMySchedule(CancellationToken.None)));
|
||||||
|
var teacherExams = new ExamsController(
|
||||||
|
db,
|
||||||
|
scope,
|
||||||
|
NoOpAppCache.Instance);
|
||||||
|
Assert.Single(ReadItems(
|
||||||
|
await teacherExams.GetMySchedule(CancellationToken.None)));
|
||||||
|
|
||||||
|
var exportResult = Assert.IsType<FileContentResult>(
|
||||||
|
await exams.ExportSignInSheets(examPlan.Id, CancellationToken.None));
|
||||||
|
using var workbookStream = new MemoryStream(exportResult.FileContents);
|
||||||
|
using var workbook = new XLWorkbook(workbookStream);
|
||||||
|
Assert.Equal(2, workbook.Worksheets.Count);
|
||||||
|
var signInSheet = workbook.Worksheets.Single(x => x.Name != "考场汇总");
|
||||||
|
Assert.Equal("202601001", signInSheet.Cell("C8").GetString());
|
||||||
|
Assert.Equal("周同学", signInSheet.Cell("D8").GetString());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<object> ReadItems(ActionResult result)
|
private static List<object> ReadItems(ActionResult result)
|
||||||
@@ -166,6 +324,12 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
private static int ReadIntProperty(object value, string name) =>
|
private static int ReadIntProperty(object value, string name) =>
|
||||||
(int)value.GetType().GetProperty(name)!.GetValue(value)!;
|
(int)value.GetType().GetProperty(name)!.GetValue(value)!;
|
||||||
|
|
||||||
|
private static List<object> ReadEnumerableProperty(object value, string name) =>
|
||||||
|
Assert.IsAssignableFrom<System.Collections.IEnumerable>(
|
||||||
|
value.GetType().GetProperty(name)!.GetValue(value))
|
||||||
|
.Cast<object>()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope
|
private sealed class TeacherDataScope(Guid userId) : ICurrentUserDataScope
|
||||||
{
|
{
|
||||||
public CurrentUserScope Current { get; } = new(
|
public CurrentUserScope Current { get; } = new(
|
||||||
@@ -175,4 +339,24 @@ public sealed class TeachingWorkflowRosterTests
|
|||||||
DataScope.Self,
|
DataScope.Self,
|
||||||
new HashSet<string>([SystemRoles.Teacher]));
|
new HashSet<string>([SystemRoles.Teacher]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class StudentDataScope(Guid userId) : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
userId,
|
||||||
|
"周同学",
|
||||||
|
null,
|
||||||
|
DataScope.Self,
|
||||||
|
new HashSet<string>([SystemRoles.Student]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ManagerDataScope : ICurrentUserDataScope
|
||||||
|
{
|
||||||
|
public CurrentUserScope Current { get; } = new(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"考试管理员",
|
||||||
|
null,
|
||||||
|
DataScope.All,
|
||||||
|
new HashSet<string>([SystemRoles.AcademicAdmin]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace Jiaowu.Api.Tests;
|
|||||||
public sealed class TimetableExcelExporterTests
|
public sealed class TimetableExcelExporterTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Create_builds_week_grid_and_flexible_course_section()
|
public void Create_builds_week_grid_with_exam_and_flexible_course_section()
|
||||||
{
|
{
|
||||||
var timetable = new TimetableData(
|
var timetable = new TimetableData(
|
||||||
new TimetableTermDto(
|
new TimetableTermDto(
|
||||||
@@ -84,7 +84,65 @@ public sealed class TimetableExcelExporterTests
|
|||||||
["计算机科学 2601 班"],
|
["计算机科学 2601 班"],
|
||||||
null)
|
null)
|
||||||
],
|
],
|
||||||
[]);
|
[
|
||||||
|
new TimetableEntryDto(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"TASK-EXAM",
|
||||||
|
"高等数学期末考试",
|
||||||
|
"MATH101",
|
||||||
|
"高等数学",
|
||||||
|
["张老师", "监考:李老师"],
|
||||||
|
["计算机科学 2601 班"],
|
||||||
|
"201",
|
||||||
|
"第二教学楼",
|
||||||
|
"主校区",
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
WeekPattern.All,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
"2026-2027 学年第一学期期末考试",
|
||||||
|
new DateOnly(2027, 1, 5),
|
||||||
|
DateTime.UtcNow)
|
||||||
|
])
|
||||||
|
{
|
||||||
|
ExperimentEntries =
|
||||||
|
[
|
||||||
|
new TimetableEntryDto(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"TASK-LAB",
|
||||||
|
"程序设计实验教学班",
|
||||||
|
"CSLAB101",
|
||||||
|
"程序设计实验",
|
||||||
|
["陈老师"],
|
||||||
|
["计算机科学 2601 班"],
|
||||||
|
"301",
|
||||||
|
"实验中心",
|
||||||
|
"主校区",
|
||||||
|
4,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
WeekPattern.All,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
DateTime.UtcNow,
|
||||||
|
true,
|
||||||
|
Guid.NewGuid(),
|
||||||
|
"LAB-01",
|
||||||
|
"基础控制实验",
|
||||||
|
new DateOnly(2026, 9, 10),
|
||||||
|
ExperimentArrangementMode.SelfScheduled)
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
var bytes = TimetableExcelExporter.Create(timetable);
|
var bytes = TimetableExcelExporter.Create(timetable);
|
||||||
|
|
||||||
@@ -92,7 +150,13 @@ public sealed class TimetableExcelExporterTests
|
|||||||
using var workbook = new XLWorkbook(stream);
|
using var workbook = new XLWorkbook(stream);
|
||||||
var sheet = workbook.Worksheet("课表");
|
var sheet = workbook.Worksheet("课表");
|
||||||
Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString());
|
Assert.Contains("计算机科学 2601 班", sheet.Cell("A1").GetString());
|
||||||
|
Assert.Contains("已融入 1 场考试", sheet.Cell("A2").GetString());
|
||||||
|
Assert.Contains("已融入 1 场实验", sheet.Cell("A2").GetString());
|
||||||
Assert.Contains("高等数学", sheet.Cell(5, 2).GetString());
|
Assert.Contains("高等数学", sheet.Cell(5, 2).GetString());
|
||||||
|
Assert.Contains("【考试】高等数学", sheet.Cell(5, 3).GetString());
|
||||||
|
Assert.Contains("【实验】基础控制实验", sheet.Cell(5, 5).GetString());
|
||||||
|
Assert.Contains("自主预约", sheet.Cell(5, 5).GetString());
|
||||||
|
Assert.Contains("2027-01-05", sheet.Cell(5, 3).GetString());
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
sheet.CellsUsed(),
|
sheet.CellsUsed(),
|
||||||
cell => cell.GetString().Contains("非排时课程"));
|
cell => cell.GetString().Contains("非排时课程"));
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
# Capacitor
|
||||||
|
/android
|
||||||
|
/ios
|
||||||
|
.env.capacitor
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { CapacitorConfig } from '@capacitor/cli'
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'edu.mingxu.jiaowu',
|
||||||
|
appName: '明序教务',
|
||||||
|
webDir: 'dist',
|
||||||
|
bundledWebRuntime: false,
|
||||||
|
android: {
|
||||||
|
allowMixedContent: true,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
CapacitorUpdater: {
|
||||||
|
autoUpdate: 'off',
|
||||||
|
version: '1.0.0',
|
||||||
|
appReadyTimeout: 10000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#152550" />
|
<meta name="theme-color" content="#152550" />
|
||||||
<meta name="description" content="明序大学教务管理系统" />
|
<meta name="description" content="明序大学教务管理系统" />
|
||||||
<title>明序教务管理系统</title>
|
<title>明序教务管理系统</title>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation|density"
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:exported="true">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="mingxu" android:host="open" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
|
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".ScheduleWidgetProvider"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/widget_schedule_name">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/schedule_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".ExamWidgetProvider"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/widget_exam_name">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/exam_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths"></meta-data>
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
|
<uses-feature android:name="android.hardware.location.gps" android:required="false" />
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package edu.mingxu.jiaowu;
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager;
|
||||||
|
import android.appwidget.AppWidgetProvider;
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
public class ExamWidgetProvider extends AppWidgetProvider {
|
||||||
|
@Override
|
||||||
|
public void onUpdate(
|
||||||
|
Context context,
|
||||||
|
AppWidgetManager appWidgetManager,
|
||||||
|
int[] appWidgetIds
|
||||||
|
) {
|
||||||
|
WidgetRenderer.updateExams(context, appWidgetManager, appWidgetIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package edu.mingxu.jiaowu;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
private static final Set<String> SHORTCUT_ROUTES =
|
||||||
|
new HashSet<>(Arrays.asList(
|
||||||
|
"timetable",
|
||||||
|
"exams",
|
||||||
|
"attendance",
|
||||||
|
"notifications"));
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
registerPlugin(NativeHomePlugin.class);
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
dispatchShortcut(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatchShortcut(Intent intent) {
|
||||||
|
Uri data = intent == null ? null : intent.getData();
|
||||||
|
if (data == null ||
|
||||||
|
!"mingxu".equals(data.getScheme()) ||
|
||||||
|
!"open".equals(data.getHost())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String route = data.getLastPathSegment();
|
||||||
|
if (route == null || !SHORTCUT_ROUTES.contains(route)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NativeHomePlugin.savePendingRoute(this, route);
|
||||||
|
if (bridge != null) {
|
||||||
|
JSObject detail = new JSObject();
|
||||||
|
detail.put("route", route);
|
||||||
|
bridge.triggerWindowJSEvent("mingxuShortcut", detail.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package edu.mingxu.jiaowu;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
|
import com.getcapacitor.JSObject;
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
@CapacitorPlugin(name = "NativeHome")
|
||||||
|
public class NativeHomePlugin extends Plugin {
|
||||||
|
static final String PREFERENCES = "mingxu_native_home";
|
||||||
|
static final String WIDGET_PAYLOAD = "widget_payload";
|
||||||
|
private static final String PENDING_ROUTE = "pending_route";
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void updateWidgets(PluginCall call) {
|
||||||
|
JSObject payload = call.getObject("payload");
|
||||||
|
if (payload == null) {
|
||||||
|
call.reject("缺少桌面组件数据。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
preferences(getContext()).edit()
|
||||||
|
.putString(WIDGET_PAYLOAD, payload.toString())
|
||||||
|
.apply();
|
||||||
|
WidgetRenderer.updateAll(getContext());
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void clearWidgets(PluginCall call) {
|
||||||
|
preferences(getContext()).edit()
|
||||||
|
.remove(WIDGET_PAYLOAD)
|
||||||
|
.apply();
|
||||||
|
WidgetRenderer.updateAll(getContext());
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void getLaunchRoute(PluginCall call) {
|
||||||
|
SharedPreferences preferences = preferences(getContext());
|
||||||
|
String route = preferences.getString(PENDING_ROUTE, null);
|
||||||
|
preferences.edit().remove(PENDING_ROUTE).apply();
|
||||||
|
JSObject result = new JSObject();
|
||||||
|
if (route != null) {
|
||||||
|
result.put("route", route);
|
||||||
|
}
|
||||||
|
call.resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void savePendingRoute(Context context, String route) {
|
||||||
|
preferences(context).edit().putString(PENDING_ROUTE, route).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
static SharedPreferences preferences(Context context) {
|
||||||
|
return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package edu.mingxu.jiaowu;
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager;
|
||||||
|
import android.appwidget.AppWidgetProvider;
|
||||||
|
import android.content.Context;
|
||||||
|
|
||||||
|
public class ScheduleWidgetProvider extends AppWidgetProvider {
|
||||||
|
@Override
|
||||||
|
public void onUpdate(
|
||||||
|
Context context,
|
||||||
|
AppWidgetManager appWidgetManager,
|
||||||
|
int[] appWidgetIds
|
||||||
|
) {
|
||||||
|
WidgetRenderer.updateSchedule(context, appWidgetManager, appWidgetIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user