14 Commits
42 changed files with 13983 additions and 342 deletions

No files matched your search

+6 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: Set up .NET - name: Set up .NET
uses: https://github.com/actions/setup-dotnet@v4 uses: https://github.com/actions/setup-dotnet@v4
with: with:
dotnet-version: "10.0.302" dotnet-version: "10.0.400"
- name: Set up Node.js - name: Set up Node.js
uses: https://github.com/actions/setup-node@v4 uses: https://github.com/actions/setup-node@v4
@@ -46,6 +46,11 @@ jobs:
npm ci npm ci
npm run build npm run build
- name: Install Chinese PDF font for backend tests
run: |
sudo apt-get update
sudo apt-get install --yes fonts-noto-cjk
- name: Run backend tests - name: Run backend tests
run: dotnet test Jiaowu.slnx --configuration Release run: dotnet test Jiaowu.slnx --configuration Release
+16
View File
@@ -67,6 +67,14 @@ npm run cap:open:android
并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android 并记录签到设备摘要、IP、失败次数和异常频率,供任课教师在考勤明细中复核。Android
最低版本为 API 26;相机和精确位置权限均按需申请。 最低版本为 API 26;相机和精确位置权限均按需申请。
### Web/PWA 应用
浏览器通过 HTTPS 打开系统后,可使用浏览器的“安装应用”功能将明序教务固定到桌面或
开始菜单。PWA 会缓存应用界面与静态资源;接口请求始终走网络,不会离线缓存登录态、
成绩或其他业务数据。发现新版本时由用户确认刷新,避免正在填写表单时被强制中断。
Capacitor Android 构建会自动禁用 PWA Service Worker,仍使用原生能力和独立的 OTA
更新流程。
### App 前端热更新 ### App 前端热更新
App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源; App 内置自建 OTA 更新器。它只更新 `dist` 中的 HTML、JavaScript、CSS 和静态资源;
@@ -186,6 +194,14 @@ Sso__CallbackUrl=https://jiaowu.example.edu.cn/signin-keycloak
显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内 显示当前生效的完整回调地址。多实例部署应配置 Redis,以便任意实例都能兑换两分钟内
有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。 有效、使用后即删除的 SSO 登录码及五分钟内有效的绑定意图。
Android App 使用系统浏览器完成 Keycloak 登录,再通过 `https://eis.biss.click/sso/callback`
或 `/sso/bind` 的 Android App Link 回到应用;Keycloak 的 Valid redirect URI 仍然只配置
`Sso__CallbackUrl`(即 `/signin-keycloak`),不要配置 `mingxu://`。正式发布前,将 Play
App Signing 证书的 SHA-256 指纹写入 `web/public/.well-known/assetlinks.json`,并确保该文件
以 `application/json` 在 `https://eis.biss.click/.well-known/assetlinks.json` 可匿名访问。当前
配置的包名为 `edu.mingxu.jiaowu`;站点域名或正式签名证书变更时必须同时更新此文件和 Android
Manifest 后重新签名发布 APK/AAB,此类变更不能通过 OTA 下发。
用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先 用户登录后可从页面右上角进入“个人账户”,主动绑定或解除 Keycloak 账号。主动绑定先
使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该 使用当前 JWT 创建五分钟有效的一次性绑定意图,再跳转 Keycloak;回调只能绑定到发起该
意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。 意图的本地账号。解绑需要再次验证本地密码,避免仅凭未锁屏的登录会话解除身份关联。
@@ -503,12 +503,32 @@ public sealed class ExperimentsController(
x.CourseId != first.CourseId)) x.CourseId != first.CourseId))
return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。"); return ValidationProblem("批量设置仅支持同一学期、同一课程的教学任务。");
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
request.ProjectCount is < 1 or > 100)
return ValidationProblem("自行安排一次可生成 1 至 100 个实验项目。");
var code = request.Code.Trim(); var code = request.Code.Trim();
var names = NormalizeBatchProjectNames(request.Names);
var definitionCount = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? request.ProjectCount
: 1;
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
names is not null && names.Count != definitionCount)
return ValidationProblem($"已填写 {names.Count} 个项目名称,请将生成项目数设为相同数量。");
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled &&
taskIds.Count * definitionCount > 1000)
return ValidationProblem("本次最多生成 1000 个自行安排实验项目,请缩小教学任务或项目数量。");
var projectCodes = Enumerable.Range(1, definitionCount)
.Select(index => ProjectCode(code, index, definitionCount))
.ToList();
if (projectCodes.Any(item => item.Length > 40))
return ValidationProblem("项目编码前缀过长,无法追加顺序号。");
if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled) if (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled)
{ {
var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking() var conflictingTaskNumbers = await db.ExperimentProjects.AsNoTracking()
.WhereIn(taskIds, x => x.TeachingTaskId) .WhereIn(taskIds, x => x.TeachingTaskId)
.Where(x => x.Code == code) .Where(x => projectCodes.Contains(x.Code))
.Select(x => x.TeachingTask!.TaskNumber) .Select(x => x.TeachingTask!.TaskNumber)
.OrderBy(x => x) .OrderBy(x => x)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -565,7 +585,7 @@ public sealed class ExperimentsController(
return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。"); return ConflictProblem("所选实验课中已存在相同实验项目编码和周次,不能重复生成。");
} }
var projects = new List<ExperimentProject>(tasks.Count); var projects = new List<ExperimentProject>(tasks.Count * definitionCount);
foreach (var task in tasks) foreach (var task in tasks)
{ {
var item = request.ForTeachingTask(task.Id); var item = request.ForTeachingTask(task.Id);
@@ -594,12 +614,14 @@ public sealed class ExperimentsController(
continue; continue;
} }
} }
for (var definitionIndex = 1; definitionIndex <= definitionCount; definitionIndex++)
{
projects.Add(new ExperimentProject projects.Add(new ExperimentProject
{ {
TeachingTaskId = task.Id, TeachingTaskId = task.Id,
ScheduleEntryId = occurrence.Entry?.Id, ScheduleEntryId = occurrence.Entry?.Id,
ScheduleWeek = occurrence.Week, ScheduleWeek = occurrence.Week,
Code = code, Code = ProjectCode(code, definitionIndex, definitionCount),
Name = request.Name.Trim(), Name = request.Name.Trim(),
ArrangementMode = request.ArrangementMode, ArrangementMode = request.ArrangementMode,
Description = Normalize(request.Description), Description = Normalize(request.Description),
@@ -611,26 +633,38 @@ public sealed class ExperimentsController(
}); });
} }
} }
}
var names = NormalizeBatchProjectNames(request.Names);
if (request.GenerateSequentialNames) if (request.GenerateSequentialNames)
{ {
if (names is not null) if (names is not null)
return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。"); return ValidationProblem("自动生成顺序号时无需填写逐行项目名称。");
var width = Math.Max(2, projects.Count.ToString().Length); var width = Math.Max(2, (request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? definitionCount
: projects.Count).ToString().Length);
if (request.Name.Trim().Length + width + 1 > 120) if (request.Name.Trim().Length + width + 1 > 120)
return ValidationProblem("项目名称前缀过长,无法追加顺序号。"); return ValidationProblem("项目名称前缀过长,无法追加顺序号。");
for (var index = 0; index < projects.Count; index++) for (var index = 0; index < projects.Count; index++)
projects[index].Name = $"{request.Name.Trim()} {(index + 1).ToString($"D{width}")}"; {
var number = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? index % definitionCount + 1
: index + 1;
projects[index].Name = $"{request.Name.Trim()} {number.ToString($"D{width}")}";
}
} }
else if (names is not null) else if (names is not null)
{ {
if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120)) if (names.Any(name => string.IsNullOrWhiteSpace(name) || name.Length > 120))
return ValidationProblem("每个实验项目名称均不能为空,且不能超过 120 个字符。"); return ValidationProblem("每个实验项目名称均不能为空,且不能超过 120 个字符。");
if (names.Count != projects.Count) var expectedNameCount = request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? definitionCount
: projects.Count;
if (names.Count != expectedNameCount)
return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。"); return ValidationProblem($"已填写 {names.Count} 个项目名称,但本次将生成 {projects.Count} 个实验项目;请确保每行对应一个项目。");
for (var index = 0; index < projects.Count; index++) for (var index = 0; index < projects.Count; index++)
projects[index].Name = names[index]; projects[index].Name = names[request.ArrangementMode == ExperimentArrangementMode.SelfScheduled
? index % definitionCount
: index];
} }
db.ExperimentProjects.AddRange(projects); db.ExperimentProjects.AddRange(projects);
@@ -1174,6 +1208,106 @@ public sealed class ExperimentsController(
.ToListAsync(cancellationToken)); .ToListAsync(cancellationToken));
} }
[HttpGet("sessions/{id:guid}/eligible-students")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> GetProxyBookingStudents(
Guid id,
string? keyword,
CancellationToken cancellationToken)
{
var session = await LoadManagedSessionAsync(id, cancellationToken);
if (session is null) return NotFound();
if (!CanManageSession(session)) return Forbid();
if (session.ExperimentProject!.ArrangementMode != ExperimentArrangementMode.SelfScheduled)
return ConflictProblem("集中安排实验无需代选场次。");
var bookedStudentIds = await db.ExperimentBookings.AsNoTracking()
.Where(x => x.ExperimentProjectId == session.ExperimentProjectId &&
x.Status == ExperimentBookingStatus.Booked)
.Select(x => x.StudentId)
.ToListAsync(cancellationToken);
var normalizedKeyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim();
var roster = await TeachingTaskRosterQuery.LoadForTasksAsync(
db, [session.ExperimentProject.TeachingTaskId], cancellationToken);
return Ok(roster
.Where(x => !bookedStudentIds.Contains(x.StudentId))
.Where(x => normalizedKeyword is null ||
x.StudentNumber.Contains(normalizedKeyword) ||
x.Name.Contains(normalizedKeyword) ||
x.ClassName.Contains(normalizedKeyword))
.OrderBy(x => x.StudentNumber)
.Take(100)
.Select(x => new { x.StudentId, x.StudentNumber, x.Name, x.ClassName }));
}
[HttpPost("sessions/{id:guid}/proxy-bookings")]
[Authorize(Roles = Managers)]
public async Task<ActionResult> CreateProxyBooking(
Guid id,
ProxyExperimentBookingRequest request,
CancellationToken cancellationToken)
{
return await db.ExecuteInRetriableTransactionAsync<ActionResult>(
async transaction =>
{
db.ChangeTracker.Clear();
var session = await LoadManagedSessionAsync(id, cancellationToken);
if (session is null) return NotFound();
if (!CanManageSession(session)) return Forbid();
if (session.Status != ExperimentSessionStatus.Scheduled ||
session.ExperimentProject!.Status != ExperimentProjectStatus.Published ||
session.ExperimentProject.ArrangementMode != ExperimentArrangementMode.SelfScheduled)
return ConflictProblem("只能为已发布的自行安排实验场次代选学生。");
var student = await db.Students.FirstOrDefaultAsync(x =>
x.Id == request.StudentId && x.Status == StudentStatus.Active,
cancellationToken);
if (student is null) return ValidationProblem("学生不存在或不在学。");
if (!await TeachingTaskRosterQuery.TaskIdsForStudent(db, student.Id)
.ContainsAsync(session.ExperimentProject.TeachingTaskId, cancellationToken))
return ValidationProblem("该学生不在本实验项目的教学任务名单内。");
var existing = await db.ExperimentBookings.FirstOrDefaultAsync(x =>
x.ExperimentProjectId == session.ExperimentProjectId && x.StudentId == student.Id,
cancellationToken);
if (existing?.Status == ExperimentBookingStatus.Booked)
return existing.ExperimentSessionId == session.Id
? NoContent()
: ConflictProblem("该学生已预约本实验项目的其他场次。");
var scheduleProblem = await StudentConflictAsync(student.Id, session, cancellationToken);
if (scheduleProblem is not null) return ConflictProblem(scheduleProblem);
var reservedCount = await db.ExperimentBookings.CountAsync(x =>
x.ExperimentSessionId == session.Id && x.Status == ExperimentBookingStatus.Booked,
cancellationToken);
if (reservedCount >= session.Capacity)
return ConflictProblem("该实验场次名额已满,请选择其他时间。");
if (existing is null)
{
db.ExperimentBookings.Add(new ExperimentBooking
{
ExperimentProjectId = session.ExperimentProjectId,
ExperimentSessionId = session.Id,
StudentId = student.Id
});
}
else
{
existing.ExperimentSessionId = session.Id;
existing.Status = ExperimentBookingStatus.Booked;
existing.BookedAt = DateTime.UtcNow;
existing.CancelledAt = null;
}
session.ReservedCount = reservedCount + 1;
await db.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
return NoContent();
},
cancellationToken,
IsolationLevel.Serializable);
}
[HttpPost("sessions/{id:guid}/book")] [HttpPost("sessions/{id:guid}/book")]
[Authorize(Roles = SystemRoles.Student)] [Authorize(Roles = SystemRoles.Student)]
public async Task<ActionResult> Book( public async Task<ActionResult> Book(
@@ -1370,9 +1504,7 @@ public sealed class ExperimentsController(
.FirstOrDefaultAsync(x => .FirstOrDefaultAsync(x =>
x.Id == request.ClassroomId && x.IsEnabled, x.Id == request.ClassroomId && x.IsEnabled,
cancellationToken); cancellationToken);
if (classroom is null) return "实验教室不存在或已停用。"; if (classroom is null) return "实验场地不存在或已停用。";
if (!TeachingVenueNatureRules.SupportsExperiment(classroom.TeachingVenueNature))
return "所选场地未标注实验教学性质。";
if (request.Capacity > classroom.Capacity) if (request.Capacity > classroom.Capacity)
return $"场次容量不能超过教室容量 {classroom.Capacity} 人。"; return $"场次容量不能超过教室容量 {classroom.Capacity} 人。";
if (project.ArrangementMode == if (project.ArrangementMode ==
@@ -1572,6 +1704,24 @@ public sealed class ExperimentsController(
taskIds.Contains(x.TeachingTaskId)); taskIds.Contains(x.TeachingTaskId));
} }
private Task<ExperimentSession?> LoadManagedSessionAsync(
Guid id,
CancellationToken cancellationToken) =>
db.ExperimentSessions
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.AcademicTerm)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Course)
.Include(x => x.ExperimentProject)
.ThenInclude(x => x!.TeachingTask)
.ThenInclude(x => x!.Teachers)
.ThenInclude(x => x.Teacher)
.Include(x => x.Instructors)
.ThenInclude(x => x.Teacher)
.FirstOrDefaultAsync(x => x.Id == id, cancellationToken);
private bool CanManageSession(ExperimentSession session) private bool CanManageSession(ExperimentSession session)
{ {
var scope = currentUserDataScope.Current; var scope = currentUserDataScope.Current;
@@ -1737,6 +1887,11 @@ public sealed class ExperimentsController(
return names.Select(name => name.Trim()).ToList(); return names.Select(name => name.Trim()).ToList();
} }
private static string ProjectCode(string prefix, int index, int count) =>
count == 1
? prefix
: $"{prefix}-{index.ToString($"D{Math.Max(2, count.ToString().Length)}")}";
private ActionResult ConflictProblem(string detail) => private ActionResult ConflictProblem(string detail) =>
Conflict(new ProblemDetails Conflict(new ProblemDetails
{ {
@@ -1776,7 +1931,8 @@ public sealed record ExperimentProjectBatchRequest(
IReadOnlyList<string>? Names = null, IReadOnlyList<string>? Names = null,
bool GenerateSequentialNames = false, bool GenerateSequentialNames = false,
DateTime? SelectionStartsAt = null, DateTime? SelectionStartsAt = null,
DateTime? SelectionEndsAt = null) DateTime? SelectionEndsAt = null,
[Range(1, 100)] int ProjectCount = 1)
{ {
public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) => public ExperimentProjectRequest ForTeachingTask(Guid teachingTaskId) =>
new( new(
@@ -1809,6 +1965,8 @@ public sealed record ExperimentSessionRequest(
[MaxLength(500)] string? Notes, [MaxLength(500)] string? Notes,
[Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null); [Required, MinLength(1)] IReadOnlyList<Guid>? InstructorTeacherIds = null);
public sealed record ProxyExperimentBookingRequest(Guid StudentId);
public sealed record ExperimentSessionBatchRequest( public sealed record ExperimentSessionBatchRequest(
[Required] IReadOnlyList<ExperimentSessionBatchItem> Items); [Required] IReadOnlyList<ExperimentSessionBatchItem> Items);
Loaded 3 of 42 files, more files were not shown because too many files have changed in this diff. Show more