实验批量发布现在会创建持久化后台任务,不再在 HTTP 请求里逐项校验和通知。
This commit is contained in:
@@ -1,7 +1,9 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using System.Data;
|
using System.Data;
|
||||||
|
using System.Text.Json;
|
||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
using Jiaowu.Api.Domain.Identity;
|
using Jiaowu.Api.Domain.Identity;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Auth;
|
using Jiaowu.Api.Infrastructure.Auth;
|
||||||
using Jiaowu.Api.Infrastructure.Experiments;
|
using Jiaowu.Api.Infrastructure.Experiments;
|
||||||
using Jiaowu.Api.Infrastructure.Persistence;
|
using Jiaowu.Api.Infrastructure.Persistence;
|
||||||
@@ -706,38 +708,33 @@ public sealed class ExperimentsController(
|
|||||||
{
|
{
|
||||||
var ids = ValidateBulkProjectIds(request.ProjectIds);
|
var ids = ValidateBulkProjectIds(request.ProjectIds);
|
||||||
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
|
if (ids is null) return ValidationProblem("请选择 1 至 100 个实验项目。");
|
||||||
var projects = await ScopedProjects()
|
if (await ScopedProjects().CountAsync(x => ids.Contains(x.Id), cancellationToken) != ids.Count)
|
||||||
.Include(x => x.Sessions)
|
return NotFound();
|
||||||
.Include(x => x.TeachingTask)
|
var userId = currentUserDataScope.Current.UserId;
|
||||||
.ThenInclude(x => x!.Course)
|
var job = new ExamPublishJob
|
||||||
.Where(x => ids.Contains(x.Id))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
if (projects.Count != ids.Count) return NotFound();
|
|
||||||
|
|
||||||
foreach (var project in projects)
|
|
||||||
{
|
{
|
||||||
if (project.Status != ExperimentProjectStatus.Draft)
|
Kind = ExamPublishJobKind.ExperimentProjects,
|
||||||
return ConflictProblem("批量发布只能包含草稿实验项目。");
|
PlanId = ids[0],
|
||||||
var hasSchedule = project.ArrangementMode == ExperimentArrangementMode.Centralized
|
RequestedByUserId = userId == Guid.Empty ? null : userId,
|
||||||
? project.ScheduleEntryId.HasValue || project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled)
|
ProjectIdsJson = JsonSerializer.Serialize(ids),
|
||||||
: project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled);
|
CurrentStep = "等待后台校验"
|
||||||
if (!hasSchedule)
|
};
|
||||||
return ConflictProblem($"“{project.Name}”尚未具备发布条件。");
|
db.ExamPublishJobs.Add(job);
|
||||||
if (project.Sessions.Any(x => x.Status == ExperimentSessionStatus.Scheduled &&
|
db.BackgroundJobOutboxMessages.Add(BackgroundJobOutboxMessage.Create(
|
||||||
(x.SessionDate < project.StartDate || x.SessionDate > project.EndDate)))
|
BackgroundJobKind.ExamPublish, job.Id));
|
||||||
return ConflictProblem($"“{project.Name}”存在不在开放日期范围内的实验场次。");
|
|
||||||
}
|
|
||||||
|
|
||||||
var publishedAt = DateTime.UtcNow;
|
|
||||||
foreach (var project in projects)
|
|
||||||
{
|
|
||||||
project.Status = ExperimentProjectStatus.Published;
|
|
||||||
project.PublishedAt = publishedAt;
|
|
||||||
}
|
|
||||||
await db.SaveChangesAsync(cancellationToken);
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
foreach (var project in projects)
|
return Accepted(new { JobId = job.Id, Status = job.Status, Message = "实验项目发布任务已提交。" });
|
||||||
await NotifyProjectPublishedAsync(project, cancellationToken);
|
}
|
||||||
return NoContent();
|
|
||||||
|
[HttpGet("batch/publish-jobs/{jobId:guid}")]
|
||||||
|
[Authorize(Roles = Managers)]
|
||||||
|
public async Task<ActionResult> 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")]
|
[HttpPost("{id:guid}/close")]
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class ExamArrangementJob : EntityBase
|
|||||||
public Guid PlanId { get; set; }
|
public Guid PlanId { get; set; }
|
||||||
public Guid? ActivePlanId { get; set; }
|
public Guid? ActivePlanId { get; set; }
|
||||||
public Guid? RequestedByUserId { get; set; }
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? ProjectIdsJson { get; set; }
|
||||||
public string? SessionIdsJson { get; set; }
|
public string? SessionIdsJson { get; set; }
|
||||||
public bool AssignClassrooms { get; set; }
|
public bool AssignClassrooms { get; set; }
|
||||||
public bool AssignInvigilators { get; set; }
|
public bool AssignInvigilators { get; set; }
|
||||||
@@ -138,6 +139,7 @@ public sealed class ExamPublishJob : EntityBase
|
|||||||
public Guid PlanId { get; set; }
|
public Guid PlanId { get; set; }
|
||||||
public Guid? ActivePlanId { get; set; }
|
public Guid? ActivePlanId { get; set; }
|
||||||
public Guid? RequestedByUserId { get; set; }
|
public Guid? RequestedByUserId { get; set; }
|
||||||
|
public string? ProjectIdsJson { get; set; }
|
||||||
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
public ExamPublishJobStatus Status { get; set; } = ExamPublishJobStatus.Queued;
|
||||||
public string? CurrentStep { get; set; }
|
public string? CurrentStep { get; set; }
|
||||||
public string? ErrorMessage { get; set; }
|
public string? ErrorMessage { get; set; }
|
||||||
@@ -148,7 +150,8 @@ public sealed class ExamPublishJob : EntityBase
|
|||||||
public enum ExamPublishJobKind
|
public enum ExamPublishJobKind
|
||||||
{
|
{
|
||||||
FormalExam = 1,
|
FormalExam = 1,
|
||||||
MakeupExam = 2
|
MakeupExam = 2,
|
||||||
|
ExperimentProjects = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ExamPublishJobStatus
|
public enum ExamPublishJobStatus
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
using Jiaowu.Api.Domain.Academic;
|
using Jiaowu.Api.Domain.Academic;
|
||||||
|
using Jiaowu.Api.Domain.System;
|
||||||
using Jiaowu.Api.Infrastructure.Caching;
|
using Jiaowu.Api.Infrastructure.Caching;
|
||||||
|
using Jiaowu.Api.Controllers;
|
||||||
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;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace Jiaowu.Api.Infrastructure.Exams;
|
namespace Jiaowu.Api.Infrastructure.Exams;
|
||||||
|
|
||||||
@@ -39,6 +42,9 @@ public sealed class ExamPublishJobProcessor(
|
|||||||
case ExamPublishJobKind.MakeupExam:
|
case ExamPublishJobKind.MakeupExam:
|
||||||
await PublishMakeupExamAsync(job, stoppingToken);
|
await PublishMakeupExamAsync(job, stoppingToken);
|
||||||
break;
|
break;
|
||||||
|
case ExamPublishJobKind.ExperimentProjects:
|
||||||
|
await PublishExperimentProjectsAsync(job, stoppingToken);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"不支持的考试发布类型:{job.Kind}。");
|
$"不支持的考试发布类型:{job.Kind}。");
|
||||||
@@ -248,6 +254,58 @@ public sealed class ExamPublishJobProcessor(
|
|||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PublishExperimentProjectsAsync(ExamPublishJob job, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var ids = JsonSerializer.Deserialize<List<Guid>>(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)
|
private async Task MarkFailedAsync(Guid jobId, string message)
|
||||||
{
|
{
|
||||||
db.ChangeTracker.Clear();
|
db.ChangeTracker.Clear();
|
||||||
|
|||||||
+29
@@ -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<string>(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamPublishJobs",
|
||||||
|
type: "longtext",
|
||||||
|
maxLength: 5000,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectIdsJson",
|
||||||
|
table: "ExamPublishJobs");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -417,16 +417,35 @@ async function publishSelectedProjects() {
|
|||||||
'批量发布实验项目',
|
'批量发布实验项目',
|
||||||
{ confirmButtonText: '确认发布', cancelButtonText: '取消', type: 'warning' },
|
{ confirmButtonText: '确认发布', cancelButtonText: '取消', type: 'warning' },
|
||||||
)
|
)
|
||||||
await http.post('/experiments/batch/publish', { projectIds: ids })
|
const { data } = await http.post('/experiments/batch/publish', { projectIds: ids })
|
||||||
ElMessage.success(`已发布 ${ids.length} 个实验项目`)
|
ElMessage.success(`已提交 ${ids.length} 个实验项目的后台发布任务`)
|
||||||
selectedProjectIds.value = []
|
selectedProjectIds.value = []
|
||||||
await load()
|
void pollPublishJob(data.jobId)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error === 'cancel' || error === 'close') return
|
if (error === 'cancel' || error === 'close') return
|
||||||
ElMessage.error(apiErrorMessage(error))
|
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() {
|
async function deleteSelectedProjects() {
|
||||||
const ids = draftSelectedProjectIds.value
|
const ids = draftSelectedProjectIds.value
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
|
|||||||
Reference in New Issue
Block a user