From e8261714da841963dff5dee794d655e8f2baa426 Mon Sep 17 00:00:00 2001 From: biss Date: Sun, 9 Aug 2026 20:08:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E6=89=B9=E9=87=8F=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E7=8E=B0=E5=9C=A8=E4=BC=9A=E5=88=9B=E5=BB=BA=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E5=90=8E=E5=8F=B0=E4=BB=BB=E5=8A=A1=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=9C=A8=20HTTP=20=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E9=87=8C=E9=80=90=E9=A1=B9=E6=A0=A1=E9=AA=8C=E5=92=8C=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/ExperimentsController.cs | 57 +++++++++--------- .../Domain/Academic/ExamEntities.cs | 5 +- .../Infrastructure/Exams/ExamPublishJobs.cs | 58 +++++++++++++++++++ ...60809121000_ExperimentPublishJobPayload.cs | 29 ++++++++++ web/src/views/ExperimentsView.vue | 25 +++++++- 5 files changed, 140 insertions(+), 34 deletions(-) create mode 100644 src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809121000_ExperimentPublishJobPayload.cs diff --git a/src/Jiaowu.Api/Controllers/ExperimentsController.cs b/src/Jiaowu.Api/Controllers/ExperimentsController.cs index 01efcde..00f7515 100644 --- a/src/Jiaowu.Api/Controllers/ExperimentsController.cs +++ b/src/Jiaowu.Api/Controllers/ExperimentsController.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; using System.Data; +using System.Text.Json; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Experiments; using Jiaowu.Api.Infrastructure.Persistence; @@ -706,38 +708,33 @@ public sealed class ExperimentsController( { var ids = ValidateBulkProjectIds(request.ProjectIds); if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。"); - var projects = await ScopedProjects() - .Include(x => x.Sessions) - .Include(x => x.TeachingTask) - .ThenInclude(x => x!.Course) - .Where(x => ids.Contains(x.Id)) - .ToListAsync(cancellationToken); - if (projects.Count != ids.Count) return NotFound(); - - foreach (var project in projects) + if (await ScopedProjects().CountAsync(x => ids.Contains(x.Id), cancellationToken) != ids.Count) + return NotFound(); + var userId = currentUserDataScope.Current.UserId; + var job = new ExamPublishJob { - if (project.Status != ExperimentProjectStatus.Draft) - return ConflictProblem("批量发布只能包含草稿实验项目。"); - var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized - ? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled) - : project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled); - if (!hasSchedule) - return ConflictProblem($"“{project.Name}”尚未具备发布条件。"); - if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled && - (x.SessionDate < project.StartDate || x.SessionDate > project.EndDate))) - return ConflictProblem($"“{project.Name}”存在不在开放日期范围内的实验场次。"); - } - - var publishedAt = DateTime.UtcNow; - foreach (var project in projects) - { - project.Status = ExperimentProjectStatus.Published; - project.PublishedAt = publishedAt; - } + Kind = ExamPublishJobKind.ExperimentProjects, + PlanId = ids[0], + RequestedByUserId = userId == Guid.Empty ? null : userId, + ProjectIdsJson = JsonSerializer.Serialize(ids), + CurrentStep = "等待后台校验" + }; + db.ExamPublishJobs.Add(job); + db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create( + BackgroundJobKind.ExamPublish, job.Id)); await db.SaveChangesAsync(cancellationToken); - foreach (var project in projects) - await NotifyProjectPublishedAsync(project, cancellationToken); - return NoContent(); + return Accepted(new { JobId = job.Id, Status = job.Status, Message = "实验项目发布任务已提交。" }); + } + + [HttpGet("batch/publish-jobs/{jobId:guid}")] + [Authorize(Roles = Managers)] + public async Task GetPublishJob(Guid jobId, CancellationToken cancellationToken) + { + var job = await db.ExamPublishJobs.AsNoTracking().FirstOrDefaultAsync(x => + x.Id == jobId && x.Kind == ExamPublishJobKind.ExperimentProjects, + cancellationToken); + if (job is null) return NotFound(); + return Ok(new { job.Id, job.Status, job.CurrentStep, job.ErrorMessage, job.StartedAt, job.CompletedAt }); } [HttpPost("{id:guid}/close")] diff --git a/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs b/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs index 287da33..7e25919 100644 --- a/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs +++ b/src/Jiaowu.Api/Domain/Academic/ExamEntities.cs @@ -20,6 +20,7 @@ public sealed class ExamArrangementJob : EntityBase public Guid PlanId { get; set; } public Guid? ActivePlanId { get; set; } public Guid? RequestedByUserId { get; set; } + public string? ProjectIdsJson { get; set; } public string? SessionIdsJson { get; set; } public bool AssignClassrooms { get; set; } public bool AssignInvigilators { get; set; } @@ -138,6 +139,7 @@ public sealed class ExamPublishJob : EntityBase public Guid PlanId { get; set; } public Guid? ActivePlanId { get; set; } public Guid? RequestedByUserId { get; set; } + public string? ProjectIdsJson { get; set; } public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued; public string? CurrentStep { get; set; } public string? ErrorMessage { get; set; } @@ -148,7 +150,8 @@ public sealed class ExamPublishJob : EntityBase public enum ExamPublishJobKind { FormalExam = 1, - MakeupExam = 2 + MakeupExam = 2, + ExperimentProjects = 3 } public enum ExamPublishJobStatus diff --git a/src/Jiaowu.Api/Infrastructure/Exams/ExamPublishJobs.cs b/src/Jiaowu.Api/Infrastructure/Exams/ExamPublishJobs.cs index 4edc2de..da9a893 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/ExamPublishJobs.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/ExamPublishJobs.cs @@ -1,8 +1,11 @@ using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Caching; +using Jiaowu.Api.Controllers; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Teaching; using Microsoft.EntityFrameworkCore; +using System.Text.Json; namespace Jiaowu.Api.Infrastructure.Exams; @@ -39,6 +42,9 @@ public sealed class ExamPublishJobProcessor( case ExamPublishJobKind.MakeupExam: await PublishMakeupExamAsync(job, stoppingToken); break; + case ExamPublishJobKind.ExperimentProjects: + await PublishExperimentProjectsAsync(job, stoppingToken); + break; default: throw new InvalidOperationException( $"不支持的考试发布类型:{job.Kind}。"); @@ -248,6 +254,58 @@ public sealed class ExamPublishJobProcessor( await db.SaveChangesAsync(ct); } + private async Task PublishExperimentProjectsAsync(ExamPublishJob job, CancellationToken ct) + { + var ids = JsonSerializer.Deserialize>(job.ProjectIdsJson ?? "[]")? + .Where(x => x != Guid.Empty).Distinct().ToList() ?? []; + if (ids.Count is 0 or > 100) + throw new ExamPublishValidationException("实验发布任务的数据无效。请重新提交。"); + + var projects = await db.ExperimentProjects + .Include(x => x.Sessions) + .Include(x => x.TeachingTask) + .ThenInclude(x => x!.Course) + .Where(x => ids.Contains(x.Id)) + .ToListAsync(ct); + if (projects.Count != ids.Count) + throw new ExamPublishValidationException("部分实验项目不存在,请刷新后重新提交。"); + + foreach (var project in projects) + { + if (project.Status != ExperimentProjectStatus.Draft) + throw new ExamPublishValidationException("批量发布只能包含草稿实验项目。"); + var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized + ? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled) + : project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled); + if (!hasSchedule) + throw new ExamPublishValidationException($"“{project.Name}”尚未具备发布条件。"); + if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled && + (x.SessionDate < project.StartDate || x.SessionDate > project.EndDate))) + throw new ExamPublishValidationException($"“{project.Name}”存在不在开放日期范围内的实验场次。"); + } + + job.CurrentStep = "正在发布实验项目"; + await db.SaveChangesAsync(ct); + var publishedAt = DateTime.UtcNow; + foreach (var project in projects) + { + project.Status = ExperimentProjectStatus.Published; + project.PublishedAt = publishedAt; + } + await db.SaveChangesAsync(ct); + + foreach (var project in projects) + { + var userIds = await TeachingTaskRosterQuery.ForTask(db, project.TeachingTaskId) + .Where(x => x.UserId.HasValue).Select(x => x.UserId!.Value).Distinct().ToListAsync(ct); + if (userIds.Count == 0) continue; + var mode = project.ArrangementMode == ExperimentArrangementMode.Centralized ? "集中安排" : "自行预约"; + await NotificationService.SendToUserIdsAsync(db, userIds, "实验项目已发布", + $"《{project.TeachingTask!.Course!.Name}》已发布“{project.Name}”({mode}),请查看实验安排。", + "/experiments", ct, NotificationCategory.Schedule); + } + } + private async Task MarkFailedAsync(Guid jobId, string message) { db.ChangeTracker.Clear(); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809121000_ExperimentPublishJobPayload.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809121000_ExperimentPublishJobPayload.cs new file mode 100644 index 0000000..0308163 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260809121000_ExperimentPublishJobPayload.cs @@ -0,0 +1,29 @@ +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql; + +[DbContext(typeof(AppDbContext))] +[Migration("20260809121000_ExperimentPublishJobPayload")] +public partial class ExperimentPublishJobPayload : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ProjectIdsJson", + table: "ExamPublishJobs", + type: "longtext", + maxLength: 5000, + nullable: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ProjectIdsJson", + table: "ExamPublishJobs"); + } +} diff --git a/web/src/views/ExperimentsView.vue b/web/src/views/ExperimentsView.vue index 2d4873a..ffaa98e 100644 --- a/web/src/views/ExperimentsView.vue +++ b/web/src/views/ExperimentsView.vue @@ -417,16 +417,35 @@ async function publishSelectedProjects() { '批量发布实验项目', { confirmButtonText: '确认发布', cancelButtonText: '取消', type: 'warning' }, ) - await http.post('/experiments/batch/publish', { projectIds: ids }) - ElMessage.success(`已发布 ${ids.length} 个实验项目`) + const { data } = await http.post('/experiments/batch/publish', { projectIds: ids }) + ElMessage.success(`已提交 ${ids.length} 个实验项目的后台发布任务`) selectedProjectIds.value = [] - await load() + void pollPublishJob(data.jobId) } catch (error: any) { if (error === 'cancel' || error === 'close') return ElMessage.error(apiErrorMessage(error)) } } +async function pollPublishJob(jobId: string) { + try { + const { data } = await http.get(`/experiments/batch/publish-jobs/${jobId}`) + if (data.status === 'Succeeded') { + ElMessage.success('实验项目已完成发布') + await load() + return + } + if (data.status === 'Failed') { + ElMessage.error(data.errorMessage || '实验项目发布失败') + await load() + return + } + window.setTimeout(() => { void pollPublishJob(jobId) }, 1200) + } catch (error) { + ElMessage.error(apiErrorMessage(error)) + } +} + async function deleteSelectedProjects() { const ids = draftSelectedProjectIds.value if (!ids.length) return