成绩通知:仅在某门课程成绩正式发布后自动发送,按成绩单学生名单通知,不展示具体分数;发布状态与通知同一次提交。

手动发信:移除“成绩通知”选项,只能发送普通通知,原有角色与范围权限保持不变。
选课通知:管理员关闭一轮选课时,自动向每位参与学生发送一条汇总通知,包含最终选中课程及候补未成功课程;候补失效、轮次关闭和通知生成保持原子性。
关闭选课界面会明确提示发送通知,并显示实际通知人数。
This commit is contained in:
2026-07-26 19:12:42 +08:00 Unverified
parent ecc6546f3a
commit b0679a253d
20 changed files with 6804 additions and 210 deletions
@@ -77,6 +77,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<EvaluationRecord> EvaluationRecords => Set<EvaluationRecord>();
public DbSet<EvaluationScore> EvaluationScores => Set<EvaluationScore>();
public DbSet<Notification> Notifications => Set<Notification>();
public DbSet<MessageDispatch> MessageDispatches => Set<MessageDispatch>();
public DbSet<StudentStatusChange> StudentStatusChanges =>
Set<StudentStatusChange>();
public DbSet<GraduationAuditBatch> GraduationAuditBatches =>
@@ -919,7 +920,23 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
entity.Property(x => x.Content).HasMaxLength(1000);
entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.UserId, x.IsRead });
entity.HasIndex(x => new { x.UserId, x.Category, x.CreatedAt });
entity.HasIndex(x => x.MessageDispatchId);
entity.HasIndex(x => x.CreatedAt);
entity.HasOne(x => x.MessageDispatch)
.WithMany(x => x.Notifications)
.HasForeignKey(x => x.MessageDispatchId)
.OnDelete(DeleteBehavior.Cascade);
});
builder.Entity<MessageDispatch>(entity =>
{
entity.Property(x => x.SenderName).HasMaxLength(100);
entity.Property(x => x.Title).HasMaxLength(200);
entity.Property(x => x.Content).HasMaxLength(1000);
entity.Property(x => x.AudienceName).HasMaxLength(200);
entity.Property(x => x.LinkUrl).HasMaxLength(300);
entity.HasIndex(x => new { x.SenderUserId, x.CreatedAt });
});
builder.Entity<AuditLog>(entity =>
@@ -54,6 +54,8 @@ public sealed class DevelopmentSqliteMigrator(
"20260726_29_personal_calendar_subscription";
private const string OfficialDocumentsMigration =
"20260726_30_official_documents";
private const string UnifiedMessageCenterMigration =
"20260726_31_unified_message_center";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -391,6 +393,19 @@ public sealed class DevelopmentSqliteMigrator(
OfficialDocumentsMigration,
officialDocumentsExist ? [] : OfficialDocumentStatements,
cancellationToken);
var messageDispatchesExist = await db.Database
.SqlQueryRaw<int>(
"""
SELECT COUNT(*) AS "Value"
FROM sqlite_master
WHERE type = 'table' AND name = 'MessageDispatches'
""")
.AnyAsync(value => value > 0, cancellationToken);
await ApplyMigrationAsync(
UnifiedMessageCenterMigration,
messageDispatchesExist ? [] : UnifiedMessageCenterStatements,
cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -1876,4 +1891,30 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE INDEX "IX_MakeupExamAutoJobs_MakeupExamPlanId_CreatedAt" ON "MakeupExamAutoJobs" ("MakeupExamPlanId", "CreatedAt");""",
"""CREATE INDEX "IX_MakeupExamAutoJobs_Status_CreatedAt" ON "MakeupExamAutoJobs" ("Status", "CreatedAt");""",
];
private static readonly string[] UnifiedMessageCenterStatements =
[
"""
CREATE TABLE "MessageDispatches" (
"Id" TEXT NOT NULL CONSTRAINT "PK_MessageDispatches" PRIMARY KEY,
"SenderUserId" TEXT NOT NULL,
"SenderName" TEXT NOT NULL,
"Title" TEXT NOT NULL,
"Content" TEXT NOT NULL,
"Category" INTEGER NOT NULL,
"AudienceType" INTEGER NOT NULL,
"AudienceId" TEXT NULL,
"AudienceName" TEXT NOT NULL,
"RecipientCount" INTEGER NOT NULL,
"LinkUrl" TEXT NULL,
"CreatedAt" TEXT NOT NULL,
"UpdatedAt" TEXT NOT NULL
);
""",
"""CREATE INDEX "IX_MessageDispatches_SenderUserId_CreatedAt" ON "MessageDispatches" ("SenderUserId", "CreatedAt");""",
"""ALTER TABLE "Notifications" ADD COLUMN "Category" INTEGER NOT NULL DEFAULT 1;""",
"""ALTER TABLE "Notifications" ADD COLUMN "MessageDispatchId" TEXT NULL REFERENCES "MessageDispatches" ("Id") ON DELETE CASCADE;""",
"""CREATE INDEX "IX_Notifications_MessageDispatchId" ON "Notifications" ("MessageDispatchId");""",
"""CREATE INDEX "IX_Notifications_UserId_Category_CreatedAt" ON "Notifications" ("UserId", "Category", "CreatedAt");"""
];
}
@@ -0,0 +1,102 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
{
/// <inheritdoc />
public partial class UnifiedMessageCenter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Category",
table: "Notifications",
type: "int",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<Guid>(
name: "MessageDispatchId",
table: "Notifications",
type: "char(36)",
nullable: true);
migrationBuilder.CreateTable(
name: "MessageDispatches",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false),
SenderUserId = table.Column<Guid>(type: "char(36)", nullable: false),
SenderName = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false),
Title = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
Content = table.Column<string>(type: "varchar(1000)", maxLength: 1000, nullable: false),
Category = table.Column<int>(type: "int", nullable: false),
AudienceType = table.Column<int>(type: "int", nullable: false),
AudienceId = table.Column<Guid>(type: "char(36)", nullable: true),
AudienceName = table.Column<string>(type: "varchar(200)", maxLength: 200, nullable: false),
RecipientCount = table.Column<int>(type: "int", nullable: false),
LinkUrl = table.Column<string>(type: "varchar(300)", maxLength: 300, nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MessageDispatches", x => x.Id);
})
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_Notifications_MessageDispatchId",
table: "Notifications",
column: "MessageDispatchId");
migrationBuilder.CreateIndex(
name: "IX_Notifications_UserId_Category_CreatedAt",
table: "Notifications",
columns: new[] { "UserId", "Category", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_MessageDispatches_SenderUserId_CreatedAt",
table: "MessageDispatches",
columns: new[] { "SenderUserId", "CreatedAt" });
migrationBuilder.AddForeignKey(
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
table: "Notifications",
column: "MessageDispatchId",
principalTable: "MessageDispatches",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Notifications_MessageDispatches_MessageDispatchId",
table: "Notifications");
migrationBuilder.DropTable(
name: "MessageDispatches");
migrationBuilder.DropIndex(
name: "IX_Notifications_MessageDispatchId",
table: "Notifications");
migrationBuilder.DropIndex(
name: "IX_Notifications_UserId_Category_CreatedAt",
table: "Notifications");
migrationBuilder.DropColumn(
name: "Category",
table: "Notifications");
migrationBuilder.DropColumn(
name: "MessageDispatchId",
table: "Notifications");
}
}
}
@@ -2210,12 +2210,73 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.ToTable("MakeupExamSessionInvigilators");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.MessageDispatch", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("AudienceId")
.HasColumnType("char(36)");
b.Property<string>("AudienceName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<int>("AudienceType")
.HasColumnType("int");
b.Property<int>("Category")
.HasColumnType("int");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("varchar(1000)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("LinkUrl")
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<int>("RecipientCount")
.HasColumnType("int");
b.Property<string>("SenderName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<Guid>("SenderUserId")
.HasColumnType("char(36)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.HasIndex("SenderUserId", "CreatedAt");
b.ToTable("MessageDispatches");
});
modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Notification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<int>("Category")
.HasColumnType("int");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(1000)
@@ -2231,6 +2292,9 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
.HasMaxLength(300)
.HasColumnType("varchar(300)");
b.Property<Guid?>("MessageDispatchId")
.HasColumnType("char(36)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
@@ -2246,8 +2310,12 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
b.HasIndex("CreatedAt");
b.HasIndex("MessageDispatchId");
b.HasIndex("UserId", "IsRead");
b.HasIndex("UserId", "Category", "CreatedAt");
b.ToTable("Notifications");
});
@@ -4076,6 +4144,16 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
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")
@@ -4549,6 +4627,11 @@ namespace Jiaowu.Api.Infrastructure.Persistence.Migrations.MySql
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");
@@ -75,11 +75,11 @@ public sealed class WarningCheckWorker(IServiceScopeFactory scopeFactory, ILogge
{
var si = await db.Students.Where(x => x.Id == w.StudentId).Select(x => new { x.UserId, x.Name, x.AdministrativeClassId }).FirstAsync(ct);
if (rule.NotifyStudent && si.UserId.HasValue)
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", cancellationToken: ct);
await NotificationService.SendAsync(db, si.UserId.Value, "学业预警", w.Detail, "/warnings", ct, NotificationCategory.Warning);
if (rule.NotifyCounselor)
{
var cid = await db.AdministrativeClasses.Where(c => c.Id == si.AdministrativeClassId && c.CounselorUserId != null).Select(c => c.CounselorUserId!.Value).FirstOrDefaultAsync(ct);
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", cancellationToken: ct);
if (cid != default) await NotificationService.SendAsync(db, cid, "学生学业预警", $"{si.Name}{w.Detail}", "/warnings", ct, NotificationCategory.Warning);
}
}
}