diff --git a/.env.example b/.env.example
index b8e0718..f9932e3 100644
--- a/.env.example
+++ b/.env.example
@@ -84,6 +84,12 @@ Operations__MySqlClientPath=mysql
# Operations__MySqlAdditionalArguments__0=--ssl-mode=VERIFY_IDENTITY
# Operations__MySqlAdditionalArguments__1=--ssl-ca=/etc/jiaowu/mysql-ca.pem
+# 签名插件包存储目录必须持久化,并仅允许服务账号写入。发布者公钥使用 RSA PEM。
+Plugins__StoragePath=/var/lib/jiaowu/plugins
+Plugins__MaximumPackageMegabytes=20
+# Plugins__TrustedPublishers__0__Id=school-it
+# Plugins__TrustedPublishers__0__PublicKeyPath=/etc/jiaowu/plugin-publishers/school-it-public.pem
+
Jwt__Issuer=Jiaowu.Api
Jwt__Audience=Jiaowu.Web
Jwt__Key=REPLACE_WITH_AT_LEAST_32_RANDOM_BYTES
diff --git a/Jiaowu.slnx b/Jiaowu.slnx
index d3a6bdb..29d3df8 100644
--- a/Jiaowu.slnx
+++ b/Jiaowu.slnx
@@ -1,8 +1,12 @@
+
+
+
+
diff --git a/README.md b/README.md
index 7a28e9a..647b79b 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,10 @@
权限采用后端强制校验的角色与数据范围模型。多角色账号按 `All > College > Class > Self` 取最高数据范围:校级角色可访问全校数据,院系管理员限定本学院,辅导员通过稳定的账号 ID 绑定所带行政班,教师和学生限定本人及当前教学关系;前端菜单和路由限制仅作为交互辅助,不替代 API 授权。
+系统提供统一插件平台。`SuperAdmin` 可以从“组织与权限 → 插件中心”启用或停用内置业务插件;状态变更同时作用于菜单、前端路由和对应后端 API,停用不会删除插件已有数据。插件只控制业务能力是否开放,原接口的角色与数据范围校验仍然有效。生产环境升级后需先执行数据库迁移,再启动新版本服务。
+
+插件平台同时支持经过受信任发布者 RSA-PSS/SHA-256 签名的 SDK 插件包。包上传后只进入暂存区,管理员标记激活或回滚后必须重启服务;启动时系统会再次校验签名、Host API、清单和包哈希,然后装载程序集并按事务执行插件迁移。SDK、示例插件、发布者公钥配置和打包流程见 [`docs/plugin-sdk.md`](docs/plugin-sdk.md)。不受信任的第三方代码仍不得作为进程内插件安装。
+
人员档案与登录账号分开维护。新增或 Excel 导入学生、教师档案时不会自动创建账号,也不会在修改档案时同步账号。学生首次使用时可以在登录页进入“自助激活”,填写姓名、学号、学院、专业、年级和行政班;全部匹配在籍档案后自行设置密码,系统才创建 Identity 登录账号并关联学生角色。`AspNetUsers` 作为 ASP.NET Core Identity 的内部安全存储,负责密码哈希、登录锁定、角色和令牌。
## 本地开发:热更新模式
diff --git a/docs/plugin-sdk.md b/docs/plugin-sdk.md
new file mode 100644
index 0000000..2e2d438
--- /dev/null
+++ b/docs/plugin-sdk.md
@@ -0,0 +1,92 @@
+# 明序插件 SDK
+
+统一插件平台只装载经过受信任发布者 RSA 签名的后端插件。上传、激活和运行是三个独立阶段:上传后先暂存;管理员标记某个版本为待激活;服务重启时再次验证签名并装载,成功后才成为活动版本。
+
+## 创建插件
+
+插件项目面向 `net10.0`,引用 `src/Jiaowu.Plugin.Abstractions`。可以从 `samples/Jiaowu.SamplePlugin` 复制起步。入口类型必须实现 `IJiaowuPluginModule`:
+
+```csharp
+public sealed class MyPluginModule : IJiaowuPluginModule
+{
+ public void ConfigureServices(
+ IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.AddScoped();
+ }
+}
+```
+
+插件控制器必须使用 `/api/plugin-extensions/{pluginId}` 前缀,并自行声明 `[Authorize]`、角色和数据范围校验。插件开关只决定扩展是否开放,不能替代业务授权。
+
+## plugin.json
+
+包根目录必须包含 `plugin.json`,后端程序集放在 `backend/`:
+
+```json
+{
+ "id": "school.example",
+ "name": "学校示例插件",
+ "description": "插件用途说明",
+ "version": "1.0.0",
+ "publisherId": "school-it",
+ "hostApiVersion": "1",
+ "category": "扩展插件",
+ "backendAssembly": "backend/School.Example.dll",
+ "moduleType": "School.Example.ExamplePluginModule",
+ "capabilities": ["example.read"]
+}
+```
+
+插件 ID 和版本一旦发布不能原地覆盖。升级必须使用新的语义版本。
+
+## 数据迁移、事件和后台任务
+
+- 实现 `IPluginDatabaseMigration` 声明 SQLite/MySQL 迁移语句。每个迁移以 `(PluginId, MigrationId)` 唯一记录,并在事务中只执行一次。
+- 实现 `IPluginBackgroundTask` 注册定时任务。宿主强制最短 30 秒间隔,并隔离记录任务异常。
+- 通过 `IPluginEventHandler` 订阅事件,使用 `AddPluginEventHandler()` 注册;通过 `IPluginEventPublisher` 发布事件。
+
+迁移只应操作本插件拥有的表。不要修改 Identity、成绩、学籍等核心表,也不要在迁移中删除业务数据。
+
+## 发布者密钥
+
+私钥只保存在发布流水线或密钥管理系统,服务器只部署公钥。示例生成命令:
+
+```powershell
+openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out school-it-private.pem
+openssl rsa -pubout -in school-it-private.pem -out school-it-public.pem
+```
+
+生产配置:
+
+```dotenv
+Plugins__StoragePath=/var/lib/jiaowu/plugins
+Plugins__TrustedPublishers__0__Id=school-it
+Plugins__TrustedPublishers__0__PublicKeyPath=/etc/jiaowu/plugin-publishers/school-it-public.pem
+```
+
+服务账号需要读取公钥、读写插件存储目录;其他普通账号不应具有写权限。
+
+## 打包与签名
+
+仓库脚本会执行 Release 发布、生成 ZIP,并使用 RSA-PSS/SHA-256 生成分离签名:
+
+```powershell
+./scripts/package-plugin.ps1 `
+ -Project samples/Jiaowu.SamplePlugin/Jiaowu.SamplePlugin.csproj `
+ -Manifest samples/Jiaowu.SamplePlugin/plugin.json `
+ -PrivateKey C:/secure/school-it-private.pem
+```
+
+产物位于 `.artifacts/plugins`。把 `.zip` 和同名 `.sig` 一起上传到插件中心。
+
+## 激活、升级与回滚
+
+1. 先执行宿主数据库迁移并启动包含插件平台的服务版本。
+2. 在插件中心上传 ZIP 和 SIG;签名、路径、大小、清单和 Host API 必须全部通过。
+3. 点击“激活”,版本进入“待重启激活”。
+4. 按维护流程重启服务;宿主重新验证包、装载模块并运行插件迁移。
+5. 若新版本异常,在插件中心对历史版本点击“回滚到此版本”,然后再次重启。
+
+插件程序集在独立加载上下文中运行,但仍属于受信任的服务器代码。只应信任经过代码审查的发布者;面向不受信任第三方的插件必须使用后续的独立进程/远程服务模式。
diff --git a/samples/Jiaowu.SamplePlugin/Jiaowu.SamplePlugin.csproj b/samples/Jiaowu.SamplePlugin/Jiaowu.SamplePlugin.csproj
new file mode 100644
index 0000000..77838f4
--- /dev/null
+++ b/samples/Jiaowu.SamplePlugin/Jiaowu.SamplePlugin.csproj
@@ -0,0 +1,11 @@
+
+
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
diff --git a/samples/Jiaowu.SamplePlugin/SamplePlugin.cs b/samples/Jiaowu.SamplePlugin/SamplePlugin.cs
new file mode 100644
index 0000000..b1e83ed
--- /dev/null
+++ b/samples/Jiaowu.SamplePlugin/SamplePlugin.cs
@@ -0,0 +1,60 @@
+using Jiaowu.Plugin.Abstractions;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Jiaowu.SamplePlugin;
+
+public sealed class SamplePluginModule : IJiaowuPluginModule
+{
+ public void ConfigureServices(
+ IServiceCollection services,
+ IConfiguration configuration)
+ {
+ services.AddSingleton();
+ }
+}
+
+public sealed class SampleGreetingService
+{
+ public string BuildGreeting() => "签名示例插件已由宿主成功装载。";
+}
+
+[ApiController]
+[Authorize(Roles = "SuperAdmin")]
+[Route("api/plugin-extensions/sample.hello")]
+public sealed class SamplePluginController(SampleGreetingService greetings)
+ : ControllerBase
+{
+ [HttpGet("greeting")]
+ public IActionResult GetGreeting() => Ok(new { message = greetings.BuildGreeting() });
+}
+
+public sealed class SamplePluginMigration : IPluginDatabaseMigration
+{
+ public string PluginId => "sample.hello";
+ public string MigrationId => "001_initial";
+
+ public IReadOnlyList GetStatements(PluginDatabaseProvider provider) =>
+ provider == PluginDatabaseProvider.Sqlite
+ ? ["CREATE TABLE IF NOT EXISTS SamplePluginNotes (Id TEXT NOT NULL PRIMARY KEY, Content TEXT NOT NULL);"]
+ : ["CREATE TABLE IF NOT EXISTS `SamplePluginNotes` (`Id` char(36) NOT NULL, `Content` varchar(500) NOT NULL, PRIMARY KEY (`Id`)) CHARACTER SET utf8mb4;"];
+}
+
+public sealed class SamplePluginBackgroundTask(
+ ILogger logger) : IPluginBackgroundTask
+{
+ public string PluginId => "sample.hello";
+ public string Name => "sample-heartbeat";
+ public TimeSpan Interval => TimeSpan.FromHours(1);
+
+ public Task ExecuteAsync(
+ IServiceProvider services,
+ CancellationToken cancellationToken)
+ {
+ logger.LogInformation("示例插件后台任务正常运行。");
+ return Task.CompletedTask;
+ }
+}
diff --git a/samples/Jiaowu.SamplePlugin/plugin.json b/samples/Jiaowu.SamplePlugin/plugin.json
new file mode 100644
index 0000000..f787c7d
--- /dev/null
+++ b/samples/Jiaowu.SamplePlugin/plugin.json
@@ -0,0 +1,14 @@
+{
+ "id": "sample.hello",
+ "name": "示例问候插件",
+ "description": "演示插件 SDK、受保护 API、数据库迁移和后台任务。",
+ "version": "1.0.0",
+ "publisherId": "school-it",
+ "hostApiVersion": "1",
+ "category": "示例插件",
+ "backendAssembly": "backend/Jiaowu.SamplePlugin.dll",
+ "moduleType": "Jiaowu.SamplePlugin.SamplePluginModule",
+ "capabilities": [
+ "sample.greeting.read"
+ ]
+}
diff --git a/scripts/package-plugin.ps1 b/scripts/package-plugin.ps1
new file mode 100644
index 0000000..56b70c9
--- /dev/null
+++ b/scripts/package-plugin.ps1
@@ -0,0 +1,77 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string] $Project,
+ [Parameter(Mandatory = $true)]
+ [string] $Manifest,
+ [Parameter(Mandatory = $true)]
+ [string] $PrivateKey,
+ [string] $OutputDirectory = '.artifacts/plugins'
+)
+
+$ErrorActionPreference = 'Stop'
+$repositoryRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$projectPath = [IO.Path]::GetFullPath($Project, (Get-Location).Path)
+$manifestPath = [IO.Path]::GetFullPath($Manifest, (Get-Location).Path)
+$privateKeyPath = [IO.Path]::GetFullPath($PrivateKey, (Get-Location).Path)
+$outputRoot = [IO.Path]::GetFullPath($OutputDirectory, $repositoryRoot)
+
+foreach ($requiredPath in @($projectPath, $manifestPath, $privateKeyPath)) {
+ if (-not (Test-Path -LiteralPath $requiredPath)) {
+ throw "Required path does not exist: $requiredPath"
+ }
+}
+
+$manifestObject = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
+if ([string]::IsNullOrWhiteSpace($manifestObject.id) -or
+ [string]::IsNullOrWhiteSpace($manifestObject.version)) {
+ throw 'plugin.json must contain id and version.'
+}
+if ($manifestObject.id -notmatch '^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)+$' -or
+ $manifestObject.version -notmatch '^\d+\.\d+\.\d+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$') {
+ throw 'plugin.json contains an unsafe id or invalid semantic version.'
+}
+
+$temporaryRoot = Join-Path ([IO.Path]::GetTempPath()) ("jiaowu-plugin-" + [Guid]::NewGuid().ToString('N'))
+$publishRoot = Join-Path $temporaryRoot 'publish'
+$packageRoot = Join-Path $temporaryRoot 'package'
+$backendRoot = Join-Path $packageRoot 'backend'
+
+try {
+ New-Item -ItemType Directory -Path $publishRoot, $backendRoot -Force | Out-Null
+ $dotnet = (Get-Command dotnet).Source
+ $publishArgs = @('publish', $projectPath, '-c', 'Release', '-o', $publishRoot)
+ & $dotnet @publishArgs
+ if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE" }
+
+ Copy-Item -LiteralPath $manifestPath -Destination (Join-Path $packageRoot 'plugin.json')
+ Copy-Item -Path (Join-Path $publishRoot '*') -Destination $backendRoot -Recurse -Force
+
+ New-Item -ItemType Directory -Path $outputRoot -Force | Out-Null
+ $baseName = "$($manifestObject.id)-$($manifestObject.version)"
+ $zipPath = Join-Path $outputRoot "$baseName.zip"
+ $signaturePath = Join-Path $outputRoot "$baseName.sig"
+ if (Test-Path -LiteralPath $zipPath) { Remove-Item -LiteralPath $zipPath -Force }
+ Compress-Archive -Path (Join-Path $packageRoot '*') -DestinationPath $zipPath
+
+ $rsa = [Security.Cryptography.RSA]::Create()
+ try {
+ $rsa.ImportFromPem((Get-Content -Raw -LiteralPath $privateKeyPath))
+ $content = [IO.File]::ReadAllBytes($zipPath)
+ $signature = $rsa.SignData(
+ $content,
+ [Security.Cryptography.HashAlgorithmName]::SHA256,
+ [Security.Cryptography.RSASignaturePadding]::Pss)
+ [IO.File]::WriteAllBytes($signaturePath, $signature)
+ }
+ finally {
+ $rsa.Dispose()
+ }
+
+ Write-Output $zipPath
+ Write-Output $signaturePath
+}
+finally {
+ if (Test-Path -LiteralPath $temporaryRoot) {
+ Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
+ }
+}
diff --git a/src/Jiaowu.Api/Controllers/PluginsController.cs b/src/Jiaowu.Api/Controllers/PluginsController.cs
new file mode 100644
index 0000000..e3b9963
--- /dev/null
+++ b/src/Jiaowu.Api/Controllers/PluginsController.cs
@@ -0,0 +1,140 @@
+using System.Security.Claims;
+using Jiaowu.Api.Domain.Identity;
+using Jiaowu.Api.Infrastructure.Plugins;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Jiaowu.Api.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/plugins")]
+public sealed class PluginsController(
+ PluginRuntimeService runtime,
+ PluginPackageService packages) : ControllerBase
+{
+ [HttpGet("runtime")]
+ public async Task>> GetRuntime(
+ CancellationToken cancellationToken) =>
+ Ok(await runtime.GetAllAsync(cancellationToken));
+
+ [HttpGet]
+ [Authorize(Roles = SystemRoles.SuperAdmin)]
+ public async Task>> GetAll(
+ CancellationToken cancellationToken) =>
+ Ok(await runtime.GetAllAsync(cancellationToken));
+
+ [HttpPut("{pluginId}/state")]
+ [Authorize(Roles = SystemRoles.SuperAdmin)]
+ public async Task> SetState(
+ string pluginId,
+ UpdatePluginStateRequest request,
+ CancellationToken cancellationToken)
+ {
+ var result = await runtime.SetEnabledAsync(
+ pluginId,
+ request.IsEnabled,
+ User.FindFirstValue(ClaimTypes.NameIdentifier),
+ cancellationToken);
+ return result is null ? NotFound() : Ok(result);
+ }
+
+ [HttpGet("packages")]
+ [Authorize(Roles = SystemRoles.SuperAdmin)]
+ public async Task>> GetPackages(
+ CancellationToken cancellationToken) =>
+ Ok(await packages.ListAsync(cancellationToken));
+
+ [HttpPost("packages")]
+ [Authorize(Roles = SystemRoles.SuperAdmin)]
+ [Consumes("multipart/form-data")]
+ [RequestSizeLimit(101 * 1024 * 1024)]
+ public async Task> UploadPackage(
+ [FromForm] PluginPackageUploadRequest request,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var package = await ReadAsync(request.Package, cancellationToken);
+ var signature = await ReadAsync(request.Signature, cancellationToken);
+ var result = await packages.StageAsync(
+ package,
+ signature,
+ User.FindFirstValue(ClaimTypes.NameIdentifier),
+ cancellationToken);
+ return CreatedAtAction(nameof(GetPackages), new { id = result.Id }, result);
+ }
+ catch (PluginPackageConflictException exception)
+ {
+ return Conflict(new ProblemDetails
+ {
+ Title = "插件版本冲突",
+ Detail = exception.Message,
+ Status = StatusCodes.Status409Conflict
+ });
+ }
+ catch (PluginPackageException exception)
+ {
+ return ValidationProblem(exception.Message);
+ }
+ }
+
+ [HttpPost("packages/{id:guid}/activate")]
+ [Authorize(Roles = SystemRoles.SuperAdmin)]
+ public async Task> ActivatePackage(
+ Guid id,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ var result = await packages.RequestActivationAsync(id, cancellationToken);
+ return result is null ? NotFound() : Ok(result);
+ }
+ catch (PluginPackageException exception)
+ {
+ return ValidationProblem(exception.Message);
+ }
+ }
+
+ [HttpDelete("packages/{id:guid}")]
+ [Authorize(Roles = SystemRoles.SuperAdmin)]
+ public async Task DeletePackage(
+ Guid id,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await packages.DeleteStagedAsync(id, cancellationToken)
+ ? NoContent()
+ : NotFound();
+ }
+ catch (PluginPackageConflictException exception)
+ {
+ return Conflict(new ProblemDetails
+ {
+ Title = "插件版本不能删除",
+ Detail = exception.Message,
+ Status = StatusCodes.Status409Conflict
+ });
+ }
+ }
+
+ private static async Task ReadAsync(
+ IFormFile file,
+ CancellationToken cancellationToken)
+ {
+ if (file.Length <= 0) throw new PluginPackageException("上传文件不能为空。");
+ await using var input = file.OpenReadStream();
+ await using var buffer = new MemoryStream(checked((int)file.Length));
+ await input.CopyToAsync(buffer, cancellationToken);
+ return buffer.ToArray();
+ }
+}
+
+public sealed record UpdatePluginStateRequest(bool IsEnabled);
+
+public sealed class PluginPackageUploadRequest
+{
+ public required IFormFile Package { get; init; }
+ public required IFormFile Signature { get; init; }
+}
diff --git a/src/Jiaowu.Api/Domain/System/PluginInstallation.cs b/src/Jiaowu.Api/Domain/System/PluginInstallation.cs
new file mode 100644
index 0000000..c077c84
--- /dev/null
+++ b/src/Jiaowu.Api/Domain/System/PluginInstallation.cs
@@ -0,0 +1,11 @@
+using Jiaowu.Api.Domain.Common;
+
+namespace Jiaowu.Api.Domain.System;
+
+public sealed class PluginInstallation : EntityBase
+{
+ public required string PluginId { get; set; }
+ public required string InstalledVersion { get; set; }
+ public bool IsEnabled { get; set; } = true;
+ public string? ChangedByUserId { get; set; }
+}
diff --git a/src/Jiaowu.Api/Domain/System/PluginPackageRelease.cs b/src/Jiaowu.Api/Domain/System/PluginPackageRelease.cs
new file mode 100644
index 0000000..dc096c6
--- /dev/null
+++ b/src/Jiaowu.Api/Domain/System/PluginPackageRelease.cs
@@ -0,0 +1,38 @@
+using Jiaowu.Api.Domain.Common;
+
+namespace Jiaowu.Api.Domain.System;
+
+public enum PluginPackageStatus
+{
+ Staged,
+ PendingActivation,
+ Active,
+ Superseded,
+ Failed
+}
+
+public sealed class PluginPackageRelease : EntityBase
+{
+ public required string PluginId { get; set; }
+ public required string Name { get; set; }
+ public required string Description { get; set; }
+ public required string Category { get; set; }
+ public required string Version { get; set; }
+ public required string PublisherId { get; set; }
+ public required string HostApiVersion { get; set; }
+ public required string Sha256 { get; set; }
+ public required string ManifestJson { get; set; }
+ public required byte[] PackageContent { get; set; }
+ public required byte[] Signature { get; set; }
+ public PluginPackageStatus Status { get; set; }
+ public string? CreatedByUserId { get; set; }
+ public DateTime? ActivationRequestedAt { get; set; }
+ public string? FailureMessage { get; set; }
+}
+
+public sealed class PluginMigrationHistory : EntityBase
+{
+ public required string PluginId { get; set; }
+ public required string MigrationId { get; set; }
+ public DateTime AppliedAt { get; set; } = DateTime.UtcNow;
+}
diff --git a/src/Jiaowu.Api/Infrastructure/Middleware/PluginAvailabilityMiddleware.cs b/src/Jiaowu.Api/Infrastructure/Middleware/PluginAvailabilityMiddleware.cs
new file mode 100644
index 0000000..064579e
--- /dev/null
+++ b/src/Jiaowu.Api/Infrastructure/Middleware/PluginAvailabilityMiddleware.cs
@@ -0,0 +1,42 @@
+using Jiaowu.Api.Infrastructure.Plugins;
+
+namespace Jiaowu.Api.Infrastructure.Middleware;
+
+public sealed class PluginAvailabilityMiddleware(RequestDelegate next)
+{
+ public async Task InvokeAsync(
+ HttpContext context,
+ BuiltInPluginCatalog catalog,
+ PluginRuntimeService runtime)
+ {
+ var plugin = catalog.FindByApiPath(context.Request.Path);
+ var externalPluginId = GetExternalPluginId(context.Request.Path);
+ var pluginId = plugin?.Id ?? externalPluginId;
+ if (pluginId is not null &&
+ !await runtime.IsEnabledAsync(pluginId, context.RequestAborted))
+ {
+ await Results.Problem(
+ statusCode: StatusCodes.Status404NotFound,
+ title: "功能暂未开放",
+ detail: plugin is null
+ ? "该扩展插件当前不可用。"
+ : $"“{plugin.Name}”插件当前已停用。")
+ .ExecuteAsync(context);
+ return;
+ }
+
+ await next(context);
+ }
+
+ private static string? GetExternalPluginId(PathString path)
+ {
+ const string prefix = "/api/plugin-extensions/";
+ var value = path.Value;
+ if (value is null || !value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
+ return null;
+ var remainder = value[prefix.Length..];
+ var slash = remainder.IndexOf('/');
+ var pluginId = slash < 0 ? remainder : remainder[..slash];
+ return string.IsNullOrWhiteSpace(pluginId) ? null : pluginId;
+ }
+}
diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs
index 66e8a98..ac5c8ab 100644
--- a/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs
+++ b/src/Jiaowu.Api/Infrastructure/Persistence/AppDbContext.cs
@@ -181,6 +181,12 @@ public sealed class AppDbContext(DbContextOptions options)
Set();
public DbSet SystemFeatureSettings =>
Set();
+ public DbSet PluginInstallations =>
+ Set();
+ public DbSet PluginPackageReleases =>
+ Set();
+ public DbSet PluginMigrationHistory =>
+ Set();
public DbSet CourseGradeStatisticsRefreshSettings =>
Set();
public DbSet RefreshSessions => Set();
@@ -1940,6 +1946,40 @@ public sealed class AppDbContext(DbContextOptions options)
entity.HasIndex(x => x.Key).IsUnique();
});
+ builder.Entity(entity =>
+ {
+ entity.Property(x => x.PluginId).HasMaxLength(100);
+ entity.Property(x => x.InstalledVersion).HasMaxLength(32);
+ entity.Property(x => x.ChangedByUserId).HasMaxLength(450);
+ entity.HasIndex(x => x.PluginId).IsUnique();
+ });
+
+ builder.Entity(entity =>
+ {
+ entity.Property(x => x.PluginId).HasMaxLength(100);
+ entity.Property(x => x.Name).HasMaxLength(160);
+ entity.Property(x => x.Description).HasMaxLength(1000);
+ entity.Property(x => x.Category).HasMaxLength(80);
+ entity.Property(x => x.Version).HasMaxLength(40);
+ entity.Property(x => x.PublisherId).HasMaxLength(100);
+ entity.Property(x => x.HostApiVersion).HasMaxLength(20);
+ entity.Property(x => x.Sha256).HasMaxLength(64);
+ entity.Property(x => x.ManifestJson).HasColumnType("longtext");
+ entity.Property(x => x.PackageContent).HasColumnType("longblob");
+ entity.Property(x => x.Signature).HasColumnType("longblob");
+ entity.Property(x => x.CreatedByUserId).HasMaxLength(450);
+ entity.Property(x => x.FailureMessage).HasMaxLength(1000);
+ entity.HasIndex(x => new { x.PluginId, x.Version }).IsUnique();
+ entity.HasIndex(x => new { x.PluginId, x.Status });
+ });
+
+ builder.Entity(entity =>
+ {
+ entity.Property(x => x.PluginId).HasMaxLength(100);
+ entity.Property(x => x.MigrationId).HasMaxLength(120);
+ entity.HasIndex(x => new { x.PluginId, x.MigrationId }).IsUnique();
+ });
+
builder.Entity(entity =>
{
entity.Property(x => x.Key).HasMaxLength(50);
diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs
index 82758ed..dc26841 100644
--- a/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs
+++ b/src/Jiaowu.Api/Infrastructure/Persistence/DatabaseInitializer.cs
@@ -1,5 +1,6 @@
using Jiaowu.Api.Domain.Academic;
using Jiaowu.Api.Domain.Identity;
+using Jiaowu.Api.Infrastructure.Plugins;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@@ -10,6 +11,7 @@ public sealed class DatabaseInitializer(
RoleManager roleManager,
UserManager userManager,
DevelopmentSqliteMigrator sqliteMigrator,
+ PluginMigrationRunner pluginMigrationRunner,
DatabaseOptions databaseOptions,
IConfiguration configuration,
IHostEnvironment environment,
@@ -44,6 +46,8 @@ public sealed class DatabaseInitializer(
}
}
+ await pluginMigrationRunner.ApplyAsync();
+
if (migrateOnly)
{
logger.LogInformation("数据库迁移已完成。");
diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs
index 379b82b..fb32b0f 100644
--- a/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs
+++ b/src/Jiaowu.Api/Infrastructure/Persistence/DevelopmentSqliteMigrator.cs
@@ -138,6 +138,10 @@ public sealed class DevelopmentSqliteMigrator(
"20260822_68_campus_wallet_balances";
private const string TuitionBillingMigration = "20260822_69_tuition_billing";
private const string PhysicalFitnessMigration = "20260929_70_physical_fitness";
+ private const string UnifiedPluginPlatformMigration =
+ "20261001_71_unified_plugin_platform";
+ private const string PluginPackagesAndSdkMigration =
+ "20261001_72_plugin_packages_and_sdk";
public async Task MigrateAsync(CancellationToken cancellationToken = default)
{
@@ -979,6 +983,14 @@ public sealed class DevelopmentSqliteMigrator(
PhysicalFitnessMigration,
physicalFitnessExists ? [] : PhysicalFitnessStatements,
cancellationToken);
+ await ApplyMigrationAsync(
+ UnifiedPluginPlatformMigration,
+ UnifiedPluginPlatformStatements,
+ cancellationToken);
+ await ApplyMigrationAsync(
+ PluginPackagesAndSdkMigration,
+ PluginPackagesAndSdkStatements,
+ cancellationToken);
}
private async Task ApplyMigrationAsync(
@@ -3621,6 +3633,64 @@ public sealed class DevelopmentSqliteMigrator(
"""CREATE UNIQUE INDEX IF NOT EXISTS "IX_PhysicalTestItemResults_PhysicalTestRecordId_ItemCode" ON "PhysicalTestItemResults" ("PhysicalTestRecordId", "ItemCode");"""
];
+ private static readonly string[] UnifiedPluginPlatformStatements =
+ [
+ """
+ CREATE TABLE IF NOT EXISTS "PluginInstallations" (
+ "Id" TEXT NOT NULL CONSTRAINT "PK_PluginInstallations" PRIMARY KEY,
+ "PluginId" TEXT NOT NULL,
+ "InstalledVersion" TEXT NOT NULL,
+ "IsEnabled" INTEGER NOT NULL,
+ "ChangedByUserId" TEXT NULL,
+ "CreatedAt" TEXT NOT NULL,
+ "UpdatedAt" TEXT NOT NULL
+ );
+ """,
+ """
+ CREATE UNIQUE INDEX IF NOT EXISTS "IX_PluginInstallations_PluginId"
+ ON "PluginInstallations" ("PluginId");
+ """
+ ];
+
+ private static readonly string[] PluginPackagesAndSdkStatements =
+ [
+ """
+ CREATE TABLE IF NOT EXISTS "PluginPackageReleases" (
+ "Id" TEXT NOT NULL CONSTRAINT "PK_PluginPackageReleases" PRIMARY KEY,
+ "PluginId" TEXT NOT NULL,
+ "Name" TEXT NOT NULL,
+ "Description" TEXT NOT NULL,
+ "Category" TEXT NOT NULL,
+ "Version" TEXT NOT NULL,
+ "PublisherId" TEXT NOT NULL,
+ "HostApiVersion" TEXT NOT NULL,
+ "Sha256" TEXT NOT NULL,
+ "ManifestJson" TEXT NOT NULL,
+ "PackageContent" BLOB NOT NULL,
+ "Signature" BLOB NOT NULL,
+ "Status" INTEGER NOT NULL,
+ "CreatedByUserId" TEXT NULL,
+ "ActivationRequestedAt" TEXT NULL,
+ "FailureMessage" TEXT NULL,
+ "CreatedAt" TEXT NOT NULL,
+ "UpdatedAt" TEXT NOT NULL
+ );
+ """,
+ """CREATE UNIQUE INDEX IF NOT EXISTS "IX_PluginPackageReleases_PluginId_Version" ON "PluginPackageReleases" ("PluginId", "Version");""",
+ """CREATE INDEX IF NOT EXISTS "IX_PluginPackageReleases_PluginId_Status" ON "PluginPackageReleases" ("PluginId", "Status");""",
+ """
+ CREATE TABLE IF NOT EXISTS "PluginMigrationHistory" (
+ "Id" TEXT NOT NULL CONSTRAINT "PK_PluginMigrationHistory" PRIMARY KEY,
+ "PluginId" TEXT NOT NULL,
+ "MigrationId" TEXT NOT NULL,
+ "AppliedAt" TEXT NOT NULL,
+ "CreatedAt" TEXT NOT NULL,
+ "UpdatedAt" TEXT NOT NULL
+ );
+ """,
+ """CREATE UNIQUE INDEX IF NOT EXISTS "IX_PluginMigrationHistory_PluginId_MigrationId" ON "PluginMigrationHistory" ("PluginId", "MigrationId");"""
+ ];
+
private static readonly string[] ReusableCourseGroupsStatements =
[
"""
diff --git a/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20261001001535_UnifiedPluginPlatform.Designer.cs b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20261001001535_UnifiedPluginPlatform.Designer.cs
new file mode 100644
index 0000000..39f0343
--- /dev/null
+++ b/src/Jiaowu.Api/Infrastructure/Persistence/Migrations/MySql/20261001001535_UnifiedPluginPlatform.Designer.cs
@@ -0,0 +1,9039 @@
+//
+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("20261001001535_UnifiedPluginPlatform")]
+ partial class UnifiedPluginPlatform
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.12")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicCalendarEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AcademicTermId")
+ .HasColumnType("char(36)");
+
+ b.Property("AudienceRoles")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Description")
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("EndsOn")
+ .HasColumnType("date");
+
+ b.Property("IsEnabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("IsOffDay")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Kind")
+ .HasColumnType("int");
+
+ b.Property("ReminderDays")
+ .HasColumnType("int");
+
+ b.Property("Source")
+ .HasColumnType("int");
+
+ b.Property("SourceYear")
+ .HasColumnType("int");
+
+ b.Property("StartsOn")
+ .HasColumnType("date");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AcademicTermId", "StartsOn");
+
+ b.HasIndex("IsEnabled", "StartsOn");
+
+ b.HasIndex("AcademicTermId", "Source", "SourceYear", "StartsOn");
+
+ b.ToTable("AcademicCalendarEvents");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicRiskCase", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AssignedCounselorUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("FollowUpDueDate")
+ .HasColumnType("date");
+
+ b.Property("ReviewDate")
+ .HasColumnType("date");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("StudentId")
+ .HasColumnType("char(36)");
+
+ b.Property("TaskCompletedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("WarningRecordId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("WarningRecordId")
+ .IsUnique();
+
+ b.HasIndex("StudentId", "Status");
+
+ b.HasIndex("AssignedCounselorUserId", "Status", "FollowUpDueDate");
+
+ b.ToTable("AcademicRiskCases");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicRiskCaseEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AcademicRiskCaseId")
+ .HasColumnType("char(36)");
+
+ b.Property("Content")
+ .IsRequired()
+ .HasMaxLength(2000)
+ .HasColumnType("varchar(2000)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("OccurredAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RecordedByUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RecordedByUserId");
+
+ b.HasIndex("AcademicRiskCaseId", "OccurredAt");
+
+ b.ToTable("AcademicRiskCaseEntries");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AcademicTerm", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AcademicYear")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ArchiveReason")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("ArchivedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ArchivedByUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EndDate")
+ .HasColumnType("date");
+
+ b.Property("IsArchived")
+ .IsConcurrencyToken()
+ .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.ArchiveEvent", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("ActorName")
+ .HasMaxLength(256)
+ .HasColumnType("varchar(256)");
+
+ b.Property("ActorUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("EntityId")
+ .HasColumnType("char(36)");
+
+ b.Property("IsArchived")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("varchar(20)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Kind", "EntityId", "CreatedAt");
+
+ b.ToTable("ArchiveEvents");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.AttendanceCheckInAttempt", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AccuracyMeters")
+ .HasColumnType("double");
+
+ b.Property("AttendanceSheetId")
+ .HasColumnType("char(36)");
+
+ b.Property("CheckInMethod")
+ .HasColumnType("int");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("DeviceIdentifierHash")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("DevicePlatform")
+ .HasMaxLength(32)
+ .HasColumnType("varchar(32)");
+
+ b.Property("DistanceMeters")
+ .HasColumnType("double");
+
+ b.Property("FailureCode")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("IpAddress")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("IsSuccessful")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Latitude")
+ .HasPrecision(10, 7)
+ .HasColumnType("decimal(10,7)");
+
+ b.Property("Longitude")
+ .HasPrecision(10, 7)
+ .HasColumnType("decimal(10,7)");
+
+ b.Property("RiskFlags")
+ .HasMaxLength(300)
+ .HasColumnType("varchar(300)");
+
+ b.Property("StudentId")
+ .HasColumnType("char(36)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("UserAgent")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StudentId");
+
+ b.HasIndex("DeviceIdentifierHash", "CreatedAt");
+
+ b.HasIndex("IpAddress", "CreatedAt");
+
+ b.HasIndex("AttendanceSheetId", "StudentId", "CreatedAt");
+
+ b.ToTable("AttendanceCheckInAttempts");
+ });
+
+ 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("CurrentGeneration")
+ .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("TotalGenerations")
+ .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.CampusPaymentOrder", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Amount")
+ .HasPrecision(10, 2)
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("ApplicationId")
+ .HasColumnType("char(36)");
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ConfirmedByUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Note")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("OrderNumber")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("PaymentMethod")
+ .HasColumnType("int");
+
+ b.Property("ReferenceNumber")
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicationId")
+ .IsUnique();
+
+ b.HasIndex("ConfirmedByUserId");
+
+ b.HasIndex("OrderNumber")
+ .IsUnique();
+
+ b.HasIndex("Status", "CreatedAt");
+
+ b.ToTable("CampusPaymentOrders");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CampusRegistrationApplication", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("ApplicantNote")
+ .HasMaxLength(1000)
+ .HasColumnType("varchar(1000)");
+
+ b.Property("CancelledAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ConfirmedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("ProjectId")
+ .HasColumnType("char(36)");
+
+ 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("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReviewedByUserId");
+
+ b.HasIndex("StudentId");
+
+ b.HasIndex("ProjectId", "StudentId")
+ .IsUnique();
+
+ b.HasIndex("ProjectId", "Status", "CreatedAt");
+
+ b.ToTable("CampusRegistrationApplications");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CampusRegistrationProject", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Capacity")
+ .HasColumnType("int");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("varchar(40)");
+
+ b.Property("CollegeId")
+ .HasColumnType("char(36)");
+
+ b.Property("ContactName")
+ .HasMaxLength(50)
+ .HasColumnType("varchar(50)");
+
+ b.Property("ContactPhone")
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("varchar(2000)");
+
+ b.Property("FeeAmount")
+ .HasPrecision(10, 2)
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("varchar(120)");
+
+ b.Property("Notes")
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("RegistrationEndsAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RegistrationStartsAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("RequiresReview")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("Status")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code")
+ .IsUnique();
+
+ b.HasIndex("CreatedByUserId");
+
+ b.HasIndex("CollegeId", "Status", "RegistrationEndsAt");
+
+ b.ToTable("CampusRegistrationProjects");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CampusWalletAccount", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Balance")
+ .HasPrecision(10, 2)
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("StudentId")
+ .HasColumnType("char(36)");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StudentId")
+ .IsUnique();
+
+ b.ToTable("CampusWalletAccounts");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.CampusWalletTransaction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Amount")
+ .HasPrecision(10, 2)
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("BalanceAfter")
+ .HasPrecision(10, 2)
+ .HasColumnType("decimal(10,2)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("OperatedByUserId")
+ .HasColumnType("char(36)");
+
+ b.Property("PaymentOrderId")
+ .HasColumnType("char(36)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("varchar(500)");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("WalletAccountId")
+ .HasColumnType("char(36)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("OperatedByUserId");
+
+ b.HasIndex("PaymentOrderId")
+ .IsUnique();
+
+ b.HasIndex("WalletAccountId", "CreatedAt");
+
+ b.ToTable("CampusWalletTransactions");
+ });
+
+ modelBuilder.Entity("Jiaowu.Api.Domain.Academic.Classroom", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AvailabilityStatus")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(1);
+
+ 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("Floor")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("int")
+ .HasDefaultValue(1);
+
+ 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("TeachingVenueNature")
+ .HasColumnType("int");
+
+ b.Property