diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0eaee16
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+MYSQL_ROOT_PASSWORD=replace-with-a-strong-root-password
+MYSQL_PASSWORD=replace-with-a-strong-application-password
+RABBITMQ_DEFAULT_USER=replace-with-a-non-default-user
+RABBITMQ_DEFAULT_PASS=replace-with-a-strong-password
+IM_INTERNAL_API_KEY=replace-with-a-long-random-key
diff --git a/.gitignore b/.gitignore
index 9491a2f..90e0302 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,9 @@
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
+.env
+.env.*
+!.env.example
# Mono auto generated files
mono_crash.*
@@ -360,4 +363,10 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
-FodyWeavers.xsd
\ No newline at end of file
+FodyWeavers.xsd
+
+.tools/
+deploy/.env.local
+deploy/data/
+artifacts/
+**/keyring/
diff --git a/API.md b/API.md
index cd5717e..b420fe6 100644
--- a/API.md
+++ b/API.md
@@ -945,3 +945,18 @@ file: (二进制分片)
| 3000-3099 | 管理后台 | 3000 管理员不存在, 3003 权限不足 |
| 3100-3199 | 会话 | 3100 会话不存在 |
| 3200-3299 | 分片 | 3201 分片不存在, 3202 分片合并失败, **3203 分片过小**, **3204 分片数不匹配**, **3205 会话过期**, **3206 分片号无效** |
+
+## 会话一致性说明
+
+- `GET /api/Conversation/List` 的请求和响应结构不变,结果按 `ModificationTime ?? CreationTime` 倒序返回。
+- 活动会话由 `(UserId, ChatType, TargetId)` 唯一确定;重复的好友或入群事件按幂等成功处理。
+- `20260911000100_ConversationUniqueness` 会保留最新活动记录、合并最大未读数和最大已读序号,并软删除其余重复记录。
+# 消息历史搜索
+
+`GET /api/Message/Search` 在当前用户拥有的会话中搜索历史文本消息。
+
+- 参数:`conversationId`、`keyword`(去除首尾空格后 1–50 字符)、可选独占游标 `cursor`、`limit`(1–50,默认 30)。
+- 只返回未撤回、未删除的文本消息,按 `sequenceId` 倒序排列。
+- 响应继续使用统一 `Result`,数据结构为 `{ messages, hasmore }`。下一页以本页最后一条消息的 `sequenceId` 作为独占游标。
+- `POST /api/Conversation/MarkRead` 新增可选 `lastReadSequenceId` 查询参数,旧客户端不传时仍兼容。
+- 会话列表的 `dateTime` 表示最后消息活动时间;标记已读不会改变此时间或会话排序。
diff --git a/Admin.Tests/Admin.Tests.csproj b/Admin.Tests/Admin.Tests.csproj
new file mode 100644
index 0000000..40d87e8
--- /dev/null
+++ b/Admin.Tests/Admin.Tests.csproj
@@ -0,0 +1,12 @@
+
+ net8.0enableenablefalsetrue
+
+
+
+
+
+
+ all
+
+
+
diff --git a/Admin.Tests/AdminFlowTests.cs b/Admin.Tests/AdminFlowTests.cs
new file mode 100644
index 0000000..71f450a
--- /dev/null
+++ b/Admin.Tests/AdminFlowTests.cs
@@ -0,0 +1,129 @@
+using System.Net;
+using System.Net.Http.Json;
+using System.Security.Cryptography;
+using System.Text.Json;
+using IM.Admin.Data;
+using IM.Admin.Services;
+using IM.InitCommon.Management;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Testcontainers.MySql;
+using Xunit;
+
+namespace IM.Admin.Tests;
+public sealed class AdminFlowTests : IAsyncLifetime
+{
+ readonly MySqlContainer mysql = new MySqlBuilder().WithImage("mysql:8.0").WithDatabase("admin_tests").WithUsername("test_admin").WithPassword(Convert.ToHexString(RandomNumberGenerator.GetBytes(24))).Build();
+ Factory factory = null!;
+ readonly DomainHandler domain = new();
+ const string Password = "Integration-password-456!";
+ readonly Guid superId = Guid.NewGuid(), reviewerId = Guid.NewGuid();
+ public async Task InitializeAsync()
+ {
+ await mysql.StartAsync(); factory = new Factory(mysql.GetConnectionString(),domain);
+ using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync();
+ var hasher = new PasswordHasher();
+ foreach (var (id,name,role) in new[] {(superId,"root_test","super"),(reviewerId,"review_test","reviewer")}) {
+ var a = new AdminAccount {Id=id,Account=name,Name=name,Role=role}; a.PasswordHash=hasher.HashPassword(a,Password); db.Accounts.Add(a);
+ }
+ await db.SaveChangesAsync();
+ }
+ public async Task DisposeAsync() { await factory.DisposeAsync(); await mysql.DisposeAsync(); }
+ HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions {BaseAddress=new Uri("https://localhost"),AllowAutoRedirect=false,HandleCookies=true});
+ static async Task Data(HttpResponseMessage response) { var json=await response.Content.ReadFromJsonAsync(); return json.GetProperty("data").Clone(); }
+ static async Task Mutate(HttpClient client,string path,object body,HttpMethod? method=null) {
+ var csrf=await Data(await client.GetAsync("/api/admin/auth/csrf"));
+ using var request=new HttpRequestMessage(method??HttpMethod.Post,"/api/admin"+path) {Content=JsonContent.Create(body)};
+ request.Headers.Add("X-CSRF-TOKEN",csrf.GetProperty("token").GetString()); return await client.SendAsync(request);
+ }
+ static async Task Login(HttpClient c,string name) => Assert.Equal(HttpStatusCode.OK,(await Mutate(c,"/auth/login",new {account=name,password=Password})).StatusCode);
+ async Task Process(Guid id) {
+ using (var scope=factory.Services.CreateScope()) { var db=scope.ServiceProvider.GetRequiredService(); var op=await db.Operations.FindAsync(id); op!.NextAttemptAt=DateTime.UtcNow; await db.SaveChangesAsync(); }
+ await new OperationWorker(factory.Services.GetRequiredService(),NullLogger.Instance).Process(CancellationToken.None);
+ }
+ [Fact]
+ public async Task Cookie_csrf_permissions_versions_durable_failures_and_revocation_work_together()
+ {
+ using var root=Client(); using var reviewer=Client();
+ Assert.Equal(HttpStatusCode.Unauthorized,(await root.GetAsync("/api/admin/settings")).StatusCode);
+ Assert.Equal(HttpStatusCode.BadRequest,(await root.PostAsJsonAsync("/api/admin/auth/login",new {account="root_test",password=Password})).StatusCode);
+ await Login(root,"root_test"); await Login(reviewer,"review_test");
+ foreach(var route in new[]{"settings","admins","logs","health","storage"}) Assert.Equal(HttpStatusCode.Forbidden,(await reviewer.GetAsync("/api/admin/"+route)).StatusCode);
+ var me=await Data(await root.GetAsync("/api/admin/auth/me")); Assert.False(me.TryGetProperty("passwordHash",out _));
+ var settings=await Data(await root.GetAsync("/api/admin/settings")); var account=settings.EnumerateArray().Single(x=>x.GetProperty("id").GetString()=="account");
+ var input=new {version=account.GetProperty("version").GetInt64(),value=new {registrationEnabled=false,passwordMinLength=10},reason="测试暂停注册"};
+ Assert.Equal(HttpStatusCode.OK,(await Mutate(root,"/settings/account",input,HttpMethod.Put)).StatusCode);
+ Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,"/settings/account",input,HttpMethod.Put)).StatusCode);
+ var platform=await Data(await root.GetAsync("/api/platform")); Assert.False(platform.GetProperty("registrationEnabled").GetBoolean());
+
+ using var internalRequest=new HttpRequestMessage(HttpMethod.Post,"/internal/management/reports") {Content=JsonContent.Create(new {reporterId=Guid.NewGuid(),type="user",targetId=domain.Target,reason="骚扰辱骂",description="服务端快照测试",messageIds=Array.Empty()})};
+ internalRequest.Headers.Add("X-IM-Management-Key","integration-internal-key");
+ var submitted=await root.SendAsync(internalRequest); Assert.Equal(HttpStatusCode.OK,submitted.StatusCode);
+ var reportId=(await submitted.Content.ReadFromJsonAsync()).GetProperty("id").GetGuid();
+ Assert.Equal(HttpStatusCode.OK,(await Mutate(reviewer,$"/reports/{reportId}/claim",new{})).StatusCode);
+ Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,$"/reports/{reportId}/claim",new{})).StatusCode);
+ var operationId=Guid.NewGuid(); var review=new {operationId,action="封禁",reason="核实违规后处置"};
+ Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,$"/reports/{reportId}/review",review)).StatusCode);
+ domain.Fail=true;
+ Assert.Equal(HttpStatusCode.Accepted,(await Mutate(reviewer,$"/reports/{reportId}/review",review)).StatusCode);
+ for(var attempt=0;attempt<3;attempt++) await Process(operationId);
+ var pending=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("处理中",pending.GetProperty("status").GetString());
+ var failed=await Data(await reviewer.GetAsync($"/api/admin/operations/{operationId}")); Assert.Equal("failed",failed.GetProperty("status").GetString());
+ Assert.Equal(0,domain.Applied);
+ domain.Fail=false; domain.LoseAcknowledgement=true;
+ Assert.Equal(HttpStatusCode.OK,(await Mutate(reviewer,$"/operations/{operationId}/retry",new{})).StatusCode);
+ await Process(operationId); Assert.Equal(1,domain.Applied);
+ pending=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("处理中",pending.GetProperty("status").GetString());
+ await Process(operationId); Assert.Equal(1,domain.Applied);
+ var closed=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("已处理",closed.GetProperty("status").GetString());
+ Assert.Equal(HttpStatusCode.Conflict,(await Mutate(reviewer,$"/reports/{reportId}/review",review)).StatusCode);
+ using(var scope=factory.Services.CreateScope()) {
+ var db=scope.ServiceProvider.GetRequiredService();
+ Assert.Equal(1,await db.Audit.CountAsync(x=>x.OperationId==operationId));
+ Assert.True(await db.Audit.AnyAsync(x=>x.Action=="查看举报证据"&&x.ReportId==reportId));
+ Assert.True(await db.Audit.AnyAsync(x=>x.Result.Contains("执行失败")));
+ }
+ var adminEdit=new {account="root_test",name="root_test",email="",password="",role="reviewer",enabled=false,reason="不允许停用最后超级管理员"};
+ Assert.Equal(HttpStatusCode.BadRequest,(await Mutate(root,$"/admins/{superId}",adminEdit,HttpMethod.Put)).StatusCode);
+ Assert.Equal(HttpStatusCode.OK,(await Mutate(root,$"/admins/{reviewerId}",new {account="review_test",name="review_test",email="",password="",role="reviewer",enabled=false,reason="停用测试账号"},HttpMethod.Put)).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized,(await reviewer.GetAsync("/api/admin/reports")).StatusCode);
+ Assert.Equal(HttpStatusCode.OK,(await Mutate(root,"/auth/password",new {currentPassword=Password,newPassword="New-integration-password!"})).StatusCode);
+ Assert.Equal(HttpStatusCode.Unauthorized,(await root.GetAsync("/api/admin/auth/me")).StatusCode);
+ }
+ sealed class Factory(string connection,DomainHandler handler) : WebApplicationFactory
+ {
+ protected override void ConfigureWebHost(IWebHostBuilder builder) {
+ builder.UseEnvironment("Development");
+ builder.UseSetting("ConnectionStrings:Admin",connection);
+ builder.UseSetting("Management:KeyRingPath",Path.Combine(Path.GetTempPath(),"im-admin-test-keys",Guid.NewGuid().ToString("N")));
+ builder.UseSetting("Management:CredentialKey",Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
+ builder.UseSetting("Management:InternalKey","integration-internal-key");
+ foreach(var name in new[]{"user","group","message","contact","file","connector","admin"}) builder.UseSetting($"Management:Services:{name}",$"http://{name}.test");
+ builder.ConfigureServices(services=> { services.RemoveAll(); services.AddHttpClient().ConfigurePrimaryHttpMessageHandler(()=>handler); });
+ }
+ }
+ sealed class DomainHandler : HttpMessageHandler
+ {
+ public Guid Target {get;}=Guid.NewGuid(); public bool Fail; public bool LoseAcknowledgement; public int Applied;
+ readonly Dictionary receipts=new();
+ protected override async Task SendAsync(HttpRequestMessage request,CancellationToken ct) {
+ if(request.RequestUri!.AbsolutePath.EndsWith("/evidence")) return Ok(new SubjectEvidence("测试对象",[]));
+ if(request.RequestUri.AbsolutePath.EndsWith("/action")) {
+ if(Fail) return new(HttpStatusCode.ServiceUnavailable);
+ var command=await request.Content!.ReadFromJsonAsync(cancellationToken:ct);
+ if(!receipts.TryGetValue(command!.Id,out var receipt)) { receipt=new("测试对象","正常","封禁"); receipts.Add(command.Id,receipt); Applied++; }
+ if(LoseAcknowledgement) { LoseAcknowledgement=false; throw new HttpRequestException("Injected response loss after commit"); }
+ return Ok(receipt);
+ }
+ return Ok(new {total=1});
+ }
+ static HttpResponseMessage Ok(object value)=>new(HttpStatusCode.OK) {Content=JsonContent.Create(value)};
+ }
+}
diff --git a/Admin.Tests/PolicyTests.cs b/Admin.Tests/PolicyTests.cs
new file mode 100644
index 0000000..77934d1
--- /dev/null
+++ b/Admin.Tests/PolicyTests.cs
@@ -0,0 +1,49 @@
+using IM.Admin.Api;
+using IM.Admin.Data;
+using IM.Admin.Services;
+using IM.InitCommon.Management;
+using FileService.Infrastructure.Storage;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.DependencyInjection;
+using System.Text.Json.Nodes;
+using Xunit;
+
+namespace IM.Admin.Tests;
+public class PolicyTests
+{
+ static readonly IConfiguration Config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Management:CredentialKey"] = Convert.ToBase64String(new byte[32]) }).Build();
+ [Fact]
+ public void Defaults_preserve_unlimited_legacy_rules_and_explicit_report_limits()
+ {
+ foreach (var id in SettingsService.Fields.Keys) SettingsService.Validate(id, SettingsService.Defaults(id));
+ var p = new Policy(); Assert.True(p.RegistrationEnabled); Assert.Equal(0,p.FriendLimit); Assert.Equal(0,p.UploadMaxBytes); Assert.Equal(0,p.RecallMinutes); Assert.Equal(20,p.ReportsPerDay); Assert.Equal(10,p.ReportCooldownMinutes);
+ }
+ [Fact]
+ public void Validation_rejects_unknown_fields_negative_limits_and_malformed_extensions()
+ {
+ var p = SettingsService.Defaults("social"); p["friendLimit"] = -1; Assert.Throws(() => SettingsService.Validate("social",p));
+ p = SettingsService.Defaults("account"); p["password"] = "must not be persisted"; Assert.Throws(() => SettingsService.Validate("account",p));
+ p = SettingsService.Defaults("messaging"); p["allowedFileTypes"] = new JsonArray("*.exe"); Assert.Throws(() => SettingsService.Validate("messaging",p));
+ }
+ [Fact]
+ public void Credentials_are_authenticated_encrypted_and_require_the_original_deployment_key()
+ {
+ using var db = new AdminDb(new DbContextOptionsBuilder().Options);
+ var service = new SettingsService(db,new EphemeralDataProtectionProvider(),Config);
+ var one = service.Protect("private-secret"); var two = service.Protect("private-secret");
+ Assert.NotEqual(one,two); Assert.DoesNotContain("private-secret",one); Assert.Equal("private-secret",service.Unprotect(one));
+ var wrong = new SettingsService(db,new EphemeralDataProtectionProvider(),new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Management:CredentialKey"] = Convert.ToBase64String(Enumerable.Repeat((byte)1,32).ToArray()) }).Build());
+ Assert.ThrowsAny(() => wrong.Unprotect(one));
+ }
+ [Fact]
+ public void Local_storage_paths_cannot_escape_the_managed_root()
+ {
+ var root = Path.Combine(Path.GetTempPath(),"im-storage-test");
+ Assert.StartsWith(Path.GetFullPath(root),LocalStorageAdapter.SafePath(root,"private","2026/file.txt"));
+ Assert.Throws(() => LocalStorageAdapter.SafePath(root,"..","outside.txt"));
+ Assert.Throws(() => LocalStorageAdapter.SafePath(root,Path.GetFullPath(Path.Combine(root,"..","outside.txt"))));
+ }
+}
diff --git a/Admin.WebApi/Admin.WebApi.csproj b/Admin.WebApi/Admin.WebApi.csproj
new file mode 100644
index 0000000..b66609d
--- /dev/null
+++ b/Admin.WebApi/Admin.WebApi.csproj
@@ -0,0 +1,8 @@
+
+ net8.0enableenable
+
+
+
+ all
+
+
diff --git a/Admin.WebApi/Api/ApiSupport.cs b/Admin.WebApi/Api/ApiSupport.cs
new file mode 100644
index 0000000..fa66a4d
--- /dev/null
+++ b/Admin.WebApi/Api/ApiSupport.cs
@@ -0,0 +1,19 @@
+using System.Security.Claims;
+using IM.Admin.Data;
+using IM.InitCommon.Management;
+using Microsoft.EntityFrameworkCore;
+
+namespace IM.Admin.Api;
+
+public sealed class ApiError(int status, string message) : Exception(message) { public int Status { get; } = status; }
+public static class ApiSupport
+{
+ public static Guid Actor(this HttpContext c) => Guid.Parse(c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);
+ public static string ActorName(this HttpContext c) => c.User.Identity?.Name ?? "";
+ public static bool IsSuper(this HttpContext c) => c.User.IsInRole("super");
+ public static void Reason(string? reason) { if (string.IsNullOrWhiteSpace(reason) || reason.Length > 500) throw new ApiError(400, "请填写 1–500 字的操作原因"); }
+ public static void Audit(this AdminDb db, HttpContext c, string action, string target, string before, string after, string reason, Guid? reportId = null)
+ => db.Audit.Add(new AuditRecord { ActorId = c.Actor(), ActorName = c.ActorName(), Action = action, TargetId = target, TargetName = target, Before = before, After = after, Reason = reason, ReportId = reportId });
+ public static async Task> Page(IQueryable query, int page, int size, CancellationToken ct)
+ { page = Math.Max(1, page); size = Math.Clamp(size, 1, 100); return new(await query.Skip((page - 1) * size).Take(size).ToListAsync(ct), await query.CountAsync(ct), page, size); }
+}
diff --git a/Admin.WebApi/Api/AuthEndpoints.cs b/Admin.WebApi/Api/AuthEndpoints.cs
new file mode 100644
index 0000000..056f1f3
--- /dev/null
+++ b/Admin.WebApi/Api/AuthEndpoints.cs
@@ -0,0 +1,101 @@
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using IM.Admin.Data;
+using IM.Admin.Services;
+using IM.InitCommon.Management;
+using Microsoft.AspNetCore.Antiforgery;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.EntityFrameworkCore;
+
+namespace IM.Admin.Api;
+public record LoginInput(string Account, string Password);
+public record PasswordInput(string CurrentPassword, string NewPassword);
+public record ResetRequest(string Account);
+public record ResetInput(string Token, string Password);
+public record AccountInput(string Account, string Name, string Email, string Password, string Role, bool Enabled, string Reason);
+public static class AuthEndpoints
+{
+ public static object Public(AdminAccount a) => new { a.Id, a.Account, a.Name, a.Email, a.Role, status = a.Enabled ? "启用" : "停用", a.CreatedAt };
+ public static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
+ public static void CheckPassword(string p) { if (p.Length is < 12 or > 128) throw new ApiError(400, "管理员密码长度应为 12–128 位"); }
+ public static void MapAuth(this WebApplication app)
+ {
+ var api = app.MapGroup("/api/admin/auth");
+ api.MapGet("/csrf", (HttpContext c, IAntiforgery af) => ManagementResult.Ok(new { token = af.GetAndStoreTokens(c).RequestToken }));
+ api.MapPost("/login", async (LoginInput input, AdminDb db, IPasswordHasher hasher, SettingsService settings, HttpContext c) => {
+ if (string.IsNullOrWhiteSpace(input.Account) || input.Account.Length > 100 || string.IsNullOrEmpty(input.Password) || input.Password.Length > 128) throw new ApiError(400, "账号或密码格式不正确");
+ var name = input.Account.Trim().ToLowerInvariant();
+ await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
+ var a = await db.Accounts.FromSqlInterpolated($"SELECT * FROM admin_accounts WHERE Account = {name} FOR UPDATE").SingleOrDefaultAsync();
+ var policy = await settings.Policy();
+ if (a is null || !a.Enabled || a.LockedUntil > DateTime.UtcNow) throw new ApiError(401, "账号或密码错误,或账号暂不可用");
+ if (hasher.VerifyHashedPassword(a, a.PasswordHash, input.Password) == PasswordVerificationResult.Failed) {
+ a.FailedAttempts++; if (a.FailedAttempts >= policy.AdminLockThreshold) a.LockedUntil = DateTime.UtcNow.AddMinutes(policy.AdminLockMinutes);
+ db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "后台登录", TargetId = a.Id.ToString(), Result = "失败", Reason = "密码验证未通过", After = a.LockedUntil.HasValue ? "临时锁定" : "登录失败" });
+ await db.SaveChangesAsync(); await tx.CommitAsync(); throw new ApiError(401, "账号或密码错误,或账号暂不可用");
+ }
+ a.FailedAttempts = 0; a.LockedUntil = null;
+ db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "后台登录", TargetId = a.Id.ToString(), Reason = "独立管理员登录", After = "登录成功" });
+ await db.SaveChangesAsync(); await tx.CommitAsync();
+ var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, a.Id.ToString()), new Claim(ClaimTypes.Name, a.Name), new Claim(ClaimTypes.Role, a.Role), new Claim("stamp", a.Stamp)], "Admin"));
+ await c.SignInAsync("Admin", principal, new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(policy.AdminSessionMinutes) });
+ return ManagementResult.Ok(Public(a));
+ }).RequireRateLimiting("login");
+ api.MapGet("/me", async (HttpContext c, AdminDb db) => ManagementResult.Ok(Public(await db.Accounts.SingleAsync(x => x.Id == c.Actor())))).RequireAuthorization();
+ api.MapPost("/logout", async (HttpContext c) => { await c.SignOutAsync("Admin"); return ManagementResult.Ok(); }).RequireAuthorization();
+ api.MapPost("/password", async (PasswordInput input, HttpContext c, AdminDb db, IPasswordHasher hasher) => {
+ CheckPassword(input.NewPassword); var a = await db.Accounts.SingleAsync(x => x.Id == c.Actor());
+ if (hasher.VerifyHashedPassword(a, a.PasswordHash, input.CurrentPassword) == PasswordVerificationResult.Failed) throw new ApiError(400, "当前密码不正确");
+ a.PasswordHash = hasher.HashPassword(a, input.NewPassword); a.Stamp = Guid.NewGuid().ToString("N");
+ db.Audit(c, "修改密码", a.Id.ToString(), "", "已更新", "管理员修改本人密码"); await db.SaveChangesAsync(); await c.SignOutAsync("Admin"); return ManagementResult.Ok();
+ }).RequireAuthorization();
+ api.MapPost("/forgot", async (ResetRequest input, AdminDb db, InfrastructureService mail, IConfiguration config) => {
+ var a = await db.Accounts.SingleOrDefaultAsync(x => x.Account == input.Account.Trim().ToLowerInvariant() && x.Enabled);
+ if (a is not null && !string.IsNullOrWhiteSpace(a.Email) && await mail.MailEnabled()) {
+ var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
+ db.Resets.Add(new PasswordReset { Id = Hash(token), AccountId = a.Id, ExpiresAt = DateTime.UtcNow.AddMinutes(20) }); await db.SaveChangesAsync();
+ var origin = config["Management:AdminPublicUrl"] ?? throw new ApiError(503, "未配置后台访问地址");
+ await mail.Send(a.Email, "IM 后台密码重置", $"请在 20 分钟内打开以下地址重置密码:{origin.TrimEnd('/')}/#/reset-password?token={token}\n如果不是你发起的请求,请忽略此邮件。");
+ }
+ return ManagementResult.Ok(new { message = "如果账号可用且已配置邮件,将收到重置说明。" });
+ }).RequireRateLimiting("login");
+ api.MapPost("/reset-password", async (ResetInput input, AdminDb db, IPasswordHasher hasher) => {
+ CheckPassword(input.Password); await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
+ var reset = await db.Resets.SingleOrDefaultAsync(x => x.Id == Hash(input.Token) && x.ExpiresAt > DateTime.UtcNow);
+ if (reset is null) throw new ApiError(400, "链接无效或已过期");
+ var a = await db.Accounts.SingleAsync(x => x.Id == reset.AccountId); if (!a.Enabled) throw new ApiError(400, "账号已停用");
+ a.PasswordHash = hasher.HashPassword(a, input.Password); a.Stamp = Guid.NewGuid().ToString("N"); a.FailedAttempts = 0; a.LockedUntil = null;
+ db.Resets.RemoveRange(await db.Resets.Where(x => x.AccountId == a.Id).ToListAsync()); db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "重置密码", TargetId = a.Id.ToString(), After = "已更新", Reason = "通过邮件重置密码" });
+ await db.SaveChangesAsync(); await tx.CommitAsync(); return ManagementResult.Ok();
+ }).RequireRateLimiting("login");
+ var accounts = app.MapGroup("/api/admin/admins").RequireAuthorization("super");
+ accounts.MapGet("", async (AdminDb db, string? q, string? status, string? role, int? page, int? size, CancellationToken ct) => {
+ var query = db.Accounts.AsNoTracking().Where(x => q == null || x.Account.Contains(q) || x.Name.Contains(q));
+ if (!string.IsNullOrEmpty(status)) query = query.Where(x => x.Enabled == (status == "启用"));
+ if (!string.IsNullOrEmpty(role)) query = query.Where(x => x.Role == role);
+ var result = await ApiSupport.Page(query.OrderBy(x => x.CreatedAt).Select(x => new { x.Id, x.Name, x.Account, x.Email, x.Role, status = x.Enabled ? "启用" : "停用", x.CreatedAt }), page ?? 1, size ?? 8, ct);
+ return ManagementResult.Ok(result);
+ });
+ accounts.MapPost("", async (AccountInput input, AdminDb db, HttpContext c, IPasswordHasher hasher) => {
+ Validate(input); CheckPassword(input.Password); var account = input.Account.Trim().ToLowerInvariant();
+ if (await db.Accounts.AnyAsync(x => x.Account == account)) throw new ApiError(409, "管理员账号已存在");
+ var a = new AdminAccount { Account = account, Name = input.Name.Trim(), Email = input.Email.Trim(), Role = input.Role, Enabled = input.Enabled }; a.PasswordHash = hasher.HashPassword(a, input.Password);
+ db.Accounts.Add(a); db.Audit(c, "创建管理员", a.Id.ToString(), "", input.Role, input.Reason); await db.SaveChangesAsync(); return ManagementResult.Ok(Public(a));
+ });
+ accounts.MapPut("/{id:guid}", async (Guid id, AccountInput input, AdminDb db, HttpContext c) => {
+ Validate(input); await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
+ var a = await db.Accounts.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "管理员不存在");
+ if (a.Id == c.Actor() && (input.Role != a.Role || !input.Enabled)) throw new ApiError(400, "不能停用或变更自己的角色");
+ if (a.Role == "super" && a.Enabled && (input.Role != "super" || !input.Enabled) && await db.Accounts.CountAsync(x => x.Role == "super" && x.Enabled) <= 1) throw new ApiError(400, "必须保留一名启用的超级管理员");
+ var before = $"{a.Role}/{a.Enabled}"; a.Name = input.Name.Trim(); a.Email = input.Email.Trim(); a.Role = input.Role; a.Enabled = input.Enabled; a.Stamp = Guid.NewGuid().ToString("N");
+ db.Audit(c, "修改管理员", id.ToString(), before, $"{a.Role}/{a.Enabled}", input.Reason); await db.SaveChangesAsync(); await tx.CommitAsync(); return ManagementResult.Ok(Public(a));
+ });
+ }
+ static void Validate(AccountInput i) {
+ ApiSupport.Reason(i.Reason);
+ if (i.Account.Length is < 3 or > 100 || string.IsNullOrWhiteSpace(i.Name) || i.Name.Length > 50 || !new[] { "super", "operator", "reviewer" }.Contains(i.Role)) throw new ApiError(400, "管理员资料不正确");
+ if (!string.IsNullOrEmpty(i.Email) && !System.Net.Mail.MailAddress.TryCreate(i.Email, out _)) throw new ApiError(400, "邮箱格式不正确");
+ }
+}
diff --git a/Admin.WebApi/Api/BusinessEndpoints.cs b/Admin.WebApi/Api/BusinessEndpoints.cs
new file mode 100644
index 0000000..d31cc5a
--- /dev/null
+++ b/Admin.WebApi/Api/BusinessEndpoints.cs
@@ -0,0 +1,105 @@
+using System.Data;
+using System.Text.Json;
+using IM.Admin.Data;
+using IM.Admin.Services;
+using IM.InitCommon.Management;
+using Microsoft.EntityFrameworkCore;
+
+namespace IM.Admin.Api;
+public record SubmitReport(Guid ReporterId, string Type, Guid TargetId, string Reason, string Description, Guid[] MessageIds);
+public record ReviewInput(Guid OperationId, string Action, string Reason);
+public static class BusinessEndpoints
+{
+ public static void MapAdminBusiness(this WebApplication app)
+ {
+ var api = app.MapGroup("/api/admin").RequireAuthorization();
+ api.MapGet("/dashboard", async (AdminDb db, InternalClient client) => {
+ var users = client.Send("user", "/internal/management/summary");
+ var groups = client.Send("group", "/internal/management/summary");
+ await Task.WhenAll(users, groups);
+ return ManagementResult.Ok(new { users = users.Result.GetProperty("total").GetInt32(), groups = groups.Result.GetProperty("total").GetInt32(), pending = await db.Reports.CountAsync(x => x.Status == "待处理"), disposals = await db.Operations.CountAsync(x => x.Status == "completed" && x.CompletedAt >= DateTime.UtcNow.Date) });
+ });
+ foreach (var resource in new[] { "users", "groups" }) {
+ var service = resource == "users" ? "user" : "group";
+ api.MapGet($"/{resource}", async (HttpContext c, InternalClient client) => ManagementResult.Ok(await client.Send(service, "/internal/management/list" + c.Request.QueryString)));
+ api.MapGet($"/{resource}/{{id:guid}}", async (Guid id, HttpContext c, InternalClient client, AdminDb db) => {
+ var item = await client.Send(service, $"/internal/management/detail/{id}");
+ var reports = await db.Reports.AsNoTracking().Where(x => x.TargetId == id).OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.Reason, x.Status, x.Result, x.CreatedAt }).Take(100).ToListAsync();
+ var logs = c.User.IsInRole("reviewer") ? [] : await db.Audit.AsNoTracking().Where(x => x.TargetId == id.ToString()).OrderByDescending(x => x.CreatedAt).Take(100).ToListAsync();
+ return ManagementResult.Ok(new { item, reports, logs });
+ });
+ api.MapPost($"/{resource}/{{id:guid}}/actions", async (Guid id, ReviewInput input, AdminDb db, HttpContext c) => {
+ if (input.Action is not "封禁" and not "解封") throw new ApiError(400, "操作不受支持");
+ return await Enqueue(db, c, service, id, null, input);
+ }).RequireAuthorization("operate");
+ }
+ api.MapGet("/reports", async (AdminDb db, string? q, string? status, string? type, int? page, int? size, CancellationToken ct) => {
+ var query = db.Reports.AsNoTracking().Where(x => q == null || x.TargetName.Contains(q) || x.Reason.Contains(q) || x.Id.ToString() == q);
+ if (!string.IsNullOrEmpty(status)) query = query.Where(x => x.Status == status);
+ if (!string.IsNullOrEmpty(type)) query = query.Where(x => x.Type == type);
+ return ManagementResult.Ok(await ApiSupport.Page(query.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.TargetId, x.TargetName, x.Type, x.Reason, x.Status, x.AssigneeId, x.CreatedAt, x.Result, x.Version }), page ?? 1, size ?? 8, ct));
+ });
+ api.MapGet("/reports/{id:guid}", async (Guid id, AdminDb db, HttpContext c) => {
+ var r = await db.Reports.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "举报不存在");
+ db.Audit(c, "查看举报证据", id.ToString(), "", "已访问", "审核证据读取", id); await db.SaveChangesAsync();
+ var operations = await db.Operations.AsNoTracking().Where(x => x.ReportId == id).OrderByDescending(x => x.CreatedAt).ToListAsync();
+ var history = await db.Reports.AsNoTracking().Where(x => x.TargetId == r.TargetId && x.Id != id && x.ClosedAt != null).OrderByDescending(x => x.ClosedAt).Select(x => new { x.Id, x.Result, x.Status, x.ClosedAt }).Take(30).ToListAsync();
+ var name = r.AssigneeId is null ? null : await db.Accounts.Where(x => x.Id == r.AssigneeId).Select(x => x.Name).SingleOrDefaultAsync();
+ return ManagementResult.Ok(new { r.Id, r.Type, r.TargetId, r.TargetName, r.ReporterId, r.Reason, r.Description, r.Status, r.AssigneeId, assigneeName = name, r.CreatedAt, r.ClosedAt, r.Result, r.Version, evidence = JsonSerializer.Deserialize(r.Evidence), operations, history });
+ });
+ api.MapPost("/reports/{id:guid}/claim", async (Guid id, AdminDb db, HttpContext c) => {
+ var r = await db.Reports.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "举报不存在");
+ if (r.Status != "待处理") throw new ApiError(409, "举报已领取或已结案");
+ r.Status = "处理中"; r.AssigneeId = c.Actor(); r.Version++;
+ db.Audit(c, "领取举报", id.ToString(), "待处理", r.Status, "领取并核实举报", id); await db.SaveChangesAsync(); return ManagementResult.Ok();
+ });
+ api.MapPost("/reports/{id:guid}/review", async (Guid id, ReviewInput input, AdminDb db, HttpContext c) => {
+ if (input.Action is not "警告" and not "驳回" and not "封禁") throw new ApiError(400, "操作不受支持");
+ await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
+ var r = await db.Reports.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "举报不存在");
+ if (r.Status != "处理中" || r.AssigneeId != c.Actor()) throw new ApiError(409, "仅能处置本人领取且尚未结案的举报");
+ if (await db.Operations.AnyAsync(x => x.ReportId == id && x.Status != "completed" && x.Id != input.OperationId)) throw new ApiError(409, "处置正在执行,请等待或重试原任务");
+ var result = await Enqueue(db, c, r.Type, r.TargetId, id, input); await tx.CommitAsync(); return result;
+ });
+ api.MapGet("/operations/{id:guid}", async (Guid id, AdminDb db, HttpContext c) => {
+ var op = await db.Operations.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "任务不存在");
+ if (c.User.IsInRole("reviewer") && op.ActorId != c.Actor()) throw new ApiError(403, "没有此任务权限"); return ManagementResult.Ok(op);
+ });
+ api.MapPost("/operations/{id:guid}/retry", async (Guid id, AdminDb db, HttpContext c) => {
+ var op = await db.Operations.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "任务不存在");
+ if (c.User.IsInRole("reviewer") && op.ActorId != c.Actor()) throw new ApiError(403, "没有此任务权限");
+ if (op.Status != "failed") throw new ApiError(409, "仅失败任务可重试");
+ op.Status = "pending"; op.Attempts = 0; op.Error = null; op.NextAttemptAt = DateTime.UtcNow; await db.SaveChangesAsync(); return ManagementResult.Ok(op);
+ });
+ api.MapGet("/logs", async (AdminDb db, string? q, Guid? actor, string? action, DateTime? from, DateTime? to, int? page, int? size, CancellationToken ct) => {
+ var query = db.Audit.AsNoTracking().Where(x => q == null || x.TargetId.Contains(q) || x.TargetName.Contains(q) || x.Reason.Contains(q));
+ if (actor.HasValue) query = query.Where(x => x.ActorId == actor);
+ if (!string.IsNullOrEmpty(action)) query = query.Where(x => x.Action == action);
+ if (from.HasValue) query = query.Where(x => x.CreatedAt >= from.Value);
+ if (to.HasValue) { var end = to.Value.Date.AddDays(1); query = query.Where(x => x.CreatedAt < end); }
+ return ManagementResult.Ok(await ApiSupport.Page(query.OrderByDescending(x => x.CreatedAt), page ?? 1, size ?? 8, ct));
+ }).RequireAuthorization("operate");
+ app.MapPost("/internal/management/reports", async (SubmitReport input, AdminDb db, SettingsService settings, InternalClient client) => {
+ if (input.Type is not "user" and not "group" || input.Description.Length > 1000 || input.MessageIds.Length > 20) throw new ApiError(400, "举报参数不正确");
+ var policy = await settings.Policy(); if (!policy.ReportCategories.Contains(input.Reason)) throw new ApiError(400, "请选择有效举报分类");
+ var verified = await client.Send("message", "/internal/management/evidence", new EvidenceRequest(input.ReporterId, input.Type, input.TargetId, input.MessageIds));
+ await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
+ if (await db.Reports.CountAsync(x => x.ReporterId == input.ReporterId && x.CreatedAt >= DateTime.UtcNow.Date) >= policy.ReportsPerDay) throw new ApiError(429, "已达到今日举报上限");
+ var cutoff = DateTime.UtcNow.AddMinutes(-policy.ReportCooldownMinutes);
+ if (await db.Reports.AnyAsync(x => x.ReporterId == input.ReporterId && x.TargetId == input.TargetId && x.Type == input.Type && x.CreatedAt > cutoff)) throw new ApiError(429, "请勿重复举报同一对象");
+ var r = new Report { ReporterId = input.ReporterId, TargetId = input.TargetId, TargetName = verified.TargetName, Type = input.Type, Reason = input.Reason, Description = input.Description, Evidence = JsonSerializer.Serialize(verified.Evidence, SettingsService.Json) };
+ db.Reports.Add(r); await db.SaveChangesAsync(); await tx.CommitAsync(); return new { r.Id };
+ });
+ }
+ static async Task Enqueue(AdminDb db, HttpContext c, string type, Guid target, Guid? report, ReviewInput input)
+ {
+ ApiSupport.Reason(input.Reason); if (input.OperationId == Guid.Empty) throw new ApiError(400, "缺少操作 ID");
+ var existing = await db.Operations.FindAsync(input.OperationId);
+ if (existing is not null) {
+ if (existing.ActorId != c.Actor() || existing.TargetId != target || existing.Action != input.Action || existing.ReportId != report || existing.Reason != input.Reason) throw new ApiError(409, "操作 ID 与已有请求冲突");
+ return Results.Json(ManagementResult.Ok(existing), statusCode: 202);
+ }
+ var op = new Operation { Id = input.OperationId, ActorId = c.Actor(), ActorName = c.ActorName(), TargetId = target, Type = type, Action = input.Action, Reason = input.Reason, ReportId = report };
+ db.Operations.Add(op); await db.SaveChangesAsync(); return Results.Json(ManagementResult.Ok(op), statusCode: 202);
+ }
+}
diff --git a/Admin.WebApi/Api/MonitoringEndpoints.cs b/Admin.WebApi/Api/MonitoringEndpoints.cs
new file mode 100644
index 0000000..452e293
--- /dev/null
+++ b/Admin.WebApi/Api/MonitoringEndpoints.cs
@@ -0,0 +1,19 @@
+using System.Text.Json;
+using IM.Admin.Services;
+using IM.InitCommon.Management;
+
+namespace IM.Admin.Api;
+public static class MonitoringEndpoints
+{
+ public static void MapMonitoring(this WebApplication app)
+ {
+ var api = app.MapGroup("/api/admin").RequireAuthorization("operate");
+ api.MapGet("/health", async (HealthSampler sampler, InternalClient client, CancellationToken ct) => {
+ var health = await sampler.Read(ct); JsonElement? connections = null;
+ try { connections = await client.Send("connector", "/internal/management/connections", ct: ct); } catch { }
+ return ManagementResult.Ok(new { services = health, connections, sampledAt = DateTime.UtcNow });
+ });
+ api.MapGet("/storage", async (HttpContext c, InternalClient client) => ManagementResult.Ok(await client.Send("file", "/internal/management/storage/summary" + c.Request.QueryString, ct: c.RequestAborted)));
+ app.MapGet("/api/admin/groups/{id:guid}/members", async (Guid id, HttpContext c, InternalClient client) => ManagementResult.Ok(await client.Send("group", $"/internal/management/members/{id}" + c.Request.QueryString, ct: c.RequestAborted))).RequireAuthorization();
+ }
+}
diff --git a/Admin.WebApi/Api/SettingsEndpoints.cs b/Admin.WebApi/Api/SettingsEndpoints.cs
new file mode 100644
index 0000000..f79947a
--- /dev/null
+++ b/Admin.WebApi/Api/SettingsEndpoints.cs
@@ -0,0 +1,28 @@
+using IM.Admin.Data;
+using IM.Admin.Services;
+using IM.InitCommon.Management;
+using Microsoft.EntityFrameworkCore;
+using System.Text.Json.Nodes;
+
+namespace IM.Admin.Api;
+public static class SettingsEndpoints
+{
+ public static void MapSettings(this WebApplication app)
+ {
+ app.MapGet("/api/platform", async (SettingsService s) => { var p = await s.Policy(); return ManagementResult.Ok(new { p.PlatformName, p.Description, p.SupportEmail, p.RegistrationEnabled, p.PasswordMinLength, p.ReportCategories }); });
+ app.MapGet("/internal/management/policy", async (SettingsService s) => await s.Policy());
+ app.MapGet("/internal/management/infrastructure/{id}", async (string id, AdminDb db, SettingsService s) => {
+ if (id != "storage") throw new ApiError(404, "不存在");
+ var row = await db.Settings.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id);
+ return new { version = row?.Version ?? 0, value = JsonNode.Parse(row?.Value ?? "{}"), secret = s.Unprotect(row?.Secret) };
+ });
+ var api = app.MapGroup("/api/admin/settings").RequireAuthorization("super");
+ api.MapGet("", async (SettingsService s) => ManagementResult.Ok(await s.List()));
+ api.MapPut("/{id}", async (string id, SettingInput input, SettingsService s, HttpContext c) => { await s.Save(id, input, c); return ManagementResult.Ok(await s.List()); });
+ api.MapPost("/{id}/defaults", async (string id, SettingInput input, SettingsService s, HttpContext c) => { await s.Save(id, input with { Value = SettingsService.Defaults(id) }, c); return ManagementResult.Ok(await s.List()); });
+ api.MapPost("/{id}/draft", async (string id, SettingInput input, InfrastructureService s, HttpContext c) => { await s.Draft(id, input, c); return ManagementResult.Ok(); });
+ api.MapPost("/{id}/test", async (string id, InfraTest input, InfrastructureService s, HttpContext c) => { await s.Test(id, input, c); return ManagementResult.Ok(); });
+ api.MapPost("/{id}/activate", async (string id, SettingInput input, InfrastructureService s, HttpContext c) => { await s.Activate(id, input, c); return ManagementResult.Ok(); });
+ }
+}
+public record InfraTest(long Version, string? Recipient);
diff --git a/Admin.WebApi/Data/AdminDb.cs b/Admin.WebApi/Data/AdminDb.cs
new file mode 100644
index 0000000..6a48e46
--- /dev/null
+++ b/Admin.WebApi/Data/AdminDb.cs
@@ -0,0 +1,112 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace IM.Admin.Data;
+
+public sealed class AdminAccount
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public string Account { get; set; } = "";
+ public string Name { get; set; } = "";
+ public string Email { get; set; } = "";
+ public string PasswordHash { get; set; } = "";
+ public string Role { get; set; } = "reviewer";
+ public bool Enabled { get; set; } = true;
+ public string Stamp { get; set; } = Guid.NewGuid().ToString("N");
+ public int FailedAttempts { get; set; }
+ public DateTime? LockedUntil { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
+public sealed class SystemSetting
+{
+ public string Id { get; set; } = "";
+ public long Version { get; set; }
+ public string Value { get; set; } = "{}";
+ public string? Draft { get; set; }
+ public long? TestedVersion { get; set; }
+ public string Secret { get; set; } = "";
+ public string? DraftSecret { get; set; }
+ public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
+}
+public sealed class Report
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ReporterId { get; set; }
+ public Guid TargetId { get; set; }
+ public string TargetName { get; set; } = "";
+ public string Type { get; set; } = "user";
+ public string Reason { get; set; } = "";
+ public string Description { get; set; } = "";
+ public string Evidence { get; set; } = "[]";
+ public string Status { get; set; } = "待处理";
+ public Guid? AssigneeId { get; set; }
+ public string? Result { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime? ClosedAt { get; set; }
+ public long Version { get; set; }
+}
+// A durable outbox command: both the request and the pending report update commit together.
+public sealed class Operation
+{
+ public Guid Id { get; set; }
+ public Guid ActorId { get; set; }
+ public string ActorName { get; set; } = "";
+ public Guid TargetId { get; set; }
+ public string Type { get; set; } = "";
+ public string Action { get; set; } = "";
+ public string Reason { get; set; } = "";
+ public Guid? ReportId { get; set; }
+ public string Status { get; set; } = "pending";
+ public int Attempts { get; set; }
+ public string? Error { get; set; }
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+ public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow;
+ public DateTime? CompletedAt { get; set; }
+}
+public sealed class AuditRecord
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public Guid ActorId { get; set; }
+ public string ActorName { get; set; } = "";
+ public string Action { get; set; } = "";
+ public string TargetId { get; set; } = "";
+ public string TargetName { get; set; } = "";
+ public string Before { get; set; } = "";
+ public string After { get; set; } = "";
+ public string Reason { get; set; } = "";
+ public Guid? ReportId { get; set; }
+ public Guid? OperationId { get; set; }
+ public string Result { get; set; } = "成功";
+ public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+}
+public sealed class PasswordReset
+{
+ public string Id { get; set; } = "";
+ public Guid AccountId { get; set; }
+ public DateTime ExpiresAt { get; set; }
+}
+public sealed class AdminDb(DbContextOptions options) : DbContext(options)
+{
+ public DbSet Accounts => Set();
+ public DbSet Settings => Set();
+ public DbSet Reports => Set();
+ public DbSet Operations => Set();
+ public DbSet Audit => Set();
+ public DbSet Resets => Set();
+ protected override void OnModelCreating(ModelBuilder b)
+ {
+ b.Entity().ToTable("admin_accounts").HasIndex(x => x.Account).IsUnique();
+ b.Entity().Property(x => x.Account).HasMaxLength(100);
+ b.Entity().Property(x => x.Stamp).IsConcurrencyToken();
+ b.Entity().ToTable("admin_settings").Property(x => x.Id).HasMaxLength(64);
+ b.Entity().Property(x => x.Version).IsConcurrencyToken();
+ b.Entity().ToTable("admin_reports").HasIndex(x => new { x.ReporterId, x.CreatedAt });
+ b.Entity().HasIndex(x => new { x.Status, x.CreatedAt });
+ b.Entity().Property(x => x.Status).HasMaxLength(30);
+ b.Entity().Property(x => x.Version).IsConcurrencyToken();
+ b.Entity().ToTable("admin_operations").HasIndex(x => new { x.Status, x.NextAttemptAt });
+ b.Entity().Property(x => x.Status).HasMaxLength(30);
+ b.Entity().ToTable("admin_audit").HasIndex(x => x.CreatedAt);
+ b.Entity().HasIndex(x => x.OperationId).IsUnique();
+ b.Entity().ToTable("admin_password_resets").Property(x => x.Id).HasMaxLength(64);
+ }
+}
diff --git a/Admin.WebApi/Data/DesignTimeFactory.cs b/Admin.WebApi/Data/DesignTimeFactory.cs
new file mode 100644
index 0000000..bfe55fa
--- /dev/null
+++ b/Admin.WebApi/Data/DesignTimeFactory.cs
@@ -0,0 +1,9 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+
+namespace IM.Admin.Data;
+public sealed class DesignTimeFactory : IDesignTimeDbContextFactory
+{
+ public AdminDb CreateDbContext(string[] args) => new(new DbContextOptionsBuilder()
+ .UseMySql("Server=localhost;Database=im_admin;User=migration;Password=design-time-only", new MySqlServerVersion(new Version(8, 0, 0))).Options);
+}
diff --git a/Admin.WebApi/Data/Migrations/20260915004233_InitialAdmin.Designer.cs b/Admin.WebApi/Data/Migrations/20260915004233_InitialAdmin.Designer.cs
new file mode 100644
index 0000000..6f60722
--- /dev/null
+++ b/Admin.WebApi/Data/Migrations/20260915004233_InitialAdmin.Designer.cs
@@ -0,0 +1,314 @@
+//
+using System;
+using IM.Admin.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Admin.WebApi.Data.Migrations
+{
+ [DbContext(typeof(AdminDb))]
+ [Migration("20260915004233_InitialAdmin")]
+ partial class InitialAdmin
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("IM.Admin.Data.AdminAccount", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Account")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("FailedAttempts")
+ .HasColumnType("int");
+
+ b.Property("LockedUntil")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Stamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Account")
+ .IsUnique();
+
+ b.ToTable("admin_accounts", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.AuditRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ActorId")
+ .HasColumnType("char(36)");
+
+ b.Property("ActorName")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("After")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Before")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("OperationId")
+ .HasColumnType("char(36)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ReportId")
+ .HasColumnType("char(36)");
+
+ b.Property("Result")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("TargetId")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("TargetName")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("OperationId")
+ .IsUnique();
+
+ b.ToTable("admin_audit", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.Operation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ActorId")
+ .HasColumnType("char(36)");
+
+ b.Property("ActorName")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Attempts")
+ .HasColumnType("int");
+
+ b.Property("CompletedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Error")
+ .HasColumnType("longtext");
+
+ b.Property("NextAttemptAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ReportId")
+ .HasColumnType("char(36)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("TargetId")
+ .HasColumnType("char(36)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status", "NextAttemptAt");
+
+ b.ToTable("admin_operations", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.PasswordReset", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("AccountId")
+ .HasColumnType("char(36)");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.ToTable("admin_password_resets", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AssigneeId")
+ .HasColumnType("char(36)");
+
+ b.Property("ClosedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Evidence")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ReporterId")
+ .HasColumnType("char(36)");
+
+ b.Property("Result")
+ .HasColumnType("longtext");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("TargetId")
+ .HasColumnType("char(36)");
+
+ b.Property("TargetName")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReporterId", "CreatedAt");
+
+ b.HasIndex("Status", "CreatedAt");
+
+ b.ToTable("admin_reports", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.SystemSetting", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("Draft")
+ .HasColumnType("longtext");
+
+ b.Property("DraftSecret")
+ .HasColumnType("longtext");
+
+ b.Property("Secret")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("TestedVersion")
+ .HasColumnType("bigint");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.ToTable("admin_settings", (string)null);
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Admin.WebApi/Data/Migrations/20260915004233_InitialAdmin.cs b/Admin.WebApi/Data/Migrations/20260915004233_InitialAdmin.cs
new file mode 100644
index 0000000..22d6432
--- /dev/null
+++ b/Admin.WebApi/Data/Migrations/20260915004233_InitialAdmin.cs
@@ -0,0 +1,234 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Admin.WebApi.Data.Migrations
+{
+ ///
+ public partial class InitialAdmin : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterDatabase()
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "admin_accounts",
+ columns: table => new
+ {
+ Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ Account = table.Column(type: "varchar(100)", maxLength: 100, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Name = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Email = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ PasswordHash = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Role = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Enabled = table.Column(type: "tinyint(1)", nullable: false),
+ Stamp = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ FailedAttempts = table.Column(type: "int", nullable: false),
+ LockedUntil = table.Column(type: "datetime(6)", nullable: true),
+ CreatedAt = table.Column(type: "datetime(6)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_admin_accounts", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "admin_audit",
+ columns: table => new
+ {
+ Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ ActorId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ ActorName = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Action = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ TargetId = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ TargetName = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Before = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ After = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Reason = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ ReportId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
+ OperationId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
+ Result = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ CreatedAt = table.Column(type: "datetime(6)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_admin_audit", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "admin_operations",
+ columns: table => new
+ {
+ Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ ActorId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ ActorName = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ TargetId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ Type = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Action = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Reason = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ ReportId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
+ Status = table.Column(type: "varchar(30)", maxLength: 30, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Attempts = table.Column(type: "int", nullable: false),
+ Error = table.Column(type: "longtext", nullable: true)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ CreatedAt = table.Column(type: "datetime(6)", nullable: false),
+ NextAttemptAt = table.Column(type: "datetime(6)", nullable: false),
+ CompletedAt = table.Column(type: "datetime(6)", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_admin_operations", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "admin_password_resets",
+ columns: table => new
+ {
+ Id = table.Column(type: "varchar(64)", maxLength: 64, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ AccountId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ ExpiresAt = table.Column(type: "datetime(6)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_admin_password_resets", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "admin_reports",
+ columns: table => new
+ {
+ Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ ReporterId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ TargetId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
+ TargetName = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Type = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Reason = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Description = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Evidence = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Status = table.Column(type: "varchar(30)", maxLength: 30, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ AssigneeId = table.Column(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
+ Result = table.Column(type: "longtext", nullable: true)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ CreatedAt = table.Column(type: "datetime(6)", nullable: false),
+ ClosedAt = table.Column(type: "datetime(6)", nullable: true),
+ Version = table.Column(type: "bigint", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_admin_reports", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateTable(
+ name: "admin_settings",
+ columns: table => new
+ {
+ Id = table.Column(type: "varchar(64)", maxLength: 64, nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Version = table.Column(type: "bigint", nullable: false),
+ Value = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ Draft = table.Column(type: "longtext", nullable: true)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ TestedVersion = table.Column(type: "bigint", nullable: true),
+ Secret = table.Column(type: "longtext", nullable: false)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ DraftSecret = table.Column(type: "longtext", nullable: true)
+ .Annotation("MySql:CharSet", "utf8mb4"),
+ UpdatedAt = table.Column(type: "datetime(6)", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_admin_settings", x => x.Id);
+ })
+ .Annotation("MySql:CharSet", "utf8mb4");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_admin_accounts_Account",
+ table: "admin_accounts",
+ column: "Account",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_admin_audit_CreatedAt",
+ table: "admin_audit",
+ column: "CreatedAt");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_admin_audit_OperationId",
+ table: "admin_audit",
+ column: "OperationId",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "IX_admin_operations_Status_NextAttemptAt",
+ table: "admin_operations",
+ columns: new[] { "Status", "NextAttemptAt" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_admin_reports_ReporterId_CreatedAt",
+ table: "admin_reports",
+ columns: new[] { "ReporterId", "CreatedAt" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_admin_reports_Status_CreatedAt",
+ table: "admin_reports",
+ columns: new[] { "Status", "CreatedAt" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "admin_accounts");
+
+ migrationBuilder.DropTable(
+ name: "admin_audit");
+
+ migrationBuilder.DropTable(
+ name: "admin_operations");
+
+ migrationBuilder.DropTable(
+ name: "admin_password_resets");
+
+ migrationBuilder.DropTable(
+ name: "admin_reports");
+
+ migrationBuilder.DropTable(
+ name: "admin_settings");
+ }
+ }
+}
diff --git a/Admin.WebApi/Data/Migrations/AdminDbModelSnapshot.cs b/Admin.WebApi/Data/Migrations/AdminDbModelSnapshot.cs
new file mode 100644
index 0000000..43b4da6
--- /dev/null
+++ b/Admin.WebApi/Data/Migrations/AdminDbModelSnapshot.cs
@@ -0,0 +1,311 @@
+//
+using System;
+using IM.Admin.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Admin.WebApi.Data.Migrations
+{
+ [DbContext(typeof(AdminDb))]
+ partial class AdminDbModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 64);
+
+ MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
+
+ modelBuilder.Entity("IM.Admin.Data.AdminAccount", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Account")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("varchar(100)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Enabled")
+ .HasColumnType("tinyint(1)");
+
+ b.Property("FailedAttempts")
+ .HasColumnType("int");
+
+ b.Property("LockedUntil")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Stamp")
+ .IsConcurrencyToken()
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Account")
+ .IsUnique();
+
+ b.ToTable("admin_accounts", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.AuditRecord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ActorId")
+ .HasColumnType("char(36)");
+
+ b.Property("ActorName")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("After")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Before")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("OperationId")
+ .HasColumnType("char(36)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ReportId")
+ .HasColumnType("char(36)");
+
+ b.Property("Result")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("TargetId")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("TargetName")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("OperationId")
+ .IsUnique();
+
+ b.ToTable("admin_audit", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.Operation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("Action")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ActorId")
+ .HasColumnType("char(36)");
+
+ b.Property("ActorName")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Attempts")
+ .HasColumnType("int");
+
+ b.Property("CompletedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Error")
+ .HasColumnType("longtext");
+
+ b.Property("NextAttemptAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ReportId")
+ .HasColumnType("char(36)");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("TargetId")
+ .HasColumnType("char(36)");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Status", "NextAttemptAt");
+
+ b.ToTable("admin_operations", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.PasswordReset", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property("AccountId")
+ .HasColumnType("char(36)");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("datetime(6)");
+
+ b.HasKey("Id");
+
+ b.ToTable("admin_password_resets", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.Report", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("char(36)");
+
+ b.Property("AssigneeId")
+ .HasColumnType("char(36)");
+
+ b.Property("ClosedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("datetime(6)");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Evidence")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Reason")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("ReporterId")
+ .HasColumnType("char(36)");
+
+ b.Property("Result")
+ .HasColumnType("longtext");
+
+ b.Property("Status")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("varchar(30)");
+
+ b.Property("TargetId")
+ .HasColumnType("char(36)");
+
+ b.Property("TargetName")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Type")
+ .IsRequired()
+ .HasColumnType("longtext");
+
+ b.Property("Version")
+ .IsConcurrencyToken()
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ReporterId", "CreatedAt");
+
+ b.HasIndex("Status", "CreatedAt");
+
+ b.ToTable("admin_reports", (string)null);
+ });
+
+ modelBuilder.Entity("IM.Admin.Data.SystemSetting", b =>
+ {
+ b.Property("Id")
+ .HasMaxLength(64)
+ .HasColumnType("varchar(64)");
+
+ b.Property