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("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/Program.cs b/Admin.WebApi/Program.cs new file mode 100644 index 0000000..ddf64e8 --- /dev/null +++ b/Admin.WebApi/Program.cs @@ -0,0 +1,59 @@ +using System.Security.Claims; +using IM.Admin.Api; +using IM.Admin.Data; +using IM.Admin.Services; +using IM.InitCommon.Management; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using System.Threading.RateLimiting; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddDbContext(o => o.UseMySql(builder.Configuration.GetConnectionString("Admin") ?? throw new InvalidOperationException("ConnectionStrings:Admin is required"), new MySqlServerVersion(new Version(8, 0, 0)))); +builder.Services.AddSingleton, PasswordHasher>(); +var keyPath = builder.Configuration["Management:KeyRingPath"] ?? throw new InvalidOperationException("Management:KeyRingPath is required"); +builder.Services.AddDataProtection().SetApplicationName("IM.Admin").PersistKeysToFileSystem(new DirectoryInfo(keyPath)); +builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(5)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(); +builder.Services.AddAntiforgery(o => { o.HeaderName = "X-CSRF-TOKEN"; o.Cookie.Name = "im.admin.csrf"; o.Cookie.SameSite = SameSiteMode.Strict; o.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; }); +builder.Services.AddAuthentication("Admin").AddCookie("Admin", o => { + o.Cookie.Name = "im.admin.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict; + o.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; + o.SlidingExpiration = false; + o.Events.OnRedirectToLogin = c => { c.Response.StatusCode = 401; return c.Response.WriteAsJsonAsync(ManagementResult.Fail("请登录管理后台")); }; + o.Events.OnRedirectToAccessDenied = c => { c.Response.StatusCode = 403; return c.Response.WriteAsJsonAsync(ManagementResult.Fail("没有此操作权限")); }; + o.Events.OnValidatePrincipal = async c => { + if (!Guid.TryParse(c.Principal?.FindFirstValue(ClaimTypes.NameIdentifier), out var id)) { c.RejectPrincipal(); return; } + var db = c.HttpContext.RequestServices.GetRequiredService(); + var a = await db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id); + if (a is null || !a.Enabled || a.Stamp != c.Principal!.FindFirstValue("stamp") || a.Role != c.Principal.FindFirstValue(ClaimTypes.Role)) c.RejectPrincipal(); + }; +}); +builder.Services.AddAuthorization(o => { o.AddPolicy("super", p => p.RequireRole("super")); o.AddPolicy("operate", p => p.RequireRole("super", "operator")); }); +builder.Services.AddRateLimiter(o => { o.RejectionStatusCode = 429; o.AddPolicy("login", c => RateLimitPartition.GetFixedWindowLimiter(c.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); }); +var app = builder.Build(); +if (args.Contains("--migrate") || args.Contains("--init-admin") || args.Contains("--reset-admin")) { await AdminBootstrap.Run(app.Services, args); return; } +app.Use(async (c, next) => { + try { await next(); } + catch (ApiError e) { c.Response.StatusCode = e.Status; await c.Response.WriteAsJsonAsync(ManagementResult.Fail(e.Message)); } + catch (DbUpdateConcurrencyException) { c.Response.StatusCode = 409; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("内容已被其他管理员修改,请刷新后重试")); } + catch (InternalServiceException e) { c.Response.StatusCode = e.Status is >= 400 and < 500 ? e.Status : 503; await c.Response.WriteAsJsonAsync(ManagementResult.Fail(e.Status switch { 400 => "业务校验未通过,请检查对象、配置值和已有引用", 403 => "没有访问关联对象或消息的权限", 404 => "关联对象或资源不存在", 409 => "对象已变化,请刷新重试", _ => "业务服务暂不可用,请稍后重试" })); } + catch (Exception e) { app.Logger.LogError("Admin request failed: {Type}", e.GetType().Name); c.Response.StatusCode = 503; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("服务暂不可用,请稍后重试")); } +}); +app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); +app.Use(async (c, next) => { + if (c.Request.Path.StartsWithSegments("/internal") && !InternalClient.Authorized(c)) { c.Response.StatusCode = 403; return; } + if (c.Request.Path.StartsWithSegments("/api/admin") && !HttpMethods.IsGet(c.Request.Method)) { + try { await c.RequestServices.GetRequiredService().ValidateRequestAsync(c); } + catch (Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException) { c.Response.StatusCode = 400; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("请求校验失败,请刷新页面")); return; } + } + await next(); +}); +app.MapAuth(); app.MapAdminBusiness(); app.MapSettings(); app.MapMonitoring(); +app.MapGet("/internal/management/health", async (AdminDb db) => { if (!await db.Database.CanConnectAsync()) throw new ApiError(503, "数据库不可用"); return new { status = "healthy", service = "admin" }; }); +app.Run(); +public partial class Program { } diff --git a/Admin.WebApi/Properties/launchSettings.json b/Admin.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..74d5fef --- /dev/null +++ b/Admin.WebApi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Admin.WebApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:60538;http://localhost:60539" + } + } +} \ No newline at end of file diff --git a/Admin.WebApi/Services/AdminBootstrap.cs b/Admin.WebApi/Services/AdminBootstrap.cs new file mode 100644 index 0000000..eecf77d --- /dev/null +++ b/Admin.WebApi/Services/AdminBootstrap.cs @@ -0,0 +1,28 @@ +using IM.Admin.Api; +using IM.Admin.Data; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; + +namespace IM.Admin.Services; +public static class AdminBootstrap +{ + public static async Task Run(IServiceProvider services, string[] args) + { + using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + if (args.Contains("--migrate")) { await db.Database.MigrateAsync(); Console.WriteLine("管理数据库迁移完成"); } + if (!args.Contains("--init-admin") && !args.Contains("--reset-admin")) return; + var name = Environment.GetEnvironmentVariable("IM_ADMIN_ACCOUNT")?.Trim().ToLowerInvariant() ?? throw new InvalidOperationException("IM_ADMIN_ACCOUNT is required"); + var password = Environment.GetEnvironmentVariable("IM_ADMIN_PASSWORD") ?? throw new InvalidOperationException("IM_ADMIN_PASSWORD is required"); + AuthEndpoints.CheckPassword(password); + var a = await db.Accounts.SingleOrDefaultAsync(x => x.Account == name); + if (args.Contains("--init-admin")) { + if (await db.Accounts.AnyAsync()) throw new InvalidOperationException("已存在管理员,禁止重复初始化"); + a = new AdminAccount { Account = name, Name = name, Role = "super", Email = Environment.GetEnvironmentVariable("IM_ADMIN_EMAIL") ?? "" }; db.Accounts.Add(a); + } + if (a is null) throw new InvalidOperationException("管理员不存在"); + a.PasswordHash = scope.ServiceProvider.GetRequiredService>().HashPassword(a, password); + a.Stamp = Guid.NewGuid().ToString("N"); a.FailedAttempts = 0; a.LockedUntil = null; + db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = "部署初始化命令", Action = "初始化或重置管理员", TargetId = a.Id.ToString(), Reason = "部署命令操作", After = "凭据已更新" }); + await db.SaveChangesAsync(); Console.WriteLine("管理员凭据已更新;请清除临时密码环境变量。"); + } +} diff --git a/Admin.WebApi/Services/HealthSampler.cs b/Admin.WebApi/Services/HealthSampler.cs new file mode 100644 index 0000000..fd0e127 --- /dev/null +++ b/Admin.WebApi/Services/HealthSampler.cs @@ -0,0 +1,33 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.Sockets; +using System.Text.Json; +using IM.InitCommon.Management; +using MySqlConnector; +using StackExchange.Redis; + +namespace IM.Admin.Services; +public sealed class HealthSampler(IConfiguration config, IServiceScopeFactory scopes) +{ + private readonly ConcurrentDictionary successes = new(); + public async Task Read(CancellationToken ct) + { + using var scope = scopes.CreateScope(); var client = scope.ServiceProvider.GetRequiredService(); + var tasks = new[] { "admin", "user", "contact", "group", "message", "file", "connector" }.Select(name => Check(name, async token => { + var response = await client.Send(name, "/internal/management/health", ct: token); + if (response.GetProperty("status").GetString() != "healthy") throw new InvalidOperationException(); + return response.TryGetProperty("configVersion", out var version) ? version.GetInt64() : (long?)null; + }, ct)).ToList(); + tasks.Add(Check("MySQL", async token => { await using var db = new MySqlConnection(config.GetConnectionString("Admin")); await db.OpenAsync(token); return null; }, ct)); + tasks.Add(Check("Redis", async token => { var options = ConfigurationOptions.Parse(config.GetConnectionString("Redis") ?? throw new InvalidOperationException()); options.ConnectTimeout = 2000; options.AbortOnConnectFail = true; using var redis = await ConnectionMultiplexer.ConnectAsync(options).WaitAsync(token); await redis.GetDatabase().PingAsync().WaitAsync(token); return null; }, ct)); + tasks.Add(Check("RabbitMQ(TCP)", async token => { using var tcp = new TcpClient(); await tcp.ConnectAsync(config["Management:RabbitHost"] ?? "rabbitmq", config.GetValue("Management:RabbitPort", 5672), token); return null; }, ct)); + tasks.Add(Check("Consul", async token => { using var http = new HttpClient(); using var response = await http.GetAsync((config["Management:ConsulUrl"] ?? throw new InvalidOperationException()).TrimEnd('/') + "/v1/status/leader", token); response.EnsureSuccessStatusCode(); return null; }, ct)); + return await Task.WhenAll(tasks); + } + async Task Check(string name, Func> check, CancellationToken ct) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(3)); var sw = Stopwatch.StartNew(); + try { var version = await check(timeout.Token); var now = DateTime.UtcNow; successes[name] = now; return new(name, "healthy", Math.Round(sw.Elapsed.TotalMilliseconds), now, now, null, version); } + catch { return new(name, "unavailable", null, DateTime.UtcNow, successes.TryGetValue(name, out var time) ? time : null, "连接或就绪检查失败,请检查服务日志"); } + } +} diff --git a/Admin.WebApi/Services/InfrastructureService.cs b/Admin.WebApi/Services/InfrastructureService.cs new file mode 100644 index 0000000..b0c4e3e --- /dev/null +++ b/Admin.WebApi/Services/InfrastructureService.cs @@ -0,0 +1,93 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using IM.Admin.Api; +using IM.Admin.Data; +using IM.InitCommon.Management; +using MailKit.Security; +using MimeKit; +using Microsoft.EntityFrameworkCore; + +namespace IM.Admin.Services; +public sealed class InfrastructureService(AdminDb db, SettingsService settings, InternalClient client, IConfiguration config, IHostEnvironment environment) +{ + public async Task Draft(string id, SettingInput input, HttpContext c) + { + ApiSupport.Reason(input.Reason); Validate(id, input.Value); + var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id); + if ((row?.Version ?? 0) != input.Version) throw new ApiError(409, "配置版本已变化,请刷新"); + if (row is null) { row = new SystemSetting { Id = id }; db.Settings.Add(row); } + row.Draft = input.Value.ToJsonString(); row.TestedVersion = null; row.Version++; row.UpdatedAt = DateTime.UtcNow; + var prior = settings.Unprotect(row.DraftSecret ?? row.Secret); + var secret = string.IsNullOrWhiteSpace(input.Secret) ? prior : id == "storage" ? MergeSecrets(prior, input.Secret) : input.Secret; + row.DraftSecret = settings.Protect(secret); + db.Audit(c, "保存基础设施草稿", id, "", "草稿已更新;凭据" + (string.IsNullOrEmpty(input.Secret) ? "保持" : "已更换"), input.Reason); await db.SaveChangesAsync(); + } + static string MergeSecrets(string previous, string next) + { + var old = JsonNode.Parse(string.IsNullOrEmpty(previous) ? "{}" : previous)!.AsObject(); var update = JsonNode.Parse(next)!.AsObject(); + foreach (var p in update) { var entry = old[p.Key]?.AsObject() ?? new JsonObject(); foreach (var value in p.Value!.AsObject()) if (!string.IsNullOrWhiteSpace(value.Value?.GetValue())) entry[value.Key] = value.Value!.DeepClone(); old[p.Key] = entry.DeepClone(); } + return old.ToJsonString(); + } + public async Task Test(string id, InfraTest input, HttpContext c) + { + var row = await Row(id, input.Version); var value = JsonNode.Parse(row.Draft!)!.AsObject(); + Validate(id, value); + if (id == "smtp") { + using var smtp = await Connect(value, settings.Unprotect(row.DraftSecret)); + if (!System.Net.Mail.MailAddress.TryCreate(input.Recipient, out _)) throw new ApiError(400, "请填写有效测试收件人"); + await SendMessage(smtp, value, input.Recipient!, "IM SMTP 连接测试", "这是一封由后台管理员主动发起的连接测试邮件。"); + } else await client.Send("file", "/internal/management/storage/test", new InfrastructureEnvelope(row.Version, value, settings.Unprotect(row.DraftSecret))); + row.TestedVersion = row.Version; db.Audit(c, "测试基础设施连接", id, "", "测试成功", "管理员主动执行连通性测试"); await db.SaveChangesAsync(); + } + public async Task Activate(string id, SettingInput input, HttpContext c) + { + ApiSupport.Reason(input.Reason); var row = await Row(id, input.Version); + if (row.TestedVersion != row.Version) throw new ApiError(409, "请先测试当前草稿的连接"); + if (id == "storage") await client.Send("file", "/internal/management/storage/validate", new InfrastructureEnvelope(row.Version, JsonNode.Parse(row.Draft!)!.AsObject(), settings.Unprotect(row.DraftSecret))); + var before = row.Value; row.Value = row.Draft!; row.Secret = row.DraftSecret!; row.Draft = null; row.DraftSecret = null; row.Version++; row.UpdatedAt = DateTime.UtcNow; + db.Audit(c, "启用基础设施配置", id, before, row.Value, input.Reason); await db.SaveChangesAsync(); + } + async Task Row(string id, long version) + { + if (id is not "smtp" and not "storage") throw new ApiError(404, "分组不存在"); + var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "请先保存草稿"); + if (row.Draft is null || row.Version != version) throw new ApiError(409, "草稿不存在或版本已变化"); return row; + } + void Validate(string id, JsonObject value) + { + if (id == "smtp") { + var allowed = new[] { "host", "port", "username", "from", "tls", "enabled" }; + if (value.Any(x => !allowed.Contains(x.Key))) throw new ApiError(400, "不支持的邮件字段"); + ValidateHost(value["host"]?.GetValue() ?? "", config); + if (value["port"]?.GetValue() is not (>= 1 and <= 65535) || value["tls"]?.GetValue() is not ("starttls" or "ssl") || !System.Net.Mail.MailAddress.TryCreate(value["from"]?.GetValue(), out _)) throw new ApiError(400, "邮件端口、TLS 或发件人无效"); + } else if (id == "storage") { + if (value["providers"] is not JsonObject providers || providers.Count == 0 || value["defaultProviderCode"] is null || !providers.ContainsKey(value["defaultProviderCode"]!.GetValue())) throw new ApiError(400, "请配置有效的默认存储提供商"); + var allowed = new[] { "providerCode", "providerType", "enabled", "bucket", "publicBucket", "region", "endpoint", "publicBaseUrl", "localRootPath", "localUploadApiBaseUrl", "uploadUrlExpiresIn", "downloadUrlExpiresIn", "maxObjectSizeBytes", "minPartSizeBytes", "defaultPartSizeBytes", "maxPartCount" }; + if (value.Any(x => x.Key != "providers" && x.Key != "defaultProviderCode")) throw new ApiError(400, "未知存储字段"); + foreach (var (code, node) in providers) { + var p = node!.AsObject(); if (p.Any(x => !allowed.Contains(x.Key)) || p["providerCode"]?.GetValue() != code) throw new ApiError(400, "存储字段不正确,凭据需独立提交"); + var type = p["providerType"]?.GetValue(); if (type is not 1 and not 2 and not 5) throw new ApiError(400, "仅支持本地或 S3 兼容存储"); + if (type != 1) { if (!Uri.TryCreate(p["endpoint"]?.GetValue(), UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http") || !string.IsNullOrEmpty(uri.UserInfo)) throw new ApiError(400, "存储端点无效"); ValidateHost(uri.Host, config); } + } + } else throw new ApiError(404, "分组不存在"); + } + public static void ValidateHost(string host, IConfiguration config) + { + var allowed = config.GetSection("Management:AllowedInfrastructureHosts").Get() ?? []; + if (string.IsNullOrWhiteSpace(host) || !allowed.Contains(host, StringComparer.OrdinalIgnoreCase)) throw new ApiError(400, "该目标不在部署允许列表中"); + } + async Task Connect(JsonObject value, string secret) + { + Validate("smtp", value); var smtp = new MailKit.Net.Smtp.SmtpClient { Timeout = 10000 }; + if (environment.IsDevelopment() && config["Management:DevelopmentSmtpCertificateSha256"] is { Length: > 0 } pin) + smtp.ServerCertificateValidationCallback = (_, certificate, _, errors) => errors == System.Net.Security.SslPolicyErrors.None || certificate is not null && string.Equals(certificate.GetCertHashString(System.Security.Cryptography.HashAlgorithmName.SHA256), pin, StringComparison.OrdinalIgnoreCase); + try { await smtp.ConnectAsync(value["host"]!.GetValue(), value["port"]!.GetValue(), value["tls"]!.GetValue() == "ssl" ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls); + if (!string.IsNullOrWhiteSpace(value["username"]?.GetValue())) await smtp.AuthenticateAsync(value["username"]!.GetValue(), secret); + return smtp; + } catch { smtp.Dispose(); throw new ApiError(503, "邮件连接失败,请检查允许列表、TLS 和凭据"); } + } + static async Task SendMessage(MailKit.Net.Smtp.SmtpClient smtp, JsonObject v, string recipient, string title, string text) + { var msg = new MimeMessage(); msg.From.Add(MailboxAddress.Parse(v["from"]!.GetValue())); msg.To.Add(MailboxAddress.Parse(recipient)); msg.Subject = title; msg.Body = new TextPart("plain") { Text = text }; await smtp.SendAsync(msg); await smtp.DisconnectAsync(true); } + public async Task MailEnabled() { var row = await db.Settings.AsNoTracking().SingleOrDefaultAsync(x => x.Id == "smtp"); return row is not null && JsonNode.Parse(row.Value)?["enabled"]?.GetValue() == true; } + public async Task Send(string recipient, string title, string text) { var row = await db.Settings.AsNoTracking().SingleAsync(x => x.Id == "smtp"); var value = JsonNode.Parse(row.Value)!.AsObject(); using var smtp = await Connect(value, settings.Unprotect(row.Secret)); await SendMessage(smtp, value, recipient, title, text); } +} diff --git a/Admin.WebApi/Services/OperationWorker.cs b/Admin.WebApi/Services/OperationWorker.cs new file mode 100644 index 0000000..5ca1dfc --- /dev/null +++ b/Admin.WebApi/Services/OperationWorker.cs @@ -0,0 +1,40 @@ +using System.Data; +using IM.Admin.Data; +using IM.InitCommon.Management; +using Microsoft.EntityFrameworkCore; + +namespace IM.Admin.Services; +public sealed class OperationWorker(IServiceScopeFactory scopes, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken ct) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2)); + while (await timer.WaitForNextTickAsync(ct)) { + try { await Process(ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception e) { logger.LogWarning("Operation dispatcher unavailable: {Type}", e.GetType().Name); } + } + } + public async Task Process(CancellationToken ct) + { + using var scope = scopes.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); + var id = await db.Operations.Where(x => x.Status == "pending" && x.NextAttemptAt <= DateTime.UtcNow).OrderBy(x => x.CreatedAt).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct); + if (id is null) return; + await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + // A row lock serializes dispatchers. Receipt idempotency covers response loss after the domain commit. + var op = await db.Operations.FromSqlInterpolated($"SELECT * FROM admin_operations WHERE Id = {id.Value} FOR UPDATE").SingleAsync(ct); + if (op.Status != "pending" || op.NextAttemptAt > DateTime.UtcNow) return; + try { + var receipt = op.Action is "警告" or "驳回" ? new ActionReceipt(op.TargetId.ToString(), "处理中", op.Action == "驳回" ? "已驳回" : "已处理") : await scope.ServiceProvider.GetRequiredService().Send(op.Type, "/internal/management/action", new InternalAction(op.Id, op.ActorId, op.TargetId, op.Action, op.Reason), ct); + if (op.ReportId.HasValue) { + var r = await db.Reports.SingleAsync(x => x.Id == op.ReportId, ct); + r.Status = op.Action == "驳回" ? "已驳回" : "已处理"; r.Result = op.Action + ":" + op.Reason; r.ClosedAt = DateTime.UtcNow; r.Version++; + } + op.Status = "completed"; op.CompletedAt = DateTime.UtcNow; op.Error = null; + db.Audit.Add(new AuditRecord { ActorId = op.ActorId, ActorName = op.ActorName, Action = op.Action, TargetId = op.TargetId.ToString(), TargetName = receipt.TargetName, Before = receipt.Before, After = receipt.After, Reason = op.Reason, ReportId = op.ReportId, OperationId = op.Id }); + } catch (Exception e) when (e is not OperationCanceledException || !ct.IsCancellationRequested) { + op.Attempts++; op.Error = e is InternalServiceException se ? se.Message : "业务服务暂不可用,可重试原任务"; + op.Status = op.Attempts >= 3 ? "failed" : "pending"; op.NextAttemptAt = DateTime.UtcNow.AddSeconds(10 * op.Attempts); + db.Audit.Add(new AuditRecord { ActorId = op.ActorId, ActorName = op.ActorName, Action = op.Action, TargetId = op.TargetId.ToString(), TargetName = op.TargetId.ToString(), Before = "待确认", After = "未确认", Reason = op.Reason, ReportId = op.ReportId, Result = $"第 {op.Attempts} 次执行失败;任务 {op.Id}" }); + } + await db.SaveChangesAsync(ct); await tx.CommitAsync(ct); + } +} diff --git a/Admin.WebApi/Services/SettingsService.cs b/Admin.WebApi/Services/SettingsService.cs new file mode 100644 index 0000000..a62a775 --- /dev/null +++ b/Admin.WebApi/Services/SettingsService.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using IM.Admin.Api; +using IM.Admin.Data; +using IM.InitCommon.Management; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using System.Text; + +namespace IM.Admin.Services; +public sealed class SettingsService(AdminDb db, IDataProtectionProvider protection, IConfiguration config) +{ + public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + public static readonly Dictionary Fields = new() { + ["platform"] = ["platformName", "description", "supportEmail"], ["account"] = ["registrationEnabled", "passwordMinLength"], + ["social"] = ["friendLimit", "createdGroupLimit", "groupMemberLimit", "defaultJoinAuthority"], + ["messaging"] = ["textLimit", "recallMinutes", "uploadMaxBytes", "allowedFileTypes"], + ["reports"] = ["reportCategories", "reportsPerDay", "reportCooldownMinutes"], + ["security"] = ["clientAccessMinutes", "clientRefreshDays", "adminSessionMinutes", "adminLockThreshold", "adminLockMinutes"], + }; + byte[] Key() { var key = Convert.FromBase64String(config["Management:CredentialKey"] ?? throw new InvalidOperationException("Management:CredentialKey is required")); if (key.Length != 32) throw new InvalidOperationException("CredentialKey must be 32 bytes"); return key; } + public string Protect(string secret) { + if (string.IsNullOrEmpty(secret)) return ""; + var nonce = RandomNumberGenerator.GetBytes(12); var plain = Encoding.UTF8.GetBytes(secret); var cipher = new byte[plain.Length]; var tag = new byte[16]; + using var aes = new AesGcm(Key(), 16); aes.Encrypt(nonce, plain, cipher, tag, Encoding.UTF8.GetBytes("IM.Admin.Config.v1")); + return "v1:" + Convert.ToBase64String(nonce.Concat(tag).Concat(cipher).ToArray()); + } + public string Unprotect(string? secret) { + if (string.IsNullOrEmpty(secret)) return ""; + if (!secret.StartsWith("v1:")) throw new InvalidOperationException("Unsupported credential encryption version"); + var data = Convert.FromBase64String(secret[3..]); var plain = new byte[data.Length - 28]; + using var aes = new AesGcm(Key(), 16); aes.Decrypt(data.AsSpan(0,12), data.AsSpan(28), data.AsSpan(12,16), plain, Encoding.UTF8.GetBytes("IM.Admin.Config.v1")); + return Encoding.UTF8.GetString(plain); + } + public static JsonObject Defaults(string id) + { + if (!Fields.TryGetValue(id, out var fields)) throw new ApiError(404, "配置分组不存在"); + var all = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject(); + return new JsonObject(fields.Select(k => new KeyValuePair(k, all[k]?.DeepClone()))); + } + public async Task Policy() + { + var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject(); + var rows = await db.Settings.AsNoTracking().Where(x => x.Id != "storage" && x.Id != "smtp").ToListAsync(); + foreach (var row in rows) foreach (var p in JsonNode.Parse(row.Value)!.AsObject()) merged[p.Key] = p.Value?.DeepClone(); + var policy = merged.Deserialize(Json)!; policy.Version = rows.Sum(x => x.Version); return policy; + } + public async Task List() + { + var rows = await db.Settings.AsNoTracking().ToListAsync(); + return Fields.Keys.Concat(["storage", "smtp"]).Select(id => { + var row = rows.SingleOrDefault(x => x.Id == id); + return new { id, version = row?.Version ?? 0, value = row is null ? (Fields.ContainsKey(id) ? Defaults(id) : new JsonObject()) : JsonNode.Parse(row.Value), draft = row?.Draft is null ? null : JsonNode.Parse(row.Draft), secretSet = !string.IsNullOrEmpty(row?.Secret), draftSecretSet = !string.IsNullOrEmpty(row?.DraftSecret), tested = row?.TestedVersion == row?.Version && row is not null, updatedAt = row?.UpdatedAt }; + }).ToArray(); + } + public async Task Save(string id, SettingInput input, HttpContext c) + { + ApiSupport.Reason(input.Reason); + if (!Fields.ContainsKey(id)) throw new ApiError(400, "基础设施配置需走草稿发布流程"); + Validate(id, input.Value); + var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id); + if ((row?.Version ?? 0) != input.Version) throw new ApiError(409, "配置版本已变化,请刷新后重试"); + if (row is null) { row = new SystemSetting { Id = id }; db.Settings.Add(row); } + var before = row.Value; row.Value = input.Value.ToJsonString(); row.Version++; row.UpdatedAt = DateTime.UtcNow; + db.Audit(c, "修改系统配置", id, before, row.Value, input.Reason); await db.SaveChangesAsync(); + } + public static void Validate(string id, JsonObject value) + { + if (!Fields.TryGetValue(id, out var fields) || value.Count != fields.Length || fields.Any(x => !value.ContainsKey(x))) throw new ApiError(400, "配置字段不完整或包含未知字段"); + try { + var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject(); foreach (var p in value) merged[p.Key] = p.Value?.DeepClone(); + var p1 = merged.Deserialize(Json)!; + if (string.IsNullOrWhiteSpace(p1.PlatformName) || p1.PlatformName.Length > 60 || p1.Description.Length > 500 || p1.PasswordMinLength is < 6 or > 50 || p1.AdminSessionMinutes is < 5 or > 10080 || p1.AdminLockThreshold is < 1 or > 20 || p1.AdminLockMinutes is < 1 or > 1440 || p1.DefaultJoinAuthority is < 0 or > 2 || p1.ReportsPerDay is < 1 or > 1000 || p1.ReportCooldownMinutes is < 0 or > 1440) throw new Exception(); + if (new[] { p1.FriendLimit, p1.CreatedGroupLimit, p1.GroupMemberLimit, p1.TextLimit, p1.RecallMinutes, p1.ClientAccessMinutes, p1.ClientRefreshDays }.Any(x => x < 0 || x > 1000000) || p1.UploadMaxBytes < 0 || p1.UploadMaxBytes > 1099511627776) throw new Exception(); + if (p1.ReportCategories is null || p1.ReportCategories.Length is < 1 or > 30 || p1.ReportCategories.Any(x => string.IsNullOrWhiteSpace(x) || x.Length > 40) || p1.ReportCategories.Distinct().Count() != p1.ReportCategories.Length) throw new Exception(); + if (p1.AllowedFileTypes is null || p1.AllowedFileTypes.Any(x => !System.Text.RegularExpressions.Regex.IsMatch(x, "^\\.[a-z0-9]{1,15}$"))) throw new Exception(); + if (p1.SupportEmail.Length > 0 && !System.Net.Mail.MailAddress.TryCreate(p1.SupportEmail, out _)) throw new Exception(); + } catch { throw new ApiError(400, "配置值无效,请检查类型、范围、邮箱和文件扩展名"); } + } +} +public record SettingInput(long Version, JsonObject Value, string Reason, string? Secret = null); diff --git a/ConnectorService/Hubs/ChatHub.cs b/ConnectorService/Hubs/ChatHub.cs index 0d9d83e..783ee85 100644 --- a/ConnectorService/Hubs/ChatHub.cs +++ b/ConnectorService/Hubs/ChatHub.cs @@ -10,11 +10,13 @@ namespace ConnectorService.Hubs { private readonly IConversationIntergrationService conService; private readonly StackExchange.Redis.IDatabase redis; + private readonly ConnectionRegistry registry; - public ChatHub(IConversationIntergrationService conService, IConnectionMultiplexer multiplexer) + public ChatHub(IConversationIntergrationService conService, IConnectionMultiplexer multiplexer, ConnectionRegistry registry) { this.conService = conService; this.redis = multiplexer.GetDatabase(); + this.registry = registry; } public async override Task OnConnectedAsync() @@ -34,6 +36,7 @@ namespace ConnectorService.Hubs } await redis.SetAddAsync(RedisHelper.GetConnectionIdKey(userId), Context.ConnectionId); + await registry.Add(Context); await base.OnConnectedAsync(); @@ -41,6 +44,7 @@ namespace ConnectorService.Hubs public async override Task OnDisconnectedAsync(Exception? exception) { + await registry.Remove(Context); if (Context.User.Identity.IsAuthenticated) { var userId = Context.User.FindFirstValue(ClaimTypes.NameIdentifier); diff --git a/ConnectorService/Program.cs b/ConnectorService/Program.cs index e2d1b92..71f59b2 100644 --- a/ConnectorService/Program.cs +++ b/ConnectorService/Program.cs @@ -1,5 +1,7 @@ +using IM.InitCommon.Management; using ConnectorService.Hubs; +using ConnectorService.Services; using IM.InitCommon; namespace ConnectorService @@ -15,6 +17,8 @@ namespace ConnectorService builder.ConfigureDbConfiguration(); builder.Services.AddSignalR(); + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle builder.Services.AddEndpointsApiExplorer(); @@ -23,6 +27,7 @@ namespace ConnectorService builder.ConfigExtraServices(); var app = builder.Build(); + if (app.ApplyMigrationsIfRequested(args)) return; // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -32,9 +37,12 @@ namespace ConnectorService } app.UseAppDefault(); + app.MapManagementHealth(); app.MapHub("/chat"); + app.MapGet("/internal/management/connections", async (ConnectionRegistry registry) => await registry.Counts()); + app.MapPost("/internal/management/disconnect/{id:guid}", async (Guid id, ConnectionRegistry registry) => { await registry.Disconnect(id); return new { disconnected = true }; }); app.Run(); } diff --git a/ConnectorService/Services/ConnectionRegistry.cs b/ConnectorService/Services/ConnectionRegistry.cs new file mode 100644 index 0000000..aaeab45 --- /dev/null +++ b/ConnectorService/Services/ConnectionRegistry.cs @@ -0,0 +1,38 @@ +using System.Collections.Concurrent; +using System.Security.Claims; +using Microsoft.AspNetCore.SignalR; +using StackExchange.Redis; + +namespace ConnectorService.Services; +public sealed class ConnectionRegistry(IConnectionMultiplexer redis, ILogger logger) : BackgroundService +{ + const string Active = "im:management:connections"; + static readonly RedisChannel Revocations = RedisChannel.Literal("im:management:disconnect"); + readonly ConcurrentDictionary contexts = new(); + public async Task Add(HubCallerContext c) { contexts[c.ConnectionId] = c; await Beat(c); } + public async Task Remove(HubCallerContext c) { contexts.TryRemove(c.ConnectionId, out _); await redis.GetDatabase().SortedSetRemoveAsync(Active, Key(c)); } + static string Key(HubCallerContext c) => c.User?.FindFirstValue(ClaimTypes.NameIdentifier) + ":" + c.ConnectionId; + Task Beat(HubCallerContext c) => redis.GetDatabase().SortedSetAddAsync(Active, Key(c), DateTimeOffset.UtcNow.AddSeconds(90).ToUnixTimeSeconds()); + public async Task Disconnect(Guid id) { Abort(id.ToString()); await redis.GetSubscriber().PublishAsync(Revocations, id.ToString()); } + void Abort(string userId) { foreach (var c in contexts.Values.Where(c => c.User!.FindFirstValue(ClaimTypes.NameIdentifier) == userId)) c.Abort(); } + public async Task Counts() { + var db = redis.GetDatabase(); await db.SortedSetRemoveRangeByScoreAsync(Active, double.NegativeInfinity, DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + var active = await db.SortedSetRangeByScoreAsync(Active, DateTimeOffset.UtcNow.ToUnixTimeSeconds(), double.PositiveInfinity); + return new { connections = active.Length, users = active.Select(x => x.ToString().Split(':')[0]).Distinct().Count(), sampledAt = DateTime.UtcNow, expiresAfterSeconds = 90 }; + } + protected override async Task ExecuteAsync(CancellationToken ct) { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20)); + var subscribed = false; + try { do { + try { + if (!subscribed) { await redis.GetSubscriber().SubscribeAsync(Revocations, (_, value) => Abort(value.ToString())); subscribed = true; } + foreach (var c in contexts.Values) { if (c.ConnectionAborted.IsCancellationRequested) await Remove(c); else await Beat(c); } + } catch (Exception e) when (!ct.IsCancellationRequested) { + // Fail closed while the revocation channel is unavailable. + foreach (var c in contexts.Values) c.Abort(); + logger.LogWarning("Connection heartbeat unavailable: {Type}",e.GetType().Name); + } + } while (await timer.WaitForNextTickAsync(ct)); } + finally { try { await redis.GetSubscriber().UnsubscribeAsync(Revocations); } catch { } } + } +} diff --git a/ContactService.WebApi/Application/FriendRequest/FriendRequestService.cs b/ContactService.WebApi/Application/FriendRequest/FriendRequestService.cs index 4ec6078..1100e79 100644 --- a/ContactService.WebApi/Application/FriendRequest/FriendRequestService.cs +++ b/ContactService.WebApi/Application/FriendRequest/FriendRequestService.cs @@ -1,8 +1,9 @@ -using AutoMapper; +using AutoMapper; using ContactService.Domain; using ContactService.WebApi.Application.Dtos; using ContactService.WebApi.Application.IntegrationServices; using IM.Commons; +using Microsoft.EntityFrameworkCore; namespace ContactService.WebApi.Application.FriendRequest { @@ -11,15 +12,15 @@ namespace ContactService.WebApi.Application.FriendRequest private readonly IFriendRequestReposity reposity; private readonly FriendRequestDomainService service; private readonly IMapper mapper; - private readonly IIdentityIntegrationService identityService; + private readonly IIdentityIntegrationService identityService; private readonly IM.InitCommon.Management.RuntimePolicy runtime; private readonly ContactService.Infrastructure.ContactDbContext db; public FriendRequestService(IFriendRequestReposity reposity, FriendRequestDomainService service, - IMapper mapper, IIdentityIntegrationService identityService) + IMapper mapper, IIdentityIntegrationService identityService, IM.InitCommon.Management.RuntimePolicy runtime, ContactService.Infrastructure.ContactDbContext db) { this.reposity = reposity; this.service = service; this.mapper = mapper; - this.identityService = identityService; + this.identityService = identityService; this.runtime = runtime; this.db = db; } public async Task> CreateAsync(CreateFriendRequestCommand command) @@ -50,6 +51,9 @@ namespace ContactService.WebApi.Application.FriendRequest switch (command.Action) { case FriendRequestAction.Accpet: + var limit = runtime.Current.FriendLimit; + if (limit > 0 && (await db.Friends.CountAsync(x => x.Owner.Id == request.OwnerId) >= limit || await db.Friends.CountAsync(x => x.Owner.Id == request.TargetId) >= limit)) + return Result.Fail(ResultCode.PERMISSION_DENIED, "好友数量已达到平台上限"); request.Accept(command.RemarkName); break; case FriendRequestAction.Block: diff --git a/ContactService.WebApi/Controllers/ManagementController.cs b/ContactService.WebApi/Controllers/ManagementController.cs new file mode 100644 index 0000000..f272c63 --- /dev/null +++ b/ContactService.WebApi/Controllers/ManagementController.cs @@ -0,0 +1,11 @@ +using ContactService.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace ContactService.WebApi.Controllers; +[ApiController, Route("internal/management")] +public sealed class ManagementController(ContactDbContext db) : ControllerBase +{ + [HttpGet("relation/{owner:guid}/{target:guid}")] + public async Task Relation(Guid owner, Guid target) => new { related = await db.Friends.AnyAsync(x => x.Owner.Id == owner && x.Target.Id == target) }; +} diff --git a/ContactService.WebApi/Program.cs b/ContactService.WebApi/Program.cs index d2abc9e..48e7d90 100644 --- a/ContactService.WebApi/Program.cs +++ b/ContactService.WebApi/Program.cs @@ -1,3 +1,4 @@ +using IM.InitCommon.Management; using IM.InitCommon; @@ -23,6 +24,7 @@ namespace ContactService.WebApi builder.Services.AddAllGrpcServer(); var app = builder.Build(); + if (app.ApplyMigrationsIfRequested(args)) return; // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -32,6 +34,7 @@ namespace ContactService.WebApi } app.UseAppDefault(); + app.MapManagementHealth(); app.MapControllers(); diff --git a/FileService.Application/Ports/IObjectStoragePort.cs b/FileService.Application/Ports/IObjectStoragePort.cs index b69b9af..984254a 100644 --- a/FileService.Application/Ports/IObjectStoragePort.cs +++ b/FileService.Application/Ports/IObjectStoragePort.cs @@ -6,6 +6,7 @@ namespace FileService.Application.Ports public interface IObjectStoragePort { string ProviderCode { get; } + Task WritePartAsync(UploadRuntimeCache task, int partNumber, Stream content, long size, CancellationToken ct) => throw new NotSupportedException(); public Task InitUploadAsync(InitiateUploadCommand command,CancellationToken token); public Task GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token); public Task CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token); diff --git a/FileService.Application/StorageContracts/UploadRuntimeCache.cs b/FileService.Application/StorageContracts/UploadRuntimeCache.cs index cf4a9c0..50b77a3 100644 --- a/FileService.Application/StorageContracts/UploadRuntimeCache.cs +++ b/FileService.Application/StorageContracts/UploadRuntimeCache.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -46,7 +46,7 @@ namespace FileService.Application.StorageContracts ObjectKey = objectKey; FileSize = fileSize; TotalPartCount = totalPartCount; - ExpireAt = expireAt ?? DateTime.MaxValue; + ExpireAt = expireAt ?? DateTimeOffset.UtcNow.AddHours(24); CreatedAt = DateTime.Now; } diff --git a/FileService.Application/UploadFile/UploadFileService.cs b/FileService.Application/UploadFile/UploadFileService.cs index f6c1326..09cb7f3 100644 --- a/FileService.Application/UploadFile/UploadFileService.cs +++ b/FileService.Application/UploadFile/UploadFileService.cs @@ -16,17 +16,17 @@ namespace FileService.Application.UploadFile private readonly IMapper mapper; private readonly IObjectStorageRouter router; private readonly IOptions options; - private readonly IGroupAccessService groupAccessService; + private readonly IGroupAccessService groupAccessService; private readonly IM.InitCommon.Management.RuntimePolicy runtime; public UploadFileService(IUploadFileReposity reposity, IMapper mapper, - IObjectStorageRouter router, IOptions options, - IGroupAccessService groupAccessService) + IObjectStorageRouter router, IOptionsSnapshot options, + IGroupAccessService groupAccessService, IM.InitCommon.Management.RuntimePolicy runtime) { this.reposity = reposity; this.mapper = mapper; this.router = router; this.options = options; - this.groupAccessService = groupAccessService; + this.groupAccessService = groupAccessService; this.runtime = runtime; } public async Task> GetFileInfoAsync(Guid id, Guid requesterId) @@ -54,6 +54,8 @@ namespace FileService.Application.UploadFile /// public async Task> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default) { + runtime.CheckFile(command.FileName, command.FileSize); + if (command.FileSize <= 0 || command.FileSize > options.Value.Providers[options.Value.DefaultProviderCode].MaxObjectSizeBytes) return Result.Fail(ResultCode.FILE_TOO_LARGE); var checksum = command.CheckSum; if (string.IsNullOrWhiteSpace(checksum)) { diff --git a/FileService.Application/UploadFileTask/UploadFileTaskService.cs b/FileService.Application/UploadFileTask/UploadFileTaskService.cs index 3e731c0..b9d7790 100644 --- a/FileService.Application/UploadFileTask/UploadFileTaskService.cs +++ b/FileService.Application/UploadFileTask/UploadFileTaskService.cs @@ -1,4 +1,4 @@ -using AutoMapper; +using AutoMapper; using FileService.Application.Ports; using FileService.Application.StorageContracts; using FileService.Domain.IReposities; @@ -12,9 +12,9 @@ namespace FileService.Application.UploadFileTask { public class UploadFileTaskService(IUploadTaskReposity reposity, IMapper mapper, IObjectStorageRouter router, - IOptions options, IStorageRedisCache redis, + IOptionsSnapshot options, IStorageRedisCache redis, IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage, - IUploadFileReposity uploadFileReposity + IUploadFileReposity uploadFileReposity, IM.InitCommon.Management.RuntimePolicy runtime, UploadFile.IGroupAccessService groupAccess ) { private readonly IUploadTaskReposity reposity = reposity; @@ -29,6 +29,9 @@ namespace FileService.Application.UploadFileTask public async Task> InitTaskAsync(UploadTaskInitCommand command) { + runtime.CheckFile(command.FileName, command.FileSize); + if (command.FileSize <= 0) return Result.Fail(ResultCode.PARAMETER_ERROR); + await CheckGroup(command.ChatType, command.TargetId, command.UploaderId); CancellationToken cancellationToken = CancellationToken.None; // 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录 @@ -66,7 +69,7 @@ namespace FileService.Application.UploadFileTask var initUpdateCommand = new StorageContracts.InitiateUploadCommand( ProviderCode: storageOption.ProviderCode, Bucket: storageOption.Bucket, - ObjectKey: $"{storageOption.LocalRootPath}\\{date.Year}\\{date.Month}\\{date.Day}\\{command.FileName}", + ObjectKey: $"{date:yyyy/MM/dd}/{Guid.NewGuid():N}{Path.GetExtension(command.FileName)}", ContentType: task.ContentType.Value, ContentLength: command.FileSize, null); @@ -84,14 +87,14 @@ namespace FileService.Application.UploadFileTask UploadSessionId = initRes.UploadSessionId, StorageLocation = initRes.Location, Instant = false, - UploadMode = string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase) - ? "LocalMultipart" + UploadMode = runtime.Enabled || string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase) + ? "ServerMultipart" : "Presigned", TotalPartCount = totalPartCount, PartSizeBytes = storageOption.DefaultPartSizeBytes }; - task.StartUpload(); + task.StartUpload(new Domain.ValueObjects.StorageLocation(storageOption.ProviderCode, storageOption.Bucket, initUpdateCommand.ObjectKey, storageOption.Region)); reposity.Create(task); await redis.SetAsync(new StorageContracts.UploadRuntimeCache( @@ -123,18 +126,21 @@ namespace FileService.Application.UploadFileTask return Result.Fail(ResultCode.PERMISSION_DENIED); } + runtime.CheckFile(task.FileName.Value, task.FileSize); + await CheckGroup(task.ChatType, task.TargetId, userId); if (taskCache.TotalPartCount < partNum || partNum < 1) { return Result.Fail(ResultCode.INVALID_PART_NUMBER); } - var presignUrl = await storage.GenerateUploadUrlAsync(new GenerateUploadUrlCommand( + if (runtime.Enabled) return Result.Fail(ResultCode.PERMISSION_DENIED, "请通过鉴权分片接口上传,以保证封禁立即生效"); + var presignUrl = await router.Route(taskCache.ProviderCode).GenerateUploadUrlAsync(new GenerateUploadUrlCommand( ProviderCode: taskCache.ProviderCode, Bucket: taskCache.Bucket, ObjectKey: taskCache.ObjectKey, UploadSessionId: taskCache.UploadSessionId, PartNumber: partNum, - ExpiresIn: options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn + ExpiresIn: options.Value.Providers[taskCache.ProviderCode].UploadUrlExpiresIn ), token); return Result.Success(presignUrl); @@ -159,6 +165,9 @@ namespace FileService.Application.UploadFileTask return Result.Fail(ResultCode.PERMISSION_DENIED); } + runtime.CheckFile(task.FileName.Value, task.FileSize); + await CheckGroup(task.ChatType, task.TargetId, command.userId); + if (task.State == Domain.UploadTaskState.Merging) return Result.Success(mapper.Map(task)); // 校验分片数量必须匹配 if (command.Parts.Count != taskCache.TotalPartCount) { @@ -172,7 +181,7 @@ namespace FileService.Application.UploadFileTask } // 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 ETag。 - if (string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)) + if (runtime.Enabled || string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)) { foreach (var part in command.Parts) { @@ -243,7 +252,11 @@ namespace FileService.Application.UploadFileTask return Result.Fail(ResultCode.PERMISSION_DENIED); } - var minPartSize = options.Value.Providers[options.Value.DefaultProviderCode].MinPartSizeBytes; + runtime.CheckFile(task.FileName.Value, task.FileSize); + await CheckGroup(task.ChatType, task.TargetId, userId); + if (command.PartNum < 1 || command.PartNum > taskCache.TotalPartCount || command.ContentLength <= 0 || command.ContentLength > task.FileSize) + return Result.Fail(ResultCode.PARAMETER_ERROR); + var minPartSize = options.Value.Providers[taskCache.ProviderCode].MinPartSizeBytes; // 最后一个分片豁免最小值校验(仅校验非最后一片) var isLastPart = command.PartNum == taskCache.TotalPartCount; @@ -253,13 +266,17 @@ namespace FileService.Application.UploadFileTask $"分片 {command.PartNum} 大小为 {command.ContentLength} 字节,小于最小值 {minPartSize} 字节"); } + StorageContracts.UploadPart uploaded; + if (taskCache.ProviderCode == "Local") { await localChunkStorage.SavePartAsync(new SaveLocalPartCommand( UploadSessionId: command.SessionId, PartNumber: command.PartNum, Stream: command.Stream, ContentLength: command.ContentLength )); - taskCache.AddOrUpdatePart(new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength)); + uploaded = new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength); + } else uploaded = await router.Route(taskCache.ProviderCode).WritePartAsync(taskCache, command.PartNum, command.Stream, command.ContentLength, CancellationToken.None); + taskCache.AddOrUpdatePart(uploaded); await redis.SetAsync(taskCache); var location = new Domain.ValueObjects.StorageLocation( storageProvider: taskCache.ProviderCode, @@ -267,7 +284,7 @@ namespace FileService.Application.UploadFileTask objectKey: taskCache.ObjectKey, region: taskCache.Region ); - return Result.Success(new CompleteUploadResult(location, command.PartNum.ToString(), command.ContentLength)); + return Result.Success(new CompleteUploadResult(location, uploaded.ETag, command.ContentLength)); } /// @@ -302,6 +319,11 @@ namespace FileService.Application.UploadFileTask return Result.Success(response); } + private async Task CheckGroup(string? chatType, Guid? targetId, Guid userId) + { + if (string.Equals(chatType, "GROUP", StringComparison.OrdinalIgnoreCase) && (!targetId.HasValue || !await groupAccess.CheckMemberAsync(userId, targetId.Value))) + throw new IM.DomainCommons.DomainException("群组已被封禁或没有群文件写入权限"); + } private bool CanReuse(Domain.Entities.UploadFile file, UploadTaskInitCommand command) { var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation); diff --git a/FileService.Domain/Entities/UploadTask.cs b/FileService.Domain/Entities/UploadTask.cs index d19d7c1..8a5e4ac 100644 --- a/FileService.Domain/Entities/UploadTask.cs +++ b/FileService.Domain/Entities/UploadTask.cs @@ -1,4 +1,4 @@ -using FileService.Domain.Events; +using FileService.Domain.Events; using FileService.Domain.ValueObjects; using IM.DomainCommons; @@ -34,9 +34,9 @@ namespace FileService.Domain.Entities CheckSum = checkSum; } - public void StartUpload() + public void StartUpload(StorageLocation? location = null) { - State = UploadTaskState.Uploading; + if (location != null) StorageLocation = location; State = UploadTaskState.Uploading; } public void StartMerging(StorageLocation location) diff --git a/FileService.Infrastructure/FileService.Infrastructure.csproj b/FileService.Infrastructure/FileService.Infrastructure.csproj index e1c0e98..5fe2822 100644 --- a/FileService.Infrastructure/FileService.Infrastructure.csproj +++ b/FileService.Infrastructure/FileService.Infrastructure.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -7,6 +7,7 @@ + diff --git a/FileService.Infrastructure/Storage/LocalStorageAdapter.cs b/FileService.Infrastructure/Storage/LocalStorageAdapter.cs index 62efb44..a3c44a7 100644 --- a/FileService.Infrastructure/Storage/LocalStorageAdapter.cs +++ b/FileService.Infrastructure/Storage/LocalStorageAdapter.cs @@ -1,174 +1,70 @@ -using FileService.Application.Ports; +using FileService.Application.Ports; using FileService.Application.StorageContracts; using FileService.Domain.ValueObjects; using IM.Commons; using IM.InitCommon; using Microsoft.Extensions.Options; -using StackExchange.Redis; -namespace FileService.Infrastructure.Storage +namespace FileService.Infrastructure.Storage; +public class LocalStorageAdapter(IStorageRedisCache redis, IOptionsSnapshot options) : IObjectStoragePort, ILocalChunkStorage { - public class LocalStorageAdapter(IStorageRedisCache redis, IOptions options) : IObjectStoragePort, ILocalChunkStorage + private StorageProviderOptions Provider => options.Value.Providers["Local"]; + public string ProviderCode => "Local"; + public static string SafePath(string root, params string[] segments) { - private readonly IStorageRedisCache redis = redis; - private readonly IOptions options = options; - private readonly StorageProviderOptions providerOptions = options.Value.Providers[options.Value.DefaultProviderCode]; - - public string ProviderCode => "Local"; - - /// - /// 单次直传:直接写入 LocalRootPath/{bucket}/{objectKey}。 - /// bucket 传公开桶名即落到公开目录,可被静态托管直链访问。 - /// - public async Task PutObjectAsync(PutObjectCommand command, CancellationToken token) - { - var fullPath = Path.Combine(providerOptions.LocalRootPath!, command.Bucket, command.ObjectKey); - Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); - - await using (var fs = new FileStream(fullPath, FileMode.Create)) - { - await command.Content.CopyToAsync(fs, token); - await fs.FlushAsync(token); + var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + var path = Path.GetFullPath(Path.Combine(new[] { fullRoot }.Concat(segments).ToArray())); + if (!path.StartsWith(fullRoot, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) throw new InvalidOperationException("存储路径超出允许根目录"); + for (var dir = Path.GetDirectoryName(path); dir != null && dir.Length >= fullRoot.Length; dir = Path.GetDirectoryName(dir)) + if (Directory.Exists(dir) && File.GetAttributes(dir).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用存储目录链接"); + if (File.Exists(path) && File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用文件链接"); + return path; + } + public async Task PutObjectAsync(PutObjectCommand command, CancellationToken token) + { + var path = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await using var stream = new FileStream(path, FileMode.CreateNew); + await command.Content.CopyToAsync(stream, token); + if (stream.Length != command.ContentLength) throw new InvalidOperationException("文件实际大小与申报大小不符"); + return new(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region); + } + public string? GetPublicUrl(StorageLocation location) => !string.IsNullOrEmpty(Provider.PublicBucket) && location.Bucket == Provider.PublicBucket + ? $"{(Provider.PublicBaseUrl ?? Provider.LocalUploadApiBaseUrl ?? "").TrimEnd('/')}/static/{string.Join('/', location.ObjectKey.Replace('\\', '/').Split('/').Select(Uri.EscapeDataString))}" : null; + public Task OpenReadAsync(StorageLocation location, CancellationToken token) => Task.FromResult(File.OpenRead(SafePath(Provider.LocalRootPath!, location.Bucket, location.ObjectKey))); + public Task InitUploadAsync(InitiateUploadCommand command, CancellationToken token) => Task.FromResult(new InitiateUploadResult(Guid.NewGuid().ToString(), new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region))); + public Task GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) => Task.FromResult(new PresignedUrl( + Provider.LocalUploadApiBaseUrl!.TrimEnd('/') + $"/local/parts/upload?sessionId={Uri.EscapeDataString(command.UploadSessionId)}&partNumber={command.PartNumber}", "POST", new Dictionary(), DateTimeOffset.UtcNow.Add(command.ExpiresIn))); + public async Task SavePartAsync(SaveLocalPartCommand command) + { + if (!Guid.TryParse(command.UploadSessionId, out _) || command.PartNumber < 1) throw new InvalidOperationException("分片参数无效"); + var path = SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{command.PartNumber}.part"); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await using var stream = File.Create(path); + await command.Stream.CopyToAsync(stream); + if (stream.Length != command.ContentLength) throw new InvalidOperationException("分片实际大小不符"); + } + public async Task CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token) + { + var cache = await redis.GetAsync(command.UploadSessionId) ?? throw new InvalidOperationException("上传任务已过期"); + var final = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey); + Directory.CreateDirectory(Path.GetDirectoryName(final)!); + var temporary = final + ".merging"; + await using (var output = File.Create(temporary)) { + foreach (var part in command.Parts.OrderBy(x => x.PartNumber)) { + await using var input = File.OpenRead(SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{part.PartNumber}.part")); + await input.CopyToAsync(output, token); } - - return new StorageLocation( - storageProvider: ProviderCode, - bucket: command.Bucket, - objectKey: command.ObjectKey, - region: providerOptions.Region); - } - - /// - /// 公开桶文件返回静态托管直链;私有文件返回 null。 - /// - public string? GetPublicUrl(StorageLocation location) - { - if (string.IsNullOrEmpty(providerOptions.PublicBucket) || - !string.Equals(location.Bucket, providerOptions.PublicBucket, StringComparison.OrdinalIgnoreCase)) - { - return null; - } - - var baseUrl = (providerOptions.PublicBaseUrl ?? providerOptions.LocalUploadApiBaseUrl ?? string.Empty) - .TrimEnd('/'); - var key = location.ObjectKey.Replace('\\', '/').TrimStart('/'); - return $"{baseUrl}/static/{key}"; - } - - /// - /// 打开本地文件读取流:LocalRootPath/{bucket}/{objectKey}。 - /// - public Task OpenReadAsync(StorageLocation location, CancellationToken token) - { - var fullPath = Path.Combine(providerOptions.LocalRootPath!, location.Bucket, location.ObjectKey); - if (!File.Exists(fullPath)) - { - throw new FileNotFoundException(fullPath); - } - - Stream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read); - return Task.FromResult(stream); - } - - public async Task CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token) - { - var res = await MergeAsync(command.UploadSessionId, command.ObjectKey, command.Parts); - return new CompleteUploadResult(new StorageLocation( - storageProvider: command.ProviderCode, - bucket: command.Bucket, - objectKey: command.ObjectKey, - region: command.Region - ), null, command.Parts.Sum(x => x.Size).Value); - } - public async Task> MergeAsync(string sessionId, string objectKey, IReadOnlyList parts) - { - var rootPath = options.Value.Providers[options.Value.DefaultProviderCode].LocalRootPath; - var tempPath = Path.Combine(rootPath, sessionId, "parts"); // 项目根目录下 uploads // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字) - var finalPath = Path.Combine(rootPath, objectKey); - var finalDir = Path.GetDirectoryName(finalPath); - Directory.CreateDirectory(finalDir); - - var storageCache = await redis.GetAsync(sessionId); - var totalChunks = storageCache.TotalPartCount; - try - { - using (var finalStream = new FileStream(finalPath, FileMode.Create)) - { - for (var i = 1; i <= totalChunks; i++) - { - var progress = (i * 100.0 / totalChunks); - if (i % 5 == 0 || i == totalChunks) - { - //await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[] - //{ - // new("status", "processing"), - // new("progress", progress.ToString("F2")) - //}); - } - var chunkPath = Path.Combine(tempPath, $"{i}.part"); - if (!File.Exists(chunkPath)) - return Result.Fail(ResultCode.CHUNK_NOT_FOUND); - using (var chunkStream = new FileStream(chunkPath, FileMode.Open)) - { - await chunkStream.CopyToAsync(finalStream); - } - } - Directory.Delete(tempPath, true); - await redis.DeleteAsync(sessionId); - } - - return Result.Success(); - } - catch (Exception e) - { - //_logger.LogError(e, e.Message); - throw; - - } - } - - public async Task GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) - { - var baseUrl = options.Value.Providers[options.Value.DefaultProviderCode].LocalUploadApiBaseUrl; - return new PresignedUrl( - baseUrl + $"local/parts/upload?sessionId={command.UploadSessionId}&partNumber={command.PartNumber}", - "POST", - new Dictionary(), - ExpiresAt: DateTimeOffset.Now.Add(options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn) - ); - } - - public async Task InitUploadAsync(InitiateUploadCommand command, CancellationToken token) - { - var sessionId = Guid.NewGuid(); - var location = new StorageLocation(); - return new InitiateUploadResult(sessionId.ToString(),location); - } - - public async Task SavePartAsync(SaveLocalPartCommand command) - { - var path = BuildPartPath( - command.UploadSessionId, - command.PartNumber); - - Directory.CreateDirectory( - Path.GetDirectoryName(path)!); - - await using var fs = File.Create(path); - - await command.Stream.CopyToAsync(fs); - - await fs.FlushAsync(); - } - private string BuildPartPath( - string uploadSessionId, - int partNumber) - { - return Path.Combine( - providerOptions.LocalRootPath, - uploadSessionId, - "parts", - $"{partNumber}.part"); + if (output.Length != cache.FileSize) throw new InvalidOperationException("合并文件大小不符"); } + File.Move(temporary, final, true); + // Keep parts available for idempotent retry after response loss. + return new(new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, command.Region), null, cache.FileSize); + } + public async Task> MergeAsync(string sessionId, string objectKey, IReadOnlyList parts) + { + var cache = await redis.GetAsync(sessionId) ?? throw new InvalidOperationException("上传任务已过期"); + await CompleteUploadAsync(new CompleteUploadCommand(ProviderCode: cache.ProviderCode, Bucket: cache.Bucket, Region: cache.Region, ObjectKey: objectKey, UploadSessionId: sessionId, Parts: parts), CancellationToken.None); + return Result.Success(); } } diff --git a/FileService.Infrastructure/Storage/ObjectStorageRouter.cs b/FileService.Infrastructure/Storage/ObjectStorageRouter.cs index 2a454c1..61557a8 100644 --- a/FileService.Infrastructure/Storage/ObjectStorageRouter.cs +++ b/FileService.Infrastructure/Storage/ObjectStorageRouter.cs @@ -1,22 +1,26 @@ -using FileService.Application.Ports; +using FileService.Application.Ports; using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; +using IM.InitCommon; +using Microsoft.Extensions.Options; namespace FileService.Infrastructure.Storage { - public class ObjectStorageRouter : IObjectStorageRouter + public class ObjectStorageRouter : IObjectStorageRouter, IDisposable { private IReadOnlyDictionary adpters; - public ObjectStorageRouter(IEnumerable storages) + public ObjectStorageRouter(IEnumerable storages, IOptionsSnapshot options) { - this.adpters = storages.ToDictionary(x => x.ProviderCode, StringComparer.OrdinalIgnoreCase); + var adapters = storages.ToDictionary(x => x.ProviderCode, StringComparer.OrdinalIgnoreCase); + foreach (var (code, provider) in options.Value.Providers.Where(x => x.Value.ProviderType is StorageProviderType.AwsS3 or StorageProviderType.Minio)) adapters[code] = new S3StorageAdapter(code, provider); + this.adpters = adapters; } - public IObjectStoragePort Route(string providerCode) + public void Dispose() { foreach (var adapter in adpters.Values.OfType()) adapter.Dispose(); } public IObjectStoragePort Route(string providerCode) { return this.adpters[providerCode]; } diff --git a/FileService.Infrastructure/Storage/S3StorageAdapter.cs b/FileService.Infrastructure/Storage/S3StorageAdapter.cs new file mode 100644 index 0000000..ba4e73e --- /dev/null +++ b/FileService.Infrastructure/Storage/S3StorageAdapter.cs @@ -0,0 +1,53 @@ +using Amazon.S3; +using Amazon.S3.Model; +using FileService.Application.Ports; +using FileService.Application.StorageContracts; +using FileService.Domain.ValueObjects; +using IM.InitCommon; + +namespace FileService.Infrastructure.Storage; +public sealed class S3StorageAdapter(string code, StorageProviderOptions options) : IObjectStoragePort, IDisposable +{ + private readonly AmazonS3Client client = new(options.AccessKeyId, options.AccessKeySecret, new AmazonS3Config { ServiceURL = options.Endpoint, AuthenticationRegion = string.IsNullOrWhiteSpace(options.Region) ? "us-east-1" : options.Region, ForcePathStyle = true }); + public string ProviderCode => code; + public async Task WritePartAsync(UploadRuntimeCache task, int partNumber, Stream content, long size, CancellationToken ct) { + var part = await client.UploadPartAsync(new UploadPartRequest { BucketName = task.Bucket, Key = task.ObjectKey, UploadId = task.UploadSessionId, PartNumber = partNumber, InputStream = content, PartSize = size }, ct); + return new(partNumber, part.ETag, size); + } + public async Task InitUploadAsync(InitiateUploadCommand command, CancellationToken token) { + var r = await client.InitiateMultipartUploadAsync(new InitiateMultipartUploadRequest { BucketName = command.Bucket, Key = command.ObjectKey, ContentType = command.ContentType }, token); + return new(r.UploadId, new StorageLocation(code, command.Bucket, command.ObjectKey, options.Region)); + } + public Task GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) { + var expires = DateTimeOffset.UtcNow.Add(command.ExpiresIn); + var request = new GetPreSignedUrlRequest { BucketName = command.Bucket, Key = command.ObjectKey, Verb = HttpVerb.PUT, Expires = expires.UtcDateTime, UploadId = command.UploadSessionId, PartNumber = command.PartNumber ?? 1 }; + return Task.FromResult(new PresignedUrl(client.GetPreSignedURL(request), "PUT", new Dictionary(), expires)); + } + public async Task CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token) { + var response = await client.CompleteMultipartUploadAsync(new CompleteMultipartUploadRequest { BucketName = command.Bucket, Key = command.ObjectKey, UploadId = command.UploadSessionId, PartETags = command.Parts.OrderBy(x => x.PartNumber).Select(x => new PartETag(x.PartNumber, x.ETag)).ToList() }, token); + var meta = await client.GetObjectMetadataAsync(command.Bucket, command.ObjectKey, token); + return new(new StorageLocation(code, command.Bucket, command.ObjectKey, options.Region), response.ETag, meta.ContentLength, VersionId: response.VersionId); + } + public async Task PutObjectAsync(PutObjectCommand command, CancellationToken token) { + await client.PutObjectAsync(new PutObjectRequest { BucketName = command.Bucket, Key = command.ObjectKey, ContentType = command.ContentType, InputStream = command.Content, AutoCloseStream = false }, token); + return new(code, command.Bucket, command.ObjectKey, options.Region); + } + // Public rendering also passes through FileService. Private access is always authorized there. + public string? GetPublicUrl(StorageLocation location) => null; + public async Task OpenReadAsync(StorageLocation location, CancellationToken token) { var response = await client.GetObjectAsync(location.Bucket, location.ObjectKey, token); return new ResponseStream(response); } + public async Task Test(CancellationToken ct) { + var key = "im-admin-connectivity/" + Guid.NewGuid().ToString("N"); + try { await client.PutObjectAsync(new PutObjectRequest { BucketName = options.Bucket, Key = key, ContentBody = "IM connectivity test" }, ct); using var read = await client.GetObjectAsync(options.Bucket, key, ct); } + finally { await client.DeleteObjectAsync(options.Bucket, key, CancellationToken.None); } + } + public void Dispose() => client.Dispose(); + sealed class ResponseStream(GetObjectResponse response) : Stream { + readonly Stream inner = response.ResponseStream; + public override bool CanRead => inner.CanRead; public override bool CanSeek => inner.CanSeek; public override bool CanWrite => false; + public override long Length => response.ContentLength; public override long Position { get => inner.Position; set => inner.Position = value; } + public override void Flush() => inner.Flush(); public override int Read(byte[] b, int o, int c) => inner.Read(b, o, c); + public override ValueTask ReadAsync(Memory b, CancellationToken ct = default) => inner.ReadAsync(b, ct); + public override long Seek(long o, SeekOrigin origin) => inner.Seek(o, origin); public override void SetLength(long v) => throw new NotSupportedException(); public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); + protected override void Dispose(bool disposing) { if (disposing) response.Dispose(); base.Dispose(disposing); } + } +} diff --git a/FileService.WebApi/Controllers/FileTask/FileTaskController.cs b/FileService.WebApi/Controllers/FileTask/FileTaskController.cs index 18392a0..a6e62ed 100644 --- a/FileService.WebApi/Controllers/FileTask/FileTaskController.cs +++ b/FileService.WebApi/Controllers/FileTask/FileTaskController.cs @@ -1,4 +1,4 @@ -using FileService.Application.UploadFileTask; +using FileService.Application.UploadFileTask; using FileService.Infrastructure; using IM.ASPNETCore; using Microsoft.AspNetCore.Authorization; @@ -71,10 +71,10 @@ namespace FileService.WebApi.Controllers.FileTask } [HttpPost("local/parts/upload")] - public async Task LocalUpload(string sessionId, int partNumber, IFormFile file) + public async Task LocalUpload([FromForm] string sessionId, [FromForm] int partNumber, IFormFile file) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - var stream = file.OpenReadStream(); + await using var stream = file.OpenReadStream(); var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length), Guid.Parse(userId)); return Ok(res); } diff --git a/FileService.WebApi/Controllers/ManagementController.cs b/FileService.WebApi/Controllers/ManagementController.cs new file mode 100644 index 0000000..a3acb1a --- /dev/null +++ b/FileService.WebApi/Controllers/ManagementController.cs @@ -0,0 +1,82 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using FileService.Infrastructure; +using FileService.Infrastructure.Storage; +using IM.InitCommon; +using IM.InitCommon.Management; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace FileService.WebApi.Controllers; +[ApiController, Route("internal/management/storage")] +public class ManagementController(FileDbContext db, IOptionsSnapshot current, IConfiguration config) : ControllerBase +{ + [HttpGet("summary")] + public async Task Summary(string? provider, string? type, DateTimeOffset? from, DateTimeOffset? to) + { + var files = db.Files.AsNoTracking().AsQueryable(); var tasks = db.Tasks.AsNoTracking().AsQueryable(); + if (!string.IsNullOrWhiteSpace(provider)) { files = files.Where(x => x.StorageLocation.StorageProvider == provider); tasks = tasks.Where(x => x.StorageLocation.StorageProvider == provider); } + if (!string.IsNullOrWhiteSpace(type)) { files = files.Where(x => x.ContentType.Value == type); tasks = tasks.Where(x => x.ContentType.Value == type); } + if (from.HasValue) { files = files.Where(x => x.CreationTime >= from); tasks = tasks.Where(x => x.CreationTime >= from); } + if (to.HasValue) { files = files.Where(x => x.CreationTime < to); tasks = tasks.Where(x => x.CreationTime < to); } + var totals = await files.GroupBy(x => new { provider = x.StorageLocation.StorageProvider, type = x.ContentType.Value }).Select(g => new { g.Key.provider, g.Key.type, count = g.Count(), bytes = g.Sum(x => x.FileSize) }).ToListAsync(); + var states = await tasks.GroupBy(x => x.State).Select(g => new { state = g.Key.ToString(), count = g.Count() }).ToListAsync(); + var capacities = current.Value.Providers.Select(x => { + long? total = null, available = null; string status = "未提供"; + if (x.Value.ProviderType == StorageProviderType.Local) try { var drive = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(x.Value.LocalRootPath!))!); total = drive.TotalSize; available = drive.AvailableFreeSpace; status = "可用"; } catch { status = "不可用"; } + return new { provider = x.Key, total, available, status }; + }).ToArray(); + return new { totals, tasks = states, capacities, checkedAt = DateTime.UtcNow }; + } + [HttpPost("validate")] + public async Task Validate(InfrastructureEnvelope input) + { + var next = Parse(input); + foreach (var (code, old) in current.Value.Providers) { + var referenced = await db.Files.IgnoreQueryFilters().AnyAsync(x => x.StorageLocation.StorageProvider == code) || await db.Tasks.IgnoreQueryFilters().AnyAsync(x => x.StorageLocation.StorageProvider == code); + if (!referenced) continue; + if (!next.Providers.TryGetValue(code, out var p) || !p.Enabled || p.ProviderType != old.ProviderType || p.Bucket != old.Bucket || p.PublicBucket != old.PublicBucket || p.Endpoint != old.Endpoint || p.Region != old.Region || p.LocalRootPath != old.LocalRootPath || p.PublicBaseUrl != old.PublicBaseUrl) + throw new IM.DomainCommons.DomainException("已有文件或上传任务引用该提供商,不能移除或更改定位参数"); + } + return new { valid = true }; + } + [HttpPost("test")] + public async Task Test(InfrastructureEnvelope input, CancellationToken ct) + { + await Validate(input); var next = Parse(input); + foreach (var (code, provider) in next.Providers.Where(x => x.Value.Enabled)) { + if (provider.ProviderType == StorageProviderType.Local) { + var path = LocalStorageAdapter.SafePath(provider.LocalRootPath!, "im-admin-connectivity", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + try { await System.IO.File.WriteAllTextAsync(path, "IM connection test", ct); await System.IO.File.ReadAllTextAsync(path, ct); } + finally { if (System.IO.File.Exists(path)) System.IO.File.Delete(path); } + } else { using var adapter = new S3StorageAdapter(code, provider); await adapter.Test(ct); } + } + return new { tested = true }; + } + StorageOptions Parse(InfrastructureEnvelope input) + { + var next = input.Value.Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? throw new IM.DomainCommons.DomainException("配置格式错误"); + if (next.Providers is null || !next.Providers.TryGetValue(next.DefaultProviderCode, out var chosen) || !chosen.Enabled) throw new IM.DomainCommons.DomainException("默认提供商无效"); + var secrets = JsonNode.Parse(string.IsNullOrEmpty(input.Secret) ? "{}" : input.Secret)!; + foreach (var (code, p) in next.Providers) { + if (p.ProviderCode != code || string.IsNullOrWhiteSpace(p.Bucket) || p.Bucket.IndexOfAny(['/', '\\']) >= 0 || p.PublicBucket?.IndexOfAny(['/', '\\']) >= 0 || p.Bucket is "." or ".." || p.PublicBucket is "." or "..") throw new IM.DomainCommons.DomainException("提供商或存储桶名称无效"); + if (p.DefaultPartSizeBytes < p.MinPartSizeBytes || p.MinPartSizeBytes < 1 || p.MaxPartCount < 1 || p.MaxObjectSizeBytes < 1) throw new IM.DomainCommons.DomainException("分片或容量限制无效"); + if (p.ProviderType == StorageProviderType.Local) { + if (code != "Local" || string.IsNullOrWhiteSpace(p.LocalRootPath)) throw new IM.DomainCommons.DomainException("本地提供商编码必须为 Local"); + var root = Path.GetFullPath(p.LocalRootPath); + var allowed = config.GetSection("Management:AllowedStorageRoots").Get() ?? []; + if (!allowed.Any(x => { var path = Path.GetFullPath(x).TrimEnd(Path.DirectorySeparatorChar); return root == path || root.StartsWith(path + Path.DirectorySeparatorChar, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); })) throw new IM.DomainCommons.DomainException("本地目录未被部署允许"); + LocalStorageAdapter.SafePath(root, "im-admin-connectivity", "check"); + } else { + if (p.ProviderType is not StorageProviderType.AwsS3 and not StorageProviderType.Minio || !Uri.TryCreate(p.Endpoint, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https") || !string.IsNullOrEmpty(uri.UserInfo)) throw new IM.DomainCommons.DomainException("不支持的存储端点"); + var allowed = config.GetSection("Management:AllowedInfrastructureHosts").Get() ?? []; + if (!allowed.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) throw new IM.DomainCommons.DomainException("存储主机未被部署允许"); + p.AccessKeyId = secrets[code]?["accessKeyId"]?.GetValue(); p.AccessKeySecret = secrets[code]?["accessKeySecret"]?.GetValue(); + if (string.IsNullOrWhiteSpace(p.AccessKeyId) || string.IsNullOrWhiteSpace(p.AccessKeySecret)) throw new IM.DomainCommons.DomainException("缺少存储凭据"); + } + } + return next; + } +} diff --git a/FileService.WebApi/Program.cs b/FileService.WebApi/Program.cs index 6ce7379..076df6b 100644 --- a/FileService.WebApi/Program.cs +++ b/FileService.WebApi/Program.cs @@ -1,3 +1,4 @@ +using IM.InitCommon.Management; using IM.InitCommon; using Microsoft.Extensions.FileProviders; @@ -20,6 +21,7 @@ namespace FileService.WebApi builder.ConfigExtraServices(); var app = builder.Build(); + if (app.ApplyMigrationsIfRequested(args)) return; // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -29,6 +31,7 @@ namespace FileService.WebApi } app.UseAppDefault(); + app.MapManagementHealth(); // 仅 FileService 暴露公开桶目录为静态直链,不动共享 UseAppDefault UsePublicStaticFiles(app); diff --git a/GroupService.Domain/Entities/Group.cs b/GroupService.Domain/Entities/Group.cs index ae4e380..df21ded 100644 --- a/GroupService.Domain/Entities/Group.cs +++ b/GroupService.Domain/Entities/Group.cs @@ -1,4 +1,4 @@ -using GroupService.Domain.Enums; +using GroupService.Domain.Enums; using GroupService.Domain.Events; using IM.DomainCommons; @@ -65,13 +65,15 @@ namespace GroupService.Domain.Entities AddDomainEvent(new AllMembersBannedDomainEvent(this)); } - public void Ban() + public void Ban(bool notify = true) { Status = GroupState.Blocked; ModificationTime = DateTime.Now; - AddDomainEvent(new GroupBlockedDomainEvent(this)); + if (notify) AddDomainEvent(new GroupBlockedDomainEvent(this)); } + public void Unban() { Status = GroupState.Normal; ModificationTime = DateTime.Now; } + public void Update(string? name, GroupAuthorityType? groupAuthority, string? announcement, string? avatar) { bool isChanged = false; diff --git a/GroupService.Infrastructure/Migrations/20260915000100_ManagementReceipts.cs b/GroupService.Infrastructure/Migrations/20260915000100_ManagementReceipts.cs new file mode 100644 index 0000000..e5c5bd4 --- /dev/null +++ b/GroupService.Infrastructure/Migrations/20260915000100_ManagementReceipts.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +namespace GroupService.Infrastructure.Migrations; +[DbContext(typeof(GroupDbContext))] +[Migration("20260915000100_ManagementReceipts")] +public class ManagementReceipts : Migration +{ + protected override void Up(MigrationBuilder m) => m.Sql(""" + CREATE TABLE IF NOT EXISTS management_receipts ( + Owner varchar(64) NOT NULL, + Id char(36) NOT NULL, + Payload longtext NOT NULL, + CreatedAt datetime(6) NOT NULL, + PRIMARY KEY (Owner, Id) + ) CHARACTER SET utf8mb4; + """); + // Receipts are retained on rollback: losing idempotency history can repeat a previously applied action. + protected override void Down(MigrationBuilder m) { } +} \ No newline at end of file diff --git a/GroupService.WebApi/Application/Group/GroupService.cs b/GroupService.WebApi/Application/Group/GroupService.cs index 6cd69ba..b692587 100644 --- a/GroupService.WebApi/Application/Group/GroupService.cs +++ b/GroupService.WebApi/Application/Group/GroupService.cs @@ -1,7 +1,8 @@ -using AutoMapper; +using AutoMapper; using GroupService.Domain.IReposities; using GroupService.WebApi.Application.Dtos; using IM.Commons; +using Microsoft.EntityFrameworkCore; namespace GroupService.WebApi.Application.Group { @@ -9,18 +10,22 @@ namespace GroupService.WebApi.Application.Group { private readonly IGroupReposity reposity; private readonly IGroupMemberReposity memberReposity; - private readonly IMapper mapper; + private readonly IMapper mapper; private readonly IM.InitCommon.Management.RuntimePolicy runtime; private readonly global::GroupService.Infrastructure.GroupDbContext db; - public GroupService(IGroupReposity reposity, IGroupMemberReposity memberReposity, IMapper mapper) + public GroupService(IGroupReposity reposity, IGroupMemberReposity memberReposity, IMapper mapper, IM.InitCommon.Management.RuntimePolicy runtime, global::GroupService.Infrastructure.GroupDbContext db) { this.reposity = reposity; this.memberReposity = memberReposity; - this.mapper = mapper; + this.mapper = mapper; this.runtime = runtime; this.db = db; } public async Task> CreateAsync(GroupCreateCommand command) { + var policy = runtime.Current; + if (policy.CreatedGroupLimit > 0 && await db.Groups.CountAsync(x => x.GroupMaster == command.GroupMasterId) >= policy.CreatedGroupLimit) + return Result.Fail(ResultCode.PERMISSION_DENIED, "创建群组数量已达到平台上限"); var group = new Domain.Entities.Group(command.GroupMasterId, command.Name); + group.Update(null, (Domain.Enums.GroupAuthorityType)policy.DefaultJoinAuthority, null, null); reposity.Create(group); return Result.Success(mapper.Map(group)); } diff --git a/GroupService.WebApi/Application/GroupInvitation/GroupInvitationService.cs b/GroupService.WebApi/Application/GroupInvitation/GroupInvitationService.cs index dec78ee..48058e9 100644 --- a/GroupService.WebApi/Application/GroupInvitation/GroupInvitationService.cs +++ b/GroupService.WebApi/Application/GroupInvitation/GroupInvitationService.cs @@ -1,4 +1,4 @@ -using AutoMapper; +using AutoMapper; using GroupService.Domain.IReposities; using GroupService.Domain.ValueObjects; using GroupService.WebApi.Application.Dtos; @@ -46,7 +46,7 @@ namespace GroupService.WebApi.Application.GroupInvitation return Result.Success(mapper.Map(existing)); } - var group = await groupReposity.FindByIdAsync(groupId); + var group = await groupReposity.FindByIdAsync(groupId); if (group?.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组不可用或已被封禁"); var userProfile = new UserProfile() { @@ -75,7 +75,7 @@ namespace GroupService.WebApi.Application.GroupInvitation public async Task> CreateBatchAsync(Guid operatorId, List userIds, Guid groupId) { - var group = await groupReposity.FindByIdAsync(groupId); + var group = await groupReposity.FindByIdAsync(groupId); if (group?.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组不可用或已被封禁"); if (group is null) return Result.Fail(ResultCode.GROUP_NOT_FOUND); diff --git a/GroupService.WebApi/Application/GroupMember/GroupMemberService.cs b/GroupService.WebApi/Application/GroupMember/GroupMemberService.cs index 0662e2c..533512a 100644 --- a/GroupService.WebApi/Application/GroupMember/GroupMemberService.cs +++ b/GroupService.WebApi/Application/GroupMember/GroupMemberService.cs @@ -1,4 +1,4 @@ -using AutoMapper; +using AutoMapper; using GroupService.Domain; using GroupService.Domain.IReposities; using GroupService.WebApi.Application.Dtos; @@ -13,15 +13,15 @@ namespace GroupService.WebApi.Application.GroupMember private readonly IGroupReposity groupReposity; private readonly GroupMemberDomainService service; private readonly IIdentityIntegrationService idService; - private IMapper mapper; + private IMapper mapper; private readonly IM.InitCommon.Management.RuntimePolicy runtime; - public GroupMemberService(IGroupMemberReposity reposity, IGroupReposity groupReposity, GroupMemberDomainService service, IIdentityIntegrationService idService, IMapper mapper) + public GroupMemberService(IGroupMemberReposity reposity, IGroupReposity groupReposity, GroupMemberDomainService service, IIdentityIntegrationService idService, IMapper mapper, IM.InitCommon.Management.RuntimePolicy runtime) { this.reposity = reposity; this.groupReposity = groupReposity; this.service = service; this.idService = idService; - this.mapper = mapper; + this.mapper = mapper; this.runtime = runtime; } public async Task>> GetByGroupIdAsync(Guid groupId, Guid userId) @@ -49,6 +49,9 @@ namespace GroupService.WebApi.Application.GroupMember return Result.Fail(ResultCode.GROUP_NOT_FOUND); } + if (group.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组已被封禁,不能加入"); + var limit = runtime.Current.GroupMemberLimit; + if (limit > 0 && (await reposity.FindByGroupIdAsync(groupId)).Count() >= limit) throw new IM.DomainCommons.DomainException("群成员数量已达到平台上限"); var userRes = await idService.FindUserByIdAsync(userId); if (!userRes.Succeeded) { @@ -68,7 +71,7 @@ namespace GroupService.WebApi.Application.GroupMember public async Task> CheckMemberAsync(Guid groupId, Guid userId) { - var exist = await reposity.CheckMemberExistAsync(groupId, userId); + var group = await groupReposity.FindByIdAsync(groupId); var exist = group?.Status == Domain.Enums.GroupState.Normal && await reposity.CheckMemberExistAsync(groupId, userId); return Result.Success(exist); } diff --git a/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs b/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs index ed17071..d4f2880 100644 --- a/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs +++ b/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs @@ -1,4 +1,4 @@ -using AutoMapper; +using AutoMapper; using GroupService.Domain.Entities; using GroupService.Domain.IReposities; using GroupService.Domain.ValueObjects; @@ -33,6 +33,8 @@ namespace GroupService.WebApi.Application.GroupRequest return Result.Fail(ResultCode.GROUP_NOT_FOUND); } + if (group.Status != Domain.Enums.GroupState.Normal || group.Authority == Domain.Enums.GroupAuthorityType.NOT_ALLOWED_TO_JOIN) + return Result.Fail(ResultCode.PERMISSION_DENIED, "群组当前不允许加入"); var user = await idService.FindUserByIdAsync(userId); var groupProfile = new GroupProfile() diff --git a/GroupService.WebApi/Controllers/ManagementController.cs b/GroupService.WebApi/Controllers/ManagementController.cs new file mode 100644 index 0000000..862f6e5 --- /dev/null +++ b/GroupService.WebApi/Controllers/ManagementController.cs @@ -0,0 +1,40 @@ +using GroupService.Infrastructure; +using GroupService.Domain.Enums; +using IM.InitCommon.Management; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace GroupService.WebApi.Controllers; +[ApiController, Route("internal/management")] +public sealed class ManagementController(GroupDbContext db) : ControllerBase +{ + [HttpGet("summary")] public async Task Summary() => new { total = await db.Groups.CountAsync() }; + [HttpGet("list")] public async Task List(string? q, string? status, int page = 1, int size = 8) + { + page = Math.Max(1, page); size = Math.Clamp(size, 1, 100); + var query = db.Groups.AsNoTracking().Where(x => q == null || x.Name.Contains(q) || x.Id.ToString() == q || x.GroupMaster.ToString() == q || db.GroupMembers.Any(m => m.GroupId == x.Id && m.UserId == x.GroupMaster && m.GroupNickName.Contains(q))); + if (!string.IsNullOrEmpty(status)) query = query.Where(x => x.Status == (status == "封禁" ? GroupState.Blocked : GroupState.Normal)); + return new { items = await query.OrderByDescending(x => x.CreationTime).Skip((page - 1) * size).Take(size).Select(x => new { x.Id, x.Name, ownerId = x.GroupMaster, ownerName = db.GroupMembers.Where(m => m.GroupId == x.Id && m.UserId == x.GroupMaster).Select(m => m.GroupNickName).FirstOrDefault(), memberCount = db.GroupMembers.Count(m => m.GroupId == x.Id), status = x.Status == GroupState.Blocked ? "封禁" : "正常", createdAt = x.CreationTime }).ToListAsync(), total = await query.CountAsync(), page, size }; + } + [HttpGet("detail/{id:guid}")] public async Task Detail(Guid id) + { + var g = await db.Groups.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id); if (g is null) return NotFound(); + return Ok(new { g.Id, g.Name, ownerId = g.GroupMaster, g.Announcement, authority = g.Authority, memberCount = await db.GroupMembers.CountAsync(x => x.GroupId == id), status = g.Status == GroupState.Blocked ? "封禁" : "正常", createdAt = g.CreationTime }); + } + [HttpGet("members/{id:guid}")] public async Task Members(Guid id, string? q, int page = 1, int size = 8) + { + page = Math.Max(1, page); size = Math.Clamp(size, 1, 100); var query = db.GroupMembers.AsNoTracking().Where(x => x.GroupId == id && (q == null || x.GroupNickName.Contains(q) || x.UserId.ToString() == q)); + return new { items = await query.OrderBy(x => x.UserId).Skip((page - 1) * size).Take(size).Select(x => new { id = x.UserId, name = x.GroupNickName, x.Role }).ToListAsync(), total = await query.CountAsync(), page, size }; + } + [HttpGet("user/{id:guid}/groups")] public async Task UserGroups(Guid id) => await db.Groups.Where(x => db.GroupMembers.Any(m => m.GroupId == x.Id && m.UserId == id)).Select(x => new { x.Id, x.Name, status = x.Status == GroupState.Blocked ? "封禁" : "正常" }).Take(100).ToListAsync(); + [HttpGet("access/{id:guid}/{userId:guid}")] public async Task Access(Guid id, Guid userId) => new { exists = await db.Groups.AnyAsync(x => x.Id == id), enabled = await db.Groups.AnyAsync(x => x.Id == id && x.Status == GroupState.Normal), member = await db.GroupMembers.AnyAsync(x => x.GroupId == id && x.UserId == userId) }; + [HttpPost("action")] public async Task Action(InternalAction command, CancellationToken ct) + { + if (command.Action is not "封禁" and not "解封") return BadRequest(); + return Ok(await ReceiptStore.Execute(db, command, async () => { + var g = await db.Groups.SingleOrDefaultAsync(x => x.Id == command.TargetId, ct) ?? throw new InvalidOperationException("群组不存在"); + var before = g.Status == GroupState.Blocked ? "封禁" : "正常"; if (command.Action == "封禁") g.Ban(notify: false); else g.Unban(); + return new ActionReceipt(g.Name, before, command.Action == "封禁" ? "封禁" : "正常"); + }, ct)); + } +} diff --git a/GroupService.WebApi/Program.cs b/GroupService.WebApi/Program.cs index 05a7d19..4737a31 100644 --- a/GroupService.WebApi/Program.cs +++ b/GroupService.WebApi/Program.cs @@ -1,3 +1,4 @@ +using IM.InitCommon.Management; using IM.InitCommon; @@ -19,6 +20,7 @@ namespace GroupService.WebApi builder.ConfigExtraServices(); var app = builder.Build(); + if (app.ApplyMigrationsIfRequested(args)) return; // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -28,6 +30,7 @@ namespace GroupService.WebApi } app.UseAppDefault(); + app.MapManagementHealth(); app.MapControllers(); diff --git a/IM.InitCommon/AddDbContextExtensions.cs b/IM.InitCommon/AddDbContextExtensions.cs index 7fc16f4..c20ccac 100644 --- a/IM.InitCommon/AddDbContextExtensions.cs +++ b/IM.InitCommon/AddDbContextExtensions.cs @@ -25,6 +25,7 @@ namespace IM.InitCommon //similar to serviceCollection.AddDbContextPool(opt=>new DbContextOptionsBuilder(dbCtxOpt)); var methodGenericAddDbContext = methodAddDbContext.MakeGenericMethod(dbCtxType); methodGenericAddDbContext.Invoke(null, new object[] { services, action, ServiceLifetime.Scoped, ServiceLifetime.Scoped }); + services.AddScoped(sp => new Management.ManagementDbProbe((DbContext)sp.GetRequiredService(dbCtxType))); } } return services; diff --git a/IM.InitCommon/ApplicationBuilderExtension.cs b/IM.InitCommon/ApplicationBuilderExtension.cs index 2956b5b..829f6b4 100644 --- a/IM.InitCommon/ApplicationBuilderExtension.cs +++ b/IM.InitCommon/ApplicationBuilderExtension.cs @@ -1,5 +1,6 @@ using IM.ASPNETCore; using Microsoft.AspNetCore.Builder; +using IM.InitCommon.Management; namespace IM.InitCommon { @@ -9,6 +10,7 @@ namespace IM.InitCommon { app.UseCors(); app.UseAuthentication(); + app.UseManagementRuntime(); app.UseAuthorization(); app.UseMiddleware(); app.UseForwardedHeaders(); diff --git a/IM.InitCommon/Management/Contracts.cs b/IM.InitCommon/Management/Contracts.cs new file mode 100644 index 0000000..334910f --- /dev/null +++ b/IM.InitCommon/Management/Contracts.cs @@ -0,0 +1,42 @@ +using System.Text.Json.Nodes; + +namespace IM.InitCommon.Management; + +public record PageResult(IReadOnlyList Items, int Total, int Page, int Size); +public record InternalAction(Guid Id, Guid ActorId, Guid TargetId, string Action, string Reason); +public record ActionReceipt(string TargetName, string Before, string After); +public record EvidenceRequest(Guid ReporterId, string Type, Guid TargetId, Guid[] MessageIds); +public record EvidenceSnapshot(Guid Id, Guid SenderId, string SenderName, string Text, string Type, Guid? FileId, DateTimeOffset SentAt); +public record SubjectEvidence(string TargetName, IReadOnlyList Evidence); +public record UserAccess(bool Enabled, string Stamp); +public record ServiceHealth(string Service, string Status, double? LatencyMs, DateTime CheckedAt, DateTime? LastSuccessAt, string? Error, long? ConfigVersion = null); +public sealed class Policy +{ + public long Version { get; set; } + public string PlatformName { get; set; } = "IM"; + public string Description { get; set; } = ""; + public string SupportEmail { get; set; } = ""; + public bool RegistrationEnabled { get; set; } = true; + public int PasswordMinLength { get; set; } = 6; + public int FriendLimit { get; set; } + public int CreatedGroupLimit { get; set; } + public int GroupMemberLimit { get; set; } + public int DefaultJoinAuthority { get; set; } + public int TextLimit { get; set; } + public int RecallMinutes { get; set; } + public long UploadMaxBytes { get; set; } + public string[] AllowedFileTypes { get; set; } = []; + public string[] ReportCategories { get; set; } = ["推广信息", "骚扰辱骂", "冒充身份", "其他"]; + public int ReportsPerDay { get; set; } = 20; + public int ReportCooldownMinutes { get; set; } = 10; + public int ClientAccessMinutes { get; set; } + public int ClientRefreshDays { get; set; } + public int AdminSessionMinutes { get; set; } = 480; + public int AdminLockThreshold { get; set; } = 5; + public int AdminLockMinutes { get; set; } = 15; +} +public static class ManagementResult +{ + public static object Ok(object? data = null) => new { code = 0, message = "成功", data }; + public static object Fail(string message) => new { code = 1003, message, data = (object?)null }; +} diff --git a/IM.InitCommon/Management/DeploymentCommands.cs b/IM.InitCommon/Management/DeploymentCommands.cs new file mode 100644 index 0000000..0b7da94 --- /dev/null +++ b/IM.InitCommon/Management/DeploymentCommands.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.EntityFrameworkCore; + +namespace IM.InitCommon.Management; +public static class DeploymentCommands +{ + public static bool ApplyMigrationsIfRequested(this WebApplication app, string[] args) + { + if (!args.Contains("--migrate")) return false; + using var scope = app.Services.CreateScope(); + foreach (var probe in scope.ServiceProvider.GetServices().Where(x => x.Context.Database.GetMigrations().Any())) + probe.Context.Database.Migrate(); + Console.WriteLine("服务数据库迁移完成"); return true; + } +} diff --git a/IM.InitCommon/Management/InternalClient.cs b/IM.InitCommon/Management/InternalClient.cs new file mode 100644 index 0000000..9af9019 --- /dev/null +++ b/IM.InitCommon/Management/InternalClient.cs @@ -0,0 +1,35 @@ +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace IM.InitCommon.Management; + +public sealed class InternalClient(HttpClient http, IConfiguration config) +{ + public async Task Send(string service, string path, object? body = null, CancellationToken ct = default) + { + var address = config[$"Management:Services:{service}"] ?? throw new InvalidOperationException($"未配置服务 {service}"); + var key = config["Management:InternalKey"] ?? config["InternalApiKey"]; + if (string.IsNullOrWhiteSpace(key)) throw new InvalidOperationException("未配置内部服务密钥"); + using var request = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, new Uri(new Uri(address.TrimEnd('/') + "/"), path.TrimStart('/'))); + request.Headers.Add("X-IM-Management-Key", key); + if (body is not null) request.Content = JsonContent.Create(body); + using var response = await http.SendAsync(request, ct); + if (!response.IsSuccessStatusCode) throw new InternalServiceException(service, (int)response.StatusCode); + return await response.Content.ReadFromJsonAsync(cancellationToken: ct) ?? throw new InvalidOperationException("服务响应为空"); + } + public static bool Authorized(HttpContext context) + { + var c = context.RequestServices.GetService(typeof(IConfiguration)) as IConfiguration; + var expected = c?["Management:InternalKey"] ?? c?["InternalApiKey"]; + var actual = context.Request.Headers["X-IM-Management-Key"].ToString(); + return !string.IsNullOrWhiteSpace(expected) && CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(expected)), SHA256.HashData(Encoding.UTF8.GetBytes(actual))); + } +} +public sealed class InternalServiceException(string service, int status) : Exception($"{service} 服务请求失败({status})") +{ + public int Status { get; } = status; +} diff --git a/IM.InitCommon/Management/ReceiptStore.cs b/IM.InitCommon/Management/ReceiptStore.cs new file mode 100644 index 0000000..a22204b --- /dev/null +++ b/IM.InitCommon/Management/ReceiptStore.cs @@ -0,0 +1,29 @@ +using System.Data; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; + +namespace IM.InitCommon.Management; +public static class ReceiptStore +{ + public static async Task Execute(DbContext db, InternalAction command, Func> apply, CancellationToken ct) + { + await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct); + var connection = db.Database.GetDbConnection(); + await using var read = connection.CreateCommand(); read.Transaction = tx.GetDbTransaction(); + var owner = db.GetType().Name; + read.CommandText = "SELECT Payload FROM management_receipts WHERE Owner = @owner AND Id = @id FOR UPDATE"; + var ownerParameter = read.CreateParameter(); ownerParameter.ParameterName = "@owner"; ownerParameter.Value = owner; read.Parameters.Add(ownerParameter); + var parameter = read.CreateParameter(); parameter.ParameterName = "@id"; parameter.Value = command.Id.ToString(); read.Parameters.Add(parameter); + var existing = await read.ExecuteScalarAsync(ct); + if (existing is string json) { + var saved = JsonSerializer.Deserialize(json)!; + if (saved.Command != command) throw new InvalidOperationException("操作 ID 已被其他请求使用"); + await tx.CommitAsync(ct); return saved.Result; + } + var receipt = await apply(); await db.SaveChangesAsync(ct); + await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO management_receipts (Owner, Id, Payload, CreatedAt) VALUES ({owner}, {command.Id.ToString()}, {JsonSerializer.Serialize(new SavedReceipt(command, receipt))}, {DateTime.UtcNow})", ct); + await tx.CommitAsync(ct); return receipt; + } + private record SavedReceipt(InternalAction Command, ActionReceipt Result); +} diff --git a/IM.InitCommon/Management/RuntimePolicy.cs b/IM.InitCommon/Management/RuntimePolicy.cs new file mode 100644 index 0000000..78ba3d5 --- /dev/null +++ b/IM.InitCommon/Management/RuntimePolicy.cs @@ -0,0 +1,92 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; + +namespace IM.InitCommon.Management; +public record InfrastructureEnvelope(long Version, JsonObject Value, string Secret); +public record ManagementDbProbe(DbContext Context); +public sealed class RuntimePolicy(IServiceProvider services, IConfiguration config, ILogger logger) : BackgroundService +{ + private Policy? current; + public InfrastructureEnvelope? Storage { get; private set; } + public bool Enabled => config.GetValue("Management:Enabled"); + public Policy Current => current ?? (!Enabled ? new Policy() : throw new InvalidOperationException("管理配置尚未加载")); + public long AppliedVersion => current?.Version ?? 0; + public DateTime? LastLoadedAt { get; private set; } + protected override async Task ExecuteAsync(CancellationToken ct) + { + if (!Enabled) return; + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20)); + do { + try { + var client = services.GetRequiredService(); + current = await client.Send("admin", "/internal/management/policy", ct: ct); + if (config["Management:ServiceName"] == "file") { + var storage = await client.Send("admin", "/internal/management/infrastructure/storage", ct: ct); + if (Storage?.Version != storage.Version) { Storage = storage; services.GetService>()?.Clear(); } + } + LastLoadedAt = DateTime.UtcNow; + } catch (Exception e) when (!ct.IsCancellationRequested) { logger.LogWarning("Policy refresh failed; keeping last committed version: {Type}", e.GetType().Name); } + } while (await timer.WaitForNextTickAsync(ct)); + } + public void CheckFile(string fileName, long size) + { + var p = Current; + if (p.UploadMaxBytes > 0 && size > p.UploadMaxBytes) throw new IM.DomainCommons.DomainException("文件超过平台上传大小限制"); + if (p.AllowedFileTypes.Length > 0 && !p.AllowedFileTypes.Contains(Path.GetExtension(fileName).ToLowerInvariant())) throw new IM.DomainCommons.DomainException("平台不允许上传此文件类型"); + } +} +public sealed class DynamicStorageOptions(RuntimePolicy runtime) : IPostConfigureOptions +{ + public void PostConfigure(string? name, StorageOptions options) + { + var envelope = runtime.Storage; + if (envelope is null || !envelope.Value.ContainsKey("providers")) return; + var configured = envelope.Value.Deserialize(new JsonSerializerOptions(JsonSerializerDefaults.Web))!; + var secrets = JsonNode.Parse(string.IsNullOrEmpty(envelope.Secret) ? "{}" : envelope.Secret)!.AsObject(); + foreach (var (code, provider) in configured.Providers) { + if (secrets[code] is JsonObject credential) { + provider.AccessKeyId = credential["accessKeyId"]?.GetValue(); + provider.AccessKeySecret = credential["accessKeySecret"]?.GetValue(); + } + } + options.DefaultProviderCode = configured.DefaultProviderCode; options.Providers = configured.Providers; + } +} +public static class RuntimeManagementExtensions +{ + public static IServiceCollection AddManagementRuntime(this IServiceCollection services) + { + services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(4)); + services.AddSingleton(); services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton, DynamicStorageOptions>(); + return services; + } + public static IApplicationBuilder UseManagementRuntime(this IApplicationBuilder app) => app.Use(async (c, next) => { + if (c.Request.Path.StartsWithSegments("/internal/management")) { + if (!InternalClient.Authorized(c)) { c.Response.StatusCode = 403; return; } + await next(); return; + } + var policy = c.RequestServices.GetRequiredService(); + if (policy.Enabled && c.User.Identity?.IsAuthenticated == true && Guid.TryParse(c.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) { + try { + var user = await c.RequestServices.GetRequiredService().Send("user", $"/internal/management/access/{userId}", ct: c.RequestAborted); + if (!user.Enabled || user.Stamp != c.User.FindFirstValue("session_stamp")) { c.Response.StatusCode = 401; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("账号已被封禁或会话已撤销,请重新登录")); return; } + } catch { c.Response.StatusCode = 503; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("账号状态验证暂不可用,请稍后重试")); return; } + } + await next(); + }); + public static void MapManagementHealth(this WebApplication app) => app.MapGet("/internal/management/health", async (RuntimePolicy runtime, IConfiguration config, IEnumerable probes, CancellationToken ct) => { + var ready = !runtime.Enabled || runtime.LastLoadedAt is not null; + foreach (var probe in probes) ready &= await probe.Context.Database.CanConnectAsync(ct); + return new { service = config["Management:ServiceName"], status = ready ? "healthy" : "unavailable", configVersion = runtime.AppliedVersion, storageVersion = runtime.Storage?.Version, runtime.LastLoadedAt }; + }); +} diff --git a/IM.InitCommon/StorageOptions.cs b/IM.InitCommon/StorageOptions.cs index f07c293..56babf8 100644 --- a/IM.InitCommon/StorageOptions.cs +++ b/IM.InitCommon/StorageOptions.cs @@ -34,9 +34,9 @@ namespace IM.InitCommon public string? PublicBaseUrl { get; init; } - public string? AccessKeyId { get; init; } + public string? AccessKeyId { get; set; } - public string? AccessKeySecret { get; init; } + public string? AccessKeySecret { get; set; } public string? LocalRootPath { get; init; } diff --git a/IM.InitCommon/WebApplicationBuilderExtensions.cs b/IM.InitCommon/WebApplicationBuilderExtensions.cs index 9ce8912..d97616d 100644 --- a/IM.InitCommon/WebApplicationBuilderExtensions.cs +++ b/IM.InitCommon/WebApplicationBuilderExtensions.cs @@ -1,4 +1,4 @@ -using FluentValidation; +using FluentValidation; using FluentValidation.AspNetCore; using IM.ASPNETCore; using IM.Commons; @@ -14,6 +14,7 @@ using RedLockNet.SERedis.Configuration; using StackExchange.Redis; using Swashbuckle.AspNetCore.SwaggerGen; using Winton.Extensions.Configuration.Consul; +using IM.InitCommon.Management; namespace IM.InitCommon { @@ -21,7 +22,7 @@ namespace IM.InitCommon { public static void ConfigureDbConfiguration(this WebApplicationBuilder builder) { - builder.Host.ConfigureAppConfiguration((hostCtx, configbuilder) => + if (!builder.Configuration.GetValue("Consul:Enabled", true)) return; builder.Host.ConfigureAppConfiguration((hostCtx, configbuilder) => { var env = hostCtx.HostingEnvironment; @@ -66,6 +67,7 @@ namespace IM.InitCommon { var services = builder.Services; var configuration = builder.Configuration; + services.AddManagementRuntime(); var assemblies = ReflectionHelper.GetAllReferencedAssemblies(); diff --git a/IM.Jwt/ITokenService.cs b/IM.Jwt/ITokenService.cs index 6bb1826..e11e25f 100644 --- a/IM.Jwt/ITokenService.cs +++ b/IM.Jwt/ITokenService.cs @@ -11,8 +11,8 @@ namespace IM.Jwt /// /// string GetToken(IEnumerable claims, JwtOptions options); - Task CreateRefreshTokenAsync(Guid userId, CancellationToken cancellationToken = default); + Task CreateRefreshTokenAsync(Guid userId, CancellationToken cancellationToken = default, string? stamp = null, int? days = null); Task RevokeRefreshTokenAsync(string refreshToken); - Task<(bool ok, Guid userId)> ValidateRefreshTokenAsync(string token, CancellationToken cancellation = default); + Task<(bool ok, Guid userId, string? stamp)> ValidateRefreshTokenAsync(string token, CancellationToken cancellation = default); } } diff --git a/IM.Jwt/TokenService.cs b/IM.Jwt/TokenService.cs index f1fb98c..0bfd344 100644 --- a/IM.Jwt/TokenService.cs +++ b/IM.Jwt/TokenService.cs @@ -26,13 +26,13 @@ namespace IM.Jwt var bytes = RandomNumberGenerator.GetBytes(32); return Convert.ToBase64String(bytes); } - public async Task CreateRefreshTokenAsync(Guid userId, CancellationToken cancellationToken = default) + public async Task CreateRefreshTokenAsync(Guid userId, CancellationToken cancellationToken = default, string? stamp = null, int? days = null) { string token = GenerateTokenStr(); - var payload = new { UserId = userId, CreateAt = DateTime.Now }; + var payload = new { UserId = userId, CreateAt = DateTime.UtcNow, Stamp = stamp }; string json = JsonConvert.SerializeObject(payload); //token写入redis - await _redis.StringSetAsync(RedisHelper.GetRefreshTokenKey(token), json, TimeSpan.FromDays(_options.Value.RefreshTokenDays)); + await _redis.StringSetAsync(RedisHelper.GetRefreshTokenKey(token), json, TimeSpan.FromDays(days is > 0 ? days.Value : _options.Value.RefreshTokenDays)); return token; } @@ -51,19 +51,19 @@ namespace IM.Jwt await _redis.KeyDeleteAsync(RedisHelper.GetRefreshTokenKey(refreshToken)); } - public async Task<(bool ok, Guid userId)> ValidateRefreshTokenAsync(string token, CancellationToken cancellation = default) + public async Task<(bool ok, Guid userId, string? stamp)> ValidateRefreshTokenAsync(string token, CancellationToken cancellation = default) { var json = await _redis.StringGetAsync(RedisHelper.GetRefreshTokenKey(token)); - if (json.IsNullOrEmpty) return (false, Guid.Empty); + if (json.IsNullOrEmpty) return (false, Guid.Empty, null); try { using var doc = JsonDocument.Parse(json.ToString()); var userId = doc.RootElement.GetProperty("UserId").GetGuid(); - return (true, userId); + return (true, userId, doc.RootElement.TryGetProperty("Stamp", out var stamp) ? stamp.GetString() : null); } catch { - return (false, Guid.Empty); + return (false, Guid.Empty, null); } } } diff --git a/IM_API_NEW.sln b/IM_API_NEW.sln index 4833347..172e42c 100644 --- a/IM_API_NEW.sln +++ b/IM_API_NEW.sln @@ -67,112 +67,358 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FileService.Application", " EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IM.Application", "IM.Application\IM.Application.csproj", "{3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MessageService.Tests", "MessageService.Tests\MessageService.Tests.csproj", "{EA519DC4-9025-43E5-A4BE-535818F739CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Admin.WebApi", "Admin.WebApi\Admin.WebApi.csproj", "{384DB4B1-C0C8-41E2-A614-3D27E0356B68}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Admin.Tests", "Admin.Tests\Admin.Tests.csproj", "{5094E3D2-D57C-4654-8459-2DF25D8ECFF6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Debug|x64.ActiveCfg = Debug|Any CPU + {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Debug|x64.Build.0 = Debug|Any CPU + {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Debug|x86.ActiveCfg = Debug|Any CPU + {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Debug|x86.Build.0 = Debug|Any CPU {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Release|Any CPU.ActiveCfg = Release|Any CPU {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Release|Any CPU.Build.0 = Release|Any CPU + {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Release|x64.ActiveCfg = Release|Any CPU + {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Release|x64.Build.0 = Release|Any CPU + {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Release|x86.ActiveCfg = Release|Any CPU + {A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Release|x86.Build.0 = Release|Any CPU {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Debug|x64.ActiveCfg = Debug|Any CPU + {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Debug|x64.Build.0 = Debug|Any CPU + {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Debug|x86.ActiveCfg = Debug|Any CPU + {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Debug|x86.Build.0 = Debug|Any CPU {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Release|Any CPU.ActiveCfg = Release|Any CPU {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Release|Any CPU.Build.0 = Release|Any CPU + {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Release|x64.ActiveCfg = Release|Any CPU + {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Release|x64.Build.0 = Release|Any CPU + {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Release|x86.ActiveCfg = Release|Any CPU + {DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Release|x86.Build.0 = Release|Any CPU {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Debug|x64.ActiveCfg = Debug|Any CPU + {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Debug|x64.Build.0 = Debug|Any CPU + {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Debug|x86.ActiveCfg = Debug|Any CPU + {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Debug|x86.Build.0 = Debug|Any CPU {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Release|Any CPU.ActiveCfg = Release|Any CPU {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Release|Any CPU.Build.0 = Release|Any CPU + {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Release|x64.ActiveCfg = Release|Any CPU + {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Release|x64.Build.0 = Release|Any CPU + {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Release|x86.ActiveCfg = Release|Any CPU + {C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Release|x86.Build.0 = Release|Any CPU {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Debug|x64.ActiveCfg = Debug|Any CPU + {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Debug|x64.Build.0 = Debug|Any CPU + {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Debug|x86.ActiveCfg = Debug|Any CPU + {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Debug|x86.Build.0 = Debug|Any CPU {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Release|Any CPU.ActiveCfg = Release|Any CPU {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Release|Any CPU.Build.0 = Release|Any CPU + {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Release|x64.ActiveCfg = Release|Any CPU + {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Release|x64.Build.0 = Release|Any CPU + {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Release|x86.ActiveCfg = Release|Any CPU + {148C0E23-8225-4790-A920-6C5DE6C8FF50}.Release|x86.Build.0 = Release|Any CPU {B245AB7B-841A-469E-950D-B08E4C1C8094}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B245AB7B-841A-469E-950D-B08E4C1C8094}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B245AB7B-841A-469E-950D-B08E4C1C8094}.Debug|x64.ActiveCfg = Debug|Any CPU + {B245AB7B-841A-469E-950D-B08E4C1C8094}.Debug|x64.Build.0 = Debug|Any CPU + {B245AB7B-841A-469E-950D-B08E4C1C8094}.Debug|x86.ActiveCfg = Debug|Any CPU + {B245AB7B-841A-469E-950D-B08E4C1C8094}.Debug|x86.Build.0 = Debug|Any CPU {B245AB7B-841A-469E-950D-B08E4C1C8094}.Release|Any CPU.ActiveCfg = Release|Any CPU {B245AB7B-841A-469E-950D-B08E4C1C8094}.Release|Any CPU.Build.0 = Release|Any CPU + {B245AB7B-841A-469E-950D-B08E4C1C8094}.Release|x64.ActiveCfg = Release|Any CPU + {B245AB7B-841A-469E-950D-B08E4C1C8094}.Release|x64.Build.0 = Release|Any CPU + {B245AB7B-841A-469E-950D-B08E4C1C8094}.Release|x86.ActiveCfg = Release|Any CPU + {B245AB7B-841A-469E-950D-B08E4C1C8094}.Release|x86.Build.0 = Release|Any CPU {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Debug|x64.ActiveCfg = Debug|Any CPU + {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Debug|x64.Build.0 = Debug|Any CPU + {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Debug|x86.ActiveCfg = Debug|Any CPU + {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Debug|x86.Build.0 = Debug|Any CPU {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Release|Any CPU.ActiveCfg = Release|Any CPU {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Release|Any CPU.Build.0 = Release|Any CPU + {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Release|x64.ActiveCfg = Release|Any CPU + {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Release|x64.Build.0 = Release|Any CPU + {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Release|x86.ActiveCfg = Release|Any CPU + {E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Release|x86.Build.0 = Release|Any CPU {6795A287-3488-B0A3-B242-C19526B6A88D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6795A287-3488-B0A3-B242-C19526B6A88D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6795A287-3488-B0A3-B242-C19526B6A88D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6795A287-3488-B0A3-B242-C19526B6A88D}.Debug|x64.Build.0 = Debug|Any CPU + {6795A287-3488-B0A3-B242-C19526B6A88D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6795A287-3488-B0A3-B242-C19526B6A88D}.Debug|x86.Build.0 = Debug|Any CPU {6795A287-3488-B0A3-B242-C19526B6A88D}.Release|Any CPU.ActiveCfg = Release|Any CPU {6795A287-3488-B0A3-B242-C19526B6A88D}.Release|Any CPU.Build.0 = Release|Any CPU + {6795A287-3488-B0A3-B242-C19526B6A88D}.Release|x64.ActiveCfg = Release|Any CPU + {6795A287-3488-B0A3-B242-C19526B6A88D}.Release|x64.Build.0 = Release|Any CPU + {6795A287-3488-B0A3-B242-C19526B6A88D}.Release|x86.ActiveCfg = Release|Any CPU + {6795A287-3488-B0A3-B242-C19526B6A88D}.Release|x86.Build.0 = Release|Any CPU {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Debug|x64.ActiveCfg = Debug|Any CPU + {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Debug|x64.Build.0 = Debug|Any CPU + {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Debug|x86.ActiveCfg = Debug|Any CPU + {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Debug|x86.Build.0 = Debug|Any CPU {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Release|Any CPU.ActiveCfg = Release|Any CPU {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Release|Any CPU.Build.0 = Release|Any CPU + {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Release|x64.ActiveCfg = Release|Any CPU + {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Release|x64.Build.0 = Release|Any CPU + {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Release|x86.ActiveCfg = Release|Any CPU + {096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Release|x86.Build.0 = Release|Any CPU {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Debug|x64.ActiveCfg = Debug|Any CPU + {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Debug|x64.Build.0 = Debug|Any CPU + {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Debug|x86.ActiveCfg = Debug|Any CPU + {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Debug|x86.Build.0 = Debug|Any CPU {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Release|Any CPU.ActiveCfg = Release|Any CPU {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Release|Any CPU.Build.0 = Release|Any CPU + {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Release|x64.ActiveCfg = Release|Any CPU + {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Release|x64.Build.0 = Release|Any CPU + {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Release|x86.ActiveCfg = Release|Any CPU + {2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Release|x86.Build.0 = Release|Any CPU {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Debug|x64.Build.0 = Debug|Any CPU + {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Debug|x86.ActiveCfg = Debug|Any CPU + {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Debug|x86.Build.0 = Debug|Any CPU {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Release|Any CPU.ActiveCfg = Release|Any CPU {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Release|Any CPU.Build.0 = Release|Any CPU + {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Release|x64.ActiveCfg = Release|Any CPU + {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Release|x64.Build.0 = Release|Any CPU + {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Release|x86.ActiveCfg = Release|Any CPU + {11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Release|x86.Build.0 = Release|Any CPU {130FE785-7DCA-4609-9E9C-5257198FE36A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {130FE785-7DCA-4609-9E9C-5257198FE36A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {130FE785-7DCA-4609-9E9C-5257198FE36A}.Debug|x64.ActiveCfg = Debug|Any CPU + {130FE785-7DCA-4609-9E9C-5257198FE36A}.Debug|x64.Build.0 = Debug|Any CPU + {130FE785-7DCA-4609-9E9C-5257198FE36A}.Debug|x86.ActiveCfg = Debug|Any CPU + {130FE785-7DCA-4609-9E9C-5257198FE36A}.Debug|x86.Build.0 = Debug|Any CPU {130FE785-7DCA-4609-9E9C-5257198FE36A}.Release|Any CPU.ActiveCfg = Release|Any CPU {130FE785-7DCA-4609-9E9C-5257198FE36A}.Release|Any CPU.Build.0 = Release|Any CPU + {130FE785-7DCA-4609-9E9C-5257198FE36A}.Release|x64.ActiveCfg = Release|Any CPU + {130FE785-7DCA-4609-9E9C-5257198FE36A}.Release|x64.Build.0 = Release|Any CPU + {130FE785-7DCA-4609-9E9C-5257198FE36A}.Release|x86.ActiveCfg = Release|Any CPU + {130FE785-7DCA-4609-9E9C-5257198FE36A}.Release|x86.Build.0 = Release|Any CPU {EB435E96-1088-49DF-AF28-74098BFEA14D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EB435E96-1088-49DF-AF28-74098BFEA14D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB435E96-1088-49DF-AF28-74098BFEA14D}.Debug|x64.ActiveCfg = Debug|Any CPU + {EB435E96-1088-49DF-AF28-74098BFEA14D}.Debug|x64.Build.0 = Debug|Any CPU + {EB435E96-1088-49DF-AF28-74098BFEA14D}.Debug|x86.ActiveCfg = Debug|Any CPU + {EB435E96-1088-49DF-AF28-74098BFEA14D}.Debug|x86.Build.0 = Debug|Any CPU {EB435E96-1088-49DF-AF28-74098BFEA14D}.Release|Any CPU.ActiveCfg = Release|Any CPU {EB435E96-1088-49DF-AF28-74098BFEA14D}.Release|Any CPU.Build.0 = Release|Any CPU + {EB435E96-1088-49DF-AF28-74098BFEA14D}.Release|x64.ActiveCfg = Release|Any CPU + {EB435E96-1088-49DF-AF28-74098BFEA14D}.Release|x64.Build.0 = Release|Any CPU + {EB435E96-1088-49DF-AF28-74098BFEA14D}.Release|x86.ActiveCfg = Release|Any CPU + {EB435E96-1088-49DF-AF28-74098BFEA14D}.Release|x86.Build.0 = Release|Any CPU {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Debug|x64.ActiveCfg = Debug|Any CPU + {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Debug|x64.Build.0 = Debug|Any CPU + {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Debug|x86.ActiveCfg = Debug|Any CPU + {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Debug|x86.Build.0 = Debug|Any CPU {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Release|Any CPU.ActiveCfg = Release|Any CPU {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Release|Any CPU.Build.0 = Release|Any CPU + {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Release|x64.ActiveCfg = Release|Any CPU + {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Release|x64.Build.0 = Release|Any CPU + {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Release|x86.ActiveCfg = Release|Any CPU + {9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Release|x86.Build.0 = Release|Any CPU {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Debug|x64.ActiveCfg = Debug|Any CPU + {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Debug|x64.Build.0 = Debug|Any CPU + {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Debug|x86.ActiveCfg = Debug|Any CPU + {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Debug|x86.Build.0 = Debug|Any CPU {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Release|Any CPU.ActiveCfg = Release|Any CPU {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Release|Any CPU.Build.0 = Release|Any CPU + {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Release|x64.ActiveCfg = Release|Any CPU + {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Release|x64.Build.0 = Release|Any CPU + {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Release|x86.ActiveCfg = Release|Any CPU + {80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Release|x86.Build.0 = Release|Any CPU {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Debug|x64.ActiveCfg = Debug|Any CPU + {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Debug|x64.Build.0 = Debug|Any CPU + {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Debug|x86.Build.0 = Debug|Any CPU {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Release|Any CPU.Build.0 = Release|Any CPU + {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Release|x64.ActiveCfg = Release|Any CPU + {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Release|x64.Build.0 = Release|Any CPU + {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Release|x86.ActiveCfg = Release|Any CPU + {E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Release|x86.Build.0 = Release|Any CPU {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Debug|x64.ActiveCfg = Debug|Any CPU + {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Debug|x64.Build.0 = Debug|Any CPU + {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Debug|x86.ActiveCfg = Debug|Any CPU + {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Debug|x86.Build.0 = Debug|Any CPU {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Release|Any CPU.ActiveCfg = Release|Any CPU {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Release|Any CPU.Build.0 = Release|Any CPU + {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Release|x64.ActiveCfg = Release|Any CPU + {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Release|x64.Build.0 = Release|Any CPU + {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Release|x86.ActiveCfg = Release|Any CPU + {AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Release|x86.Build.0 = Release|Any CPU {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Debug|x64.ActiveCfg = Debug|Any CPU + {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Debug|x64.Build.0 = Debug|Any CPU + {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Debug|x86.ActiveCfg = Debug|Any CPU + {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Debug|x86.Build.0 = Debug|Any CPU {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Release|Any CPU.ActiveCfg = Release|Any CPU {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Release|Any CPU.Build.0 = Release|Any CPU + {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Release|x64.ActiveCfg = Release|Any CPU + {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Release|x64.Build.0 = Release|Any CPU + {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Release|x86.ActiveCfg = Release|Any CPU + {2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Release|x86.Build.0 = Release|Any CPU {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Debug|Any CPU.Build.0 = Debug|Any CPU + {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Debug|x64.ActiveCfg = Debug|Any CPU + {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Debug|x64.Build.0 = Debug|Any CPU + {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Debug|x86.ActiveCfg = Debug|Any CPU + {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Debug|x86.Build.0 = Debug|Any CPU {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Release|Any CPU.ActiveCfg = Release|Any CPU {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Release|Any CPU.Build.0 = Release|Any CPU + {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Release|x64.ActiveCfg = Release|Any CPU + {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Release|x64.Build.0 = Release|Any CPU + {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Release|x86.ActiveCfg = Release|Any CPU + {432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Release|x86.Build.0 = Release|Any CPU {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Debug|Any CPU.Build.0 = Debug|Any CPU + {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Debug|x64.ActiveCfg = Debug|Any CPU + {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Debug|x64.Build.0 = Debug|Any CPU + {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Debug|x86.ActiveCfg = Debug|Any CPU + {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Debug|x86.Build.0 = Debug|Any CPU {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Release|Any CPU.ActiveCfg = Release|Any CPU {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Release|Any CPU.Build.0 = Release|Any CPU + {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Release|x64.ActiveCfg = Release|Any CPU + {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Release|x64.Build.0 = Release|Any CPU + {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Release|x86.ActiveCfg = Release|Any CPU + {693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Release|x86.Build.0 = Release|Any CPU {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Debug|x64.ActiveCfg = Debug|Any CPU + {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Debug|x64.Build.0 = Debug|Any CPU + {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Debug|x86.ActiveCfg = Debug|Any CPU + {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Debug|x86.Build.0 = Debug|Any CPU {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Release|Any CPU.ActiveCfg = Release|Any CPU {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Release|Any CPU.Build.0 = Release|Any CPU + {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Release|x64.ActiveCfg = Release|Any CPU + {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Release|x64.Build.0 = Release|Any CPU + {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Release|x86.ActiveCfg = Release|Any CPU + {DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Release|x86.Build.0 = Release|Any CPU {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Debug|x64.ActiveCfg = Debug|Any CPU + {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Debug|x64.Build.0 = Debug|Any CPU + {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Debug|x86.ActiveCfg = Debug|Any CPU + {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Debug|x86.Build.0 = Debug|Any CPU {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Release|Any CPU.ActiveCfg = Release|Any CPU {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Release|Any CPU.Build.0 = Release|Any CPU + {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Release|x64.ActiveCfg = Release|Any CPU + {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Release|x64.Build.0 = Release|Any CPU + {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Release|x86.ActiveCfg = Release|Any CPU + {4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Release|x86.Build.0 = Release|Any CPU {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Debug|x64.ActiveCfg = Debug|Any CPU + {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Debug|x64.Build.0 = Debug|Any CPU + {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Debug|x86.ActiveCfg = Debug|Any CPU + {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Debug|x86.Build.0 = Debug|Any CPU {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Release|Any CPU.ActiveCfg = Release|Any CPU {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Release|Any CPU.Build.0 = Release|Any CPU + {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Release|x64.ActiveCfg = Release|Any CPU + {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Release|x64.Build.0 = Release|Any CPU + {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Release|x86.ActiveCfg = Release|Any CPU + {6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Release|x86.Build.0 = Release|Any CPU {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Debug|x64.ActiveCfg = Debug|Any CPU + {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Debug|x64.Build.0 = Debug|Any CPU + {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Debug|x86.ActiveCfg = Debug|Any CPU + {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Debug|x86.Build.0 = Debug|Any CPU {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Release|Any CPU.ActiveCfg = Release|Any CPU {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Release|Any CPU.Build.0 = Release|Any CPU + {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Release|x64.ActiveCfg = Release|Any CPU + {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Release|x64.Build.0 = Release|Any CPU + {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Release|x86.ActiveCfg = Release|Any CPU + {83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Release|x86.Build.0 = Release|Any CPU {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Debug|x64.ActiveCfg = Debug|Any CPU + {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Debug|x64.Build.0 = Debug|Any CPU + {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Debug|x86.ActiveCfg = Debug|Any CPU + {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Debug|x86.Build.0 = Debug|Any CPU {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Release|Any CPU.ActiveCfg = Release|Any CPU {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Release|Any CPU.Build.0 = Release|Any CPU + {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Release|x64.ActiveCfg = Release|Any CPU + {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Release|x64.Build.0 = Release|Any CPU + {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Release|x86.ActiveCfg = Release|Any CPU + {A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Release|x86.Build.0 = Release|Any CPU {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Debug|x64.ActiveCfg = Debug|Any CPU + {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Debug|x64.Build.0 = Debug|Any CPU + {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Debug|x86.Build.0 = Debug|Any CPU {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Release|Any CPU.ActiveCfg = Release|Any CPU {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Release|Any CPU.Build.0 = Release|Any CPU + {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Release|x64.ActiveCfg = Release|Any CPU + {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Release|x64.Build.0 = Release|Any CPU + {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Release|x86.ActiveCfg = Release|Any CPU + {3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Release|x86.Build.0 = Release|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Debug|x64.ActiveCfg = Debug|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Debug|x64.Build.0 = Debug|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Debug|x86.Build.0 = Debug|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Release|Any CPU.Build.0 = Release|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Release|x64.ActiveCfg = Release|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Release|x64.Build.0 = Release|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Release|x86.ActiveCfg = Release|Any CPU + {EA519DC4-9025-43E5-A4BE-535818F739CA}.Release|x86.Build.0 = Release|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Debug|Any CPU.Build.0 = Debug|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Debug|x64.ActiveCfg = Debug|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Debug|x64.Build.0 = Debug|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Debug|x86.ActiveCfg = Debug|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Debug|x86.Build.0 = Debug|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Release|Any CPU.ActiveCfg = Release|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Release|Any CPU.Build.0 = Release|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Release|x64.ActiveCfg = Release|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Release|x64.Build.0 = Release|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Release|x86.ActiveCfg = Release|Any CPU + {384DB4B1-C0C8-41E2-A614-3D27E0356B68}.Release|x86.Build.0 = Release|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Debug|x64.ActiveCfg = Debug|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Debug|x64.Build.0 = Debug|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Debug|x86.ActiveCfg = Debug|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Debug|x86.Build.0 = Debug|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Release|Any CPU.Build.0 = Release|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Release|x64.ActiveCfg = Release|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Release|x64.Build.0 = Release|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Release|x86.ActiveCfg = Release|Any CPU + {5094E3D2-D57C-4654-8459-2DF25D8ECFF6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/MIGRATION_RUNBOOK.md b/MIGRATION_RUNBOOK.md index d051ec5..04d982b 100644 --- a/MIGRATION_RUNBOOK.md +++ b/MIGRATION_RUNBOOK.md @@ -3,6 +3,7 @@ ## 适用迁移 - MessageService:`20260909000100_ApiAlignmentFixes` +- MessageService:`20260911000100_ConversationUniqueness` - GroupService:`20260909000200_ApiAlignmentFixes` - FileService:`20260909000300_AsyncUploadResult` @@ -11,6 +12,13 @@ 先完成三个库的可恢复备份,并在对应数据库执行: ```sql +-- MessageService:记录迁移将处理的活动会话重复项。 +SELECT UserId, ChatType, TargetId, COUNT(*) AS duplicate_count +FROM conversations +WHERE IsDeleted = 0 +GROUP BY UserId, ChatType, TargetId +HAVING COUNT(*) > 1; + -- GroupService:新增 Id 唯一索引前必须无重复。 SELECT Id, COUNT(*) AS duplicate_count FROM group_join_requests @@ -49,12 +57,23 @@ dotnet ef database update --project FileService.Infrastructure --startup-project 建议顺序为 Message → Group → File,随后发布后端,再发布最终前端包。 +本地具备 Docker 时执行 MessageService 的 MySQL 8 集成测试: + +```powershell +$env:RUN_DOCKER_TESTS = '1' +dotnet test MessageService.Tests/MessageService.Tests.csproj +``` + +未设置该变量时,`dotnet test IM_API_NEW.sln` 仍会运行领域模型和迁移脚本检查,并明确跳过需要 Docker 的两项测试。 + ## 数据兼容说明 - 所有新业务列均可空或有安全默认值,不删除历史记录。 - 历史文件的 `IsPublic` 默认 `false`,无法确认作用域的旧文件因此只允许所有者读取。 - 新上传文件会写入 `SourceTaskId/ChatType/TargetId/ResultFileId`;不要批量猜测旧文件作用域。 - 群退出、群解散和会话隐藏使用软删除。 +- 会话唯一性迁移不会物理删除记录;它保留更新时间最新的一条活动会话,将其他重复项软删除,并创建只约束活动记录的生成列唯一索引。 +- Docker Compose 要求从环境注入 MySQL、RabbitMQ 和内部 API 凭据,可复制 `.env.example` 后填入部署环境的真实值;不得提交 `.env`。 ## 回滚 @@ -72,3 +91,10 @@ dotnet ef database update 20260509073447_InitFileDb --project FileService.Infras ``` FileService 回滚会删除新作用域和任务结果列,并把收紧的字符串列恢复为 `longtext`;回滚前应另行导出这些新列的数据。 +# 20260913000100 会话活动时间与消息搜索 + +部署消息服务前先备份数据库,并在 MySQL 8 测试库执行 `20260913000100_ConversationActivityAndMessageSearch`。 + +- 迁移新增可空 `conversations.LastMessageTime`,按相同 `StreamKey` 的最新未删除消息时间回填;没有消息时回退到会话创建时间。 +- 新增 `messages(StreamKey, MsgType, State, SequenceId)` 复合索引,为会话内文本搜索和独占游标分页提供支持。 +- 迁移只更新内部存储结构,不改变现有 DTO;发布后确认旧会话排序未因已读操作变化,并抽查搜索翻页无重复。 diff --git a/MessageService.Domain/Entities/Conversation.cs b/MessageService.Domain/Entities/Conversation.cs index 245f0cb..8de353c 100644 --- a/MessageService.Domain/Entities/Conversation.cs +++ b/MessageService.Domain/Entities/Conversation.cs @@ -40,6 +40,11 @@ namespace MessageService.Domain.Entities /// 最后一条最新消息 /// public string LastMessage { get; private set; } + + /// + /// 最后一条消息产生的时间。已读状态等会话元数据更新不得改变该值。 + /// + public DateTimeOffset? LastMessageTime { get; private set; } private Conversation() { } public Conversation(Guid userId, Guid targetId, string targetAvatar, string targetName, long? lastReadSequenceId, int unreadCount, ChatType chatType, string lastMessage) @@ -52,6 +57,7 @@ namespace MessageService.Domain.Entities UnreadCount = unreadCount; ChatType = chatType; LastMessage = lastMessage; + LastMessageTime = DateTimeOffset.Now; ModificationTime = DateTime.Now; StreamKey = ChatType == ChatType.GROUP ? StreamKeyBuilder.Group(targetId) : StreamKeyBuilder.Private(userId, targetId); AddDomainEvent(new ConversationCreatedDomainEvent(this)); @@ -69,9 +75,10 @@ namespace MessageService.Domain.Entities NotifyModified(); } - public void UpdateLastMessage(string lastMessage) + public void UpdateLastMessage(string lastMessage, DateTimeOffset messageTime) { LastMessage = lastMessage; + LastMessageTime = messageTime; NotifyModified(); } diff --git a/MessageService.Domain/IReposities/IConversationReposity.cs b/MessageService.Domain/IReposities/IConversationReposity.cs index c54848b..aea6dc6 100644 --- a/MessageService.Domain/IReposities/IConversationReposity.cs +++ b/MessageService.Domain/IReposities/IConversationReposity.cs @@ -1,14 +1,17 @@ using MessageService.Domain.Entities; +using MessageService.Domain.Models; + namespace MessageService.Domain.IReposities { public interface IConversationReposity { - Task FindByIdAsync(Guid id); - Task> FindByUserIdAsync(Guid userId); - Task> FindByTargetIdAsync(Guid targetId); - Task> FindByStreamKeyAsync(string streamKey); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task> ListByUserIdAsync(Guid userId, CancellationToken cancellationToken = default); + Task> FindByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default); + Task> FindByStreamKeyAsync(string streamKey, CancellationToken cancellationToken = default); + Task FindActiveAsync(Guid userId, Guid targetId, Enums.ChatType chatType, CancellationToken cancellationToken = default); void Create(Conversation conversation); - Task> FindAllStreamKeyAsync(Guid userId); + Task> FindAllStreamKeyAsync(Guid userId, CancellationToken cancellationToken = default); } } diff --git a/MessageService.Domain/IReposities/IMessageReposity.cs b/MessageService.Domain/IReposities/IMessageReposity.cs index 1c923dd..5700fcc 100644 --- a/MessageService.Domain/IReposities/IMessageReposity.cs +++ b/MessageService.Domain/IReposities/IMessageReposity.cs @@ -4,8 +4,9 @@ namespace MessageService.Domain.IReposities { public interface IMessageReposity { - Task FindByIdAsync(Guid id); - Task<(IEnumerable messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default); + Task<(IEnumerable messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit, CancellationToken cancellationToken = default); + Task<(IEnumerable messages, bool hasMore)> SearchAsync(string streamKey, string keyword, long? cursor, int limit, CancellationToken cancellationToken = default); void Create(Message message); } } diff --git a/MessageService.Domain/Models/ConversationSummary.cs b/MessageService.Domain/Models/ConversationSummary.cs new file mode 100644 index 0000000..f43ed8e --- /dev/null +++ b/MessageService.Domain/Models/ConversationSummary.cs @@ -0,0 +1,16 @@ +using MessageService.Domain.Enums; + +namespace MessageService.Domain.Models +{ + public sealed record ConversationSummary( + Guid Id, + Guid UserId, + Guid TargetId, + string TargetAvatar, + string TargetName, + long? LastReadSequenceId, + int UnreadCount, + ChatType ChatType, + string LastMessage, + DateTimeOffset DateTime); +} diff --git a/MessageService.Infrastructure/Configs/ConversationConfig.cs b/MessageService.Infrastructure/Configs/ConversationConfig.cs index 80aa927..b9dbd9b 100644 --- a/MessageService.Infrastructure/Configs/ConversationConfig.cs +++ b/MessageService.Infrastructure/Configs/ConversationConfig.cs @@ -12,6 +12,14 @@ namespace MessageService.Infrastructure.Configs builder.HasKey(x => x.Id); builder.HasIndex(x => x.UserId); builder.HasIndex(x => new { x.UserId, x.ChatType, x.TargetId, x.IsDeleted }); + builder.Property("ActiveConversationKey") + .HasMaxLength(96) + .HasComputedColumnSql( + "CASE WHEN `IsDeleted` = 0 THEN CONCAT(`UserId`, ':', `ChatType`, ':', `TargetId`) ELSE NULL END", + stored: true); + builder.HasIndex("ActiveConversationKey") + .IsUnique() + .HasDatabaseName("UX_conversations_ActiveConversationKey"); } } diff --git a/MessageService.Infrastructure/Configs/MessageConfig.cs b/MessageService.Infrastructure/Configs/MessageConfig.cs index a19d29f..ca139d0 100644 --- a/MessageService.Infrastructure/Configs/MessageConfig.cs +++ b/MessageService.Infrastructure/Configs/MessageConfig.cs @@ -11,6 +11,7 @@ namespace MessageService.Infrastructure.Configs builder.ToTable("messages"); builder.HasKey(x => x.Id); builder.HasIndex(x => new { x.StreamKey, x.SequenceId }); + builder.HasIndex(x => new { x.StreamKey, x.MsgType, x.State, x.SequenceId }); builder.ComplexProperty(x => x.Content, c => { // 1. Fallback 是简单字符串,直接映射 diff --git a/MessageService.Infrastructure/Migrations/20260911000100_ConversationUniqueness.cs b/MessageService.Infrastructure/Migrations/20260911000100_ConversationUniqueness.cs new file mode 100644 index 0000000..b25fcc5 --- /dev/null +++ b/MessageService.Infrastructure/Migrations/20260911000100_ConversationUniqueness.cs @@ -0,0 +1,81 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace MessageService.Infrastructure.Migrations +{ + [DbContext(typeof(MessageDbContext))] + [Migration("20260911000100_ConversationUniqueness")] + public partial class ConversationUniqueness : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + CREATE TEMPORARY TABLE `conversation_dedup` AS + SELECT + `Id`, + ROW_NUMBER() OVER ( + PARTITION BY `UserId`, `ChatType`, `TargetId` + ORDER BY COALESCE(`ModificationTime`, `CreationTime`) DESC, `Id` DESC + ) AS `row_num`, + MAX(`UnreadCount`) OVER ( + PARTITION BY `UserId`, `ChatType`, `TargetId` + ) AS `max_unread_count`, + MAX(`LastReadSequenceId`) OVER ( + PARTITION BY `UserId`, `ChatType`, `TargetId` + ) AS `max_last_read_sequence_id` + FROM `conversations` + WHERE `IsDeleted` = 0; + """); + + migrationBuilder.Sql(""" + UPDATE `conversations` AS `conversation` + INNER JOIN `conversation_dedup` AS `dedup` ON `conversation`.`Id` = `dedup`.`Id` + SET + `conversation`.`UnreadCount` = CASE + WHEN `dedup`.`row_num` = 1 THEN `dedup`.`max_unread_count` + ELSE `conversation`.`UnreadCount` + END, + `conversation`.`LastReadSequenceId` = CASE + WHEN `dedup`.`row_num` = 1 THEN `dedup`.`max_last_read_sequence_id` + ELSE `conversation`.`LastReadSequenceId` + END, + `conversation`.`IsDeleted` = CASE + WHEN `dedup`.`row_num` = 1 THEN `conversation`.`IsDeleted` + ELSE 1 + END, + `conversation`.`Deletion` = CASE + WHEN `dedup`.`row_num` = 1 THEN `conversation`.`Deletion` + ELSE CURRENT_TIMESTAMP(6) + END; + """); + + migrationBuilder.Sql("DROP TEMPORARY TABLE `conversation_dedup`;"); + + migrationBuilder.AddColumn( + name: "ActiveConversationKey", + table: "conversations", + type: "varchar(96)", + maxLength: 96, + nullable: true, + computedColumnSql: "CASE WHEN `IsDeleted` = 0 THEN CONCAT(`UserId`, ':', `ChatType`, ':', `TargetId`) ELSE NULL END", + stored: true); + + migrationBuilder.CreateIndex( + name: "UX_conversations_ActiveConversationKey", + table: "conversations", + column: "ActiveConversationKey", + unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "UX_conversations_ActiveConversationKey", + table: "conversations"); + + migrationBuilder.DropColumn( + name: "ActiveConversationKey", + table: "conversations"); + } + } +} diff --git a/MessageService.Infrastructure/Migrations/20260913000100_ConversationActivityAndMessageSearch.cs b/MessageService.Infrastructure/Migrations/20260913000100_ConversationActivityAndMessageSearch.cs new file mode 100644 index 0000000..a25e8ed --- /dev/null +++ b/MessageService.Infrastructure/Migrations/20260913000100_ConversationActivityAndMessageSearch.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace MessageService.Infrastructure.Migrations +{ + [DbContext(typeof(MessageDbContext))] + [Migration("20260913000100_ConversationActivityAndMessageSearch")] + public partial class ConversationActivityAndMessageSearch : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastMessageTime", + table: "conversations", + type: "datetime(6)", + nullable: true); + + migrationBuilder.Sql(""" + UPDATE `conversations` AS `conversation` + SET `conversation`.`LastMessageTime` = COALESCE( + ( + SELECT MAX(`message`.`CreationTime`) + FROM `messages` AS `message` + WHERE `message`.`StreamKey` = `conversation`.`StreamKey` + AND `message`.`IsDeleted` = 0 + ), + `conversation`.`CreationTime` + ); + """); + + migrationBuilder.CreateIndex( + name: "IX_messages_StreamKey_MsgType_State_SequenceId", + table: "messages", + columns: new[] { "StreamKey", "MsgType", "State", "SequenceId" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_messages_StreamKey_MsgType_State_SequenceId", + table: "messages"); + + migrationBuilder.DropColumn( + name: "LastMessageTime", + table: "conversations"); + } + } +} diff --git a/MessageService.Infrastructure/Migrations/MessageDbContextModelSnapshot.cs b/MessageService.Infrastructure/Migrations/MessageDbContextModelSnapshot.cs index 254f20b..5ed7516 100644 --- a/MessageService.Infrastructure/Migrations/MessageDbContextModelSnapshot.cs +++ b/MessageService.Infrastructure/Migrations/MessageDbContextModelSnapshot.cs @@ -25,6 +25,12 @@ namespace MessageService.Infrastructure.Migrations modelBuilder.Entity("MessageService.Domain.Entities.Conversation", b => { + b.Property("ActiveConversationKey") + .ValueGeneratedOnAddOrUpdate() + .HasMaxLength(96) + .HasColumnType("varchar(96)") + .HasComputedColumnSql("CASE WHEN `IsDeleted` = 0 THEN CONCAT(`UserId`, ':', `ChatType`, ':', `TargetId`) ELSE NULL END", true); + b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("char(36)"); @@ -45,6 +51,9 @@ namespace MessageService.Infrastructure.Migrations .IsRequired() .HasColumnType("longtext"); + b.Property("LastMessageTime") + .HasColumnType("datetime(6)"); + b.Property("LastReadSequenceId") .HasColumnType("bigint"); @@ -74,6 +83,10 @@ namespace MessageService.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("ActiveConversationKey") + .IsUnique() + .HasDatabaseName("UX_conversations_ActiveConversationKey"); + b.HasIndex("UserId"); b.HasIndex("UserId", "ChatType", "TargetId", "IsDeleted"); @@ -178,6 +191,8 @@ namespace MessageService.Infrastructure.Migrations b.HasIndex("StreamKey", "SequenceId"); + b.HasIndex("StreamKey", "MsgType", "State", "SequenceId"); + b.ToTable("messages", (string)null); }); #pragma warning restore 612, 618 diff --git a/MessageService.Infrastructure/Reposities/ConversationReposity.cs b/MessageService.Infrastructure/Reposities/ConversationReposity.cs index 657eb10..e529283 100644 --- a/MessageService.Infrastructure/Reposities/ConversationReposity.cs +++ b/MessageService.Infrastructure/Reposities/ConversationReposity.cs @@ -1,5 +1,6 @@ using MessageService.Domain.Entities; using MessageService.Domain.IReposities; +using MessageService.Domain.Models; using Microsoft.EntityFrameworkCore; namespace MessageService.Infrastructure.Reposities @@ -18,31 +19,54 @@ namespace MessageService.Infrastructure.Reposities db.Conversations.Add(conversation); } - public async Task> FindAllStreamKeyAsync(Guid userId) + public async Task> FindAllStreamKeyAsync(Guid userId, CancellationToken cancellationToken = default) { return await db.Conversations.Where(x => x.UserId == userId) + .AsNoTracking() .Select(s => s.StreamKey) - .ToListAsync(); + .ToListAsync(cancellationToken); } - public async Task FindByIdAsync(Guid id) + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) { - return await db.Conversations.FirstOrDefaultAsync(x => x.Id == id); + return await db.Conversations.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); } - public async Task> FindByStreamKeyAsync(string streamKey) + public async Task> FindByStreamKeyAsync(string streamKey, CancellationToken cancellationToken = default) { - return await db.Conversations.Where(x => x.StreamKey == streamKey).ToListAsync(); + return await db.Conversations.Where(x => x.StreamKey == streamKey).ToListAsync(cancellationToken); } - public async Task> FindByTargetIdAsync(Guid targetId) + public async Task> FindByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default) { - return await db.Conversations.Where(x => x.TargetId == targetId).ToListAsync(); + return await db.Conversations.Where(x => x.TargetId == targetId).ToListAsync(cancellationToken); } - public async Task> FindByUserIdAsync(Guid userId) + public async Task> ListByUserIdAsync(Guid userId, CancellationToken cancellationToken = default) { - return await db.Conversations.Where(x => x.UserId == userId).ToListAsync(); + return await db.Conversations + .AsNoTracking() + .Where(x => x.UserId == userId) + .OrderByDescending(x => x.LastMessageTime ?? x.CreationTime) + .Select(x => new ConversationSummary( + x.Id, + x.UserId, + x.TargetId, + x.TargetAvatar, + x.TargetName, + x.LastReadSequenceId, + x.UnreadCount, + x.ChatType, + x.LastMessage, + x.LastMessageTime ?? x.CreationTime)) + .ToListAsync(cancellationToken); + } + + public Task FindActiveAsync(Guid userId, Guid targetId, Domain.Enums.ChatType chatType, CancellationToken cancellationToken = default) + { + return db.Conversations.FirstOrDefaultAsync( + x => x.UserId == userId && x.TargetId == targetId && x.ChatType == chatType, + cancellationToken); } } } diff --git a/MessageService.Infrastructure/Reposities/MessageReposity.cs b/MessageService.Infrastructure/Reposities/MessageReposity.cs index d7e1fa5..0ba4623 100644 --- a/MessageService.Infrastructure/Reposities/MessageReposity.cs +++ b/MessageService.Infrastructure/Reposities/MessageReposity.cs @@ -18,12 +18,12 @@ namespace MessageService.Infrastructure.Reposities db.Messages.Add(message); } - public async Task FindByIdAsync(Guid id) + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken = default) { - return await db.Messages.FirstOrDefaultAsync(x => x.Id == id); + return await db.Messages.FirstOrDefaultAsync(x => x.Id == id, cancellationToken); } - public async Task<(IEnumerable messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit) + public async Task<(IEnumerable messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit, CancellationToken cancellationToken = default) { var query = db.Messages.Where(x => x.StreamKey == streamKey); List fetched; @@ -35,7 +35,7 @@ namespace MessageService.Infrastructure.Reposities fetched = await query .OrderByDescending(m => m.SequenceId) // 最新消息在最前 .Take(limit + 1) - .ToListAsync(); + .ToListAsync(cancellationToken); } else { @@ -46,12 +46,40 @@ namespace MessageService.Infrastructure.Reposities .Where(m => m.SequenceId > cusor.Value) .OrderBy(o => o.SequenceId) .Take(limit + 1) - .ToListAsync(); + .ToListAsync(cancellationToken); } var hasMore = fetched.Count > limit; var messages = fetched.Take(limit).OrderBy(s => s.SequenceId).ToList(); return (messages, hasMore); } + + public async Task<(IEnumerable messages, bool hasMore)> SearchAsync( + string streamKey, + string keyword, + long? cursor, + int limit, + CancellationToken cancellationToken = default) + { + var query = db.Messages + .AsNoTracking() + .Where(message => + message.StreamKey == streamKey && + message.MsgType == Domain.Enums.MessageType.Text && + message.State == Domain.Enums.MessageState.Sent && + message.Content.Fallback.Contains(keyword)); + + if (cursor.HasValue) + { + query = query.Where(message => message.SequenceId < cursor.Value); + } + + var fetched = await query + .OrderByDescending(message => message.SequenceId) + .Take(limit + 1) + .ToListAsync(cancellationToken); + + return (fetched.Take(limit).ToList(), fetched.Count > limit); + } } } diff --git a/MessageService.Tests/ConversationModelTests.cs b/MessageService.Tests/ConversationModelTests.cs new file mode 100644 index 0000000..376d0af --- /dev/null +++ b/MessageService.Tests/ConversationModelTests.cs @@ -0,0 +1,92 @@ +using MessageService.Domain.Entities; +using MessageService.Domain.Enums; +using MessageService.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Xunit; + +namespace MessageService.Tests; + +public sealed class ConversationModelTests +{ + [Fact] + public void Conversation_read_state_and_unread_count_are_consistent() + { + var conversation = new Conversation( + Guid.NewGuid(), Guid.NewGuid(), string.Empty, "target", null, 0, ChatType.PRIVATE, string.Empty); + + conversation.IncrementUnread(); + conversation.IncrementUnread(); + conversation.MarkAsRead(12); + + Assert.Equal(0, conversation.UnreadCount); + Assert.Equal(12, conversation.LastReadSequenceId); + } + + [Fact] + public void Marking_conversation_read_does_not_change_last_message_activity() + { + var conversation = new Conversation( + Guid.NewGuid(), Guid.NewGuid(), string.Empty, "target", null, 1, ChatType.PRIVATE, "old"); + var messageTime = new DateTimeOffset(2026, 9, 13, 8, 30, 0, TimeSpan.Zero); + conversation.UpdateLastMessage("new", messageTime); + + conversation.MarkAsRead(42); + + Assert.Equal(messageTime, conversation.LastMessageTime); + Assert.Equal(42, conversation.LastReadSequenceId); + Assert.Equal(0, conversation.UnreadCount); + } + + [Fact] + public void Active_conversation_key_is_generated_and_unique() + { + using var db = CreateContext(); + var entity = db.Model.FindEntityType(typeof(Conversation)); + var property = entity!.FindProperty("ActiveConversationKey"); + var index = entity.GetIndexes().Single(item => item.Properties.Contains(property!)); + + Assert.NotNull(property!.GetComputedColumnSql()); + Assert.True(index.IsUnique); + } + + [Fact] + public void Migration_script_contains_deduplication_and_active_unique_index() + { + using var db = CreateContext(); + var migrator = db.Database.GetService(); + + var script = migrator.GenerateScript( + "20260909000100_ApiAlignmentFixes", + "20260911000100_ConversationUniqueness"); + + Assert.Contains("conversation_dedup", script); + Assert.Contains("UX_conversations_ActiveConversationKey", script); + Assert.Contains("ROW_NUMBER() OVER", script); + } + + [Fact] + public void Activity_migration_backfills_latest_message_time_and_adds_search_index() + { + using var db = CreateContext(); + var migrator = db.Database.GetService(); + + var script = migrator.GenerateScript( + "20260911000100_ConversationUniqueness", + "20260913000100_ConversationActivityAndMessageSearch"); + + Assert.Contains("LastMessageTime", script); + Assert.Contains("MAX(`message`.`CreationTime`)", script); + Assert.Contains("IX_messages_StreamKey_MsgType_State_SequenceId", script); + } + + private static MessageDbContext CreateContext() + { + const string connectionString = "Server=localhost;Database=im_message_tests;User=test;Password=test"; + var options = new DbContextOptionsBuilder() + .UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 36))) + .Options; + return new MessageDbContext(options, null!); + } +} diff --git a/MessageService.Tests/ConversationUniquenessTests.cs b/MessageService.Tests/ConversationUniquenessTests.cs new file mode 100644 index 0000000..b309d6a --- /dev/null +++ b/MessageService.Tests/ConversationUniquenessTests.cs @@ -0,0 +1,153 @@ +using MessageService.Domain.Entities; +using MessageService.Domain.Enums; +using MessageService.Infrastructure; +using MessageService.Infrastructure.Reposities; +using MessageService.Domain.KeyObjects; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using MySqlConnector; +using Testcontainers.MySql; +using Xunit; + +namespace MessageService.Tests; + +public sealed class ConversationUniquenessTests : IAsyncLifetime +{ + private readonly MySqlContainer mysql = new MySqlBuilder("mysql:8.0").Build(); + + public Task InitializeAsync() => + string.Equals(Environment.GetEnvironmentVariable("RUN_DOCKER_TESTS"), "1", StringComparison.Ordinal) + ? mysql.StartAsync() + : Task.CompletedTask; + + public Task DisposeAsync() => mysql.DisposeAsync().AsTask(); + + [DockerFact] + [Trait("Category", "Docker")] + public async Task Migration_deduplicates_active_rows_and_enforces_active_uniqueness() + { + await using var db = CreateContext(); + var migrator = db.Database.GetService(); + await migrator.MigrateAsync("20260909000100_ApiAlignmentFixes"); + + var userId = Guid.NewGuid(); + var targetId = Guid.NewGuid(); + await InsertConversationAsync(db, Guid.NewGuid(), userId, targetId, 2, 10, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + await InsertConversationAsync(db, Guid.NewGuid(), userId, targetId, 7, 15, new DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero)); + + await migrator.MigrateAsync(); + + var all = await db.Conversations.IgnoreQueryFilters().AsNoTracking().ToListAsync(); + var active = all.Single(x => !x.IsDeleted); + Assert.Equal(2, all.Count); + Assert.Equal(7, active.UnreadCount); + Assert.Equal(15, active.LastReadSequenceId); + + await Assert.ThrowsAsync(() => + InsertConversationAsync(db, Guid.NewGuid(), userId, targetId, 0, null, DateTimeOffset.UtcNow)); + + db.ChangeTracker.Clear(); + var trackedActive = await db.Conversations.SingleAsync(); + trackedActive.SoftDelete(); + await db.SaveChangesAsync(); + await InsertConversationAsync(db, Guid.NewGuid(), userId, targetId, 0, null, DateTimeOffset.UtcNow); + Assert.Equal(1, await db.Conversations.CountAsync()); + } + + [DockerFact] + [Trait("Category", "Docker")] + public async Task Repository_uses_exact_active_lookup_and_orders_owner_list_newest_first() + { + await using var db = CreateContext(); + await db.Database.MigrateAsync(); + var userId = Guid.NewGuid(); + var firstTarget = Guid.NewGuid(); + var secondTarget = Guid.NewGuid(); + await InsertConversationAsync(db, Guid.NewGuid(), userId, firstTarget, 0, null, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); + await InsertConversationAsync(db, Guid.NewGuid(), userId, secondTarget, 0, null, new DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero)); + var repository = new ConversationReposity(db); + + var exact = await repository.FindActiveAsync(userId, firstTarget, ChatType.PRIVATE); + var list = (await repository.ListByUserIdAsync(userId)).ToList(); + + Assert.NotNull(exact); + Assert.Equal(firstTarget, exact.TargetId); + Assert.Equal(secondTarget, list[0].TargetId); + } + + [DockerFact] + [Trait("Category", "Docker")] + public async Task Message_search_is_scoped_filtered_and_uses_an_exclusive_cursor() + { + await using var db = CreateContext(); + await db.Database.MigrateAsync(); + var senderId = Guid.NewGuid(); + var targetId = Guid.NewGuid(); + var context = new MessageCreateContext(ChatType.PRIVATE, Guid.NewGuid(), senderId, targetId); + var first = Message.BuildTxt(context, "项目进度 一", 1); + var second = Message.BuildTxt(context with { ClientMsgId = Guid.NewGuid() }, "项目进度 二", 2); + var withdrawn = Message.BuildTxt(context with { ClientMsgId = Guid.NewGuid() }, "项目进度 已撤回", 3); + withdrawn.Withdraw(); + var image = Message.BuildImg(context with { ClientMsgId = Guid.NewGuid() }, "image", 10, 10, "thumb", 4); + var other = Message.BuildTxt( + new MessageCreateContext(ChatType.GROUP, Guid.NewGuid(), senderId, Guid.NewGuid()), + "项目进度 其他会话", + 5); + db.Messages.AddRange(first, second, withdrawn, image, other); + await db.SaveChangesAsync(); + var repository = new MessageReposity(db); + + var firstPage = await repository.SearchAsync(first.StreamKey, "项目进度", null, 1); + var secondPage = await repository.SearchAsync(first.StreamKey, "项目进度", firstPage.messages.Single().SequenceId, 10); + + Assert.True(firstPage.hasMore); + Assert.Equal(2, firstPage.messages.Single().SequenceId); + Assert.False(secondPage.hasMore); + Assert.Equal(1, secondPage.messages.Single().SequenceId); + } + + private MessageDbContext CreateContext() + { + var connectionString = mysql.GetConnectionString(); + var options = new DbContextOptionsBuilder() + .UseMySql( + connectionString, + new MySqlServerVersion(new Version(8, 0)), + mysqlOptions => mysqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(2), null)) + .Options; + return new MessageDbContext(options, null!); + } + + private static Task InsertConversationAsync( + MessageDbContext db, + Guid id, + Guid userId, + Guid targetId, + int unreadCount, + long? lastReadSequenceId, + DateTimeOffset modificationTime) + { + return db.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO `conversations` + (`Id`, `UserId`, `TargetId`, `TargetAvatar`, `TargetName`, `LastReadSequenceId`, + `UnreadCount`, `ChatType`, `StreamKey`, `LastMessage`, `CreationTime`, + `ModificationTime`, `IsDeleted`, `Deletion`) + VALUES + ({id}, {userId}, {targetId}, {string.Empty}, {"target"}, {lastReadSequenceId}, + {unreadCount}, {(int)ChatType.PRIVATE}, {"private-stream"}, {string.Empty}, {modificationTime}, + {modificationTime}, {false}, {null}); + """); + } +} + +public sealed class DockerFactAttribute : FactAttribute +{ + public DockerFactAttribute() + { + if (!string.Equals(Environment.GetEnvironmentVariable("RUN_DOCKER_TESTS"), "1", StringComparison.Ordinal)) + { + Skip = "Set RUN_DOCKER_TESTS=1 when a Docker daemon is available."; + } + } +} diff --git a/MessageService.Tests/MessageService.Tests.csproj b/MessageService.Tests/MessageService.Tests.csproj new file mode 100644 index 0000000..47af3ed --- /dev/null +++ b/MessageService.Tests/MessageService.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/MessageService.WebApi/Application/Conversation/ConversationMapperConfig.cs b/MessageService.WebApi/Application/Conversation/ConversationMapperConfig.cs index 863becb..abd8e48 100644 --- a/MessageService.WebApi/Application/Conversation/ConversationMapperConfig.cs +++ b/MessageService.WebApi/Application/Conversation/ConversationMapperConfig.cs @@ -8,7 +8,7 @@ namespace MessageService.WebApi.Application.Conversation public ConversationMapperConfig() { CreateMap() - .ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.ModificationTime ?? src.CreationTime)) + .ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.LastMessageTime ?? src.CreationTime)) ; } } diff --git a/MessageService.WebApi/Application/Conversation/ConversationService.cs b/MessageService.WebApi/Application/Conversation/ConversationService.cs index e92bc9c..d0efd6c 100644 --- a/MessageService.WebApi/Application/Conversation/ConversationService.cs +++ b/MessageService.WebApi/Application/Conversation/ConversationService.cs @@ -1,5 +1,6 @@ using AutoMapper; using IM.Commons; +using System.Diagnostics; using MessageService.Domain.IReposities; using MessageService.WebApi.Application.Dtos; @@ -9,22 +10,53 @@ namespace MessageService.WebApi.Application.Conversation { private readonly IConversationReposity reposity; private readonly IMapper mapper; + private readonly ILogger logger; - public ConversationService(IConversationReposity reposity, IMapper mapper) + public ConversationService(IConversationReposity reposity, IMapper mapper, ILogger logger) { this.reposity = reposity; this.mapper = mapper; + this.logger = logger; } - public async Task>> GetByOwnerIdAsync(Guid userId) + public async Task>> GetByOwnerIdAsync(Guid userId, CancellationToken cancellationToken = default) { - var list = await reposity.FindByUserIdAsync(userId); - return Result.Success(mapper.Map>(list.ToList())); + var stopwatch = Stopwatch.StartNew(); + try + { + var list = await reposity.ListByUserIdAsync(userId, cancellationToken); + return Result.Success(list.Select(item => new ConversationResponse + { + Id = item.Id, + UserId = item.UserId, + TargetId = item.TargetId, + TargetAvatar = item.TargetAvatar, + TargetName = item.TargetName, + LastReadSequenceId = item.LastReadSequenceId, + UnreadCount = item.UnreadCount, + ChatType = item.ChatType, + LastMessage = item.LastMessage, + DateTime = item.DateTime + }).ToList()); + } + finally + { + stopwatch.Stop(); + var traceId = Activity.Current?.TraceId.ToString() ?? string.Empty; + if (stopwatch.ElapsedMilliseconds > 1000) + { + logger.LogWarning("Conversation list query was slow. TraceId={TraceId} UserId={UserId} ElapsedMs={ElapsedMs}", traceId, userId, stopwatch.ElapsedMilliseconds); + } + else + { + logger.LogInformation("Conversation list query completed. TraceId={TraceId} UserId={UserId} ElapsedMs={ElapsedMs}", traceId, userId, stopwatch.ElapsedMilliseconds); + } + } } - public async Task> GetByIdAsync(Guid id, Guid userId) + public async Task> GetByIdAsync(Guid id, Guid userId, CancellationToken cancellationToken = default) { - var conversation = await reposity.FindByIdAsync(id); + var conversation = await reposity.FindByIdAsync(id, cancellationToken); if (conversation is null || conversation.UserId != userId) { @@ -34,21 +66,21 @@ namespace MessageService.WebApi.Application.Conversation return Result.Success(mapper.Map(conversation)); } - public async Task>> GetStreamkeysAsync(Guid userId) + public async Task>> GetStreamkeysAsync(Guid userId, CancellationToken cancellationToken = default) { - var list = await reposity.FindAllStreamKeyAsync(userId); + var list = await reposity.FindAllStreamKeyAsync(userId, cancellationToken); return Result.Success(list.ToList()); } - public async Task> MarkAsReadAsync(Guid conversationId, Guid userId) + public async Task> MarkAsReadAsync(Guid conversationId, Guid userId, long? lastReadSequenceId = null, CancellationToken cancellationToken = default) { - var conversation = await reposity.FindByIdAsync(conversationId); + var conversation = await reposity.FindByIdAsync(conversationId, cancellationToken); if (conversation is null || conversation.UserId != userId) { return Result.Fail(ResultCode.CONVERSATION_NOT_FOUND); } - conversation.MarkAsRead(lastReadSequenceId: null); + conversation.MarkAsRead(lastReadSequenceId); return Result.Success(); } } diff --git a/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs b/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs index 4236295..412d75e 100644 --- a/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs +++ b/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs @@ -2,6 +2,8 @@ using MassTransit; using MessageService.Domain.IReposities; using MessageService.Infrastructure; +using Microsoft.EntityFrameworkCore; +using MySqlConnector; namespace MessageService.WebApi.Application.EventHandlers { @@ -21,8 +23,8 @@ namespace MessageService.WebApi.Application.EventHandlers public async Task Consume(ConsumeContext context) { var @event = context.Message; - var existing = await reposity.FindByTargetIdAsync(@event.GroupId); - if (existing.Any(x => x.UserId == @event.UserId && x.ChatType == Domain.Enums.ChatType.GROUP)) + var existing = await reposity.FindActiveAsync(@event.UserId, @event.GroupId, Domain.Enums.ChatType.GROUP, context.CancellationToken); + if (existing is not null) { return; } @@ -37,14 +39,14 @@ namespace MessageService.WebApi.Application.EventHandlers lastMessage: string.Empty )); - await messageDb.SaveChangesAsync(context.CancellationToken); + await SaveIdempotentlyAsync(context.CancellationToken); } public async Task Consume(ConsumeContext context) { var @event = context.Message; - var existing = await reposity.FindByUserIdAsync(@event.OwnerId); - if (existing.Any(x => x.TargetId == @event.TargetId && x.ChatType == Domain.Enums.ChatType.PRIVATE)) + var existing = await reposity.FindActiveAsync(@event.OwnerId, @event.TargetId, Domain.Enums.ChatType.PRIVATE, context.CancellationToken); + if (existing is not null) { return; } @@ -59,18 +61,33 @@ namespace MessageService.WebApi.Application.EventHandlers lastMessage: string.Empty )); - await messageDb.SaveChangesAsync(context.CancellationToken); + await SaveIdempotentlyAsync(context.CancellationToken); } public async Task Consume(ConsumeContext context) { - var conversations = await reposity.FindByTargetIdAsync(context.Message.GroupId); - foreach (var conversation in conversations.Where(x => - x.UserId == context.Message.UserId && x.ChatType == Domain.Enums.ChatType.GROUP)) + var conversation = await reposity.FindActiveAsync( + context.Message.UserId, + context.Message.GroupId, + Domain.Enums.ChatType.GROUP, + context.CancellationToken); + if (conversation is not null) { conversation.SoftDelete(); } await messageDb.SaveChangesAsync(context.CancellationToken); } + + private async Task SaveIdempotentlyAsync(CancellationToken cancellationToken) + { + try + { + await messageDb.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException exception) when (exception.InnerException is MySqlException { Number: 1062 }) + { + messageDb.ChangeTracker.Clear(); + } + } } } diff --git a/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs b/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs index a538418..d47ee14 100644 --- a/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs +++ b/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs @@ -24,10 +24,10 @@ namespace MessageService.WebApi.Application.EventHandlers { var message = notification.Message; - var conversations = await reposity.FindByStreamKeyAsync(message.StreamKey); + var conversations = await reposity.FindByStreamKeyAsync(message.StreamKey, cancellationToken); foreach (var conversation in conversations) { - conversation.UpdateLastMessage(message.Content.Fallback); + conversation.UpdateLastMessage(message.Content.Fallback, message.CreationTime); if (conversation.UserId == message.SenderId) { conversation.SetLastReadSequence(message.SequenceId); diff --git a/MessageService.WebApi/Application/EventHandlers/UserProfileUpdateHandler.cs b/MessageService.WebApi/Application/EventHandlers/UserProfileUpdateHandler.cs index b73db8b..e2783cd 100644 --- a/MessageService.WebApi/Application/EventHandlers/UserProfileUpdateHandler.cs +++ b/MessageService.WebApi/Application/EventHandlers/UserProfileUpdateHandler.cs @@ -19,12 +19,12 @@ namespace MessageService.WebApi.Application.EventHandlers public async Task Consume(ConsumeContext context) { var @event = context.Message; - var conversations = await reposity.FindByTargetIdAsync(@event.UserId); + var conversations = await reposity.FindByTargetIdAsync(@event.UserId, context.CancellationToken); foreach (var conversation in conversations) { conversation.UpdateProfile(@event.NickName, @event.Avatar); } - await db.SaveChangesAsync(); + await db.SaveChangesAsync(context.CancellationToken); } } } diff --git a/MessageService.WebApi/Application/Message/MessageService.cs b/MessageService.WebApi/Application/Message/MessageService.cs index 4e8c7af..69e85a4 100644 --- a/MessageService.WebApi/Application/Message/MessageService.cs +++ b/MessageService.WebApi/Application/Message/MessageService.cs @@ -1,4 +1,4 @@ -using AutoMapper; +using AutoMapper; using IM.Commons; using MessageService.Domain.Enums; using MessageService.Domain.IReposities; @@ -16,20 +16,22 @@ namespace MessageService.WebApi.Application.Message private readonly IMapper mapper; private readonly IGroupMemberIntegrationService memberService; private readonly IContactIntegrationService contactService; - private readonly SquenceService squenceService; + private readonly SquenceService squenceService; private readonly IM.InitCommon.Management.RuntimePolicy runtime; - public MessageService(IMessageReposity reposity, IConversationReposity conversationReposity, IMapper mapper, IGroupMemberIntegrationService memberService, IContactIntegrationService contactService, SquenceService squenceService) + public MessageService(IMessageReposity reposity, IConversationReposity conversationReposity, IMapper mapper, IGroupMemberIntegrationService memberService, IContactIntegrationService contactService, SquenceService squenceService, IM.InitCommon.Management.RuntimePolicy runtime) { this.reposity = reposity; this.conversationReposity = conversationReposity; this.mapper = mapper; this.memberService = memberService; this.contactService = contactService; - this.squenceService = squenceService; + this.squenceService = squenceService; this.runtime = runtime; } public async Task> SendMsgAsync(SendMsgCommand command) { + if (runtime.Current.TextLimit > 0 && command.MsgType == MessageType.Text && command.Text?.Length > runtime.Current.TextLimit) + return Result.Fail(ResultCode.PARAMETER_ERROR, "文本超过平台长度限制"); if (command.ChatType == Domain.Enums.ChatType.PRIVATE) { bool passed = await contactService.CheckContactAsync(command.SenderId, command.TargetId); @@ -105,19 +107,21 @@ namespace MessageService.WebApi.Application.Message return Result.Fail(ResultCode.MESSAGE_NOT_FOUND); } + if (runtime.Current.RecallMinutes > 0 && DateTimeOffset.UtcNow - msg.CreationTime > TimeSpan.FromMinutes(runtime.Current.RecallMinutes)) + return Result.Fail(ResultCode.PERMISSION_DENIED, "已超过平台撤回时限"); msg.Withdraw(); return Result.Success(); } - public async Task> GetMessagesAsync(GetMessageCommand command) + public async Task> GetMessagesAsync(GetMessageCommand command, CancellationToken cancellationToken = default) { if (command.direction is not 0 and not 1 || command.limit is < 1 or > 100) { return Result.Fail(ResultCode.PARAMETER_ERROR); } - var conversation = await conversationReposity.FindByIdAsync(command.conversationId); + var conversation = await conversationReposity.FindByIdAsync(command.conversationId, cancellationToken); if(conversation is null || conversation.UserId != command.userId) { @@ -125,9 +129,37 @@ namespace MessageService.WebApi.Application.Message } - var messages = await reposity.GetAsync(conversation.StreamKey, command.cusor, command.direction, command.limit); + var messages = await reposity.GetAsync(conversation.StreamKey, command.cusor, command.direction, command.limit, cancellationToken); return Result.Success(new GetMessagesResponse(mapper.Map>(messages.messages.ToList()),messages.hasMore)); } + + public async Task> SearchMessagesAsync( + SearchMessageCommand command, + CancellationToken cancellationToken = default) + { + var keyword = command.Keyword?.Trim() ?? string.Empty; + if (keyword.Length is < 1 or > 50 || command.Limit is < 1 or > 50) + { + return Result.Fail(ResultCode.PARAMETER_ERROR); + } + + var conversation = await conversationReposity.FindByIdAsync(command.ConversationId, cancellationToken); + if (conversation is null || conversation.UserId != command.UserId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } + + var messages = await reposity.SearchAsync( + conversation.StreamKey, + keyword, + command.Cursor, + command.Limit, + cancellationToken); + + return Result.Success(new GetMessagesResponse( + mapper.Map>(messages.messages.ToList()), + messages.hasMore)); + } } } diff --git a/MessageService.WebApi/Application/Message/SearchMessageCommand.cs b/MessageService.WebApi/Application/Message/SearchMessageCommand.cs new file mode 100644 index 0000000..17397c2 --- /dev/null +++ b/MessageService.WebApi/Application/Message/SearchMessageCommand.cs @@ -0,0 +1,9 @@ +namespace MessageService.WebApi.Application.Message +{ + public record SearchMessageCommand( + Guid ConversationId, + Guid UserId, + string Keyword, + long? Cursor, + int Limit); +} diff --git a/MessageService.WebApi/Controllers/Conversation/ConversationController.cs b/MessageService.WebApi/Controllers/Conversation/ConversationController.cs index d774d91..58d0875 100644 --- a/MessageService.WebApi/Controllers/Conversation/ConversationController.cs +++ b/MessageService.WebApi/Controllers/Conversation/ConversationController.cs @@ -20,24 +20,24 @@ namespace MessageService.WebApi.Controllers.Conversation } [HttpGet] - public async Task List() + public async Task List(CancellationToken cancellationToken) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - return Ok(await service.GetByOwnerIdAsync(Guid.Parse(userId))); + return Ok(await service.GetByOwnerIdAsync(Guid.Parse(userId!), cancellationToken)); } [HttpGet] - public async Task Get([FromQuery]Guid id) + public async Task Get([FromQuery]Guid id, CancellationToken cancellationToken) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - return Ok(await service.GetByIdAsync(id, Guid.Parse(userId))); + return Ok(await service.GetByIdAsync(id, Guid.Parse(userId!), cancellationToken)); } [HttpPost] [UnitOfWork(typeof(MessageDbContext))] - public async Task MarkRead([FromQuery] Guid conversationId) + public async Task MarkRead([FromQuery] Guid conversationId, long? lastReadSequenceId, CancellationToken cancellationToken) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); - return Ok(await service.MarkAsReadAsync(conversationId, Guid.Parse(userId))); + return Ok(await service.MarkAsReadAsync(conversationId, Guid.Parse(userId!), lastReadSequenceId, cancellationToken)); } } diff --git a/MessageService.WebApi/Controllers/ManagementController.cs b/MessageService.WebApi/Controllers/ManagementController.cs new file mode 100644 index 0000000..f4d12df --- /dev/null +++ b/MessageService.WebApi/Controllers/ManagementController.cs @@ -0,0 +1,53 @@ +using System.Text.Json; +using System.Security.Claims; +using IM.InitCommon.Management; +using IM.Commons; +using MessageService.Infrastructure; +using MessageService.Domain.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace MessageService.WebApi.Controllers; +[ApiController] +public sealed class ManagementController(MessageDbContext db, InternalClient client) : ControllerBase +{ + [HttpPost("api/message/report"), Authorize] + public async Task Report(ClientReport input, CancellationToken ct) + { + var reporter = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!); + JsonElement result; + try { result = await client.Send("admin", "/internal/management/reports", new { ReporterId = reporter, input.Type, input.TargetId, input.Reason, input.Description, input.MessageIds }, ct); } + catch (InternalServiceException e) { return StatusCode(e.Status, ManagementResult.Fail(e.Status switch { 429 => "举报次数已达上限或仍在重复举报冷却期", 403 => "没有访问举报对象或消息的权限", 400 => "举报内容无效,请检查分类和关联消息", _ => "举报服务暂不可用,请重试" })); } + return Ok(ManagementResult.Ok(result)); + } + [HttpPost("internal/management/evidence")] + public async Task Evidence(EvidenceRequest input, CancellationToken ct) + { + if (input.Type is not "user" and not "group" || input.MessageIds.Length > 20 || input.TargetId == input.ReporterId) return BadRequest(); + string name; + if (input.Type == "group") { + var access = await client.Send("group", $"/internal/management/access/{input.TargetId}/{input.ReporterId}", ct: ct); + if (!access.GetProperty("member").GetBoolean()) return Forbid(); + var group = await client.Send("group", $"/internal/management/detail/{input.TargetId}", ct: ct); name = group.GetProperty("name").GetString()!; + } else { + var relation = await client.Send("contact", $"/internal/management/relation/{input.ReporterId}/{input.TargetId}", ct: ct); + if (!relation.GetProperty("related").GetBoolean() && input.MessageIds.Length == 0) return Forbid(); + var user = await client.Send("user", $"/internal/management/list?q={input.TargetId}&size=1", ct: ct); + if (user.GetProperty("items").GetArrayLength() == 0) return NotFound(); name = user.GetProperty("items")[0].GetProperty("name").GetString()!; + } + var ids = input.MessageIds.Distinct().ToArray(); var messages = await db.Messages.AsNoTracking().Where(x => ids.Contains(x.Id)).ToListAsync(ct); + if (messages.Count != ids.Length) return BadRequest(); + var evidence = new List(); + foreach (var m in messages) { + var ownConversation = await db.Conversations.AnyAsync(x => x.UserId == input.ReporterId && x.StreamKey == m.StreamKey, ct); + if (!ownConversation || (input.Type == "user" && m.SenderId != input.TargetId) || (input.Type == "group" && (m.ChatType != ChatType.GROUP || m.TargetId != input.TargetId))) return Forbid(); + if (m.ChatType == ChatType.GROUP) { var access = await client.Send("group", $"/internal/management/access/{m.TargetId}/{input.ReporterId}", ct: ct); if (!access.GetProperty("member").GetBoolean()) return Forbid(); } + using var body = JsonDocument.Parse(m.Content.RawBody ?? "{}"); Guid? fileId = null; + if ((body.RootElement.TryGetProperty("FileId", out var file) || body.RootElement.TryGetProperty("fileId", out file)) && file.ValueKind == JsonValueKind.String && file.TryGetGuid(out var fid)) fileId = fid; + evidence.Add(new(m.Id, m.SenderId, m.SenderId.ToString(), m.Content.Fallback, m.MsgType.ToString(), fileId, m.CreationTime)); + } + return Ok(new SubjectEvidence(name, evidence)); + } +} +public record ClientReport(string Type, Guid TargetId, string Reason, string Description, Guid[] MessageIds); diff --git a/MessageService.WebApi/Controllers/Message/MessageController.cs b/MessageService.WebApi/Controllers/Message/MessageController.cs index 8aef793..d289713 100644 --- a/MessageService.WebApi/Controllers/Message/MessageController.cs +++ b/MessageService.WebApi/Controllers/Message/MessageController.cs @@ -37,11 +37,24 @@ namespace MessageService.WebApi.Controllers.Message } [HttpGet] - public async Task GetMessages([FromQuery]Guid conversationId, long? cursor, int direction, int limit) + public async Task GetMessages([FromQuery]Guid conversationId, long? cursor, int direction, int limit, CancellationToken cancellationToken) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var command = new GetMessageCommand(conversationId, Guid.Parse(userId), cursor, direction, limit); - return Ok(await service.GetMessagesAsync(command)); + return Ok(await service.GetMessagesAsync(command, cancellationToken)); + } + + [HttpGet] + public async Task Search( + [FromQuery] Guid conversationId, + [FromQuery] string keyword, + long? cursor, + int limit = 30, + CancellationToken cancellationToken = default) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var command = new SearchMessageCommand(conversationId, Guid.Parse(userId!), keyword, cursor, limit); + return Ok(await service.SearchMessagesAsync(command, cancellationToken)); } } } diff --git a/MessageService.WebApi/Program.cs b/MessageService.WebApi/Program.cs index 40735d1..45019e3 100644 --- a/MessageService.WebApi/Program.cs +++ b/MessageService.WebApi/Program.cs @@ -1,3 +1,4 @@ +using IM.InitCommon.Management; using IM.InitCommon; @@ -19,6 +20,7 @@ namespace MessageService.WebApi builder.Services.AddAllGrpcServer(); var app = builder.Build(); + if (app.ApplyMigrationsIfRequested(args)) return; // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -28,6 +30,7 @@ namespace MessageService.WebApi } app.UseAppDefault(); + app.MapManagementHealth(); app.MapControllers(); diff --git a/User.Domain/Entities/User.cs b/User.Domain/Entities/User.cs index d5547cd..35b7e62 100644 --- a/User.Domain/Entities/User.cs +++ b/User.Domain/Entities/User.cs @@ -69,6 +69,8 @@ namespace IdentityService.Domain.Entities AddDomainEvent(new UserBannedDomainEvent(this.Id, reason)); } + public void Unban() { if (Status == UserState.Banned) Status = UserState.Normal; } + public void Update(string? nickName, string? region, string? avatar, string? desc) { if (nickName != null) diff --git a/User.Infrastructure/Migrations/20260915000100_ManagementReceipts.cs b/User.Infrastructure/Migrations/20260915000100_ManagementReceipts.cs new file mode 100644 index 0000000..77cea4f --- /dev/null +++ b/User.Infrastructure/Migrations/20260915000100_ManagementReceipts.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +namespace IdentityService.Infrastructure.Migrations; +[DbContext(typeof(UserDbContext))] +[Migration("20260915000100_ManagementReceipts")] +public class ManagementReceipts : Migration +{ + protected override void Up(MigrationBuilder m) => m.Sql(""" + CREATE TABLE IF NOT EXISTS management_receipts ( + Owner varchar(64) NOT NULL, + Id char(36) NOT NULL, + Payload longtext NOT NULL, + CreatedAt datetime(6) NOT NULL, + PRIMARY KEY (Owner, Id) + ) CHARACTER SET utf8mb4; + """); + // Receipts are retained on rollback: losing idempotency history can repeat a previously applied action. + protected override void Down(MigrationBuilder m) { } +} \ No newline at end of file diff --git a/User.WebApi/Applications/Auth/AuthService.cs b/User.WebApi/Applications/Auth/AuthService.cs index 87d1ca4..0ee108e 100644 --- a/User.WebApi/Applications/Auth/AuthService.cs +++ b/User.WebApi/Applications/Auth/AuthService.cs @@ -4,6 +4,7 @@ using IdentityService.WebApi.Applications.Dtos; using IdentityService.WebApi.Applications.Dtos.Common; using IM.Commons; using IM.Jwt; +using IM.InitCommon.Management; using Microsoft.Extensions.Options; using System.Security.Claims; @@ -16,10 +17,11 @@ namespace IdentityService.WebApi.Applications.Auth private readonly IOptions jwtOptions; private readonly IdDomainService idDomainService; private readonly IMapper mapper; + private readonly RuntimePolicy runtime; public AuthService(ITokenService tokenService, IIdRepository idRepository, IOptions options, IdDomainService idDomainService, - IMapper mapper + IMapper mapper, RuntimePolicy runtime ) { this.tokenService = tokenService; @@ -27,6 +29,7 @@ namespace IdentityService.WebApi.Applications.Auth jwtOptions = options; this.idDomainService = idDomainService; this.mapper = mapper; + this.runtime = runtime; } public async Task> LoginAsync(string username, string password) @@ -37,17 +40,20 @@ namespace IdentityService.WebApi.Applications.Auth { return Result.Fail(ResultCode.USER_NOT_FOUND); } + if (user.Status != UserState.Normal || user.IsDeleted) return Result.Fail(ResultCode.AUTH_FAILED); var idResult = await idRepository.CheckForSignInAsync(user, password, true); if (!idResult.Succeeded) { return Result.Fail(ResultCode.PASSWORD_ERROR); } var token = await BuildTokenAsync(user); - var refreshToken = await tokenService.CreateRefreshTokenAsync(user.Id); + var refreshToken = await tokenService.CreateRefreshTokenAsync(user.Id, stamp: user.SecurityStamp, days: runtime.Current.ClientRefreshDays); return Result.Success(new LoginResponse(user.Id, token, refreshToken, null, user.UserName, user.NickName,user.Avatar, user.CreationTime)); } public async Task> RegisterAsync(string userName, string password, string nickName) { + if (!runtime.Current.RegistrationEnabled) return Result.Fail(ResultCode.PERMISSION_DENIED, "平台暂未开放注册"); + if (password.Length < runtime.Current.PasswordMinLength) return Result.Fail(ResultCode.PARAMETER_ERROR, "密码不符合当前最小长度要求"); var userResult = await idDomainService.CreateUserAsync(userName, password, nickName); if (!userResult.Succeeded) return Result.Fail(userResult); @@ -78,20 +84,24 @@ namespace IdentityService.WebApi.Applications.Auth return Result.Fail(ResultCode.USER_NOT_FOUND); } + if (user.Status != UserState.Normal || user.IsDeleted || validateRes.stamp != user.SecurityStamp) return Result.Fail(ResultCode.AUTH_FAILED); var token = await BuildTokenAsync(user); - - return Result.Success(new LoginResponse(user.Id, token, refreshToken, null, user.UserName, user.NickName, user.Avatar, user.CreationTime)); + await tokenService.RevokeRefreshTokenAsync(refreshToken); + var nextRefresh = await tokenService.CreateRefreshTokenAsync(user.Id, stamp: user.SecurityStamp, days: runtime.Current.ClientRefreshDays); + return Result.Success(new LoginResponse(user.Id, token, nextRefresh, null, user.UserName, user.NickName, user.Avatar, user.CreationTime)); } private async Task BuildTokenAsync(Domain.Entities.User user) { var roles = await idRepository.GetRolesAsync(user); List claims = new List(); claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); + claims.Add(new Claim("session_stamp", user.SecurityStamp ?? "")); foreach (string role in roles) { claims.Add(new Claim(ClaimTypes.Role, role)); } - return tokenService.GetToken(claims, jwtOptions.Value); + var original = jwtOptions.Value; + return tokenService.GetToken(claims, new JwtOptions { Key = original.Key, Issuer = original.Issuer, Audience = original.Audience, RefreshTokenDays = original.RefreshTokenDays, AccessTokenMinutes = runtime.Current.ClientAccessMinutes > 0 ? runtime.Current.ClientAccessMinutes : original.AccessTokenMinutes }); } } } diff --git a/User.WebApi/Controllers/ManagementController.cs b/User.WebApi/Controllers/ManagementController.cs new file mode 100644 index 0000000..1f3d802 --- /dev/null +++ b/User.WebApi/Controllers/ManagementController.cs @@ -0,0 +1,38 @@ +using IdentityService.Infrastructure; +using IM.InitCommon.Management; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace IdentityService.WebApi.Controllers; +[ApiController, Route("internal/management")] +public sealed class ManagementController(UserDbContext db, InternalClient client) : ControllerBase +{ + [HttpGet("summary")] public async Task Summary() => new { total = await db.Users.CountAsync() }; + [HttpGet("access/{id:guid}")] public async Task Access(Guid id) { var u = await db.Users.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id); return new(u is not null && u.Status == Domain.UserState.Normal && !u.IsDeleted, u?.SecurityStamp ?? ""); } + [HttpGet("list")] public async Task List(string? q, string? status, int page = 1, int size = 8) + { + page = Math.Max(1, page); size = Math.Clamp(size, 1, 100); + var query = db.Users.AsNoTracking().Where(x => q == null || x.NickName.Contains(q) || x.UserName!.Contains(q) || x.Id.ToString() == q); + if (!string.IsNullOrEmpty(status)) { var state = status == "封禁" ? Domain.UserState.Banned : status == "正常" ? Domain.UserState.Normal : Domain.UserState.Inactive; query = query.Where(x => x.Status == state); } + return new { items = await query.OrderByDescending(x => x.CreationTime).Skip((page - 1) * size).Take(size).Select(x => new { x.Id, name = x.NickName, account = x.UserName, x.Region, status = x.Status == Domain.UserState.Banned ? "封禁" : x.Status == Domain.UserState.Normal ? "正常" : "未激活", createdAt = x.CreationTime }).ToListAsync(), total = await query.CountAsync(), page, size }; + } + [HttpGet("detail/{id:guid}")] public async Task Detail(Guid id) + { + var u = await db.Users.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id); if (u is null) return NotFound(); + var groups = await client.Send("group", $"/internal/management/user/{id}/groups"); + return Ok(new { u.Id, name = u.NickName, account = u.UserName, u.Region, u.Description, createdAt = u.CreationTime, status = u.Status == Domain.UserState.Banned ? "封禁" : u.Status == Domain.UserState.Normal ? "正常" : "未激活", groups }); + } + [HttpPost("action")] public async Task Action(InternalAction command, CancellationToken ct) + { + if (command.Action is not "封禁" and not "解封") return BadRequest(); + var receipt = await ReceiptStore.Execute(db, command, async () => { + var u = await db.Users.SingleOrDefaultAsync(x => x.Id == command.TargetId, ct) ?? throw new InvalidOperationException("用户不存在"); + var before = u.Status == Domain.UserState.Banned ? "封禁" : "正常"; + if (command.Action == "封禁") u.Ban(command.Reason); else u.Unban(); + u.SecurityStamp = Guid.NewGuid().ToString("N"); + return new ActionReceipt(u.NickName, before, command.Action == "封禁" ? "封禁" : "正常"); + }, ct); + if (command.Action == "封禁") await client.Send("connector", $"/internal/management/disconnect/{command.TargetId}", new { }, ct); + return Ok(receipt); + } +} diff --git a/User.WebApi/Program.cs b/User.WebApi/Program.cs index 4e6234e..cd0c7eb 100644 --- a/User.WebApi/Program.cs +++ b/User.WebApi/Program.cs @@ -1,4 +1,5 @@ - +using IM.InitCommon.Management; + using IdentityService.Domain.Entities; using IdentityService.Infrastructure; using IM.InitCommon; @@ -38,6 +39,7 @@ namespace IdentityService.WebApi builder.Services.AddSwaggerGen(); var app = builder.Build(); + if (app.ApplyMigrationsIfRequested(args)) return; // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) @@ -47,6 +49,7 @@ namespace IdentityService.WebApi } app.UseAppDefault(); + app.MapManagementHealth(); app.MapControllers(); app.MapAllGrpcServer(); diff --git a/deploy/Configure-Local.ps1 b/deploy/Configure-Local.ps1 new file mode 100644 index 0000000..f274c32 --- /dev/null +++ b/deploy/Configure-Local.ps1 @@ -0,0 +1,39 @@ +param([switch]$Docker) +$ErrorActionPreference = 'Stop' +& (Join-Path $PSScriptRoot 'Initialize-Local.ps1') +$secrets = @{} +Get-Content -LiteralPath (Join-Path $PSScriptRoot '.env.local') | ForEach-Object { if ($_ -match '^([^#=]+)=(.*)$') { $secrets[$Matches[1]] = $Matches[2] } } +$serviceMap = @{ admin=@('Admin.WebApi','Admin.WebApi.dll',5180); user=@('User.WebApi','IdentityService.WebApi.dll',5181); contact=@('ContactService.WebApi','ContactService.WebApi.dll',5182); group=@('GroupService.WebApi','GroupService.WebApi.dll',5183); message=@('MessageService.WebApi','MessageService.WebApi.dll',5184); file=@('FileService.WebApi','FileService.WebApi.dll',5185); connector=@('ConnectorService','ConnectorService.dll',5186) } +$hosts = @{} +foreach ($name in $serviceMap.Keys) { $hosts[$name] = if ($Docker) { "http://$($name):8080" } else { "http://127.0.0.1:$($serviceMap[$name][2])" } } +$dbHost = if ($Docker) { 'mysql;Port=3306' } else { '127.0.0.1;Port=13306' } +$redisAddress = if ($Docker) { 'redis:6379' } else { '127.0.0.1:16379' } +$rabbitHost = if ($Docker) { 'rabbitmq' } else { '127.0.0.1' } +$rabbitPort = if ($Docker) { 5672 } else { 15672 } +$consulUrl = if ($Docker) { 'http://consul:8500' } else { 'http://127.0.0.1:18500' } +$storageRoot = if ($Docker) { '/data/files' } else { Join-Path $PSScriptRoot 'data/files' } +$keysRoot = if ($Docker) { '/data/keyring' } else { Join-Path $PSScriptRoot 'data/keyring' } +$cert = [Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem([IO.File]::ReadAllText((Join-Path $PSScriptRoot 'data/certs/smtp.crt'))) +$smtpPin = $cert.GetCertHashString([Security.Cryptography.HashAlgorithmName]::SHA256) +$cert.Dispose() +$connection = "Server=$dbHost;Database=im_local;User=im_local;Password=$($secrets.MYSQL_PASSWORD);Allow User Variables=true" +$config = @{ + ConnectionStrings = @{ DefaultConnection=$connection; Admin=$connection; Redis=$redisAddress } + Jwt = @{ Key=$secrets.JWT_KEY; Issuer='IM.Local'; Audience='IM.Client'; AccessTokenMinutes=30; RefreshTokenDays=7 } + RabbitMQOptions = @{ Host=$rabbitHost; Port=$rabbitPort; Username='im_local'; Password=$secrets.RABBITMQ_PASSWORD; QuequeName='im-local' } + Cors = @{ Origins=@('http://127.0.0.1:5178','http://localhost:5173','http://127.0.0.1:5173','http://localhost:5174') } + GrpcConfigs = @{ + IdentityServiceUrl = $(if ($Docker) {'http://user:8081'} else {'http://127.0.0.1:5281'}) + ContactServiceUrl = $(if ($Docker) {'http://contact:8081'} else {'http://127.0.0.1:5282'}) + MessageServiceUrl = $(if ($Docker) {'http://message:8081'} else {'http://127.0.0.1:5284'}) + } + InternalApiKey = $secrets.MANAGEMENT_INTERNAL_KEY + InternalServices = @{ GroupServiceBaseUrl=$hosts.group } + Management = @{ Enabled=$true; InternalKey=$secrets.MANAGEMENT_INTERNAL_KEY; CredentialKey=$secrets.CREDENTIAL_KEY; KeyRingPath=$keysRoot; Services=$hosts; AdminPublicUrl='http://127.0.0.1:5178'; AllowedInfrastructureHosts=@('localhost','127.0.0.1','minio','smtp'); AllowedStorageRoots=@($storageRoot); RabbitHost=$rabbitHost; RabbitPort=$rabbitPort; ConsulUrl=$consulUrl; DevelopmentSmtpCertificateSha256=$smtpPin } + StorageOptions = @{ DefaultProviderCode='Local'; Providers=@{ Local=@{ ProviderCode='Local'; ProviderType=1; Enabled=$true; Bucket='private'; PublicBucket='public'; Region='local'; LocalRootPath=$storageRoot; LocalUploadApiBaseUrl=$hosts.file; PublicBaseUrl=$hosts.file; MaxObjectSizeBytes=1073741824; MinPartSizeBytes=5242880; DefaultPartSizeBytes=5242880; MaxPartCount=10000 } } } +} +$json = $config | ConvertTo-Json -Depth 12 +Invoke-RestMethod -Uri 'http://127.0.0.1:18500/v1/kv/IM/Development/appsettings.json' -Method Put -ContentType 'application/json' -Body ([Text.Encoding]::UTF8.GetBytes($json)) | Out-Null +$path = Join-Path $PSScriptRoot 'data/startup.local.json' +[IO.File]::WriteAllText($path,$json) +Write-Output 'Local startup configuration was written to the isolated Consul instance.' diff --git a/deploy/Initialize-Local.ps1 b/deploy/Initialize-Local.ps1 new file mode 100644 index 0000000..1b612e6 --- /dev/null +++ b/deploy/Initialize-Local.ps1 @@ -0,0 +1,31 @@ +# Creates only local development secrets and a SMTP test certificate. No default administrator password. +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot +$envPath = Join-Path $root '.env.local' +if (!(Test-Path -LiteralPath $envPath)) { + function New-Secret { [Convert]::ToHexString([Security.Cryptography.RandomNumberGenerator]::GetBytes(32)) } + $entries = @( + ('MYSQL_ROOT_PASSWORD=' + (New-Secret)) + ('MYSQL_PASSWORD=' + (New-Secret)) + ('RABBITMQ_PASSWORD=' + (New-Secret)) + ('S3_PASSWORD=' + (New-Secret)) + ('MANAGEMENT_INTERNAL_KEY=' + (New-Secret)) + ('JWT_KEY=' + (New-Secret)) + ('CREDENTIAL_KEY=' + [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))) + ) + [IO.File]::WriteAllLines($envPath, $entries) +} +$certRoot = Join-Path $root 'data/certs' +New-Item -ItemType Directory -Force -Path $certRoot | Out-Null +if (!(Test-Path -LiteralPath (Join-Path $certRoot 'smtp.crt'))) { + $rsa = [Security.Cryptography.RSA]::Create(2048) + $request = [Security.Cryptography.X509Certificates.CertificateRequest]::new('CN=localhost', $rsa, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) + $san = [Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new() + $san.AddDnsName('localhost'); $san.AddDnsName('smtp'); $san.AddIpAddress([Net.IPAddress]::Loopback) + $request.CertificateExtensions.Add($san.Build()) + $cert = $request.CreateSelfSigned([DateTimeOffset]::UtcNow.AddMinutes(-5), [DateTimeOffset]::UtcNow.AddMonths(6)) + [IO.File]::WriteAllText((Join-Path $certRoot 'smtp.crt'), $cert.ExportCertificatePem()) + [IO.File]::WriteAllText((Join-Path $certRoot 'smtp.key'), $rsa.ExportPkcs8PrivateKeyPem()) + $rsa.Dispose(); $cert.Dispose() +} +Write-Output 'Local dependency secrets and SMTP certificate are ready. No administrator account was created.' diff --git a/deploy/Run-Local.ps1 b/deploy/Run-Local.ps1 new file mode 100644 index 0000000..d1cd2b2 --- /dev/null +++ b/deploy/Run-Local.ps1 @@ -0,0 +1,43 @@ +param([switch]$Migrate, [switch]$InitializeAdmin, [switch]$ResetAdmin, [switch]$Start) +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot +$configPath = Join-Path $PSScriptRoot 'data/startup.local.json' +if (!(Test-Path -LiteralPath $configPath)) { throw 'Run Configure-Local.ps1 first.' } +$config = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json -AsHashtable +function Set-ConfigEnvironment($node, $prefix = '') { + foreach ($key in $node.Keys) { + $name = if ($prefix) { $prefix + '__' + $key } else { $key } + $value = $node[$key] + if ($value -is [System.Collections.IDictionary]) { Set-ConfigEnvironment $value $name } + elseif ($value -is [array]) { for($i=0;$i -lt $value.Count;$i++) { [Environment]::SetEnvironmentVariable($name+'__'+$i,[string]$value[$i],'Process') } } + else { [Environment]::SetEnvironmentVariable($name,[string]$value,'Process') } + } +} +Set-ConfigEnvironment $config +$env:ASPNETCORE_ENVIRONMENT = 'Development' +$env:CONSUL_URL = 'http://127.0.0.1:18500' +$services = @(@('admin','Admin.WebApi','Admin.WebApi.dll',5180),@('user','User.WebApi','IdentityService.WebApi.dll',5181),@('contact','ContactService.WebApi','ContactService.WebApi.dll',5182),@('group','GroupService.WebApi','GroupService.WebApi.dll',5183),@('message','MessageService.WebApi','MessageService.WebApi.dll',5184),@('file','FileService.WebApi','FileService.WebApi.dll',5185),@('connector','ConnectorService','ConnectorService.dll',5186)) +$logs = Join-Path $PSScriptRoot 'data/logs' +New-Item -ItemType Directory -Force -Path $logs | Out-Null +foreach ($service in $services) { + $name,$project,$dll,$port = $service + $env:Management__ServiceName = $name + $env:Kestrel__Endpoints__Http__Url = "http://127.0.0.1:$port" + $env:Kestrel__Endpoints__Http__Protocols = 'Http1' + $env:Kestrel__Endpoints__Grpc__Url = 'http://127.0.0.1:' + ($port+100) + $env:Kestrel__Endpoints__Grpc__Protocols = 'Http2' + $binary = Join-Path $repo "$project/bin/Debug/net8.0/$dll" + if (!(Test-Path -LiteralPath $binary)) { throw "Build $project before starting." } + if ($Migrate -and $name -ne 'connector') { & dotnet $binary --migrate; if ($LASTEXITCODE -ne 0) { throw "Migration failed: $name" } } + if ($name -eq 'admin' -and ($InitializeAdmin -or $ResetAdmin)) { + if (!$env:IM_ADMIN_ACCOUNT -or !$env:IM_ADMIN_PASSWORD) { throw 'Set IM_ADMIN_ACCOUNT and IM_ADMIN_PASSWORD in this process first; no default credentials are created.' } + $action = if ($InitializeAdmin) { '--init-admin' } else { '--reset-admin' } + & dotnet $binary $action; if ($LASTEXITCODE -ne 0) { throw 'Administrator initialization failed.' } + } + if ($Start) { + if (Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue) { throw "Port $port is already in use; refusing to start a duplicate $name service." } + $process = Start-Process -FilePath 'dotnet' -ArgumentList @('"'+$binary+'"') -WorkingDirectory (Split-Path $binary) -WindowStyle Hidden -PassThru -RedirectStandardOutput (Join-Path $logs "$name.out.log") -RedirectStandardError (Join-Path $logs "$name.err.log") + [IO.File]::WriteAllText((Join-Path $logs "$name.pid"),[string]$process.Id) + Write-Output "$name started on port $port (PID $($process.Id))." + } +} diff --git a/deploy/dependencies.compose.yml b/deploy/dependencies.compose.yml new file mode 100644 index 0000000..9d3047e --- /dev/null +++ b/deploy/dependencies.compose.yml @@ -0,0 +1,61 @@ +name: im-admin-local +services: + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?Run Initialize-Local.ps1 first} + MYSQL_DATABASE: im_local + MYSQL_USER: im_local + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?Run Initialize-Local.ps1 first} + ports: ["127.0.0.1:13306:3306"] + volumes: ["mysql:/var/lib/mysql"] + healthcheck: + test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_PASSWORD mysql -u im_local -e 'SELECT 1' im_local"] + interval: 3s + timeout: 3s + retries: 40 + redis: + image: redis:7-alpine + ports: ["127.0.0.1:16379:6379"] + command: redis-server --appendonly yes + volumes: ["redis:/data"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 3s + retries: 20 + rabbitmq: + image: rabbitmq:3-management + environment: + RABBITMQ_DEFAULT_USER: im_local + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?Run Initialize-Local.ps1 first} + ports: ["127.0.0.1:15672:5672", "127.0.0.1:15674:15672"] + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 5s + timeout: 5s + retries: 30 + consul: + image: hashicorp/consul:1.20 + command: agent -dev -client=0.0.0.0 + ports: ["127.0.0.1:18500:8500"] + minio: + image: minio/minio:RELEASE.2025-04-22T22-12-26Z + command: server /data --console-address :9001 + environment: + MINIO_ROOT_USER: im_local + MINIO_ROOT_PASSWORD: ${S3_PASSWORD:?Run Initialize-Local.ps1 first} + ports: ["127.0.0.1:19000:9000", "127.0.0.1:19001:9001"] + volumes: ["minio:/data"] + smtp: + image: axllent/mailpit:v1.27 + environment: + MP_SMTP_TLS_CERT: /certs/smtp.crt + MP_SMTP_TLS_KEY: /certs/smtp.key + MP_SMTP_REQUIRE_STARTTLS: "true" + ports: ["127.0.0.1:11025:1025", "127.0.0.1:18025:8025"] + volumes: ["./data/certs:/certs:ro"] +volumes: + mysql: + redis: + minio: diff --git a/docker-compose.yml b/docker-compose.yml index d70a681..c757a7a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: nginx: image: nginx:alpine @@ -36,10 +34,10 @@ services: container_name: im-mysql restart: always environment: - MYSQL_ROOT_PASSWORD: root123456 + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required} MYSQL_DATABASE: im_db MYSQL_USER: im - MYSQL_PASSWORD: im123456 + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD is required} ports: - "3307:3306" command: @@ -66,8 +64,8 @@ services: container_name: im-rabbitmq restart: always environment: - RABBITMQ_DEFAULT_USER: im - RABBITMQ_DEFAULT_PASS: im123456 + RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER:?RABBITMQ_DEFAULT_USER is required} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS:?RABBITMQ_DEFAULT_PASS is required} ports: - "5673:5672" - "15673:15672"