diff --git a/.env.docker.example b/.env.docker.example index f825408..060499b 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -8,6 +8,9 @@ MYSQL_USER=jiaowu MYSQL_PASSWORD= MYSQL_ROOT_PASSWORD= +RABBITMQ_USER=jiaowu +RABBITMQ_PASSWORD= + JWT_KEY= ALLOWED_HOSTS=localhost CORS_ORIGIN=http://localhost:8080 diff --git a/.env.example b/.env.example index 2006d96..b118a3e 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,16 @@ ConnectionStrings__MySql="Server=db.example.edu.cn;Port=3306;Database=jiaowu;Use # Redis 是可选加速器;留空时应用仅使用进程内缓存。 # ConnectionStrings__Redis="redis.example.edu.cn:6380,user=jiaowu,password=REPLACE_WITH_A_STRONG_PASSWORD,ssl=true,abortConnect=false" +# 单机可继续使用 InMemory;多实例生产部署建议启用 RabbitMQ。 +BackgroundJobs__Transport=InMemory +# RabbitMq__HostName=rabbitmq.example.edu.cn +# RabbitMq__Port=5671 +# RabbitMq__UserName=jiaowu +# RabbitMq__Password=REPLACE_WITH_A_STRONG_PASSWORD +# RabbitMq__VirtualHost=/jiaowu +# RabbitMq__UseTls=true +# RabbitMq__TlsServerName=rabbitmq.example.edu.cn + Cache__Enabled=true Cache__KeyPrefix=jiaowu:v1 Cache__ReferenceExpirationMinutes=30 diff --git a/README.md b/README.md index bf09a3b..bed2381 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,8 @@ SQLite 只用于本地开发:新库通过 `EnsureCreated` 建立,已有开 - `/health`、`/health/ready`:实际检查数据库连接,失败时返回 HTTP 503。 - `/health/cache`:检查可选 Redis;未配置 Redis 时返回 `disabled`,Redis 故障不会影响数据库就绪探针。 +- `/health/messaging`:检查后台任务传输;单机内存队列返回 `memory`,启用 + RabbitMQ 时实际检查代理连接。 ### 查询缓存与 Redis @@ -251,6 +253,37 @@ Redis 只作为可丢弃的查询缓存。连接失败时应用回源数据库 `allkeys-lfu` 淘汰策略,不启用持久化。`compose.app.example.yml` 不创建 Redis; 如需连接外部 Redis,在 `.env` 中配置上述连接串即可。 +### 后台任务与 RabbitMQ + +自动排课、课表发布和补考自动生成使用数据库 Outbox 保存任务消息。创建业务任务与 +Outbox 消息在同一次 MySQL 提交中完成,后台发布器再将消息投递给任务 Worker;重复 +投递通过 Outbox 处理租约和唯一任务键抑制。任务状态表仍是前端查询进度与错误信息的 +唯一来源。 + +开发和单机部署默认使用有界进程内队列,不需要 RabbitMQ: + +```text +BackgroundJobs__Transport=InMemory +``` + +多实例生产部署应切换为 RabbitMQ,并配置独立账号、虚拟主机和 TLS: + +```text +BackgroundJobs__Transport=RabbitMq +RabbitMq__HostName=rabbitmq.example.edu.cn +RabbitMq__Port=5671 +RabbitMq__UserName=jiaowu +RabbitMq__Password=REPLACE_WITH_A_STRONG_PASSWORD +RabbitMq__VirtualHost=/jiaowu +RabbitMq__UseTls=true +RabbitMq__TlsServerName=rabbitmq.example.edu.cn +``` + +RabbitMQ 传输使用持久消息、发布确认、手动消费确认、每种任务独立队列和死信队列。 +默认创建 Quorum Queue,重任务的消费者预取数为 1。MySQL 或 RabbitMQ 暂时不可用时, +未完成消息会根据 Outbox 状态和租约继续补投,消费者必须保持业务处理幂等。迁移服务 +应先应用 `BackgroundJobOutbox` 数据库迁移,再启动应用实例。 + ## 跨平台发布与 Docker `.gitea/workflows/publish.yml` 只在推送 `v*` 标签或手动运行时执行,普通分支 push diff --git a/compose.example.yml b/compose.example.yml index 5e8d725..17996b0 100644 --- a/compose.example.yml +++ b/compose.example.yml @@ -16,6 +16,13 @@ x-jiaowu-environment: &jiaowu-environment ConnectionStrings__Redis: "redis:6379,abortConnect=false" Cache__Enabled: "true" Cache__KeyPrefix: "jiaowu:v1" + BackgroundJobs__Transport: RabbitMq + RabbitMq__HostName: rabbitmq + RabbitMq__Port: "5672" + RabbitMq__UserName: "${RABBITMQ_USER:-jiaowu}" + RabbitMq__Password: "${RABBITMQ_PASSWORD:?请在 .env.docker 中设置 RABBITMQ_PASSWORD}" + RabbitMq__VirtualHost: "/" + RabbitMq__UseTls: "false" Jwt__Issuer: Jiaowu.Api Jwt__Audience: Jiaowu.Web Jwt__Key: "${JWT_KEY:?请在 .env.docker 中设置 JWT_KEY}" @@ -34,6 +41,26 @@ x-json-logging: &json-logging max-file: "3" services: + rabbitmq: + image: rabbitmq:4.2-management-alpine + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: "${RABBITMQ_USER:-jiaowu}" + RABBITMQ_DEFAULT_PASS: "${RABBITMQ_PASSWORD:?请在 .env.docker 中设置 RABBITMQ_PASSWORD}" + healthcheck: + test: + - CMD + - rabbitmq-diagnostics + - -q + - ping + interval: 10s + timeout: 5s + retries: 12 + start_period: 20s + volumes: + - rabbitmq-data:/var/lib/rabbitmq + logging: *json-logging + redis: image: redis:8.8-alpine restart: unless-stopped @@ -96,6 +123,8 @@ services: <<: *jiaowu-image environment: *jiaowu-environment depends_on: + rabbitmq: + condition: service_healthy redis: condition: service_started mysql: @@ -128,3 +157,4 @@ services: volumes: mysql-data: + rabbitmq-data: diff --git a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs index 25e3f30..fad9d7f 100644 --- a/src/Jiaowu.Api/Controllers/MakeupExamsController.cs +++ b/src/Jiaowu.Api/Controllers/MakeupExamsController.cs @@ -1,6 +1,7 @@ using System.ComponentModel.DataAnnotations; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Exams; using Jiaowu.Api.Infrastructure.Grades; @@ -18,8 +19,7 @@ public sealed class MakeupExamsController( AppDbContext db, ICurrentUserDataScope currentUserDataScope, MakeupExamEligibilityService eligibilityService, - MakeupExamArrangementService arrangementService, - MakeupExamAutoJobQueue autoJobQueue) : ControllerBase + MakeupExamArrangementService arrangementService) : ControllerBase { private const string Managers = SystemRoles.SuperAdmin + "," + SystemRoles.AcademicAdmin; @@ -336,10 +336,12 @@ public sealed class MakeupExamsController( MakeupExamPlanId = planId }; db.MakeupExamAutoJobs.Add(job); + db.BackgroundJobOutboxMessages.Add( + BackgroundJobOutboxMessage.Create( + BackgroundJobKind.MakeupExamAuto, + job.Id)); await db.SaveChangesAsync(cancellationToken); - autoJobQueue.Enqueue(job.Id); - return AcceptedAtAction(nameof(GetAutoJob), new { jobId = job.Id }, new { jobId = job.Id, status = job.Status.ToString() }); } diff --git a/src/Jiaowu.Api/Controllers/SchedulesController.cs b/src/Jiaowu.Api/Controllers/SchedulesController.cs index 687f8da..86cf10e 100644 --- a/src/Jiaowu.Api/Controllers/SchedulesController.cs +++ b/src/Jiaowu.Api/Controllers/SchedulesController.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using System.Text.Json; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Microsoft.AspNetCore.Authorization; @@ -15,9 +16,7 @@ namespace Jiaowu.Api.Controllers; [Authorize(Roles = ManagementRoles)] [Route("api/schedules")] public sealed class SchedulesController( - AppDbContext db, - AutomaticScheduleJobQueue automaticScheduleJobQueue, - SchedulePublishJobQueue schedulePublishJobQueue) : ControllerBase + AppDbContext db) : ControllerBase { private const string ManagementRoles = SystemRoles.SuperAdmin + "," + @@ -241,6 +240,10 @@ public sealed class SchedulesController( CurrentStep = "等待后台检查" }; db.SchedulePublishJobs.Add(job); + db.BackgroundJobOutboxMessages.Add( + BackgroundJobOutboxMessage.Create( + BackgroundJobKind.SchedulePublish, + job.Id)); try { await db.SaveChangesAsync(cancellationToken); @@ -263,7 +266,6 @@ public sealed class SchedulesController( ToResponse(existing)); } - schedulePublishJobQueue.Enqueue(job.Id); return AcceptedAtAction( nameof(GetSchedulePublishJob), new { jobId = job.Id }, @@ -321,6 +323,10 @@ public sealed class SchedulesController( RequestedByUserId = requestedByUserId }; db.AutomaticScheduleJobs.Add(job); + db.BackgroundJobOutboxMessages.Add( + BackgroundJobOutboxMessage.Create( + BackgroundJobKind.AutomaticSchedule, + job.Id)); try { await db.SaveChangesAsync(cancellationToken); @@ -339,7 +345,6 @@ public sealed class SchedulesController( ToResponse(existing)); } - automaticScheduleJobQueue.Enqueue(job.Id); return AcceptedAtAction( nameof(GetAutomaticScheduleJob), new { jobId = job.Id }, diff --git a/src/Jiaowu.Api/Domain/System/BackgroundJobOutboxMessage.cs b/src/Jiaowu.Api/Domain/System/BackgroundJobOutboxMessage.cs new file mode 100644 index 0000000..3cd4422 --- /dev/null +++ b/src/Jiaowu.Api/Domain/System/BackgroundJobOutboxMessage.cs @@ -0,0 +1,43 @@ +using Jiaowu.Api.Domain.Common; + +namespace Jiaowu.Api.Domain.System; + +public sealed class BackgroundJobOutboxMessage : EntityBase +{ + public BackgroundJobKind JobKind { get; set; } + public Guid JobId { get; set; } + public BackgroundJobOutboxState State { get; set; } = + BackgroundJobOutboxState.Pending; + public int PublishAttempts { get; set; } + public int ProcessingAttempts { get; set; } + public DateTime? PublishedAt { get; set; } + public Guid? ProcessingToken { get; set; } + public DateTime? LeaseExpiresAt { get; set; } + public DateTime? CompletedAt { get; set; } + public string? LastError { get; set; } + + public static BackgroundJobOutboxMessage Create( + BackgroundJobKind jobKind, + Guid jobId) => + new() + { + JobKind = jobKind, + JobId = jobId + }; +} + +public enum BackgroundJobKind +{ + AutomaticSchedule = 1, + SchedulePublish = 2, + MakeupExamAuto = 3 +} + +public enum BackgroundJobOutboxState +{ + Pending = 1, + Publishing = 2, + Published = 3, + Processing = 4, + Completed = 5 +} diff --git a/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOptions.cs b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOptions.cs new file mode 100644 index 0000000..cc23ef5 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOptions.cs @@ -0,0 +1,31 @@ +namespace Jiaowu.Api.Infrastructure.BackgroundJobs; + +public sealed class BackgroundJobOptions +{ + public const string SectionName = "BackgroundJobs"; + + public string Transport { get; set; } = "InMemory"; + public int PollIntervalMilliseconds { get; set; } = 500; + public int LeaseSeconds { get; set; } = 120; + public ushort PrefetchCount { get; set; } = 1; + public string Exchange { get; set; } = "jiaowu.background-jobs"; + public string QueuePrefix { get; set; } = "jiaowu.background-jobs"; + public bool UseQuorumQueues { get; set; } = true; + public int ProcessingAttemptLimit { get; set; } = 5; + + public bool UsesRabbitMq => + Transport.Equals("RabbitMq", StringComparison.OrdinalIgnoreCase); +} + +public sealed class RabbitMqOptions +{ + public const string SectionName = "RabbitMq"; + + public string HostName { get; set; } = "localhost"; + public int Port { get; set; } = 5672; + public string UserName { get; set; } = "guest"; + public string Password { get; set; } = "guest"; + public string VirtualHost { get; set; } = "/"; + public bool UseTls { get; set; } + public string? TlsServerName { get; set; } +} diff --git a/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs new file mode 100644 index 0000000..beeb81f --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobOutboxPublisher.cs @@ -0,0 +1,219 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.System; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.BackgroundJobs; + +public sealed class BackgroundJobOutboxPublisher( + IServiceScopeFactory scopeFactory, + IBackgroundJobTransport transport, + BackgroundJobOptions options, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await PrepareForStartupAsync(stoppingToken); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await RecoverExpiredProcessingAsync(stoppingToken); + var claimed = await ClaimNextAsync(stoppingToken); + if (claimed is null) + { + await Task.Delay(options.PollIntervalMilliseconds, stoppingToken); + continue; + } + + await PublishClaimedAsync(claimed.Value.Message, claimed.Value.Token, + stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogError(exception, "Background job outbox publishing failed."); + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + } + } + } + + private async Task PrepareForStartupAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + if (!transport.IsDurable) + { + await db.BackgroundJobOutboxMessages + .Where(x => x.State != BackgroundJobOutboxState.Completed) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.State, BackgroundJobOutboxState.Pending) + .SetProperty(x => x.ProcessingToken, (Guid?)null) + .SetProperty(x => x.LeaseExpiresAt, (DateTime?)null), + cancellationToken); + } + + await AddMissingOutboxMessagesAsync(db, cancellationToken); + } + + private static async Task AddMissingOutboxMessagesAsync( + AppDbContext db, + CancellationToken cancellationToken) + { + var existing = await db.BackgroundJobOutboxMessages.AsNoTracking() + .Select(x => new { x.JobKind, x.JobId }) + .ToListAsync(cancellationToken); + var existingKeys = existing + .Select(x => (x.JobKind, x.JobId)) + .ToHashSet(); + + var automaticJobs = await db.AutomaticScheduleJobs.AsNoTracking() + .Where(x => x.Status == AutomaticScheduleJobStatus.Queued || + x.Status == AutomaticScheduleJobStatus.Running) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + var publishJobs = await db.SchedulePublishJobs.AsNoTracking() + .Where(x => x.Status == SchedulePublishJobStatus.Queued || + x.Status == SchedulePublishJobStatus.Running) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + var makeupJobs = await db.MakeupExamAutoJobs.AsNoTracking() + .Where(x => x.Status == MakeupExamAutoJobStatus.Queued || + x.Status == MakeupExamAutoJobStatus.Running) + .Select(x => x.Id) + .ToListAsync(cancellationToken); + + AddMissing(BackgroundJobKind.AutomaticSchedule, automaticJobs); + AddMissing(BackgroundJobKind.SchedulePublish, publishJobs); + AddMissing(BackgroundJobKind.MakeupExamAuto, makeupJobs); + + if (db.ChangeTracker.HasChanges()) + await db.SaveChangesAsync(cancellationToken); + + void AddMissing(BackgroundJobKind kind, IEnumerable jobIds) + { + foreach (var jobId in jobIds) + { + if (existingKeys.Add((kind, jobId))) + { + db.BackgroundJobOutboxMessages.Add( + BackgroundJobOutboxMessage.Create(kind, jobId)); + } + } + } + } + + private async Task RecoverExpiredProcessingAsync( + CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + var recoveredState = transport.IsDurable + ? BackgroundJobOutboxState.Published + : BackgroundJobOutboxState.Pending; + + await db.BackgroundJobOutboxMessages + .Where(x => + (x.State == BackgroundJobOutboxState.Publishing || + x.State == BackgroundJobOutboxState.Processing) && + x.LeaseExpiresAt != null && + x.LeaseExpiresAt < now) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.State, recoveredState) + .SetProperty(x => x.ProcessingToken, (Guid?)null) + .SetProperty(x => x.LeaseExpiresAt, (DateTime?)null), + cancellationToken); + } + + private async Task<(BackgroundJobEnvelope Message, Guid Token)?> ClaimNextAsync( + CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var candidateId = await db.BackgroundJobOutboxMessages.AsNoTracking() + .Where(x => x.State == BackgroundJobOutboxState.Pending) + .OrderBy(x => x.CreatedAt) + .Select(x => (Guid?)x.Id) + .FirstOrDefaultAsync(cancellationToken); + if (!candidateId.HasValue) + return null; + + var token = Guid.NewGuid(); + var leaseExpiresAt = DateTime.UtcNow.AddSeconds(options.LeaseSeconds); + var claimed = await db.BackgroundJobOutboxMessages + .Where(x => + x.Id == candidateId.Value && + x.State == BackgroundJobOutboxState.Pending) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.State, BackgroundJobOutboxState.Publishing) + .SetProperty(x => x.ProcessingToken, token) + .SetProperty(x => x.LeaseExpiresAt, leaseExpiresAt) + .SetProperty(x => x.PublishAttempts, x => x.PublishAttempts + 1) + .SetProperty(x => x.LastError, (string?)null), + cancellationToken); + if (claimed == 0) + return null; + + var message = await db.BackgroundJobOutboxMessages.AsNoTracking() + .Where(x => x.Id == candidateId.Value) + .Select(x => new BackgroundJobEnvelope(x.Id, x.JobKind, x.JobId)) + .SingleAsync(cancellationToken); + return (message, token); + } + + private async Task PublishClaimedAsync( + BackgroundJobEnvelope message, + Guid token, + CancellationToken cancellationToken) + { + try + { + await transport.PublishAsync(message, cancellationToken); + + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.BackgroundJobOutboxMessages + .Where(x => + x.Id == message.OutboxMessageId && + x.State == BackgroundJobOutboxState.Publishing && + x.ProcessingToken == token) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.State, BackgroundJobOutboxState.Published) + .SetProperty(x => x.PublishedAt, DateTime.UtcNow) + .SetProperty(x => x.ProcessingToken, (Guid?)null) + .SetProperty(x => x.LeaseExpiresAt, (DateTime?)null), + cancellationToken); + } + catch (Exception exception) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var messageText = exception.GetBaseException().Message; + if (messageText.Length > 2000) + messageText = messageText[..2000]; + await db.BackgroundJobOutboxMessages + .Where(x => + x.Id == message.OutboxMessageId && + x.State == BackgroundJobOutboxState.Publishing && + x.ProcessingToken == token) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.State, BackgroundJobOutboxState.Pending) + .SetProperty(x => x.ProcessingToken, (Guid?)null) + .SetProperty(x => x.LeaseExpiresAt, (DateTime?)null) + .SetProperty(x => x.LastError, messageText), + cancellationToken); + throw; + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobRunner.cs b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobRunner.cs new file mode 100644 index 0000000..79b2bb1 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobRunner.cs @@ -0,0 +1,333 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.System; +using Jiaowu.Api.Infrastructure.Exams; +using Jiaowu.Api.Infrastructure.Persistence; +using Jiaowu.Api.Infrastructure.Scheduling; +using Microsoft.EntityFrameworkCore; + +namespace Jiaowu.Api.Infrastructure.BackgroundJobs; + +public sealed record BackgroundJobRunResult( + BackgroundJobRunOutcome Outcome, + TimeSpan RetryAfter) +{ + public static BackgroundJobRunResult Completed { get; } = + new(BackgroundJobRunOutcome.Completed, TimeSpan.Zero); + + public static BackgroundJobRunResult Retry(TimeSpan retryAfter) => + new(BackgroundJobRunOutcome.Retry, retryAfter); +} + +public enum BackgroundJobRunOutcome +{ + Completed, + Retry +} + +public sealed class BackgroundJobRunner( + IServiceScopeFactory scopeFactory, + IBackgroundJobTransport transport, + BackgroundJobOptions options, + ILogger logger) +{ + public async Task RunAsync( + BackgroundJobEnvelope message, + CancellationToken cancellationToken) + { + var token = Guid.NewGuid(); + var claim = await TryClaimAsync(message, token, cancellationToken); + if (!claim.Claimed) + { + return claim.Completed + ? BackgroundJobRunResult.Completed + : BackgroundJobRunResult.Retry(claim.RetryAfter); + } + + if (claim.AttemptsExceeded) + { + await MarkJobRetryLimitExceededAsync(message, cancellationToken); + await MarkCompletedAsync(message.OutboxMessageId, token, + cancellationToken); + return BackgroundJobRunResult.Completed; + } + + using var heartbeatCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var heartbeat = RunHeartbeatAsync( + message.OutboxMessageId, + token, + heartbeatCancellation.Token); + + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + switch (message.JobKind) + { + case BackgroundJobKind.AutomaticSchedule: + await scope.ServiceProvider + .GetRequiredService() + .ProcessAsync(message.JobId, cancellationToken); + break; + case BackgroundJobKind.SchedulePublish: + await scope.ServiceProvider + .GetRequiredService() + .ProcessAsync(message.JobId, cancellationToken); + break; + case BackgroundJobKind.MakeupExamAuto: + await scope.ServiceProvider + .GetRequiredService() + .ProcessAsync(message.JobId, cancellationToken); + break; + default: + throw new InvalidOperationException( + $"Unsupported background job kind '{message.JobKind}'."); + } + + await MarkCompletedAsync(message.OutboxMessageId, token, + cancellationToken); + return BackgroundJobRunResult.Completed; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logger.LogError( + exception, + "Background job {JobKind}/{JobId} will be retried.", + message.JobKind, + message.JobId); + await ReleaseForRetryAsync( + message.OutboxMessageId, + token, + exception, + CancellationToken.None); + return BackgroundJobRunResult.Retry(TimeSpan.FromSeconds(2)); + } + finally + { + await heartbeatCancellation.CancelAsync(); + try + { + await heartbeat; + } + catch (OperationCanceledException) + { + // Expected when processing completes or the application stops. + } + } + } + + private async Task<( + bool Claimed, + bool Completed, + bool AttemptsExceeded, + TimeSpan RetryAfter)> + TryClaimAsync( + BackgroundJobEnvelope message, + Guid token, + CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var now = DateTime.UtcNow; + var leaseExpiresAt = now.AddSeconds(options.LeaseSeconds); + var claimed = await db.BackgroundJobOutboxMessages + .Where(x => + x.Id == message.OutboxMessageId && + x.JobKind == message.JobKind && + x.JobId == message.JobId && + (x.State == BackgroundJobOutboxState.Pending || + x.State == BackgroundJobOutboxState.Publishing || + x.State == BackgroundJobOutboxState.Published || + (x.State == BackgroundJobOutboxState.Processing && + x.LeaseExpiresAt != null && + x.LeaseExpiresAt < now))) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.State, BackgroundJobOutboxState.Processing) + .SetProperty(x => x.ProcessingToken, token) + .SetProperty(x => x.LeaseExpiresAt, leaseExpiresAt) + .SetProperty( + x => x.ProcessingAttempts, + x => x.ProcessingAttempts + 1) + .SetProperty(x => x.LastError, (string?)null), + cancellationToken); + if (claimed == 1) + { + var attempts = await db.BackgroundJobOutboxMessages.AsNoTracking() + .Where(x => x.Id == message.OutboxMessageId) + .Select(x => x.ProcessingAttempts) + .SingleAsync(cancellationToken); + return ( + true, + false, + attempts > options.ProcessingAttemptLimit, + TimeSpan.Zero); + } + + var current = await db.BackgroundJobOutboxMessages.AsNoTracking() + .Where(x => x.Id == message.OutboxMessageId) + .Select(x => new { x.State, x.LeaseExpiresAt }) + .FirstOrDefaultAsync(cancellationToken); + if (current is null || current.State == BackgroundJobOutboxState.Completed) + return (false, true, false, TimeSpan.Zero); + + var retryAfter = current.LeaseExpiresAt.HasValue + ? current.LeaseExpiresAt.Value - now + : TimeSpan.FromSeconds(2); + retryAfter = TimeSpan.FromSeconds(Math.Clamp( + retryAfter.TotalSeconds, + 1, + options.LeaseSeconds)); + return (false, false, false, retryAfter); + } + + private async Task MarkJobRetryLimitExceededAsync( + BackgroundJobEnvelope message, + CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var completedAt = DateTime.UtcNow; + var error = $"后台任务连续处理失败超过 {options.ProcessingAttemptLimit} 次," + + "已停止自动重试,请检查服务日志后重新创建任务。"; + + switch (message.JobKind) + { + case BackgroundJobKind.AutomaticSchedule: + await db.AutomaticScheduleJobs + .Where(x => + x.Id == message.JobId && + x.Status != AutomaticScheduleJobStatus.Succeeded && + x.Status != AutomaticScheduleJobStatus.Failed) + .ExecuteUpdateAsync( + setters => setters + .SetProperty( + x => x.Status, + AutomaticScheduleJobStatus.Failed) + .SetProperty(x => x.ActiveSchedulePlanId, (Guid?)null) + .SetProperty(x => x.ErrorMessage, error) + .SetProperty(x => x.CompletedAt, completedAt), + cancellationToken); + break; + case BackgroundJobKind.SchedulePublish: + await db.SchedulePublishJobs + .Where(x => + x.Id == message.JobId && + x.Status != SchedulePublishJobStatus.Succeeded && + x.Status != SchedulePublishJobStatus.Failed) + .ExecuteUpdateAsync( + setters => setters + .SetProperty( + x => x.Status, + SchedulePublishJobStatus.Failed) + .SetProperty(x => x.ActiveAcademicTermId, (Guid?)null) + .SetProperty(x => x.CurrentStep, "后台处理已停止") + .SetProperty(x => x.ErrorMessage, error) + .SetProperty(x => x.CompletedAt, completedAt), + cancellationToken); + break; + case BackgroundJobKind.MakeupExamAuto: + await db.MakeupExamAutoJobs + .Where(x => + x.Id == message.JobId && + x.Status != MakeupExamAutoJobStatus.Succeeded && + x.Status != MakeupExamAutoJobStatus.Failed) + .ExecuteUpdateAsync( + setters => setters + .SetProperty( + x => x.Status, + MakeupExamAutoJobStatus.Failed) + .SetProperty(x => x.ErrorMessage, error) + .SetProperty(x => x.CompletedAt, completedAt), + cancellationToken); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(message.JobKind), + message.JobKind, + null); + } + + logger.LogError( + "Background job {JobKind}/{JobId} exceeded {AttemptLimit} processing attempts.", + message.JobKind, + message.JobId, + options.ProcessingAttemptLimit); + } + + private async Task RunHeartbeatAsync( + Guid outboxMessageId, + Guid token, + CancellationToken cancellationToken) + { + var intervalSeconds = Math.Max(5, options.LeaseSeconds / 3); + while (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), cancellationToken); + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.BackgroundJobOutboxMessages + .Where(x => + x.Id == outboxMessageId && + x.State == BackgroundJobOutboxState.Processing && + x.ProcessingToken == token) + .ExecuteUpdateAsync( + setters => setters.SetProperty( + x => x.LeaseExpiresAt, + DateTime.UtcNow.AddSeconds(options.LeaseSeconds)), + cancellationToken); + } + } + + private async Task MarkCompletedAsync( + Guid outboxMessageId, + Guid token, + CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.BackgroundJobOutboxMessages + .Where(x => + x.Id == outboxMessageId && + x.State == BackgroundJobOutboxState.Processing && + x.ProcessingToken == token) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.State, BackgroundJobOutboxState.Completed) + .SetProperty(x => x.CompletedAt, DateTime.UtcNow) + .SetProperty(x => x.ProcessingToken, (Guid?)null) + .SetProperty(x => x.LeaseExpiresAt, (DateTime?)null), + cancellationToken); + } + + private async Task ReleaseForRetryAsync( + Guid outboxMessageId, + Guid token, + Exception exception, + CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var message = exception.GetBaseException().Message; + if (message.Length > 2000) + message = message[..2000]; + var state = transport.IsDurable + ? BackgroundJobOutboxState.Published + : BackgroundJobOutboxState.Pending; + await db.BackgroundJobOutboxMessages + .Where(x => + x.Id == outboxMessageId && + x.State == BackgroundJobOutboxState.Processing && + x.ProcessingToken == token) + .ExecuteUpdateAsync( + setters => setters + .SetProperty(x => x.State, state) + .SetProperty(x => x.ProcessingToken, (Guid?)null) + .SetProperty(x => x.LeaseExpiresAt, (DateTime?)null) + .SetProperty(x => x.LastError, message), + cancellationToken); + } +} diff --git a/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobTransport.cs b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobTransport.cs new file mode 100644 index 0000000..ff1c381 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/BackgroundJobTransport.cs @@ -0,0 +1,46 @@ +using System.Threading.Channels; +using Jiaowu.Api.Domain.System; + +namespace Jiaowu.Api.Infrastructure.BackgroundJobs; + +public sealed record BackgroundJobEnvelope( + Guid OutboxMessageId, + BackgroundJobKind JobKind, + Guid JobId); + +public interface IBackgroundJobTransport +{ + bool IsDurable { get; } + + ValueTask PublishAsync( + BackgroundJobEnvelope message, + CancellationToken cancellationToken); + + Task CheckHealthAsync(CancellationToken cancellationToken); +} + +public sealed class InMemoryBackgroundJobTransport : IBackgroundJobTransport +{ + private readonly Channel _channel = + Channel.CreateBounded( + new BoundedChannelOptions(256) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = true, + SingleWriter = false + }); + + public bool IsDurable => false; + + public ValueTask PublishAsync( + BackgroundJobEnvelope message, + CancellationToken cancellationToken) => + _channel.Writer.WriteAsync(message, cancellationToken); + + public Task CheckHealthAsync(CancellationToken cancellationToken) => + Task.FromResult(true); + + public IAsyncEnumerable ReadAllAsync( + CancellationToken cancellationToken) => + _channel.Reader.ReadAllAsync(cancellationToken); +} diff --git a/src/Jiaowu.Api/Infrastructure/BackgroundJobs/InMemoryBackgroundJobWorker.cs b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/InMemoryBackgroundJobWorker.cs new file mode 100644 index 0000000..67d4a24 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/InMemoryBackgroundJobWorker.cs @@ -0,0 +1,27 @@ +namespace Jiaowu.Api.Infrastructure.BackgroundJobs; + +public sealed class InMemoryBackgroundJobWorker( + InMemoryBackgroundJobTransport transport, + BackgroundJobRunner runner, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + await foreach (var message in transport.ReadAllAsync(stoppingToken)) + { + var result = await runner.RunAsync(message, stoppingToken); + if (result.Outcome != BackgroundJobRunOutcome.Retry) + continue; + + await Task.Delay(result.RetryAfter, stoppingToken); + await transport.PublishAsync(message, stoppingToken); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + logger.LogInformation("In-memory background job worker is stopping."); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs new file mode 100644 index 0000000..06809a1 --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/BackgroundJobs/RabbitMqBackgroundJobs.cs @@ -0,0 +1,416 @@ +using System.Text.Json; +using Jiaowu.Api.Domain.System; +using RabbitMQ.Client; +using RabbitMQ.Client.Events; + +namespace Jiaowu.Api.Infrastructure.BackgroundJobs; + +public sealed class RabbitMqBackgroundJobTransport( + BackgroundJobOptions jobOptions, + RabbitMqOptions rabbitOptions, + ILogger logger) + : IBackgroundJobTransport, IAsyncDisposable +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private IConnection? _connection; + private IChannel? _channel; + + public bool IsDurable => true; + + public async ValueTask PublishAsync( + BackgroundJobEnvelope message, + CancellationToken cancellationToken) + { + var body = JsonSerializer.SerializeToUtf8Bytes(message); + await _gate.WaitAsync(cancellationToken); + try + { + var channel = await GetChannelAsync(cancellationToken); + var properties = new BasicProperties + { + Persistent = true, + ContentType = "application/json", + MessageId = message.OutboxMessageId.ToString("D"), + Type = message.JobKind.ToString(), + AppId = "jiaowu-api" + }; + await channel.BasicPublishAsync( + jobOptions.Exchange, + RabbitMqBackgroundJobTopology.RoutingKey(message.JobKind), + mandatory: true, + properties, + body, + cancellationToken); + } + catch + { + await ResetConnectionAsync(); + throw; + } + finally + { + _gate.Release(); + } + } + + public async Task CheckHealthAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + var channel = await GetChannelAsync(cancellationToken); + return channel.IsOpen; + } + catch (Exception exception) + { + logger.LogWarning(exception, "RabbitMQ health check failed."); + await ResetConnectionAsync(); + return false; + } + finally + { + _gate.Release(); + } + } + + private async Task GetChannelAsync(CancellationToken cancellationToken) + { + if (_channel is { IsOpen: true }) + return _channel; + + await ResetConnectionAsync(); + _connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync( + rabbitOptions, + "jiaowu-background-job-publisher", + cancellationToken); + _channel = await _connection.CreateChannelAsync( + new CreateChannelOptions( + publisherConfirmationsEnabled: true, + publisherConfirmationTrackingEnabled: true), + cancellationToken); + await RabbitMqBackgroundJobTopology.DeclareAsync( + _channel, + jobOptions, + cancellationToken); + return _channel; + } + + private async Task ResetConnectionAsync() + { + if (_channel is not null) + { + try + { + await _channel.DisposeAsync(); + } + catch + { + // The broker may already have closed the channel. + } + _channel = null; + } + + if (_connection is not null) + { + try + { + await _connection.DisposeAsync(); + } + catch + { + // The broker may already have closed the connection. + } + _connection = null; + } + } + + public async ValueTask DisposeAsync() + { + await _gate.WaitAsync(); + try + { + await ResetConnectionAsync(); + } + finally + { + _gate.Release(); + _gate.Dispose(); + } + } +} + +public sealed class RabbitMqBackgroundJobWorker( + BackgroundJobOptions jobOptions, + RabbitMqOptions rabbitOptions, + BackgroundJobRunner runner, + ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + IConnection? connection = null; + var channels = new List(); + try + { + connection = await RabbitMqBackgroundJobTopology.CreateConnectionAsync( + rabbitOptions, + "jiaowu-background-job-worker", + stoppingToken); + + foreach (var kind in RabbitMqBackgroundJobTopology.JobKinds) + { + var channel = await connection.CreateChannelAsync( + new CreateChannelOptions( + publisherConfirmationsEnabled: false, + publisherConfirmationTrackingEnabled: false), + stoppingToken); + channels.Add(channel); + await RabbitMqBackgroundJobTopology.DeclareAsync( + channel, + jobOptions, + stoppingToken); + await channel.BasicQosAsync( + 0, + jobOptions.PrefetchCount, + global: false, + stoppingToken); + + var consumer = new AsyncEventingBasicConsumer(channel); + consumer.ReceivedAsync += async (_, eventArgs) => + { + using var deliveryCancellation = + CancellationTokenSource.CreateLinkedTokenSource( + eventArgs.CancellationToken, + stoppingToken); + var deliveryToken = deliveryCancellation.Token; + try + { + var message = JsonSerializer.Deserialize( + eventArgs.Body.Span); + if (message is null || message.JobKind != kind) + { + await channel.BasicNackAsync( + eventArgs.DeliveryTag, + multiple: false, + requeue: false, + deliveryToken); + return; + } + + var result = await runner.RunAsync( + message, + deliveryToken); + if (result.Outcome == BackgroundJobRunOutcome.Completed) + { + await channel.BasicAckAsync( + eventArgs.DeliveryTag, + multiple: false, + deliveryToken); + return; + } + + await Task.Delay( + result.RetryAfter, + deliveryToken); + await channel.BasicNackAsync( + eventArgs.DeliveryTag, + multiple: false, + requeue: true, + deliveryToken); + } + catch (OperationCanceledException) + { + // Closing the channel requeues unacknowledged deliveries. + } + catch (Exception exception) + { + logger.LogError( + exception, + "RabbitMQ delivery for {JobKind} failed and will be requeued.", + kind); + if (channel.IsOpen) + { + await channel.BasicNackAsync( + eventArgs.DeliveryTag, + multiple: false, + requeue: true, + CancellationToken.None); + } + } + }; + await channel.BasicConsumeAsync( + RabbitMqBackgroundJobTopology.QueueName(jobOptions, kind), + autoAck: false, + consumer, + stoppingToken); + } + + logger.LogInformation( + "RabbitMQ background job consumers are connected to {HostName}:{Port}.", + rabbitOptions.HostName, + rabbitOptions.Port); + while (connection.IsOpen && !stoppingToken.IsCancellationRequested) + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogError( + exception, + "RabbitMQ background job consumer connection failed; retrying."); + } + finally + { + foreach (var channel in channels) + { + try + { + await channel.DisposeAsync(); + } + catch + { + // The connection may already have disposed its channels. + } + } + + if (connection is not null) + { + try + { + await connection.DisposeAsync(); + } + catch + { + // The broker may already have closed the connection. + } + } + } + + if (!stoppingToken.IsCancellationRequested) + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } +} + +internal static class RabbitMqBackgroundJobTopology +{ + public static readonly BackgroundJobKind[] JobKinds = + [ + BackgroundJobKind.AutomaticSchedule, + BackgroundJobKind.SchedulePublish, + BackgroundJobKind.MakeupExamAuto + ]; + + public static async Task CreateConnectionAsync( + RabbitMqOptions options, + string clientName, + CancellationToken cancellationToken) + { + var factory = new ConnectionFactory + { + HostName = options.HostName, + Port = options.Port, + UserName = options.UserName, + Password = options.Password, + VirtualHost = options.VirtualHost, + ClientProvidedName = clientName, + AutomaticRecoveryEnabled = false, + TopologyRecoveryEnabled = false, + RequestedHeartbeat = TimeSpan.FromSeconds(30), + ConsumerDispatchConcurrency = 1 + }; + if (options.UseTls) + { + factory.Ssl = new SslOption + { + Enabled = true, + ServerName = string.IsNullOrWhiteSpace(options.TlsServerName) + ? options.HostName + : options.TlsServerName + }; + } + + return await factory.CreateConnectionAsync(cancellationToken); + } + + public static async Task DeclareAsync( + IChannel channel, + BackgroundJobOptions options, + CancellationToken cancellationToken) + { + var deadExchange = options.Exchange + ".dead"; + await channel.ExchangeDeclareAsync( + options.Exchange, + ExchangeType.Direct, + durable: true, + autoDelete: false, + cancellationToken: cancellationToken); + await channel.ExchangeDeclareAsync( + deadExchange, + ExchangeType.Direct, + durable: true, + autoDelete: false, + cancellationToken: cancellationToken); + + foreach (var kind in JobKinds) + { + var routingKey = RoutingKey(kind); + var queueName = QueueName(options, kind); + var deadQueueName = queueName + ".dead"; + var queueArguments = QueueArguments(options); + queueArguments["x-dead-letter-exchange"] = deadExchange; + queueArguments["x-dead-letter-routing-key"] = routingKey; + await channel.QueueDeclareAsync( + queueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: queueArguments, + cancellationToken: cancellationToken); + await channel.QueueBindAsync( + queueName, + options.Exchange, + routingKey, + cancellationToken: cancellationToken); + + await channel.QueueDeclareAsync( + deadQueueName, + durable: true, + exclusive: false, + autoDelete: false, + arguments: QueueArguments(options), + cancellationToken: cancellationToken); + await channel.QueueBindAsync( + deadQueueName, + deadExchange, + routingKey, + cancellationToken: cancellationToken); + } + } + + public static string RoutingKey(BackgroundJobKind kind) => kind switch + { + BackgroundJobKind.AutomaticSchedule => "schedule.automatic", + BackgroundJobKind.SchedulePublish => "schedule.publish", + BackgroundJobKind.MakeupExamAuto => "makeup-exam.automatic", + _ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null) + }; + + public static string QueueName( + BackgroundJobOptions options, + BackgroundJobKind kind) => + $"{options.QueuePrefix}.{RoutingKey(kind)}"; + + private static Dictionary QueueArguments( + BackgroundJobOptions options) + { + var arguments = new Dictionary(); + if (options.UseQuorumQueues) + arguments["x-queue-type"] = "quorum"; + return arguments; + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamAutoJobs.cs b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamAutoJobs.cs index 9f343f4..be325aa 100644 --- a/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamAutoJobs.cs +++ b/src/Jiaowu.Api/Infrastructure/Exams/MakeupExamAutoJobs.cs @@ -1,111 +1,10 @@ using System.Text.Json; -using System.Threading.Channels; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Exams; -public sealed class MakeupExamAutoJobQueue -{ - private readonly Channel _channel = Channel.CreateUnbounded( - new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false - }); - - public void Enqueue(Guid jobId) - { - if (!_channel.Writer.TryWrite(jobId)) - throw new InvalidOperationException("补考自动生成任务队列当前不可用。"); - } - - public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) => - _channel.Reader.ReadAllAsync(cancellationToken); -} - -public sealed class MakeupExamAutoJobWorker( - IServiceScopeFactory scopeFactory, - MakeupExamAutoJobQueue queue, - ILogger logger) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - try - { - await RecoverInterruptedJobsAsync(stoppingToken); - } - catch (Exception exception) - { - logger.LogWarning( - exception, - "Could not recover makeup exam auto jobs (table may not exist yet)."); - } - - try - { - await foreach (var jobId in queue.ReadAllAsync(stoppingToken)) - { - try - { - await using var scope = scopeFactory.CreateAsyncScope(); - var processor = scope.ServiceProvider - .GetRequiredService(); - await processor.ProcessAsync(jobId, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - throw; - } - catch (Exception exception) - { - logger.LogError( - exception, - "Unexpected failure while dispatching makeup exam auto job {JobId}.", - jobId); - } - } - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - logger.LogInformation("Makeup exam auto job worker is stopping."); - } - } - - private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var jobs = await db.MakeupExamAutoJobs - .Where(x => - x.Status == MakeupExamAutoJobStatus.Queued || - x.Status == MakeupExamAutoJobStatus.Running) - .OrderBy(x => x.CreatedAt) - .ToListAsync(cancellationToken); - - foreach (var job in jobs) - { - job.Status = MakeupExamAutoJobStatus.Queued; - job.StartedAt = null; - job.CompletedAt = null; - job.ErrorMessage = null; - } - - if (jobs.Count > 0) - await db.SaveChangesAsync(cancellationToken); - foreach (var job in jobs) - queue.Enqueue(job.Id); - - if (jobs.Count > 0) - { - logger.LogInformation( - "Recovered {JobCount} queued or interrupted makeup exam auto jobs.", - jobs.Count); - } - } -} - public sealed class MakeupExamAutoJobProcessor( AppDbContext db, MakeupExamEligibilityService eligibilityService, diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs index cf4923b..c84962e 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs @@ -98,6 +98,8 @@ public sealed class AppDbContext(DbContextOptions options) public DbSet OfficialDocumentDownloads => Set(); public DbSet AuditLogs => Set(); + public DbSet BackgroundJobOutboxMessages => + Set(); protected override void ConfigureConventions( ModelConfigurationBuilder configurationBuilder) @@ -993,6 +995,14 @@ public sealed class AppDbContext(DbContextOptions options) entity.HasIndex(x => x.CreatedAt); }); + builder.Entity(entity => + { + entity.Property(x => x.LastError).HasMaxLength(2000); + entity.HasIndex(x => new { x.JobKind, x.JobId }).IsUnique(); + entity.HasIndex(x => new { x.State, x.CreatedAt }); + entity.HasIndex(x => x.LeaseExpiresAt); + }); + builder.Entity(entity => { entity.Property(x => x.DocumentNumber).HasMaxLength(50); diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs index e92ca56..c4abaa6 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs @@ -58,6 +58,8 @@ public sealed class DevelopmentSqliteMigrator( "20260726_31_unified_message_center"; private const string ClassroomReservationsMigration = "20260726_32_classroom_reservations"; + private const string BackgroundJobOutboxMigration = + "20260726_33_background_job_outbox"; public async Task MigrateAsync(CancellationToken cancellationToken = default) { @@ -421,6 +423,19 @@ public sealed class DevelopmentSqliteMigrator( ClassroomReservationsMigration, classroomReservationsExist ? [] : ClassroomReservationStatements, cancellationToken); + + var backgroundJobOutboxExists = await db.Database + .SqlQueryRaw( + """ + SELECT COUNT(*) AS "Value" + FROM sqlite_master + WHERE type = 'table' AND name = 'BackgroundJobOutboxMessages' + """) + .AnyAsync(value => value > 0, cancellationToken); + await ApplyMigrationAsync( + BackgroundJobOutboxMigration, + backgroundJobOutboxExists ? [] : BackgroundJobOutboxStatements, + cancellationToken); } private async Task ApplyMigrationAsync( @@ -2004,4 +2019,37 @@ public sealed class DevelopmentSqliteMigrator( ON "ClassroomReservations" ("ReviewedByUserId"); """ ]; + + private static readonly string[] BackgroundJobOutboxStatements = + [ + """ + CREATE TABLE "BackgroundJobOutboxMessages" ( + "Id" TEXT NOT NULL CONSTRAINT "PK_BackgroundJobOutboxMessages" PRIMARY KEY, + "JobKind" INTEGER NOT NULL, + "JobId" TEXT NOT NULL, + "State" INTEGER NOT NULL, + "PublishAttempts" INTEGER NOT NULL, + "ProcessingAttempts" INTEGER NOT NULL, + "PublishedAt" TEXT NULL, + "ProcessingToken" TEXT NULL, + "LeaseExpiresAt" TEXT NULL, + "CompletedAt" TEXT NULL, + "LastError" TEXT NULL, + "CreatedAt" TEXT NOT NULL, + "UpdatedAt" TEXT NOT NULL + ); + """, + """ + CREATE UNIQUE INDEX "IX_BackgroundJobOutboxMessages_JobKind_JobId" + ON "BackgroundJobOutboxMessages" ("JobKind", "JobId"); + """, + """ + CREATE INDEX "IX_BackgroundJobOutboxMessages_LeaseExpiresAt" + ON "BackgroundJobOutboxMessages" ("LeaseExpiresAt"); + """, + """ + CREATE INDEX "IX_BackgroundJobOutboxMessages_State_CreatedAt" + ON "BackgroundJobOutboxMessages" ("State", "CreatedAt"); + """ + ]; } diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726220000_BackgroundJobOutbox.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726220000_BackgroundJobOutbox.Designer.cs new file mode 100644 index 0000000..95e6b5e --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726220000_BackgroundJobOutbox.Designer.cs @@ -0,0 +1,4845 @@ +// +using System; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260726220000_BackgroundJobOutbox")] + partial class BackgroundJobOutbox + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicYear") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("ArchivedAt") + .HasColumnType("datetime(6)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("IsArchived") + .HasColumnType("tinyint(1)"); + + b.Property("IsCurrent") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Season") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsArchived"); + + b.HasIndex("IsCurrent"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AcademicTerms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CounselorName") + .HasColumnType("longtext"); + + b.Property("CounselorUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CounselorUserId"); + + b.HasIndex("MajorId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("AdministrativeClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b => + { + b.Property("AttendanceSheetId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("AppealReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("AppealReviewComment") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("AppealReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("AppealStatus") + .HasColumnType("int"); + + b.Property("AppealSubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInAccuracyMeters") + .HasColumnType("double"); + + b.Property("CheckInAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInDistanceMeters") + .HasColumnType("double"); + + b.Property("CheckInLatitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("CheckInLongitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("CheckedInMethod") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("AttendanceSheetId", "StudentId"); + + b.HasIndex("AppealStatus"); + + b.HasIndex("StudentId"); + + b.ToTable("AttendanceRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AttendanceDate") + .HasColumnType("datetime(6)"); + + b.Property("CheckInEndsAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInMethod") + .HasColumnType("int"); + + b.Property("CheckInStartsAt") + .HasColumnType("datetime(6)"); + + b.Property("CheckInToken") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LocationRadiusMeters") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetLatitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("TargetLongitude") + .HasPrecision(10, 7) + .HasColumnType("decimal(10,7)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CheckInToken") + .IsUnique(); + + b.HasIndex("TeachingTaskId", "AttendanceDate"); + + b.ToTable("AttendanceSheets"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ActiveSchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedTasks") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedEntries") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedTasks") + .HasColumnType("int"); + + b.Property("RequestedByUserId") + .HasColumnType("char(36)"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalTasks") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveSchedulePlanId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("AutomaticScheduleJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Buildings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Campus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Address") + .HasColumnType("longtext"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Campuses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("BuildingId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Equipment") + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RoomType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("BuildingId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Classrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("ApplicantCollegeId") + .HasColumnType("char(36)"); + + b.Property("ApplicantName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("ApplicantUserId") + .HasColumnType("char(36)"); + + b.Property("AttendeeCount") + .HasColumnType("int"); + + b.Property("CancelledAt") + .HasColumnType("datetime(6)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("Purpose") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ReservationDate") + .HasColumnType("date"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("ApplicantCollegeId", "Status", "ReservationDate"); + + b.HasIndex("ApplicantUserId", "Status", "CreatedAt"); + + b.HasIndex("ClassroomId", "ReservationDate", "Status", "StartPeriod"); + + b.ToTable("ClassroomReservations"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CampusId") + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ShortName") + .HasColumnType("longtext"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CampusId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Colleges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AssessmentMethod") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CourseCategoryId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Credits") + .HasPrecision(5, 2) + .HasColumnType("decimal(5,2)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EnglishName") + .HasMaxLength(150) + .HasColumnType("varchar(150)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LectureHours") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Nature") + .HasColumnType("int"); + + b.Property("PracticeHours") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("TotalHours") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CourseCategoryId"); + + b.HasIndex("CollegeId", "Nature"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseAdjustment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApplicantUserId") + .HasColumnType("char(36)"); + + b.Property("CancelDate") + .HasColumnType("date"); + + b.Property("CancelWeek") + .HasColumnType("int"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("SubstituteTeacherId") + .HasColumnType("char(36)"); + + b.Property("TargetDate") + .HasColumnType("date"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicantUserId"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("SubstituteTeacherId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("TeachingTaskId", "Status"); + + b.ToTable("CourseAdjustments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("CourseCategories"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionOfferingId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrolledAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrollmentType") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WaitlistedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawnAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseSelectionOfferingId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.HasIndex("CourseSelectionOfferingId", "Status", "WaitlistedAt") + .HasDatabaseName("IX_CE_Offering_Status_WaitlistedAt"); + + b.ToTable("CourseEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseExemption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseExemptions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsOpenToAll") + .HasColumnType("tinyint(1)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("CourseSelectionRoundId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("CourseSelectionOfferings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("MaxCourseCount") + .HasColumnType("int"); + + b.Property("MaxCredits") + .HasPrecision(6, 1) + .HasColumnType("decimal(6,1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WithdrawalEndsAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("CourseSelectionRounds"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseSelectionRoundId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Grade") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Grade"); + + b.HasIndex("CourseSelectionRoundId", "Grade") + .IsUnique(); + + b.ToTable("CourseSelectionRoundGrades"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalCourseId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("SubstituteCourseId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("OriginalCourseId"); + + b.HasIndex("SubstituteCourseId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "OriginalCourseId") + .IsUnique(); + + b.ToTable("CourseSubstitutions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumModuleId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RecommendedSemester") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("CurriculumModuleId", "CourseId") + .IsUnique(); + + b.ToTable("CurriculumCourses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId", "Code") + .IsUnique(); + + b.ToTable("CurriculumModules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("EffectiveGrade") + .HasColumnType("int"); + + b.Property("MajorId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("Status", "EffectiveGrade"); + + b.HasIndex("MajorId", "EffectiveGrade", "Version") + .IsUnique(); + + b.ToTable("CurriculumPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DeferredExam", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("Status", "CreatedAt"); + + b.HasIndex("StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("DeferredExams"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("varchar(80)"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("MinimumGradePoint") + .HasPrecision(3, 2) + .HasColumnType("decimal(3,2)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("DegreeAwardBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AverageGradePoint") + .HasPrecision(4, 2) + .HasColumnType("decimal(4,2)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeAwardBatchId") + .HasColumnType("char(36)"); + + b.Property("ExceptionReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("GraduationAuditResultId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationAuditResultId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("DegreeAwardBatchId", "StudentId") + .IsUnique(); + + b.ToTable("DegreeAwardResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationDimension", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EvaluationSetupId") + .HasColumnType("char(36)"); + + b.Property("MaxScore") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("EvaluationSetupId", "SortOrder"); + + b.ToTable("EvaluationDimensions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EvaluationSetupId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("EvaluationSetupId", "StudentId", "TeachingTaskId") + .IsUnique(); + + b.ToTable("EvaluationRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationScore", b => + { + b.Property("EvaluationRecordId") + .HasColumnType("char(36)"); + + b.Property("EvaluationDimensionId") + .HasColumnType("char(36)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("EvaluationRecordId", "EvaluationDimensionId"); + + b.HasIndex("EvaluationDimensionId"); + + b.ToTable("EvaluationScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("EvaluationSetups"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("ExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamDate") + .HasColumnType("date"); + + b.Property("ExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("ExamPlanId", "ExamDate"); + + b.HasIndex("ExamPlanId", "StartsAt"); + + b.ToTable("ExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.Property("ExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("ExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("ExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("varchar(60)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Weight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.HasKey("Id"); + + b.HasIndex("GradeSheetId", "SortOrder"); + + b.ToTable("GradeItems"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItemScore", b => + { + b.Property("GradeRecordId") + .HasColumnType("char(36)"); + + b.Property("GradeItemId") + .HasColumnType("char(36)"); + + b.Property("Score") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.HasKey("GradeRecordId", "GradeItemId"); + + b.HasIndex("GradeItemId"); + + b.ToTable("GradeItemScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeModification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApplicantUserId") + .HasColumnType("char(36)"); + + b.Property("CollegeReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("CollegeReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("FinalReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("GradeRecordId") + .HasColumnType("char(36)"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("RequestedScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GradeRecordId"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("GradeModifications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamStatus") + .HasColumnType("int"); + + b.Property("FinalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("GradePoint") + .HasPrecision(3, 1) + .HasColumnType("decimal(3,1)"); + + b.Property("GradeSheetId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RegularScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TotalScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GradeSheetId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "TotalScore"); + + b.ToTable("GradeRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("FinalWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("RegularWeight") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("GradeSheets"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("GraduationYear", "EnrollmentYear"); + + b.ToTable("GraduationAuditBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CalculatedConclusion") + .HasColumnType("int"); + + b.Property("Conclusion") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurriculumPlanId") + .HasColumnType("char(36)"); + + b.Property("EarnedCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("FailedCourseCount") + .HasColumnType("int"); + + b.Property("GraduationAuditBatchId") + .HasColumnType("char(36)"); + + b.Property("IsOverridden") + .HasColumnType("tinyint(1)"); + + b.Property("MissingCourseNames") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("PassedRequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCourseCount") + .HasColumnType("int"); + + b.Property("RequiredCredits") + .HasPrecision(6, 2) + .HasColumnType("decimal(6,2)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("StudentStatusSnapshot") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CurriculumPlanId"); + + b.HasIndex("StudentId"); + + b.HasIndex("Conclusion", "IsOverridden"); + + b.HasIndex("GraduationAuditBatchId", "StudentId") + .IsUnique(); + + b.ToTable("GraduationAuditResults"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClosedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationYear") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationYear", "Status"); + + b.ToTable("GraduationClearanceBatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceBatchId") + .HasColumnType("char(36)"); + + b.Property("IsRequired") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("ResponsibleRole") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("ResponsibleUnit") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceBatchId", "Code") + .IsUnique(); + + b.ToTable("GraduationClearanceItems"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedByUserId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("GraduationClearanceItemId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("GraduationClearanceItemId", "StudentId") + .IsUnique(); + + b.HasIndex("StudentId", "Status"); + + b.ToTable("GraduationClearanceRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DegreeType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SchoolingYears") + .HasColumnType("int"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CollegeId"); + + b.HasIndex("IsEnabled", "SortOrder"); + + b.ToTable("Majors"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamAutoJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedSessions") + .HasColumnType("int"); + + b.Property("EnrolledStudents") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("MakeupExamPlanId") + .HasColumnType("char(36)"); + + b.Property("MessagesJson") + .HasColumnType("longtext"); + + b.Property("ProcessedCourses") + .HasColumnType("int"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalCourses") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MakeupExamPlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("MakeupExamAutoJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamEnrollment", b => + { + b.Property("MakeupExamSessionId") + .HasColumnType("char(36)"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("MakeupScore") + .HasPrecision(5, 1) + .HasColumnType("decimal(5,1)"); + + b.Property("Reason") + .HasColumnType("int"); + + b.Property("SourceDeferredExamId") + .HasColumnType("char(36)"); + + b.Property("SourceGradeRecordId") + .HasColumnType("char(36)"); + + b.HasKey("MakeupExamSessionId", "StudentId"); + + b.HasIndex("SourceDeferredExamId"); + + b.HasIndex("SourceGradeRecordId"); + + b.HasIndex("StudentId", "MakeupExamSessionId"); + + b.ToTable("MakeupExamEnrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("MakeupExamPlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("datetime(6)"); + + b.Property("ExamDate") + .HasColumnType("date"); + + b.Property("MakeupExamPlanId") + .HasColumnType("char(36)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredInvigilatorCount") + .HasColumnType("int"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("datetime(6)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("MakeupExamPlanId", "ExamDate"); + + b.HasIndex("MakeupExamPlanId", "StartsAt"); + + b.ToTable("MakeupExamSessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSessionInvigilator", b => + { + b.Property("MakeupExamSessionId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.HasKey("MakeupExamSessionId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("MakeupExamSessionInvigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AudienceId") + .HasColumnType("char(36)"); + + b.Property("AudienceName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("AudienceType") + .HasColumnType("int"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("LinkUrl") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("RecipientCount") + .HasColumnType("int"); + + b.Property("SenderName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("SenderUserId") + .HasColumnType("char(36)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("SenderUserId", "CreatedAt"); + + b.ToTable("MessageDispatches"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Category") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRead") + .HasColumnType("tinyint(1)"); + + b.Property("LinkUrl") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("MessageDispatchId") + .HasColumnType("char(36)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("MessageDispatchId"); + + b.HasIndex("UserId", "IsRead"); + + b.HasIndex("UserId", "Category", "CreatedAt"); + + b.ToTable("Notifications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DocumentNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("InvalidatedAt") + .HasColumnType("datetime(6)"); + + b.Property("InvalidatedByUserId") + .HasColumnType("char(36)"); + + b.Property("InvalidationReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("IssuedAt") + .HasColumnType("datetime(6)"); + + b.Property("IssuedByUserId") + .HasColumnType("char(36)"); + + b.Property("PdfContent") + .IsRequired() + .HasColumnType("longblob"); + + b.Property("PdfSha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Purpose") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ReissuedFromDocumentId") + .HasColumnType("char(36)"); + + b.Property("SnapshotJson") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("VerificationCodeHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("DocumentNumber") + .IsUnique(); + + b.HasIndex("InvalidatedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("ReissuedFromDocumentId") + .IsUnique(); + + b.HasIndex("VerificationCodeHash") + .IsUnique(); + + b.HasIndex("Status", "IssuedAt"); + + b.HasIndex("StudentId", "IssuedAt"); + + b.ToTable("OfficialDocuments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DownloadedByUserId") + .HasColumnType("char(36)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("OfficialDocumentId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserAgent") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("DownloadedByUserId", "CreatedAt"); + + b.HasIndex("OfficialDocumentId", "CreatedAt"); + + b.ToTable("OfficialDocumentDownloads"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DayOfWeek") + .HasColumnType("int"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PeriodCount") + .HasColumnType("int"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartPeriod") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeekPattern") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ClassroomId"); + + b.HasIndex("TeachingTaskId"); + + b.HasIndex("SchedulePlanId", "DayOfWeek", "StartPeriod"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Status"); + + b.HasIndex("AcademicTermId", "Version") + .IsUnique(); + + b.ToTable("SchedulePlans"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("ActiveAcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CompletedSteps") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CurrentStep") + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("RequestedByUserId") + .HasColumnType("char(36)"); + + b.Property("SchedulePlanId") + .HasColumnType("char(36)"); + + b.Property("StartedAt") + .HasColumnType("datetime(6)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TotalSteps") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ActiveAcademicTermId") + .IsUnique(); + + b.HasIndex("RequestedByUserId"); + + b.HasIndex("SchedulePlanId", "CreatedAt"); + + b.HasIndex("Status", "CreatedAt"); + + b.ToTable("SchedulePublishJobs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndsAt") + .HasColumnType("time"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("PeriodNumber") + .HasColumnType("int"); + + b.Property("StartsAt") + .HasColumnType("time"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "PeriodNumber") + .IsUnique(); + + b.ToTable("ScheduleTimeSlots"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DateOfBirth") + .HasColumnType("date"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("EnrollmentDate") + .HasColumnType("date"); + + b.Property("EnrollmentYear") + .HasColumnType("int"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("EnrollmentYear"); + + b.HasIndex("StudentNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("AdministrativeClassId", "Status"); + + b.ToTable("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ApprovedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("OriginalStatus") + .HasColumnType("int"); + + b.Property("Reason") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("State") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TargetStatus") + .HasColumnType("int"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("StudentId", "State"); + + b.ToTable("StudentStatusChanges"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Email") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("HireDate") + .HasColumnType("date"); + + b.Property("IsExternal") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Phone") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TeacherNumber") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("Title") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("TeacherNumber") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("CollegeId", "Status"); + + b.ToTable("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewComment") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ReviewedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReviewedByUserId") + .HasColumnType("char(36)"); + + b.Property("Statement") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("SubmittedAt") + .HasColumnType("datetime(6)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("ReviewedByUserId"); + + b.HasIndex("TeacherId"); + + b.HasIndex("Status", "AcademicTermId"); + + b.HasIndex("AcademicTermId", "TeacherId", "CourseId") + .IsUnique(); + + b.ToTable("TeacherCourseApplications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("Capacity") + .HasColumnType("int"); + + b.Property("CourseId") + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EndWeek") + .HasColumnType("int"); + + b.Property("GenerationBatchCode") + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("varchar(120)"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("SchedulingMode") + .HasColumnType("int"); + + b.Property("StartWeek") + .HasColumnType("int"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("TaskNumber") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("varchar(40)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("WeeklyHours") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.HasIndex("TaskNumber") + .IsUnique(); + + b.HasIndex("AcademicTermId", "Status"); + + b.ToTable("TeachingTasks"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.Property("TeachingTaskScheduleConstraintId") + .HasColumnType("char(36)"); + + b.Property("ClassroomId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskScheduleConstraintId", "ClassroomId"); + + b.HasIndex("ClassroomId"); + + b.ToTable("TeachingTaskAllowedClassrooms"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("AdministrativeClassId") + .HasColumnType("char(36)"); + + b.HasKey("TeachingTaskId", "AdministrativeClassId"); + + b.HasIndex("AdministrativeClassId"); + + b.ToTable("TeachingTaskClasses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AllowedDayOfWeeks") + .HasMaxLength(20) + .HasColumnType("varchar(20)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("EarliestPeriod") + .HasColumnType("int"); + + b.Property("LatestPeriod") + .HasColumnType("int"); + + b.Property("RequiredBuildingId") + .HasColumnType("char(36)"); + + b.Property("RequiredCampusId") + .HasColumnType("char(36)"); + + b.Property("RequiresClassroom") + .HasColumnType("tinyint(1)"); + + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("RequiredBuildingId"); + + b.HasIndex("RequiredCampusId"); + + b.HasIndex("TeachingTaskId") + .IsUnique(); + + b.ToTable("TeachingTaskScheduleConstraints"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.Property("TeachingTaskId") + .HasColumnType("char(36)"); + + b.Property("TeacherId") + .HasColumnType("char(36)"); + + b.Property("IsPrimary") + .HasColumnType("tinyint(1)"); + + b.HasKey("TeachingTaskId", "TeacherId"); + + b.HasIndex("TeacherId"); + + b.ToTable("TeachingTaskTeachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("AcknowledgeComment") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("AcknowledgedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Detail") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("varchar(1000)"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("StudentId") + .HasColumnType("char(36)"); + + b.Property("TriggerValue") + .HasPrecision(7, 2) + .HasColumnType("decimal(7,2)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.HasIndex("StudentId", "AcademicTermId", "Type") + .IsUnique(); + + b.ToTable("WarningRecords"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AcademicTermId") + .HasColumnType("char(36)"); + + b.Property("AutoCheckEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("CheckDayOfWeek") + .HasColumnType("int"); + + b.Property("CheckHour") + .HasColumnType("int"); + + b.Property("CheckMinute") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastCheckAt") + .HasColumnType("datetime(6)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("NotifyCounselor") + .HasColumnType("tinyint(1)"); + + b.Property("NotifyStudent") + .HasColumnType("tinyint(1)"); + + b.Property("Threshold") + .HasPrecision(7, 2) + .HasColumnType("decimal(7,2)"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("AcademicTermId", "Type") + .IsUnique(); + + b.ToTable("WarningRules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("DataScope") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("CalendarSubscriptionCreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("CalendarSubscriptionStamp") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("CollegeId") + .HasColumnType("char(36)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("longtext"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("IsEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LastLoginAt") + .HasColumnType("datetime(6)"); + + b.Property("LockoutEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("LockoutEnd") + .HasColumnType("datetime"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("longtext"); + + b.Property("PhoneNumber") + .HasColumnType("longtext"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("tinyint(1)"); + + b.Property("SecurityStamp") + .HasColumnType("longtext"); + + b.Property("StaffNumber") + .HasMaxLength(30) + .HasColumnType("varchar(30)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("tinyint(1)"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("StaffNumber"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("Method") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("varchar(10)"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("varchar(300)"); + + b.Property("StatusCode") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("UserName") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.System.BackgroundJobOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("JobId") + .HasColumnType("char(36)"); + + b.Property("JobKind") + .HasColumnType("int"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("ProcessingAttempts") + .HasColumnType("int"); + + b.Property("ProcessingToken") + .HasColumnType("char(36)"); + + b.Property("PublishAttempts") + .HasColumnType("int"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("State") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("LeaseExpiresAt"); + + b.HasIndex("JobKind", "JobId") + .IsUnique(); + + b.HasIndex("State", "CreatedAt"); + + b.ToTable("BackgroundJobOutboxMessages"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + b.Property("ClaimType") + .HasColumnType("longtext"); + + b.Property("ClaimValue") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("ProviderKey") + .HasColumnType("varchar(255)"); + + b.Property("ProviderDisplayName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("RoleId") + .HasColumnType("char(36)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("LoginProvider") + .HasColumnType("varchar(255)"); + + b.Property("Name") + .HasColumnType("varchar(255)"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "CounselorUser") + .WithMany() + .HasForeignKey("CounselorUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CounselorUser"); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AttendanceSheet", "AttendanceSheet") + .WithMany("Records") + .HasForeignKey("AttendanceSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AttendanceSheet"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AutomaticScheduleJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Building", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Building"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ClassroomReservation", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.College", "ApplicantCollege") + .WithMany() + .HasForeignKey("ApplicantCollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ApplicantUser") + .WithMany() + .HasForeignKey("ApplicantUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "ReviewedByUser") + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AcademicTerm"); + + b.Navigation("ApplicantCollege"); + + b.Navigation("ApplicantUser"); + + b.Navigation("Classroom"); + + b.Navigation("ReviewedByUser"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.College", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "Campus") + .WithMany() + .HasForeignKey("CampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Campus"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Course", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CourseCategory", "CourseCategory") + .WithMany() + .HasForeignKey("CourseCategoryId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("College"); + + b.Navigation("CourseCategory"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseAdjustment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "SubstituteTeacher") + .WithMany() + .HasForeignKey("SubstituteTeacherId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SubstituteTeacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", "CourseSelectionOffering") + .WithMany("Enrollments") + .HasForeignKey("CourseSelectionOfferingId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionOffering"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseExemption", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("Offerings") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRoundGrade", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CourseSelectionRound", "CourseSelectionRound") + .WithMany("EligibleGrades") + .HasForeignKey("CourseSelectionRoundId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CourseSelectionRound"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSubstitution", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "OriginalCourse") + .WithMany() + .HasForeignKey("OriginalCourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "SubstituteCourse") + .WithMany() + .HasForeignKey("SubstituteCourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("OriginalCourse"); + + b.Navigation("Student"); + + b.Navigation("SubstituteCourse"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumCourse", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumModule", "CurriculumModule") + .WithMany("Courses") + .HasForeignKey("CurriculumModuleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Course"); + + b.Navigation("CurriculumModule"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany("Modules") + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Major", "Major") + .WithMany() + .HasForeignKey("MajorId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Major"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DeferredExam", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", "DegreeAwardBatch") + .WithMany("Results") + .HasForeignKey("DegreeAwardBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditResult", "GraduationAuditResult") + .WithMany() + .HasForeignKey("GraduationAuditResultId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("DegreeAwardBatch"); + + b.Navigation("GraduationAuditResult"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationDimension", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationSetup", "EvaluationSetup") + .WithMany("Dimensions") + .HasForeignKey("EvaluationSetupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EvaluationSetup"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationSetup", "EvaluationSetup") + .WithMany("Records") + .HasForeignKey("EvaluationSetupId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("EvaluationSetup"); + + b.Navigation("Student"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationScore", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationDimension", "EvaluationDimension") + .WithMany("Scores") + .HasForeignKey("EvaluationDimensionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.EvaluationRecord", "EvaluationRecord") + .WithMany("Scores") + .HasForeignKey("EvaluationRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("EvaluationDimension"); + + b.Navigation("EvaluationRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.ExamPlan", "ExamPlan") + .WithMany("Sessions") + .HasForeignKey("ExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("ExamPlan"); + + b.Navigation("RequiredBuilding"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.ExamSession", "ExamSession") + .WithMany("Invigilators") + .HasForeignKey("ExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ExamSession"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") + .WithMany("Items") + .HasForeignKey("GradeSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeSheet"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItemScore", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeItem", "GradeItem") + .WithMany("Scores") + .HasForeignKey("GradeItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "GradeRecord") + .WithMany("ItemScores") + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GradeItem"); + + b.Navigation("GradeRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeModification", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "GradeRecord") + .WithMany() + .HasForeignKey("GradeRecordId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GradeRecord"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GradeSheet", "GradeSheet") + .WithMany("Records") + .HasForeignKey("GradeSheetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GradeSheet"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditResult", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.CurriculumPlan", "CurriculumPlan") + .WithMany() + .HasForeignKey("CurriculumPlanId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", "GraduationAuditBatch") + .WithMany("Results") + .HasForeignKey("GraduationAuditBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CurriculumPlan"); + + b.Navigation("GraduationAuditBatch"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", "GraduationClearanceBatch") + .WithMany("Items") + .HasForeignKey("GraduationClearanceBatchId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GraduationClearanceBatch"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", "GraduationClearanceItem") + .WithMany("Records") + .HasForeignKey("GraduationClearanceItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("GraduationClearanceItem"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Major", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamAutoJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamPlan", "MakeupExamPlan") + .WithMany() + .HasForeignKey("MakeupExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MakeupExamPlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamEnrollment", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamSession", "MakeupExamSession") + .WithMany("Enrollments") + .HasForeignKey("MakeupExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.DeferredExam", "SourceDeferredExam") + .WithMany() + .HasForeignKey("SourceDeferredExamId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.GradeRecord", "SourceGradeRecord") + .WithMany() + .HasForeignKey("SourceGradeRecordId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MakeupExamSession"); + + b.Navigation("SourceDeferredExam"); + + b.Navigation("SourceGradeRecord"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamPlan", "MakeupExamPlan") + .WithMany("Sessions") + .HasForeignKey("MakeupExamPlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("MakeupExamPlan"); + + b.Navigation("RequiredBuilding"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSessionInvigilator", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MakeupExamSession", "MakeupExamSession") + .WithMany("Invigilators") + .HasForeignKey("MakeupExamSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("MakeupExamSession"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.MessageDispatch", "MessageDispatch") + .WithMany("Notifications") + .HasForeignKey("MessageDispatchId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("MessageDispatch"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "InvalidatedByUser") + .WithMany() + .HasForeignKey("InvalidatedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "ReissuedFromDocument") + .WithMany() + .HasForeignKey("ReissuedFromDocumentId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("InvalidatedByUser"); + + b.Navigation("IssuedByUser"); + + b.Navigation("ReissuedFromDocument"); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocumentDownload", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", "DownloadedByUser") + .WithMany() + .HasForeignKey("DownloadedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.OfficialDocument", "OfficialDocument") + .WithMany("Downloads") + .HasForeignKey("OfficialDocumentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DownloadedByUser"); + + b.Navigation("OfficialDocument"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleEntry", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany("Entries") + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("SchedulePlan"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePublishJob", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("RequestedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.SchedulePlan", "SchedulePlan") + .WithMany() + .HasForeignKey("SchedulePlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SchedulePlan"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ScheduleTimeSlot", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Student", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany("Students") + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AdministrativeClass"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.StudentStatusChange", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Teacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.College", "College") + .WithMany() + .HasForeignKey("CollegeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("College"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeacherCourseApplication", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("ReviewedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + + b.Navigation("Teacher"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.Course", "Course") + .WithMany() + .HasForeignKey("CourseId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AcademicTerm"); + + b.Navigation("Course"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskAllowedClassroom", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Classroom", "Classroom") + .WithMany() + .HasForeignKey("ClassroomId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", "TeachingTaskScheduleConstraint") + .WithMany("AllowedClassrooms") + .HasForeignKey("TeachingTaskScheduleConstraintId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Classroom"); + + b.Navigation("TeachingTaskScheduleConstraint"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskClass", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AdministrativeClass", "AdministrativeClass") + .WithMany() + .HasForeignKey("AdministrativeClassId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Classes") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AdministrativeClass"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Building", "RequiredBuilding") + .WithMany() + .HasForeignKey("RequiredBuildingId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.Campus", "RequiredCampus") + .WithMany() + .HasForeignKey("RequiredCampusId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany() + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RequiredBuilding"); + + b.Navigation("RequiredCampus"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskTeacher", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Teacher", "Teacher") + .WithMany() + .HasForeignKey("TeacherId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Academic.TeachingTask", "TeachingTask") + .WithMany("Teachers") + .HasForeignKey("TeachingTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Teacher"); + + b.Navigation("TeachingTask"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRecord", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.Student", "Student") + .WithMany() + .HasForeignKey("StudentId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Student"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.WarningRule", b => + { + b.HasOne("Jiaowu.Api.Domain.Academic.AcademicTerm", "AcademicTerm") + .WithMany() + .HasForeignKey("AcademicTermId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AcademicTerm"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Jiaowu.Api.Domain.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AdministrativeClass", b => + { + b.Navigation("Students"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceSheet", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionOffering", b => + { + b.Navigation("Enrollments"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CourseSelectionRound", b => + { + b.Navigation("EligibleGrades"); + + b.Navigation("Offerings"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumModule", b => + { + b.Navigation("Courses"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CurriculumPlan", b => + { + b.Navigation("Modules"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.DegreeAwardBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationDimension", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationRecord", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.EvaluationSetup", b => + { + b.Navigation("Dimensions"); + + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.ExamSession", b => + { + b.Navigation("Invigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeItem", b => + { + b.Navigation("Scores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeRecord", b => + { + b.Navigation("ItemScores"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GradeSheet", b => + { + b.Navigation("Items"); + + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationAuditBatch", b => + { + b.Navigation("Results"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceBatch", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.GraduationClearanceItem", b => + { + b.Navigation("Records"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamPlan", b => + { + b.Navigation("Sessions"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MakeupExamSession", b => + { + b.Navigation("Enrollments"); + + b.Navigation("Invigilators"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b => + { + b.Navigation("Notifications"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.OfficialDocument", b => + { + b.Navigation("Downloads"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.SchedulePlan", b => + { + b.Navigation("Entries"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTask", b => + { + b.Navigation("Classes"); + + b.Navigation("Teachers"); + }); + + modelBuilder.Entity("Jiaowu.Api.Domain.Academic.TeachingTaskScheduleConstraint", b => + { + b.Navigation("AllowedClassrooms"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726220000_BackgroundJobOutbox.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726220000_BackgroundJobOutbox.cs new file mode 100644 index 0000000..364017e --- /dev/null +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20260726220000_BackgroundJobOutbox.cs @@ -0,0 +1,62 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql +{ + /// + public partial class BackgroundJobOutbox : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "BackgroundJobOutboxMessages", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false), + JobKind = table.Column(type: "int", nullable: false), + JobId = table.Column(type: "char(36)", nullable: false), + State = table.Column(type: "int", nullable: false), + PublishAttempts = table.Column(type: "int", nullable: false), + ProcessingAttempts = table.Column(type: "int", nullable: false), + PublishedAt = table.Column(type: "datetime(6)", nullable: true), + ProcessingToken = table.Column(type: "char(36)", nullable: true), + LeaseExpiresAt = table.Column(type: "datetime(6)", nullable: true), + CompletedAt = table.Column(type: "datetime(6)", nullable: true), + LastError = table.Column(type: "varchar(2000)", maxLength: 2000, nullable: true), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + UpdatedAt = table.Column(type: "datetime(6)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BackgroundJobOutboxMessages", x => x.Id); + }) + .Annotation("MySQL:Charset", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_BackgroundJobOutboxMessages_JobKind_JobId", + table: "BackgroundJobOutboxMessages", + columns: new[] { "JobKind", "JobId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BackgroundJobOutboxMessages_LeaseExpiresAt", + table: "BackgroundJobOutboxMessages", + column: "LeaseExpiresAt"); + + migrationBuilder.CreateIndex( + name: "IX_BackgroundJobOutboxMessages_State_CreatedAt", + table: "BackgroundJobOutboxMessages", + columns: new[] { "State", "CreatedAt" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BackgroundJobOutboxMessages"); + } + } +} diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs index 0df9cfa..a99c945 100644 --- a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs +++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/AppDbContextModelSnapshot.cs @@ -3423,6 +3423,61 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql b.ToTable("AuditLogs"); }); + modelBuilder.Entity("Jiaowu.Api.Domain.System.BackgroundJobOutboxMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CompletedAt") + .HasColumnType("datetime(6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("JobId") + .HasColumnType("char(36)"); + + b.Property("JobKind") + .HasColumnType("int"); + + b.Property("LastError") + .HasMaxLength(2000) + .HasColumnType("varchar(2000)"); + + b.Property("LeaseExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("ProcessingAttempts") + .HasColumnType("int"); + + b.Property("ProcessingToken") + .HasColumnType("char(36)"); + + b.Property("PublishAttempts") + .HasColumnType("int"); + + b.Property("PublishedAt") + .HasColumnType("datetime(6)"); + + b.Property("State") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("LeaseExpiresAt"); + + b.HasIndex("JobKind", "JobId") + .IsUnique(); + + b.HasIndex("State", "CreatedAt"); + + b.ToTable("BackgroundJobOutboxMessages"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.Property("Id") diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs index 43056de..d863bab 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/AutomaticScheduleJobs.cs @@ -1,104 +1,11 @@ using System.Diagnostics; using System.Text.Json; -using System.Threading.Channels; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Scheduling; -public sealed class AutomaticScheduleJobQueue -{ - private readonly Channel _channel = Channel.CreateUnbounded( - new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false - }); - - public void Enqueue(Guid jobId) - { - if (!_channel.Writer.TryWrite(jobId)) - throw new InvalidOperationException("自动排课任务队列当前不可用。"); - } - - public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) => - _channel.Reader.ReadAllAsync(cancellationToken); -} - -public sealed class AutomaticScheduleJobWorker( - IServiceScopeFactory scopeFactory, - AutomaticScheduleJobQueue queue, - ILogger logger) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await RecoverInterruptedJobsAsync(stoppingToken); - - try - { - await foreach (var jobId in queue.ReadAllAsync(stoppingToken)) - { - try - { - await using var scope = scopeFactory.CreateAsyncScope(); - var processor = scope.ServiceProvider - .GetRequiredService(); - await processor.ProcessAsync(jobId, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - throw; - } - catch (Exception exception) - { - logger.LogError( - exception, - "Unexpected failure while dispatching automatic schedule job {JobId}.", - jobId); - } - } - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - logger.LogInformation("Automatic schedule job worker is stopping."); - } - } - - private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var jobs = await db.AutomaticScheduleJobs - .Where(x => - x.Status == AutomaticScheduleJobStatus.Queued || - x.Status == AutomaticScheduleJobStatus.Running) - .OrderBy(x => x.CreatedAt) - .ToListAsync(cancellationToken); - - foreach (var job in jobs) - { - job.Status = AutomaticScheduleJobStatus.Queued; - job.ActiveSchedulePlanId = job.SchedulePlanId; - job.StartedAt = null; - job.CompletedAt = null; - job.ErrorMessage = null; - } - - if (jobs.Count > 0) - await db.SaveChangesAsync(cancellationToken); - foreach (var job in jobs) - queue.Enqueue(job.Id); - - if (jobs.Count > 0) - { - logger.LogInformation( - "Recovered {JobCount} queued or interrupted automatic schedule jobs.", - jobs.Count); - } - } -} - public sealed class AutomaticScheduleJobProcessor( AppDbContext db, AutomaticScheduleGenerator generator, diff --git a/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs index 9aa58f8..c3b5a71 100644 --- a/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs +++ b/src/Jiaowu.Api/Infrastructure/Scheduling/SchedulePublishJobs.cs @@ -1,4 +1,3 @@ -using System.Threading.Channels; using System.Diagnostics.CodeAnalysis; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Caching; @@ -7,100 +6,6 @@ using Microsoft.EntityFrameworkCore; namespace Jiaowu.Api.Infrastructure.Scheduling; -public sealed class SchedulePublishJobQueue -{ - private readonly Channel _channel = Channel.CreateUnbounded( - new UnboundedChannelOptions - { - SingleReader = true, - SingleWriter = false - }); - - public void Enqueue(Guid jobId) - { - if (!_channel.Writer.TryWrite(jobId)) - throw new InvalidOperationException("课表发布任务队列当前不可用。"); - } - - public IAsyncEnumerable ReadAllAsync(CancellationToken cancellationToken) => - _channel.Reader.ReadAllAsync(cancellationToken); -} - -public sealed class SchedulePublishJobWorker( - IServiceScopeFactory scopeFactory, - SchedulePublishJobQueue queue, - ILogger logger) : BackgroundService -{ - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - await RecoverInterruptedJobsAsync(stoppingToken); - - try - { - await foreach (var jobId in queue.ReadAllAsync(stoppingToken)) - { - try - { - await using var scope = scopeFactory.CreateAsyncScope(); - var processor = scope.ServiceProvider - .GetRequiredService(); - await processor.ProcessAsync(jobId, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - throw; - } - catch (Exception exception) - { - logger.LogError( - exception, - "Unexpected failure while dispatching schedule publish job {JobId}.", - jobId); - } - } - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - logger.LogInformation("Schedule publish job worker is stopping."); - } - } - - private async Task RecoverInterruptedJobsAsync(CancellationToken cancellationToken) - { - await using var scope = scopeFactory.CreateAsyncScope(); - var db = scope.ServiceProvider.GetRequiredService(); - var jobs = await db.SchedulePublishJobs - .Where(x => - x.Status == SchedulePublishJobStatus.Queued || - x.Status == SchedulePublishJobStatus.Running) - .OrderBy(x => x.CreatedAt) - .ToListAsync(cancellationToken); - - foreach (var job in jobs) - { - job.Status = SchedulePublishJobStatus.Queued; - job.ActiveAcademicTermId = job.AcademicTermId; - job.CompletedSteps = 0; - job.CurrentStep = "等待后台检查"; - job.StartedAt = null; - job.CompletedAt = null; - job.ErrorMessage = null; - } - - if (jobs.Count > 0) - await db.SaveChangesAsync(cancellationToken); - foreach (var job in jobs) - queue.Enqueue(job.Id); - - if (jobs.Count > 0) - { - logger.LogInformation( - "Recovered {JobCount} queued or interrupted schedule publish jobs.", - jobs.Count); - } - } -} - public sealed class SchedulePublishJobProcessor( AppDbContext db, SchedulePlanPublisher publisher, diff --git a/src/Jiaowu.Api/Jiaowu.Api.csproj b/src/Jiaowu.Api/Jiaowu.Api.csproj index c7bb74b..32bd256 100644 --- a/src/Jiaowu.Api/Jiaowu.Api.csproj +++ b/src/Jiaowu.Api/Jiaowu.Api.csproj @@ -32,6 +32,7 @@ + diff --git a/src/Jiaowu.Api/Program.cs b/src/Jiaowu.Api/Program.cs index a2bc168..a1eae50 100644 --- a/src/Jiaowu.Api/Program.cs +++ b/src/Jiaowu.Api/Program.cs @@ -1,6 +1,7 @@ using System.Text; using System.Text.Json.Serialization; using Jiaowu.Api.Domain.Identity; +using Jiaowu.Api.Infrastructure.BackgroundJobs; using Jiaowu.Api.Infrastructure.Configuration; using Jiaowu.Api.Infrastructure.Auth; using Jiaowu.Api.Infrastructure.Caching; @@ -70,6 +71,12 @@ var cacheOptions = builder.Configuration var officialDocumentOptions = builder.Configuration .GetSection(OfficialDocumentOptions.SectionName) .Get() ?? new OfficialDocumentOptions(); +var backgroundJobOptions = builder.Configuration + .GetSection(BackgroundJobOptions.SectionName) + .Get() ?? new BackgroundJobOptions(); +var rabbitMqOptions = builder.Configuration + .GetSection(RabbitMqOptions.SectionName) + .Get() ?? new RabbitMqOptions(); if (string.IsNullOrWhiteSpace(officialDocumentOptions.InstitutionName) || string.IsNullOrWhiteSpace(officialDocumentOptions.IssuingOffice) || @@ -119,9 +126,46 @@ if (cacheOptions.ReferenceExpirationMinutes is < 1 or > 1440 || "Cache 缓存时间或 MaximumPayloadKilobytes 超出允许范围。"); } +if (!backgroundJobOptions.Transport.Equals( + "InMemory", + StringComparison.OrdinalIgnoreCase) && + !backgroundJobOptions.UsesRabbitMq) +{ + throw new InvalidOperationException( + "BackgroundJobs:Transport 仅支持 InMemory 或 RabbitMq。"); +} +if (backgroundJobOptions.PollIntervalMilliseconds is < 100 or > 30000 || + backgroundJobOptions.LeaseSeconds is < 30 or > 3600 || + backgroundJobOptions.PrefetchCount is < 1 or > 100 || + backgroundJobOptions.ProcessingAttemptLimit is < 1 or > 100 || + string.IsNullOrWhiteSpace(backgroundJobOptions.Exchange) || + string.IsNullOrWhiteSpace(backgroundJobOptions.QueuePrefix)) +{ + throw new InvalidOperationException("BackgroundJobs 配置超出允许范围。"); +} +if (backgroundJobOptions.UsesRabbitMq && + (string.IsNullOrWhiteSpace(rabbitMqOptions.HostName) || + rabbitMqOptions.Port is < 1 or > 65535 || + string.IsNullOrWhiteSpace(rabbitMqOptions.UserName) || + string.IsNullOrWhiteSpace(rabbitMqOptions.Password) || + string.IsNullOrWhiteSpace(rabbitMqOptions.VirtualHost))) +{ + throw new InvalidOperationException("RabbitMq 连接配置不完整。"); +} +if (backgroundJobOptions.UsesRabbitMq && + !builder.Environment.IsDevelopment() && + (rabbitMqOptions.UserName.Equals("guest", StringComparison.OrdinalIgnoreCase) || + rabbitMqOptions.Password == "guest")) +{ + throw new InvalidOperationException( + "生产环境启用 RabbitMQ 时不能使用默认 guest 凭据。"); +} + builder.Services.AddSingleton(databaseOptions); builder.Services.AddSingleton(cacheOptions); builder.Services.AddSingleton(officialDocumentOptions); +builder.Services.AddSingleton(backgroundJobOptions); +builder.Services.AddSingleton(rabbitMqOptions); builder.Services.Configure( builder.Configuration.GetSection(OfficialDocumentOptions.SectionName)); builder.Services.AddDbContextPool(options => @@ -218,19 +262,29 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddHostedService(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +if (backgroundJobOptions.UsesRabbitMq) +{ + builder.Services.AddSingleton< + IBackgroundJobTransport, + RabbitMqBackgroundJobTransport>(); + builder.Services.AddHostedService(); +} +else +{ + builder.Services.AddSingleton(); + builder.Services.AddSingleton(services => + services.GetRequiredService()); + builder.Services.AddHostedService(); +} +builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddScoped(); @@ -391,6 +445,7 @@ app.MapGet("/health/live", () => Results.Ok(new { Status = "healthy" })) app.MapGet("/health", CheckDatabaseHealthAsync).AllowAnonymous(); app.MapGet("/health/ready", CheckDatabaseHealthAsync).AllowAnonymous(); app.MapGet("/health/cache", CheckCacheHealthAsync).AllowAnonymous(); +app.MapGet("/health/messaging", CheckMessagingHealthAsync).AllowAnonymous(); app.MapFallback(async context => { if (context.Request.Path.StartsWithSegments("/api") || @@ -477,4 +532,17 @@ static async Task CheckCacheHealthAsync( } } +static async Task CheckMessagingHealthAsync( + IBackgroundJobTransport transport, + CancellationToken cancellationToken) +{ + var healthy = await transport.CheckHealthAsync(cancellationToken); + var backend = transport.IsDurable ? "rabbitmq" : "memory"; + return healthy + ? Results.Ok(new { Status = "healthy", Backend = backend }) + : Results.Json( + new { Status = "unhealthy", Backend = backend }, + statusCode: StatusCodes.Status503ServiceUnavailable); +} + public partial class Program; diff --git a/src/Jiaowu.Api/appsettings.json b/src/Jiaowu.Api/appsettings.json index 1863974..8ed5bed 100644 --- a/src/Jiaowu.Api/appsettings.json +++ b/src/Jiaowu.Api/appsettings.json @@ -19,6 +19,25 @@ "AnalyticsLocalExpirationSeconds": 30, "MaximumPayloadKilobytes": 2048 }, + "BackgroundJobs": { + "Transport": "InMemory", + "PollIntervalMilliseconds": 500, + "LeaseSeconds": 120, + "PrefetchCount": 1, + "Exchange": "jiaowu.background-jobs", + "QueuePrefix": "jiaowu.background-jobs", + "UseQuorumQueues": true, + "ProcessingAttemptLimit": 5 + }, + "RabbitMq": { + "HostName": "localhost", + "Port": 5672, + "UserName": "guest", + "Password": "guest", + "VirtualHost": "/", + "UseTls": false, + "TlsServerName": "" + }, "Jwt": { "Issuer": "Jiaowu.Api", "Audience": "Jiaowu.Web", diff --git a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs index a0b93ee..c463b06 100644 --- a/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs +++ b/tests/Jiaowu.Api.Tests/AutomaticScheduleGeneratorTests.cs @@ -19,6 +19,8 @@ public sealed class AutomaticScheduleGeneratorTests .Options; await using var db = new AppDbContext(options); await db.Database.EnsureCreatedAsync(); + await db.Database.ExecuteSqlRawAsync( + "DROP TABLE \"BackgroundJobOutboxMessages\""); var migrator = new DevelopmentSqliteMigrator( db, @@ -27,6 +29,7 @@ public sealed class AutomaticScheduleGeneratorTests Assert.True(await db.ScheduleTimeSlots.CountAsync() == 0); Assert.True(await db.AutomaticScheduleJobs.CountAsync() == 0); + Assert.True(await db.BackgroundJobOutboxMessages.CountAsync() == 0); Assert.True(await db.Database .SqlQueryRaw( """ diff --git a/tests/Jiaowu.Api.Tests/BackgroundJobOutboxTests.cs b/tests/Jiaowu.Api.Tests/BackgroundJobOutboxTests.cs new file mode 100644 index 0000000..ae82c51 --- /dev/null +++ b/tests/Jiaowu.Api.Tests/BackgroundJobOutboxTests.cs @@ -0,0 +1,182 @@ +using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.System; +using Jiaowu.Api.Infrastructure.BackgroundJobs; +using Jiaowu.Api.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Jiaowu.Api.Tests; + +public sealed class BackgroundJobOutboxTests +{ + [Fact] + public async Task Publisher_moves_pending_message_to_in_memory_transport() + { + var databasePath = Path.Combine( + Path.GetTempPath(), + $"jiaowu-outbox-{Guid.NewGuid():N}.sqlite"); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => + options.UseSqlite($"Data Source={databasePath};Pooling=False")); + var options = new BackgroundJobOptions + { + PollIntervalMilliseconds = 100 + }; + services.AddSingleton(options); + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + + try + { + Guid jobId; + await using (var scope = provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + var term = new AcademicTerm + { + Code = "RECOVERY", + Name = "补投测试学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 15) + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "补投测试排课", + Version = "V1" + }; + var job = new AutomaticScheduleJob + { + SchedulePlan = plan, + ActiveSchedulePlanId = plan.Id + }; + jobId = job.Id; + db.AddRange(term, plan, job); + await db.SaveChangesAsync(); + } + + var publisher = provider.GetRequiredService(); + await publisher.StartAsync(CancellationToken.None); + try + { + var published = false; + for (var attempt = 0; attempt < 50 && !published; attempt++) + { + await Task.Delay(50); + await using var scope = provider.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + published = await db.BackgroundJobOutboxMessages.AsNoTracking() + .AnyAsync(x => + x.JobId == jobId && + x.JobKind == BackgroundJobKind.AutomaticSchedule && + x.State == BackgroundJobOutboxState.Published); + } + + Assert.True(published); + } + finally + { + await publisher.StopAsync(CancellationToken.None); + } + } + finally + { + await provider.DisposeAsync(); + File.Delete(databasePath); + } + } + + [Fact] + public async Task Runner_stops_after_retry_limit_and_ignores_duplicate_delivery() + { + var databasePath = Path.Combine( + Path.GetTempPath(), + $"jiaowu-runner-{Guid.NewGuid():N}.sqlite"); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContext(options => + options.UseSqlite($"Data Source={databasePath};Pooling=False")); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(new BackgroundJobOptions()); + services.AddSingleton(); + services.AddSingleton(provider => + provider.GetRequiredService()); + services.AddSingleton(); + var provider = services.BuildServiceProvider(); + + try + { + BackgroundJobEnvelope envelope; + await using (var scope = provider.CreateAsyncScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + var term = new AcademicTerm + { + Code = "OUTBOX", + Name = "消息测试学期", + AcademicYear = "2026-2027", + Season = TermSeason.Autumn, + StartDate = new DateOnly(2026, 9, 1), + EndDate = new DateOnly(2027, 1, 15) + }; + var plan = new SchedulePlan + { + AcademicTerm = term, + Name = "消息测试排课", + Version = "V1" + }; + var job = new AutomaticScheduleJob + { + SchedulePlan = plan, + ActiveSchedulePlanId = plan.Id, + Status = AutomaticScheduleJobStatus.Queued + }; + var outbox = BackgroundJobOutboxMessage.Create( + BackgroundJobKind.AutomaticSchedule, + job.Id); + outbox.State = BackgroundJobOutboxState.Published; + outbox.ProcessingAttempts = 5; + db.AddRange(term, plan, job, outbox); + await db.SaveChangesAsync(); + envelope = new BackgroundJobEnvelope( + outbox.Id, + outbox.JobKind, + outbox.JobId); + } + + var runner = provider.GetRequiredService(); + var first = await runner.RunAsync(envelope, CancellationToken.None); + var duplicate = await runner.RunAsync(envelope, CancellationToken.None); + + Assert.Equal(BackgroundJobRunOutcome.Completed, first.Outcome); + Assert.Equal(BackgroundJobRunOutcome.Completed, duplicate.Outcome); + await using var assertScope = provider.CreateAsyncScope(); + var assertDb = assertScope.ServiceProvider.GetRequiredService(); + var persistedOutbox = + await assertDb.BackgroundJobOutboxMessages.SingleAsync(); + Assert.Equal(BackgroundJobOutboxState.Completed, persistedOutbox.State); + Assert.NotNull(persistedOutbox.CompletedAt); + Assert.Null(persistedOutbox.ProcessingToken); + Assert.Null(persistedOutbox.LeaseExpiresAt); + Assert.Equal(6, persistedOutbox.ProcessingAttempts); + var persistedJob = await assertDb.AutomaticScheduleJobs.SingleAsync(); + Assert.Equal(AutomaticScheduleJobStatus.Failed, persistedJob.Status); + Assert.Null(persistedJob.ActiveSchedulePlanId); + Assert.Contains("超过 5 次", persistedJob.ErrorMessage); + } + finally + { + await provider.DisposeAsync(); + File.Delete(databasePath); + } + } +} diff --git a/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs index a91b84c..0f6e1fb 100644 --- a/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/ScheduleSettingsControllerTests.cs @@ -301,7 +301,6 @@ public sealed class ScheduleSettingsControllerTests db, null!, null!, - null!, null!) .GetTimeSlotsForTerm(term.Id, CancellationToken.None); var makeupTimeSlotsOk = diff --git a/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs b/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs index a4ff62e..24eada7 100644 --- a/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs +++ b/tests/Jiaowu.Api.Tests/SchedulesControllerTests.cs @@ -1,5 +1,6 @@ using Jiaowu.Api.Controllers; using Jiaowu.Api.Domain.Academic; +using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.Persistence; using Jiaowu.Api.Infrastructure.Scheduling; using Microsoft.AspNetCore.Http; @@ -70,10 +71,7 @@ public sealed class SchedulesControllerTests db.AddRange(term, college, course, task, plan); await db.SaveChangesAsync(); - var controller = new SchedulesController( - db, - new AutomaticScheduleJobQueue(), - new SchedulePublishJobQueue()) + var controller = new SchedulesController(db) { ControllerContext = new ControllerContext { @@ -90,6 +88,10 @@ public sealed class SchedulesControllerTests SchedulePlanStatus.Draft, (await db.SchedulePlans.SingleAsync()).Status); Assert.Equal(1, await db.SchedulePublishJobs.CountAsync()); + var outbox = await db.BackgroundJobOutboxMessages.SingleAsync(); + Assert.Equal(BackgroundJobKind.SchedulePublish, outbox.JobKind); + Assert.Equal(response.Id, outbox.JobId); + Assert.Equal(BackgroundJobOutboxState.Pending, outbox.State); } [Fact] @@ -132,10 +134,7 @@ public sealed class SchedulesControllerTests db.AddRange(term, plan, completed); await db.SaveChangesAsync(); - var controller = new SchedulesController( - db, - new AutomaticScheduleJobQueue(), - new SchedulePublishJobQueue()); + var controller = new SchedulesController(db); var result = await controller.GetLatestAutomaticScheduleJob( plan.Id, CancellationToken.None);