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/EventHandler/UploadTaskCompleteEventHandler.cs b/FileService.Application/EventHandler/UploadTaskCompleteEventHandler.cs index f2766ff..ae38539 100644 --- a/FileService.Application/EventHandler/UploadTaskCompleteEventHandler.cs +++ b/FileService.Application/EventHandler/UploadTaskCompleteEventHandler.cs @@ -16,22 +16,33 @@ namespace FileService.Application.EventHandler private readonly IObjectStorageRouter router; private readonly IUploadFileReposity uploadFile; private readonly IUnitOfWork uwork; - private readonly IStorageRedisCache storageCache; + private readonly IUploadTaskReposity uploadTask; - public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUnitOfWork uwork, IStorageRedisCache storageCache) + public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUploadTaskReposity uploadTask, IUnitOfWork uwork) { this.router = router; this.uploadFile = uploadFile; this.uwork = uwork; - this.storageCache = storageCache; + this.uploadTask = uploadTask; } public async Task Consume(ConsumeContext context) { var @event = context.Message; + var existingFile = await uploadFile.FindBySourceTaskIdAsync(@event.TaskId); + if (existingFile != null) + { + return; + } + + var task = await uploadTask.FindByIdAsync(@event.TaskId); + if (task is null) + { + throw new InvalidOperationException($"Upload task {@event.TaskId} has not been committed yet."); + } + var storage = router.Route(@event.ProviderCode); - var taskCache = await storageCache.GetAsync(@event.SessionId); - if(@event.ProviderCode == "Local") + try { await storage.CompleteUploadAsync(new StorageContracts.CompleteUploadCommand( ProviderCode: @event.ProviderCode, @@ -46,14 +57,27 @@ namespace FileService.Application.EventHandler Checksum: s.Checksum )).ToList() ), context.CancellationToken); - uploadFile.Create(new Domain.Entities.UploadFile( + var file = new Domain.Entities.UploadFile( ownerId: @event.OperatorId, fileName: @event.FileName, - fileSize: taskCache.FileSize, + fileSize: @event.FileSize, contentType: @event.ContentType, - new Domain.ValueObjects.StorageLocation(taskCache.ProviderCode, taskCache.Bucket, taskCache.ObjectKey, taskCache.Region), - checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun) - )); + new Domain.ValueObjects.StorageLocation(@event.ProviderCode, @event.Bucket, @event.ObjectKey, @event.Region), + checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun), + sourceTaskId: @event.TaskId, + chatType: @event.ChatType, + targetId: @event.TargetId, + isPublic: false + ); + uploadFile.Create(file); + task.CompleteUpload(file.Id); + await uwork.SaveChangesAsync(context.CancellationToken); + } + catch (Exception ex) + { + task.Fail(ex.Message); + await uwork.SaveChangesAsync(context.CancellationToken); + throw; } } } 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/StorageDto.cs b/FileService.Application/StorageContracts/StorageDto.cs index 66c112c..db72d94 100644 --- a/FileService.Application/StorageContracts/StorageDto.cs +++ b/FileService.Application/StorageContracts/StorageDto.cs @@ -60,6 +60,7 @@ namespace FileService.Application.StorageContracts public sealed record PresignedUrl( string Url, + string Method, IReadOnlyDictionary Headers, DateTimeOffset ExpiresAt); 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/FileResponse.cs b/FileService.Application/UploadFile/FileResponse.cs index 98b2ec7..65f531f 100644 --- a/FileService.Application/UploadFile/FileResponse.cs +++ b/FileService.Application/UploadFile/FileResponse.cs @@ -12,12 +12,14 @@ namespace FileService.Application.UploadFile { public Guid Id { get; set; } public Guid OwnerId { get; set; } - public FileName FileName { get; set; } + public string FileName { get; set; } public long FileSize { get; set; } - public ContentType ContentType { get; set; } + public string ContentType { get; set; } public FileState State { get; set; } - public StorageLocation StorageLocation { get; set; } - public CheckSum CheckSum { get; set; } + public string CheckSum { get; set; } + public string? ChatType { get; set; } + public Guid? TargetId { get; set; } + public bool IsPublic { get; set; } public DateTimeOffset Created { get; set; } public DateTimeOffset Updated { get; set; } diff --git a/FileService.Application/UploadFile/IGroupAccessService.cs b/FileService.Application/UploadFile/IGroupAccessService.cs new file mode 100644 index 0000000..ba881a6 --- /dev/null +++ b/FileService.Application/UploadFile/IGroupAccessService.cs @@ -0,0 +1,19 @@ +using System.Net.Http.Json; + +namespace FileService.Application.UploadFile +{ + public interface IGroupAccessService + { + Task CheckMemberAsync(Guid userId, Guid groupId); + } + + public class GroupAccessService(HttpClient httpClient) : IGroupAccessService + { + public async Task CheckMemberAsync(Guid userId, Guid groupId) + { + var result = await httpClient.GetFromJsonAsync>( + $"api/groupmember/checkmember?userId={userId}&groupId={groupId}"); + return result?.Succeeded == true && result.Data; + } + } +} diff --git a/FileService.Application/UploadFile/UploadFileMapperConfig.cs b/FileService.Application/UploadFile/UploadFileMapperConfig.cs index 5a26bdb..cd6e07e 100644 --- a/FileService.Application/UploadFile/UploadFileMapperConfig.cs +++ b/FileService.Application/UploadFile/UploadFileMapperConfig.cs @@ -12,8 +12,11 @@ namespace FileService.Application.UploadFile public UploadFileMapperConfig() { CreateMap() + .ForMember(dest => dest.FileName, opt => opt.MapFrom(src => src.FileName.Value)) + .ForMember(dest => dest.ContentType, opt => opt.MapFrom(src => src.ContentType.Value)) + .ForMember(dest => dest.CheckSum, opt => opt.MapFrom(src => src.CheckSum.Value)) .ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.CreationTime)) - .ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime)) + .ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime ?? src.CreationTime)) ; } } diff --git a/FileService.Application/UploadFile/UploadFileService.cs b/FileService.Application/UploadFile/UploadFileService.cs index 9670aa1..09cb7f3 100644 --- a/FileService.Application/UploadFile/UploadFileService.cs +++ b/FileService.Application/UploadFile/UploadFileService.cs @@ -6,6 +6,7 @@ using FileService.Domain.ValueObjects; using IM.Commons; using IM.InitCommon; using Microsoft.Extensions.Options; +using System.Security.Cryptography; namespace FileService.Application.UploadFile { @@ -15,27 +16,35 @@ namespace FileService.Application.UploadFile private readonly IMapper mapper; private readonly IObjectStorageRouter router; private readonly IOptions options; + private readonly IGroupAccessService groupAccessService; private readonly IM.InitCommon.Management.RuntimePolicy runtime; public UploadFileService(IUploadFileReposity reposity, IMapper mapper, - IObjectStorageRouter router, IOptions options) + 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.runtime = runtime; } - public async Task> GetFileInfoAsync(Guid id) + public async Task> GetFileInfoAsync(Guid id, Guid requesterId) { var file = await reposity.FindByIdAsync(id); if (file == null) { return Result.Fail(ResultCode.FILE_NOT_FOUND); } + if (!await CanAccessAsync(file, requesterId)) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } var response = mapper.Map(file); response.Url = router.Route(file.StorageLocation.StorageProvider) .GetPublicUrl(file.StorageLocation); + response.IsPublic = response.IsPublic || response.Url != null; return Result.Success(response); } @@ -45,15 +54,31 @@ namespace FileService.Application.UploadFile /// public async Task> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default) { - // 秒传:相同 checksum 的文件已存在则直接返回已有记录 - if (!string.IsNullOrEmpty(command.CheckSum)) + 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)) { - var existing = await reposity.FindByCheckSumGlobalAsync("md5", command.CheckSum); - if (existing != null) + var hash = await MD5.HashDataAsync(command.Content, token); + checksum = Convert.ToHexString(hash).ToLowerInvariant(); + if (command.Content.CanSeek) + { + command.Content.Position = 0; + } + } + // 秒传:相同 checksum 的文件已存在则直接返回已有记录 + if (!string.IsNullOrEmpty(checksum)) + { + var existing = await reposity.FindByCheckSumGlobalAsync("md5", checksum); + var existingPublicUrl = existing == null + ? null + : router.Route(existing.StorageLocation.StorageProvider).GetPublicUrl(existing.StorageLocation); + if (existing != null && (existingPublicUrl != null || + (!command.IsPublic && existing.OwnerId == command.OwnerId))) { var hit = mapper.Map(existing); - var hitStorage = router.Route(existing.StorageLocation.StorageProvider); - hit.Url = hitStorage.GetPublicUrl(existing.StorageLocation); + hit.Url = existingPublicUrl; + hit.IsPublic = hit.IsPublic || hit.Url != null; return Result.Success(hit); } } @@ -84,7 +109,8 @@ namespace FileService.Application.UploadFile fileSize: command.FileSize, contentType: command.ContentType, storageLocation: location, - checkSum: new CheckSum("md5", command.CheckSum ?? string.Empty)); + checkSum: new CheckSum("md5", checksum), + isPublic: command.IsPublic); reposity.Create(file); @@ -103,6 +129,10 @@ namespace FileService.Application.UploadFile { return Result.Fail(ResultCode.FILE_NOT_FOUND); } + if (!await CanAccessAsync(file, requesterId)) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } var stream = await router.Route(file.StorageLocation.StorageProvider) .OpenReadAsync(file.StorageLocation, token); @@ -113,17 +143,33 @@ namespace FileService.Application.UploadFile file.FileName.Value)); } - // FileName 值对象限制 20 字符;原始名超长时安全截断(保留扩展名),真实文件名由 objectKey 保证唯一。 + private async Task CanAccessAsync(Domain.Entities.UploadFile file, Guid requesterId) + { + var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation); + if (file.IsPublic || publicUrl != null || file.OwnerId == requesterId) return true; + if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase)) + { + return file.TargetId == requesterId; + } + if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) && file.TargetId.HasValue) + { + return await groupAccessService.CheckMemberAsync(requesterId, file.TargetId.Value); + } + return false; + } + + // 文件名超长时安全截断(保留扩展名),真实存储键由 objectKey 保证唯一。 private static FileName SafeFileName(string fileName) { - if (fileName.Length <= 20) + const int maxFileNameLength = 255; + if (fileName.Length <= maxFileNameLength) { return new FileName(fileName); } var ext = Path.GetExtension(fileName); var stem = Path.GetFileNameWithoutExtension(fileName); - var keep = Math.Max(0, 20 - ext.Length); + var keep = Math.Max(0, maxFileNameLength - ext.Length); return new FileName(stem[..Math.Min(stem.Length, keep)] + ext); } } diff --git a/FileService.Application/UploadFileTask/TaskInitResponse.cs b/FileService.Application/UploadFileTask/TaskInitResponse.cs index 0edd39b..0e033a1 100644 --- a/FileService.Application/UploadFileTask/TaskInitResponse.cs +++ b/FileService.Application/UploadFileTask/TaskInitResponse.cs @@ -13,5 +13,10 @@ namespace FileService.Application.UploadFileTask public string UploadSessionId { get; init; } public StorageLocation StorageLocation { get; init; } + public bool Instant { get; init; } + public string UploadMode { get; init; } = "LocalMultipart"; + public int TotalPartCount { get; init; } + public long PartSizeBytes { get; init; } + public global::FileService.Application.UploadFile.FileResponse? File { get; init; } } } diff --git a/FileService.Application/UploadFileTask/UploadFileTaskService.cs b/FileService.Application/UploadFileTask/UploadFileTaskService.cs index d8b679c..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,18 +29,29 @@ 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 的文件若已存在于已完成文件表,直接返回已有记录 var existingFile = await uploadFileReposity.FindByCheckSumGlobalAsync("md5", command.checkSum); - if (existingFile != null) + if (existingFile != null && CanReuse(existingFile, command)) { var storageForResponse = router.Route(existingFile.StorageLocation.StorageProvider); + var fileResponse = mapper.Map(existingFile); + fileResponse.Url = storageForResponse.GetPublicUrl(existingFile.StorageLocation); + fileResponse.IsPublic = fileResponse.IsPublic || fileResponse.Url != null; return Result.Success(new TaskInitResponse { TaskId = existingFile.Id, UploadSessionId = existingFile.Id.ToString(), - StorageLocation = existingFile.StorageLocation + StorageLocation = existingFile.StorageLocation, + Instant = true, + UploadMode = "Instant", + TotalPartCount = 0, + PartSizeBytes = 0, + File = fileResponse }); } @@ -58,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); @@ -70,8 +81,20 @@ namespace FileService.Application.UploadFileTask var res = mapper.Map(initRes); res.TaskId = task.Id; + res = new TaskInitResponse + { + TaskId = task.Id, + UploadSessionId = initRes.UploadSessionId, + StorageLocation = initRes.Location, + Instant = false, + 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( @@ -97,18 +120,27 @@ namespace FileService.Application.UploadFileTask return Result.Fail(ResultCode.CHUNK_NOT_FOUND); } + var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task is null || task.UploaderId != userId) + { + 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); @@ -123,24 +155,61 @@ namespace FileService.Application.UploadFileTask return Result.Fail(ResultCode.CHUNK_NOT_FOUND); } + var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task is null) + { + return Result.Fail(ResultCode.CHUNK_NOT_FOUND); + } + if (task.UploaderId != command.userId) + { + 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) { return Result.Fail(ResultCode.PART_COUNT_MISMATCH); } - // 校验所有分片都已在上传缓存中注册 - foreach (var part in command.Parts) + var expectedPartNumbers = Enumerable.Range(1, taskCache.TotalPartCount).ToHashSet(); + if (!expectedPartNumbers.SetEquals(command.Parts.Select(x => x.PartNumber))) { - if (!taskCache.Parts.TryGetValue(part.PartNumber, out _)) + return Result.Fail(ResultCode.INVALID_PART_NUMBER); + } + + // 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 ETag。 + if (runtime.Enabled || string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)) + { + foreach (var part in command.Parts) { - return Result.Fail(ResultCode.CHUNK_NOT_FOUND); + if (!taskCache.Parts.TryGetValue(part.PartNumber, out _)) + { + return Result.Fail(ResultCode.CHUNK_NOT_FOUND); + } } } - var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task.State == Domain.UploadTaskState.Completed) + { + var completedResponse = mapper.Map(task); + if (task.ResultFileId.HasValue) + { + var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value); + if (file != null) + { + completedResponse.File = mapper.Map(file); + completedResponse.File.Url = router.Route(file.StorageLocation.StorageProvider) + .GetPublicUrl(file.StorageLocation); + completedResponse.File.IsPublic = completedResponse.File.IsPublic || completedResponse.File.Url != null; + } + } + return Result.Success(completedResponse); + } - task.CompleteUpload(new Domain.ValueObjects.StorageLocation( + task.StartMerging(new Domain.ValueObjects.StorageLocation( taskCache.ProviderCode, taskCache.Bucket, taskCache.ObjectKey, taskCache.Region )); @@ -162,21 +231,32 @@ namespace FileService.Application.UploadFileTask FileSize = task.FileSize, ContentType = task.ContentType.ToString(), CheckSun = task.CheckSum.Value + ,ChatType = task.ChatType + ,TargetId = task.TargetId }, cancellationToken); return Result.Success(mapper.Map(task)); } - public async Task> UploadPartAsync(UploadPartCommand command) + public async Task> UploadPartAsync(UploadPartCommand command, Guid userId) { var taskCache = await redis.GetAsync(command.SessionId); if (taskCache is null) { return Result.Fail(ResultCode.CHUNK_NOT_FOUND); } + var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task is null || task.UploaderId != userId) + { + 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; @@ -186,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, @@ -200,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)); } /// @@ -213,6 +297,11 @@ namespace FileService.Application.UploadFileTask { return Result.Fail(ResultCode.CHUNK_NOT_FOUND); } + var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId)); + if (task is null || task.UploaderId != userId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } var response = new UploadProgressResponse { @@ -229,5 +318,59 @@ 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); + if (file.IsPublic || publicUrl != null) return true; + if (file.OwnerId == command.UploaderId) + { + return string.Equals(file.ChatType, command.ChatType, StringComparison.OrdinalIgnoreCase) && + file.TargetId == command.TargetId; + } + if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(command.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) && + file.TargetId == command.TargetId; + } + if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase)) + { + return string.Equals(command.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase) && + file.TargetId == command.UploaderId && command.TargetId == file.OwnerId; + } + return false; + } + + public async Task> GetStatusAsync(Guid taskId, Guid userId) + { + var task = await reposity.FindByIdAsync(taskId); + if (task is null) + { + return Result.Fail(ResultCode.CHUNK_NOT_FOUND); + } + if (task.UploaderId != userId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } + + var response = mapper.Map(task); + if (task.ResultFileId.HasValue) + { + var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value); + if (file != null) + { + response.File = mapper.Map(file); + response.File.Url = router.Route(file.StorageLocation.StorageProvider) + .GetPublicUrl(file.StorageLocation); + response.File.IsPublic = response.File.IsPublic || response.File.Url != null; + } + } + return Result.Success(response); + } } } diff --git a/FileService.Application/UploadFileTask/UploadTaskInitCommand.cs b/FileService.Application/UploadFileTask/UploadTaskInitCommand.cs index 083f7b1..c710025 100644 --- a/FileService.Application/UploadFileTask/UploadTaskInitCommand.cs +++ b/FileService.Application/UploadFileTask/UploadTaskInitCommand.cs @@ -8,7 +8,7 @@ namespace FileService.Application.UploadFileTask { public record UploadTaskInitCommand( Guid UploaderId, - Guid ConversationId, string FileName, + Guid ConversationId, string? ChatType, Guid? TargetId, string FileName, long FileSize,string contentType, string checkSum ) @@ -17,7 +17,7 @@ namespace FileService.Application.UploadFileTask { return new Domain.Entities.UploadTask( UploaderId, - ConversationId, FileName, FileSize, contentType, + ConversationId, ChatType, TargetId, FileName, FileSize, contentType, null,new Domain.ValueObjects.CheckSum("md5", checkSum) ); } diff --git a/FileService.Application/UploadFileTask/UploadTaskResponse.cs b/FileService.Application/UploadFileTask/UploadTaskResponse.cs index d9ed42f..5a5e784 100644 --- a/FileService.Application/UploadFileTask/UploadTaskResponse.cs +++ b/FileService.Application/UploadFileTask/UploadTaskResponse.cs @@ -19,5 +19,8 @@ namespace FileService.Application.UploadFileTask public StorageLocation StorageLocation { get; set; } public string State { get; set; } public string CheckSum { get; set; } + public Guid? ResultFileId { get; set; } + public string? FailureReason { get; set; } + public global::FileService.Application.UploadFile.FileResponse? File { get; set; } } } diff --git a/FileService.Domain/Entities/UploadFile.cs b/FileService.Domain/Entities/UploadFile.cs index cf8d06a..b8de288 100644 --- a/FileService.Domain/Entities/UploadFile.cs +++ b/FileService.Domain/Entities/UploadFile.cs @@ -12,10 +12,14 @@ namespace FileService.Domain.Entities public FileState State { get; private set; } = FileState.Uploaded; public StorageLocation StorageLocation { get; private set; } = new StorageLocation(); public CheckSum CheckSum { get; private set; } + public Guid? SourceTaskId { get; private set; } + public string? ChatType { get; private set; } + public Guid? TargetId { get; private set; } + public bool IsPublic { get; private set; } private UploadFile() { } - public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum) + public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum, Guid? sourceTaskId = null, string? chatType = null, Guid? targetId = null, bool isPublic = false) { OwnerId = ownerId; FileName = fileName; @@ -23,6 +27,10 @@ namespace FileService.Domain.Entities ContentType = contentType; StorageLocation = storageLocation ?? new StorageLocation(); CheckSum = checkSum; + SourceTaskId = sourceTaskId; + ChatType = chatType?.ToUpperInvariant(); + TargetId = targetId; + IsPublic = isPublic; State = FileState.Uploaded; } diff --git a/FileService.Domain/Entities/UploadTask.cs b/FileService.Domain/Entities/UploadTask.cs index c368987..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; @@ -8,19 +8,25 @@ namespace FileService.Domain.Entities { public Guid UploaderId { get; private set; } public Guid ConversationId { get; private set; } + public string? ChatType { get; private set; } + public Guid? TargetId { get; private set; } public FileName FileName { get; private set; } public long FileSize { get; private set; } public ContentType ContentType { get; private set; } public StorageLocation StorageLocation { get; private set; } public UploadTaskState State { get; private set; } public CheckSum CheckSum { get; private set; } + public Guid? ResultFileId { get; private set; } + public string? FailureReason { get; private set; } private UploadTask() { } - public UploadTask(Guid uploaderId, Guid conversationId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum) + public UploadTask(Guid uploaderId, Guid conversationId, string? chatType, Guid? targetId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum) { UploaderId = uploaderId; ConversationId = conversationId; + ChatType = chatType?.ToUpperInvariant(); + TargetId = targetId; FileName = fileName; FileSize = fileSize; ContentType = contentType; @@ -28,21 +34,33 @@ 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 CompleteUpload(StorageLocation location) + public void StartMerging(StorageLocation location) { + StorageLocation = location; + State = UploadTaskState.Merging; + FailureReason = null; + NotifyModified(); + } + + public void CompleteUpload(Guid fileId) + { + ResultFileId = fileId; State = UploadTaskState.Completed; + FailureReason = null; + NotifyModified(); AddDomainEvent(new UploadTaskCompletedDomainEvent(this)); } - public void Fail() + public void Fail(string reason) { State = UploadTaskState.Failed; - + FailureReason = reason.Length > 500 ? reason[..500] : reason; + NotifyModified(); } } } diff --git a/FileService.Domain/IReposities/IUploadFileReposity.cs b/FileService.Domain/IReposities/IUploadFileReposity.cs index cea61d4..244ec1b 100644 --- a/FileService.Domain/IReposities/IUploadFileReposity.cs +++ b/FileService.Domain/IReposities/IUploadFileReposity.cs @@ -19,5 +19,6 @@ namespace FileService.Domain.IReposities /// 全局按 checksum 查询(用于跨用户秒传去重) /// Task FindByCheckSumGlobalAsync(string algorithm, string value); + Task FindBySourceTaskIdAsync(Guid taskId); } } diff --git a/FileService.Domain/ValueObjects/FileName.cs b/FileService.Domain/ValueObjects/FileName.cs index cd086de..2a87eba 100644 --- a/FileService.Domain/ValueObjects/FileName.cs +++ b/FileService.Domain/ValueObjects/FileName.cs @@ -12,9 +12,9 @@ namespace FileService.Domain.ValueObjects public FileName(string value) { - if(value.Length > 20) + if (string.IsNullOrWhiteSpace(value) || value.Length > 255) { - throw new ArgumentException("文件名超出长度"); + throw new ArgumentException("文件名不能为空且不能超过 255 个字符"); } Value = value; diff --git a/FileService.Infrastructure/Configs/UploadFileConfig.cs b/FileService.Infrastructure/Configs/UploadFileConfig.cs index dcc0fd3..34ae306 100644 --- a/FileService.Infrastructure/Configs/UploadFileConfig.cs +++ b/FileService.Infrastructure/Configs/UploadFileConfig.cs @@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs { builder.ToTable("upload_files"); builder.Property(x => x.FileName) + .HasMaxLength(255) .HasConversion( a => a.Value, b => new Domain.ValueObjects.FileName(b) ); builder.Property(x => x.ContentType) + .HasMaxLength(255) .HasConversion( a => a.Value, b => new Domain.ValueObjects.ContentType(b) @@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs builder.ComplexProperty(x => x.CheckSum, c => { c.Property(p => p.Value) + .HasMaxLength(128) .HasColumnName("checksum_value"); c.Property(p => p.Algorithm) + .HasMaxLength(16) .HasColumnName("checksum_algorithm"); }); + builder.HasIndex(x => x.SourceTaskId).IsUnique(); + builder.Property(x => x.ChatType).HasMaxLength(16); builder.ComplexProperty(x => x.StorageLocation, c => { c.Property(p => p.StorageProvider) + .HasMaxLength(64) .HasColumnName("storage_provider"); c.Property(p => p.ObjectKey) + .HasMaxLength(1024) .HasColumnName("storage_key"); c.Property(p => p.Region) + .HasMaxLength(128) .HasColumnName("storage_region"); c.Property(p => p.Bucket) + .HasMaxLength(255) .HasColumnName("storage_bucket"); }); } diff --git a/FileService.Infrastructure/Configs/UploadTaskConfig.cs b/FileService.Infrastructure/Configs/UploadTaskConfig.cs index 737aa4c..3e62b06 100644 --- a/FileService.Infrastructure/Configs/UploadTaskConfig.cs +++ b/FileService.Infrastructure/Configs/UploadTaskConfig.cs @@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs { builder.ToTable("upload_tasks"); builder.Property(x => x.FileName) + .HasMaxLength(255) .HasConversion( a => a.Value, b => new Domain.ValueObjects.FileName(b) ); builder.Property(x => x.ContentType) + .HasMaxLength(255) .HasConversion( a => a.Value, b => new Domain.ValueObjects.ContentType(b) @@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs builder.ComplexProperty(x => x.CheckSum, c => { c.Property(p => p.Value) + .HasMaxLength(128) .HasColumnName("checksum_value"); c.Property(p => p.Algorithm) + .HasMaxLength(16) .HasColumnName("checksum_algorithm"); }); + builder.Property(x => x.FailureReason).HasMaxLength(500); + builder.Property(x => x.ChatType).HasMaxLength(16); builder.ComplexProperty(x => x.StorageLocation, c => { c.Property(p => p.StorageProvider) + .HasMaxLength(64) .HasColumnName("storage_provider"); c.Property(p => p.ObjectKey) + .HasMaxLength(1024) .HasColumnName("storage_key"); c.Property(p => p.Region) + .HasMaxLength(128) .HasColumnName("storage_region"); c.Property(p => p.Bucket) + .HasMaxLength(255) .HasColumnName("storage_bucket"); }); } 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/Migrations/20260909000300_AsyncUploadResult.cs b/FileService.Infrastructure/Migrations/20260909000300_AsyncUploadResult.cs new file mode 100644 index 0000000..f822acf --- /dev/null +++ b/FileService.Infrastructure/Migrations/20260909000300_AsyncUploadResult.cs @@ -0,0 +1,131 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace FileService.Infrastructure.Migrations +{ + [DbContext(typeof(FileDbContext))] + [Migration("20260909000300_AsyncUploadResult")] + public partial class AsyncUploadResult : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + AlterStringColumns(migrationBuilder, narrowing: true); + + migrationBuilder.AddColumn( + name: "ChatType", + table: "upload_files", + type: "varchar(16)", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "IsPublic", + table: "upload_files", + type: "tinyint(1)", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "SourceTaskId", + table: "upload_files", + type: "char(36)", + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetId", + table: "upload_files", + type: "char(36)", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChatType", + table: "upload_tasks", + type: "varchar(16)", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "FailureReason", + table: "upload_tasks", + type: "varchar(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "ResultFileId", + table: "upload_tasks", + type: "char(36)", + nullable: true); + + migrationBuilder.AddColumn( + name: "TargetId", + table: "upload_tasks", + type: "char(36)", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_upload_files_SourceTaskId", + table: "upload_files", + column: "SourceTaskId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_upload_files_checksum", + table: "upload_files", + columns: new[] { "checksum_algorithm", "checksum_value", "IsDeleted" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex("IX_upload_files_checksum", "upload_files"); + migrationBuilder.DropIndex("IX_upload_files_SourceTaskId", "upload_files"); + migrationBuilder.DropColumn("ChatType", "upload_files"); + migrationBuilder.DropColumn("IsPublic", "upload_files"); + migrationBuilder.DropColumn("SourceTaskId", "upload_files"); + migrationBuilder.DropColumn("TargetId", "upload_files"); + migrationBuilder.DropColumn("ChatType", "upload_tasks"); + migrationBuilder.DropColumn("FailureReason", "upload_tasks"); + migrationBuilder.DropColumn("ResultFileId", "upload_tasks"); + migrationBuilder.DropColumn("TargetId", "upload_tasks"); + AlterStringColumns(migrationBuilder, narrowing: false); + } + + + private static void AlterStringColumns(MigrationBuilder migrationBuilder, bool narrowing) + { + var columns = new (string Table, string Column, int Length, bool Nullable)[] + { + ("upload_files", "FileName", 255, false), + ("upload_files", "ContentType", 255, false), + ("upload_files", "checksum_algorithm", 16, false), + ("upload_files", "checksum_value", 128, false), + ("upload_files", "storage_provider", 64, false), + ("upload_files", "storage_bucket", 255, false), + ("upload_files", "storage_key", 1024, false), + ("upload_files", "storage_region", 128, true), + ("upload_tasks", "FileName", 255, false), + ("upload_tasks", "ContentType", 255, false), + ("upload_tasks", "checksum_algorithm", 16, false), + ("upload_tasks", "checksum_value", 128, false), + ("upload_tasks", "storage_provider", 64, false), + ("upload_tasks", "storage_bucket", 255, false), + ("upload_tasks", "storage_key", 1024, false), + ("upload_tasks", "storage_region", 128, true) + }; + + foreach (var (table, column, length, nullable) in columns) + { + migrationBuilder.AlterColumn( + name: column, + table: table, + type: narrowing ? $"varchar({length})" : "longtext", + maxLength: narrowing ? length : null, + nullable: nullable, + oldClrType: typeof(string), + oldType: narrowing ? "longtext" : $"varchar({length})", + oldMaxLength: narrowing ? null : length, + oldNullable: nullable); + } + } + } +} diff --git a/FileService.Infrastructure/Migrations/FileDbContextModelSnapshot.cs b/FileService.Infrastructure/Migrations/FileDbContextModelSnapshot.cs index 72719ce..5b815b9 100644 --- a/FileService.Infrastructure/Migrations/FileDbContextModelSnapshot.cs +++ b/FileService.Infrastructure/Migrations/FileDbContextModelSnapshot.cs @@ -25,13 +25,18 @@ namespace FileService.Infrastructure.Migrations modelBuilder.Entity("FileService.Domain.Entities.UploadFile", b => { + b.Property("ChatType") + .HasMaxLength(16) + .HasColumnType("varchar(16)"); + b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("char(36)"); b.Property("ContentType") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(255) + .HasColumnType("varchar(255)"); b.Property("CreationTime") .HasColumnType("datetime(6)"); @@ -41,7 +46,8 @@ namespace FileService.Infrastructure.Migrations b.Property("FileName") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(255) + .HasColumnType("varchar(255)"); b.Property("FileSize") .HasColumnType("bigint"); @@ -49,27 +55,38 @@ namespace FileService.Infrastructure.Migrations b.Property("IsDeleted") .HasColumnType("tinyint(1)"); + b.Property("IsPublic") + .HasColumnType("tinyint(1)"); + b.Property("ModificationTime") .HasColumnType("datetime(6)"); b.Property("OwnerId") .HasColumnType("char(36)"); + b.Property("SourceTaskId") + .HasColumnType("char(36)"); + b.Property("State") .HasColumnType("int"); + b.Property("TargetId") + .HasColumnType("char(36)"); + b.ComplexProperty>("CheckSum", "FileService.Domain.Entities.UploadFile.CheckSum#CheckSum", b1 => { b1.IsRequired(); b1.Property("Algorithm") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(16) + .HasColumnType("varchar(16)") .HasColumnName("checksum_algorithm"); b1.Property("Value") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(128) + .HasColumnType("varchar(128)") .HasColumnName("checksum_value"); }); @@ -79,38 +96,50 @@ namespace FileService.Infrastructure.Migrations b1.Property("Bucket") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(255) + .HasColumnType("varchar(255)") .HasColumnName("storage_bucket"); b1.Property("ObjectKey") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)") .HasColumnName("storage_key"); b1.Property("Region") - .HasColumnType("longtext") + .HasMaxLength(128) + .HasColumnType("varchar(128)") .HasColumnName("storage_region"); b1.Property("StorageProvider") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(64) + .HasColumnType("varchar(64)") .HasColumnName("storage_provider"); }); b.HasKey("Id"); + b.HasIndex("SourceTaskId") + .IsUnique(); + b.ToTable("upload_files", (string)null); }); modelBuilder.Entity("FileService.Domain.Entities.UploadTask", b => { + b.Property("ChatType") + .HasMaxLength(16) + .HasColumnType("varchar(16)"); + b.Property("Id") .ValueGeneratedOnAdd() .HasColumnType("char(36)"); b.Property("ContentType") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(255) + .HasColumnType("varchar(255)"); b.Property("ConversationId") .HasColumnType("char(36)"); @@ -123,20 +152,31 @@ namespace FileService.Infrastructure.Migrations b.Property("FileName") .IsRequired() - .HasColumnType("longtext"); + .HasMaxLength(255) + .HasColumnType("varchar(255)"); b.Property("FileSize") .HasColumnType("bigint"); + b.Property("FailureReason") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + b.Property("IsDeleted") .HasColumnType("tinyint(1)"); b.Property("ModificationTime") .HasColumnType("datetime(6)"); + b.Property("ResultFileId") + .HasColumnType("char(36)"); + b.Property("State") .HasColumnType("int"); + b.Property("TargetId") + .HasColumnType("char(36)"); + b.Property("UploaderId") .HasColumnType("char(36)"); @@ -146,12 +186,14 @@ namespace FileService.Infrastructure.Migrations b1.Property("Algorithm") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(16) + .HasColumnType("varchar(16)") .HasColumnName("checksum_algorithm"); b1.Property("Value") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(128) + .HasColumnType("varchar(128)") .HasColumnName("checksum_value"); }); @@ -161,21 +203,25 @@ namespace FileService.Infrastructure.Migrations b1.Property("Bucket") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(255) + .HasColumnType("varchar(255)") .HasColumnName("storage_bucket"); b1.Property("ObjectKey") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(1024) + .HasColumnType("varchar(1024)") .HasColumnName("storage_key"); b1.Property("Region") - .HasColumnType("longtext") + .HasMaxLength(128) + .HasColumnType("varchar(128)") .HasColumnName("storage_region"); b1.Property("StorageProvider") .IsRequired() - .HasColumnType("longtext") + .HasMaxLength(64) + .HasColumnType("varchar(64)") .HasColumnName("storage_provider"); }); diff --git a/FileService.Infrastructure/Reposites/UploadFileReposity.cs b/FileService.Infrastructure/Reposites/UploadFileReposity.cs index c55c0d2..3d9a957 100644 --- a/FileService.Infrastructure/Reposites/UploadFileReposity.cs +++ b/FileService.Infrastructure/Reposites/UploadFileReposity.cs @@ -42,5 +42,10 @@ namespace FileService.Infrastructure.Reposites x.CheckSum.Value == value ); } + + public Task FindBySourceTaskIdAsync(Guid taskId) + { + return db.Files.FirstOrDefaultAsync(x => x.SourceTaskId == taskId); + } } } diff --git a/FileService.Infrastructure/Storage/LocalStorageAdapter.cs b/FileService.Infrastructure/Storage/LocalStorageAdapter.cs index 117ed54..a3c44a7 100644 --- a/FileService.Infrastructure/Storage/LocalStorageAdapter.cs +++ b/FileService.Infrastructure/Storage/LocalStorageAdapter.cs @@ -1,173 +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}", - 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/File/FileController.cs b/FileService.WebApi/Controllers/File/FileController.cs index ae4bdb4..3fbb52f 100644 --- a/FileService.WebApi/Controllers/File/FileController.cs +++ b/FileService.WebApi/Controllers/File/FileController.cs @@ -1,6 +1,7 @@ using FileService.Application.UploadFile; using FileService.Infrastructure; using IM.ASPNETCore; +using IM.Commons; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -51,7 +52,8 @@ namespace FileService.WebApi.Controllers.File [HttpGet("{id}")] public async Task Get(Guid id) { - var res = await service.GetFileInfoAsync(id); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var res = await service.GetFileInfoAsync(id, Guid.Parse(userId)); return Ok(res); } @@ -65,11 +67,15 @@ namespace FileService.WebApi.Controllers.File var res = await service.OpenDownloadAsync(id, Guid.Parse(userId)); if (res.Data == null) { + if (res.Code == (int)ResultCode.PERMISSION_DENIED) + { + return StatusCode(StatusCodes.Status403Forbidden, res); + } return NotFound(res); } Response.Headers["Cache-Control"] = "private,max-age=86400"; - return File(res.Data.Content, res.Data.ContentType); + return File(res.Data.Content, res.Data.ContentType, enableRangeProcessing: true); } } } diff --git a/FileService.WebApi/Controllers/FileTask/CompleteTaskRequest.cs b/FileService.WebApi/Controllers/FileTask/CompleteTaskRequest.cs index c79b5ce..5866a84 100644 --- a/FileService.WebApi/Controllers/FileTask/CompleteTaskRequest.cs +++ b/FileService.WebApi/Controllers/FileTask/CompleteTaskRequest.cs @@ -5,8 +5,8 @@ namespace FileService.WebApi.Controllers.FileTask { public class CompleteTaskRequest { - public string SessionId { get; set; } - public List Parts { get; set; } + public string SessionId { get; set; } = string.Empty; + public List Parts { get; set; } = []; } public class CompleteTaskRequestValidator : AbstractValidator diff --git a/FileService.WebApi/Controllers/FileTask/FileTaskController.cs b/FileService.WebApi/Controllers/FileTask/FileTaskController.cs index 28f1342..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; @@ -28,6 +28,8 @@ namespace FileService.WebApi.Controllers.FileTask var res = await service.InitTaskAsync(new UploadTaskInitCommand( UploaderId: Guid.Parse(userId), ConversationId: request.ConversationId, + ChatType: request.ChatType, + TargetId: request.TargetId, FileName: request.FileName, FileSize: request.FileSize, contentType: request.ContentType, @@ -44,6 +46,13 @@ namespace FileService.WebApi.Controllers.FileTask return Ok(res); } + [HttpGet("status")] + public async Task Status(Guid taskId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.GetStatusAsync(taskId, Guid.Parse(userId))); + } + [HttpGet("Getuploadurl")] public async Task GetUploadUrl(string sessionId, int partNum) { @@ -53,19 +62,20 @@ namespace FileService.WebApi.Controllers.FileTask } [HttpPost("complete")] + [UnitOfWork(typeof(FileDbContext))] public async Task Complete([FromBody] CompleteTaskRequest request) { var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var res = await service.CompleteTaskAsync(new UploadTaskCompleteCommand(request.SessionId, Guid.Parse(userId), request.Parts)); - return Ok(res); + return res.Succeeded ? Accepted(res) : Ok(res); } [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(); - var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length)); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + 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/FileTask/FileTaskInitRequest.cs b/FileService.WebApi/Controllers/FileTask/FileTaskInitRequest.cs index 2918aff..77f0b40 100644 --- a/FileService.WebApi/Controllers/FileTask/FileTaskInitRequest.cs +++ b/FileService.WebApi/Controllers/FileTask/FileTaskInitRequest.cs @@ -5,14 +5,30 @@ namespace FileService.WebApi.Controllers.FileTask public class FileTaskInitRequest { public Guid ConversationId { get; set; } - public string FileName { get; set; } + public string? ChatType { get; set; } + public Guid? TargetId { get; set; } + public string FileName { get; set; } = string.Empty; public long FileSize { get; set; } - public string ContentType { get; set; } - public string CheckSum { get; set; } + public string ContentType { get; set; } = string.Empty; + public string CheckSum { get; set; } = string.Empty; } public class FileTaskInitRequestValidator: AbstractValidator { - + public FileTaskInitRequestValidator() + { + RuleFor(x => x.FileName).NotEmpty().MaximumLength(255); + RuleFor(x => x.FileSize).GreaterThan(0); + RuleFor(x => x.ContentType).NotEmpty().MaximumLength(255); + RuleFor(x => x.CheckSum).NotEmpty().MaximumLength(128); + RuleFor(x => x.ChatType) + .Must(value => string.IsNullOrWhiteSpace(value) || + value.Equals("PRIVATE", StringComparison.OrdinalIgnoreCase) || + value.Equals("GROUP", StringComparison.OrdinalIgnoreCase)) + .WithMessage("chatType 必须为 PRIVATE 或 GROUP"); + RuleFor(x => x.TargetId) + .NotEmpty() + .When(x => !string.IsNullOrWhiteSpace(x.ChatType)); + } } } 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/ModueInit.cs b/FileService.WebApi/ModueInit.cs index 14f8d9f..cadc035 100644 --- a/FileService.WebApi/ModueInit.cs +++ b/FileService.WebApi/ModueInit.cs @@ -9,6 +9,17 @@ namespace FileService.WebApi public void Initialize(IServiceCollection services) { services.AddScoped(); + services.AddHttpClient((sp, client) => + { + var configuration = sp.GetRequiredService(); + client.BaseAddress = new Uri(configuration["InternalServices:GroupServiceBaseUrl"] + ?? "http://im-group-service:8080/"); + var internalApiKey = configuration["InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(internalApiKey)) + { + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", internalApiKey); + } + }); } } } 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/FileService.WebApi/appsettings.Development.json b/FileService.WebApi/appsettings.Development.json index 0c208ae..69768ca 100644 --- a/FileService.WebApi/appsettings.Development.json +++ b/FileService.WebApi/appsettings.Development.json @@ -1,4 +1,8 @@ { + "InternalApiKey": "development-only-change-me", + "InternalServices": { + "GroupServiceBaseUrl": "http://localhost:5070/" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/FileService.WebApi/appsettings.json b/FileService.WebApi/appsettings.json index 10f68b8..5add975 100644 --- a/FileService.WebApi/appsettings.json +++ b/FileService.WebApi/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "InternalApiKey": "", + "InternalServices": { + "GroupServiceBaseUrl": "http://im-group-service:8080/" + } } 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.Domain/Entities/GroupMember.cs b/GroupService.Domain/Entities/GroupMember.cs index 158c730..428b79e 100644 --- a/GroupService.Domain/Entities/GroupMember.cs +++ b/GroupService.Domain/Entities/GroupMember.cs @@ -49,5 +49,12 @@ namespace GroupService.Domain.Entities { GroupNickName = nickname; } + + public void Leave() + { + if (IsDeleted) return; + SoftDelete(); + AddDomainEvent(new GroupMemberLeftDomainEvent(this)); + } } } diff --git a/GroupService.Domain/Events/GroupMemberLeftDomainEvent.cs b/GroupService.Domain/Events/GroupMemberLeftDomainEvent.cs new file mode 100644 index 0000000..9b53687 --- /dev/null +++ b/GroupService.Domain/Events/GroupMemberLeftDomainEvent.cs @@ -0,0 +1,7 @@ +using GroupService.Domain.Entities; +using MediatR; + +namespace GroupService.Domain.Events +{ + public record GroupMemberLeftDomainEvent(GroupMember Member) : INotification; +} diff --git a/GroupService.Domain/IReposities/IGroupReposity.cs b/GroupService.Domain/IReposities/IGroupReposity.cs index 7bce2f6..8967661 100644 --- a/GroupService.Domain/IReposities/IGroupReposity.cs +++ b/GroupService.Domain/IReposities/IGroupReposity.cs @@ -22,6 +22,7 @@ namespace GroupService.Domain.IReposities /// /// Task> FindByMasterIdAsync(Guid userId); + Task> FindByMemberIdAsync(Guid userId); /// /// 创建群聊 /// diff --git a/GroupService.Domain/IReposities/IGroupRequestReposity.cs b/GroupService.Domain/IReposities/IGroupRequestReposity.cs index 758553d..d2cadca 100644 --- a/GroupService.Domain/IReposities/IGroupRequestReposity.cs +++ b/GroupService.Domain/IReposities/IGroupRequestReposity.cs @@ -8,5 +8,6 @@ namespace GroupService.Domain.IReposities Task FindByIdAsync(Guid id); Task> FindByGroupIdAsync(Guid groupId); Task> FindByUserIdAsync(Guid userId); + Task> FindVisibleToUserAsync(Guid userId); } } diff --git a/GroupService.Infrastructure/Configs/GroupJoinRequestConfig.cs b/GroupService.Infrastructure/Configs/GroupJoinRequestConfig.cs index 2e6fc4e..ad23dfa 100644 --- a/GroupService.Infrastructure/Configs/GroupJoinRequestConfig.cs +++ b/GroupService.Infrastructure/Configs/GroupJoinRequestConfig.cs @@ -11,6 +11,9 @@ namespace GroupService.Infrastructure.Configs builder.ToTable("group_join_requests"); builder.HasKey(x => x.Id); builder.HasKey(x => new { x.GroupId, x.UserId }); + builder.HasIndex(x => x.Id).IsUnique(); + builder.HasIndex(x => new { x.GroupId, x.State, x.CreationTime }); + builder.HasIndex(x => new { x.UserId, x.CreationTime }); builder.ComplexProperty(x => x.UserProfile, u => { diff --git a/GroupService.Infrastructure/Configs/GroupMemberConfig.cs b/GroupService.Infrastructure/Configs/GroupMemberConfig.cs index 0c21f6b..539fdf2 100644 --- a/GroupService.Infrastructure/Configs/GroupMemberConfig.cs +++ b/GroupService.Infrastructure/Configs/GroupMemberConfig.cs @@ -9,6 +9,8 @@ namespace GroupService.Infrastructure.Configs public void Configure(EntityTypeBuilder builder) { builder.ToTable("group_members"); + builder.HasIndex(x => new { x.UserId, x.IsDeleted, x.GroupId }); + builder.HasIndex(x => new { x.GroupId, x.IsDeleted, x.Role }); } } } diff --git a/GroupService.Infrastructure/Migrations/20260909000200_ApiAlignmentFixes.cs b/GroupService.Infrastructure/Migrations/20260909000200_ApiAlignmentFixes.cs new file mode 100644 index 0000000..4bf7cb3 --- /dev/null +++ b/GroupService.Infrastructure/Migrations/20260909000200_ApiAlignmentFixes.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace GroupService.Infrastructure.Migrations +{ + [DbContext(typeof(GroupDbContext))] + [Migration("20260909000200_ApiAlignmentFixes")] + public partial class ApiAlignmentFixes : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_group_members_UserId_IsDeleted_GroupId", + table: "group_members", + columns: new[] { "UserId", "IsDeleted", "GroupId" }); + + migrationBuilder.CreateIndex( + name: "IX_group_members_GroupId_IsDeleted_Role", + table: "group_members", + columns: new[] { "GroupId", "IsDeleted", "Role" }); + + migrationBuilder.CreateIndex( + name: "IX_group_join_requests_Id", + table: "group_join_requests", + column: "Id", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_group_join_requests_GroupId_State_CreationTime", + table: "group_join_requests", + columns: new[] { "GroupId", "State", "CreationTime" }); + + migrationBuilder.CreateIndex( + name: "IX_group_join_requests_UserId_CreationTime", + table: "group_join_requests", + columns: new[] { "UserId", "CreationTime" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex("IX_group_members_UserId_IsDeleted_GroupId", "group_members"); + migrationBuilder.DropIndex("IX_group_members_GroupId_IsDeleted_Role", "group_members"); + migrationBuilder.DropIndex("IX_group_join_requests_Id", "group_join_requests"); + migrationBuilder.DropIndex("IX_group_join_requests_GroupId_State_CreationTime", "group_join_requests"); + migrationBuilder.DropIndex("IX_group_join_requests_UserId_CreationTime", "group_join_requests"); + } + } +} 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.Infrastructure/Migrations/GroupDbContextModelSnapshot.cs b/GroupService.Infrastructure/Migrations/GroupDbContextModelSnapshot.cs index 592ecec..399455a 100644 --- a/GroupService.Infrastructure/Migrations/GroupDbContextModelSnapshot.cs +++ b/GroupService.Infrastructure/Migrations/GroupDbContextModelSnapshot.cs @@ -227,6 +227,13 @@ namespace GroupService.Infrastructure.Migrations b.HasKey("GroupId", "UserId"); + b.HasIndex("Id") + .IsUnique(); + + b.HasIndex("GroupId", "State", "CreationTime"); + + b.HasIndex("UserId", "CreationTime"); + b.ToTable("group_join_requests", (string)null); }); @@ -266,6 +273,10 @@ namespace GroupService.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("GroupId", "IsDeleted", "Role"); + + b.HasIndex("UserId", "IsDeleted", "GroupId"); + b.ToTable("group_members", (string)null); }); #pragma warning restore 612, 618 diff --git a/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs b/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs index d3dc850..4e918a7 100644 --- a/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs +++ b/GroupService.Infrastructure/Reposities/GroupJoinRequestReposity.cs @@ -36,5 +36,19 @@ namespace GroupService.Infrastructure.Reposities x.UserId == userId || x.OperatorId == userId ).ToListAsync(); } + + public async Task> FindVisibleToUserAsync(Guid userId) + { + var managedGroupIds = db.GroupMembers + .Where(member => member.UserId == userId && + (member.Role == Domain.Enums.GroupMemberRole.Administrator || + member.Role == Domain.Enums.GroupMemberRole.Master)) + .Select(member => member.GroupId); + + return await db.GroupJoinRequests + .Where(request => request.UserId == userId || managedGroupIds.Contains(request.GroupId)) + .OrderByDescending(request => request.CreationTime) + .ToListAsync(); + } } } diff --git a/GroupService.Infrastructure/Reposities/GroupReposity.cs b/GroupService.Infrastructure/Reposities/GroupReposity.cs index 981b962..d55c695 100644 --- a/GroupService.Infrastructure/Reposities/GroupReposity.cs +++ b/GroupService.Infrastructure/Reposities/GroupReposity.cs @@ -29,6 +29,18 @@ namespace GroupService.Infrastructure.Reposities return await db.Groups.Where(x => x.GroupMaster == userId).ToListAsync(); } + public async Task> FindByMemberIdAsync(Guid userId) + { + var groupIds = db.GroupMembers + .Where(member => member.UserId == userId) + .Select(member => member.GroupId); + + return await db.Groups + .Where(group => groupIds.Contains(group.Id)) + .OrderByDescending(group => group.ModificationTime ?? group.CreationTime) + .ToListAsync(); + } + public async Task> FindByNameAsync(string name) { return await db.Groups.Where(x => x.Name == name).ToListAsync(); diff --git a/GroupService.WebApi/Application/EventHandler/GroupMemberLeftHandler.cs b/GroupService.WebApi/Application/EventHandler/GroupMemberLeftHandler.cs new file mode 100644 index 0000000..61b7b3e --- /dev/null +++ b/GroupService.WebApi/Application/EventHandler/GroupMemberLeftHandler.cs @@ -0,0 +1,18 @@ +using GroupService.Domain.Events; +using IM.Commons.IntegrationEvents; +using MassTransit; +using MediatR; + +namespace GroupService.WebApi.Application.EventHandler +{ + public class GroupMemberLeftHandler(IPublishEndpoint endpoint) + : INotificationHandler + { + public Task Handle(GroupMemberLeftDomainEvent notification, CancellationToken cancellationToken) + { + return endpoint.Publish( + new GroupMemberLeftEvent(notification.Member.UserId, notification.Member.GroupId), + cancellationToken); + } + } +} diff --git a/GroupService.WebApi/Application/Group/GroupService.cs b/GroupService.WebApi/Application/Group/GroupService.cs index aadea97..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,29 +10,33 @@ 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)); } public async Task>> GetAllAsync(Guid userId) { - var groups = await reposity.FindByMasterIdAsync(userId); + var groups = await reposity.FindByMemberIdAsync(userId); return Result>.Success(mapper.Map>(groups)); } - public async Task> GetByIdAsync(Guid groupId) + public async Task> GetByIdAsync(Guid groupId, Guid userId) { var group = await reposity.FindByIdAsync(groupId); if (group is null) @@ -39,9 +44,36 @@ namespace GroupService.WebApi.Application.Group return Result.Fail(ResultCode.GROUP_NOT_FOUND); } + if (!await memberReposity.CheckMemberExistAsync(groupId, userId)) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } + return Result.Success(mapper.Map(group)); } + public async Task> DissolveAsync(Guid groupId, Guid userId) + { + var group = await reposity.FindByIdAsync(groupId); + if (group is null) + { + return Result.Fail(ResultCode.GROUP_NOT_FOUND); + } + + if (group.GroupMaster != userId) + { + return Result.Fail(ResultCode.PERMISSION_DENIED); + } + + var members = await memberReposity.FindByGroupIdAsync(groupId); + foreach (var member in members) + { + member.Leave(); + } + group.SoftDelete(); + return Result.Success(); + } + public async Task> UpdateAsync(GroupUpdateCommand command) { var group = await reposity.FindByIdAsync(command.GroupId); 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 1a3b02e..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,24 +13,28 @@ 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) + public async Task>> GetByGroupIdAsync(Guid groupId, Guid userId) { var group = await groupReposity.FindByIdAsync(groupId); if (group is null) { return Result>.Fail(ResultCode.GROUP_NOT_FOUND); } + if (!await reposity.CheckMemberExistAsync(groupId, userId)) + { + return Result>.Fail(ResultCode.PERMISSION_DENIED); + } var members = await reposity.FindByGroupIdAsync(groupId); return Result>.Success(mapper.Map>(members.ToList())); @@ -45,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) { @@ -64,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); } @@ -78,14 +85,32 @@ namespace GroupService.WebApi.Application.GroupMember } var operatorMember = await reposity.FindOneByGroupIdAndUserIdAsync(member.GroupId, operatorId); - if (operatorMember is null || operatorMember.Role == Domain.Enums.GroupMemberRole.Normal) + if (operatorMember is null || operatorMember.Id == member.Id || + member.Role == Domain.Enums.GroupMemberRole.Master || + operatorMember.Role <= member.Role) { return Result.Fail(ResultCode.PERMISSION_DENIED); } - member.SoftDelete(); + member.Leave(); return Result.Success(); } + + public async Task> LeaveAsync(Guid groupId, Guid userId) + { + var member = await reposity.FindOneByGroupIdAndUserIdAsync(groupId, userId); + if (member is null) + { + return Result.Fail(ResultCode.GROUP_MEMBER_NOT_FOUNT); + } + if (member.Role == Domain.Enums.GroupMemberRole.Master) + { + return Result.Fail(ResultCode.PERMISSION_DENIED, "群主不能直接退群,请使用解散群接口"); + } + + member.Leave(); + return Result.Success(); + } } } diff --git a/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs b/GroupService.WebApi/Application/GroupRequest/GroupRequestService.cs index faedf95..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() @@ -98,7 +100,7 @@ namespace GroupService.WebApi.Application.GroupRequest } public async Task>> GetListAsync(Guid userId) { - var list = await reposity.FindByUserIdAsync(userId); + var list = await reposity.FindVisibleToUserAsync(userId); return Result.Success(mapper.Map>(list.ToList())); } diff --git a/GroupService.WebApi/Controllers/Group/GroupController.cs b/GroupService.WebApi/Controllers/Group/GroupController.cs index bd89aa1..ae7645e 100644 --- a/GroupService.WebApi/Controllers/Group/GroupController.cs +++ b/GroupService.WebApi/Controllers/Group/GroupController.cs @@ -32,7 +32,8 @@ namespace GroupService.WebApi.Controllers.Group [ProducesDefaultResponseType(typeof(Result))] public async Task GetOne(Guid groupId) { - return Ok(await service.GetByIdAsync(groupId)); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.GetByIdAsync(groupId, Guid.Parse(userId))); } [HttpPost] @@ -49,5 +50,13 @@ namespace GroupService.WebApi.Controllers.Group var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); return Ok(await service.UpdateAsync(new GroupUpdateCommand(Guid.Parse(userId), request.GroupId, request.Avatar, request.GroupName, request.Description))); } + + [HttpPost] + [UnitOfWork(typeof(GroupDbContext))] + public async Task Dissolve([FromQuery] Guid groupId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.DissolveAsync(groupId, Guid.Parse(userId))); + } } } diff --git a/GroupService.WebApi/Controllers/GroupMember/GroupMemberController.cs b/GroupService.WebApi/Controllers/GroupMember/GroupMemberController.cs index 859d286..93f01f8 100644 --- a/GroupService.WebApi/Controllers/GroupMember/GroupMemberController.cs +++ b/GroupService.WebApi/Controllers/GroupMember/GroupMemberController.cs @@ -1,6 +1,8 @@ using GroupService.Infrastructure; using GroupService.WebApi.Application.GroupMember; using IM.ASPNETCore; +using IM.Commons; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System.Security.Claims; @@ -11,25 +13,36 @@ namespace GroupService.WebApi.Controllers.GroupMember public class GroupMemberController : ControllerBase { private readonly GroupMemberService service; + private readonly IConfiguration configuration; - public GroupMemberController(GroupMemberService service) + public GroupMemberController(GroupMemberService service, IConfiguration configuration) { this.service = service; + this.configuration = configuration; } [HttpGet] public async Task CheckMember(Guid userId, Guid groupId) { + var expectedKey = configuration["InternalApiKey"]; + var suppliedKey = Request.Headers["X-Internal-Api-Key"].ToString(); + if (string.IsNullOrWhiteSpace(expectedKey) || suppliedKey != expectedKey) + { + return Unauthorized(Result.Fail(ResultCode.AUTH_FAILED)); + } return Ok(await service.CheckMemberAsync(groupId, userId)); } [HttpGet] + [Authorize] public async Task List(Guid groupId) { - return Ok(await service.GetByGroupIdAsync(groupId)); + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.GetByGroupIdAsync(groupId, Guid.Parse(userId))); } [HttpPost] + [Authorize] [UnitOfWork(typeof(GroupDbContext))] public async Task Delete([FromQuery] Guid memberId) { @@ -37,6 +50,15 @@ namespace GroupService.WebApi.Controllers.GroupMember return Ok(await service.DeleteAsync(memberId, Guid.Parse(userId))); } + [HttpPost] + [Authorize] + [UnitOfWork(typeof(GroupDbContext))] + public async Task Leave([FromQuery] Guid groupId) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + return Ok(await service.LeaveAsync(groupId, Guid.Parse(userId))); + } + } } 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/GroupService.WebApi/appsettings.Development.json b/GroupService.WebApi/appsettings.Development.json index 0c208ae..712f178 100644 --- a/GroupService.WebApi/appsettings.Development.json +++ b/GroupService.WebApi/appsettings.Development.json @@ -1,4 +1,5 @@ { + "InternalApiKey": "development-only-change-me", "Logging": { "LogLevel": { "Default": "Information", diff --git a/GroupService.WebApi/appsettings.json b/GroupService.WebApi/appsettings.json index 10f68b8..5ad3a91 100644 --- a/GroupService.WebApi/appsettings.json +++ b/GroupService.WebApi/appsettings.json @@ -5,5 +5,6 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "InternalApiKey": "" } diff --git a/IM.ASPNETCore/ExceptionMiddleware.cs b/IM.ASPNETCore/ExceptionMiddleware.cs index 3151725..d8ef797 100644 --- a/IM.ASPNETCore/ExceptionMiddleware.cs +++ b/IM.ASPNETCore/ExceptionMiddleware.cs @@ -1,15 +1,19 @@ using IM.Commons; using IM.DomainCommons; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; namespace IM.ASPNETCore { public class ExceptionMiddleware { private readonly RequestDelegate _next; - public ExceptionMiddleware(RequestDelegate next) + private readonly ILogger logger; + + public ExceptionMiddleware(RequestDelegate next, ILogger logger) { _next = next; + this.logger = logger; } public async Task InvokeAsync(HttpContext context) @@ -22,12 +26,35 @@ namespace IM.ASPNETCore { await DomainExceptionHandlerAsync(context, ex); } + catch (Exception ex) + { + await UnhandledExceptionHandlerAsync(context, ex); + } } public Task DomainExceptionHandlerAsync(HttpContext context, DomainException ex) { context.Response.ContentType = "application/json"; - var result = Result.Fail(ResultCode.PARAMETER_ERROR, ex.Message); // 包装成你的 Result + context.Response.StatusCode = StatusCodes.Status400BadRequest; + var result = Result.Fail(ResultCode.PARAMETER_ERROR, ex.Message); + return context.Response.WriteAsJsonAsync(result); + } + + private Task UnhandledExceptionHandlerAsync(HttpContext context, Exception ex) + { + var correlationId = context.TraceIdentifier; + logger.LogError(ex, + "Unhandled exception. CorrelationId: {CorrelationId}, Path: {Path}", + correlationId, + context.Request.Path); + + context.Response.ContentType = "application/json"; + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.Headers["X-Correlation-ID"] = correlationId; + + var result = Result.Fail( + ResultCode.SYSTEM_ERROR, + $"系统错误,关联编号:{correlationId}"); return context.Response.WriteAsJsonAsync(result); } } diff --git a/IM.Commons/IntegrationEvents/GroupMemberLeftEvent.cs b/IM.Commons/IntegrationEvents/GroupMemberLeftEvent.cs new file mode 100644 index 0000000..fa344b0 --- /dev/null +++ b/IM.Commons/IntegrationEvents/GroupMemberLeftEvent.cs @@ -0,0 +1,4 @@ +namespace IM.Commons.IntegrationEvents +{ + public record GroupMemberLeftEvent(Guid UserId, Guid GroupId); +} diff --git a/IM.Commons/IntegrationEvents/UploadTaskCompleteEvent.cs b/IM.Commons/IntegrationEvents/UploadTaskCompleteEvent.cs index e77e84c..de39b1d 100644 --- a/IM.Commons/IntegrationEvents/UploadTaskCompleteEvent.cs +++ b/IM.Commons/IntegrationEvents/UploadTaskCompleteEvent.cs @@ -20,6 +20,8 @@ namespace IM.Commons.IntegrationEvents public string ContentType { get; set; } public string CheckSun { get; set; } public IReadOnlyList Parts { get; set; } + public string? ChatType { get; set; } + public Guid? TargetId { get; set; } } public sealed record UploadPart( int PartNumber, 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/RabbitMqExtension.cs b/IM.InitCommon/RabbitMqExtension.cs index 8f628b8..f7ba8f5 100644 --- a/IM.InitCommon/RabbitMqExtension.cs +++ b/IM.InitCommon/RabbitMqExtension.cs @@ -28,6 +28,10 @@ namespace IM.InitCommon c.Password(options.Password); }); + cfg.UseMessageRetry(retry => retry.Intervals( + TimeSpan.FromMilliseconds(200), + TimeSpan.FromSeconds(1), + TimeSpan.FromSeconds(5))); cfg.ConfigureEndpoints(context); }); }); 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.Jwt/WebApplicationJwtExtension.cs b/IM.Jwt/WebApplicationJwtExtension.cs index 91c6af4..b19b3af 100644 --- a/IM.Jwt/WebApplicationJwtExtension.cs +++ b/IM.Jwt/WebApplicationJwtExtension.cs @@ -61,7 +61,7 @@ namespace IM.Jwt context.HandleResponse(); context.Response.ContentType = "application/json"; - context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.StatusCode = StatusCodes.Status401Unauthorized; var result = Result.Fail(ResultCode.AUTH_FAILED); await context.Response.WriteAsJsonAsync(result); @@ -71,7 +71,7 @@ namespace IM.Jwt { context.Response.ContentType = "application/json"; - context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.StatusCode = StatusCodes.Status403Forbidden; var result = Result.Fail(ResultCode.PERMISSION_DENIED); await context.Response.WriteAsJsonAsync(result); } 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 new file mode 100644 index 0000000..04d982b --- /dev/null +++ b/MIGRATION_RUNBOOK.md @@ -0,0 +1,100 @@ +# API 对齐数据库迁移运行手册 + +## 适用迁移 + +- MessageService:`20260909000100_ApiAlignmentFixes` +- MessageService:`20260911000100_ConversationUniqueness` +- GroupService:`20260909000200_ApiAlignmentFixes` +- FileService:`20260909000300_AsyncUploadResult` + +## 发布前检查 + +先完成三个库的可恢复备份,并在对应数据库执行: + +```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 +GROUP BY Id +HAVING COUNT(*) > 1; + +-- FileService:收紧 longtext 前检查历史最大长度。 +SELECT + MAX(CHAR_LENGTH(FileName)) AS max_file_name, + MAX(CHAR_LENGTH(ContentType)) AS max_content_type, + MAX(CHAR_LENGTH(checksum_algorithm)) AS max_checksum_algorithm, + MAX(CHAR_LENGTH(checksum_value)) AS max_checksum_value, + MAX(CHAR_LENGTH(storage_provider)) AS max_storage_provider, + MAX(CHAR_LENGTH(storage_bucket)) AS max_storage_bucket, + MAX(CHAR_LENGTH(storage_key)) AS max_storage_key, + MAX(CHAR_LENGTH(storage_region)) AS max_storage_region +FROM upload_files; +``` + +上面的长度必须分别不超过 `255/255/16/128/64/255/1024/128`。对 `upload_tasks` 执行同样检查。超长值应先人工确认和修正,不要依赖数据库静默截断。 + +## 执行迁移 + +在仓库根目录设置目标数据库连接字符串后执行。不要把真实密码写入仓库或命令记录。 + +```powershell +$env:DefaultDB_ConnStr = '' +dotnet ef database update --project MessageService.Infrastructure --startup-project MessageService.WebApi --context MessageDbContext + +$env:DefaultDB_ConnStr = '' +dotnet ef database update --project GroupService.Infrastructure --startup-project GroupService.WebApi --context GroupDbContext + +$env:DefaultDB_ConnStr = '' +dotnet ef database update --project FileService.Infrastructure --startup-project FileService.WebApi --context FileDbContext +``` + +建议顺序为 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`。 + +## 回滚 + +只有在已经回滚依赖新字段/接口的前后端版本后,才允许回滚数据库迁移: + +```powershell +$env:DefaultDB_ConnStr = '' +dotnet ef database update 20260423115234_InitMessageDb --project MessageService.Infrastructure --startup-project MessageService.WebApi --context MessageDbContext + +$env:DefaultDB_ConnStr = '' +dotnet ef database update 20260429103435_removeGroupRequestOperatorProfile --project GroupService.Infrastructure --startup-project GroupService.WebApi --context GroupDbContext + +$env:DefaultDB_ConnStr = '' +dotnet ef database update 20260509073447_InitFileDb --project FileService.Infrastructure --startup-project FileService.WebApi --context FileDbContext +``` + +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 19dcc92..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,29 +57,29 @@ 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)); } - public void Update(long? LastReadSequenceId = default, int? unreadCount = default, string? lastMsg = default) + public void SetLastReadSequence(long sequenceId) { - if (LastReadSequenceId != null) - { - LastReadSequenceId = LastReadSequenceId.Value; - this.NotifyModified(); - } - if (unreadCount != null) - { - UnreadCount += unreadCount.Value; - NotifyModified(); - } + LastReadSequenceId = sequenceId; + NotifyModified(); + } - if (lastMsg != null) - { - LastMessage = lastMsg; - NotifyModified(); - } + public void IncrementUnread() + { + UnreadCount += 1; + NotifyModified(); + } + + public void UpdateLastMessage(string lastMessage, DateTimeOffset messageTime) + { + LastMessage = lastMessage; + LastMessageTime = messageTime; + NotifyModified(); } @@ -86,6 +91,7 @@ namespace MessageService.Domain.Entities UnreadCount = 0; if (lastReadSequenceId.HasValue) LastReadSequenceId = lastReadSequenceId.Value; + NotifyModified(); } public void UpdateProfile(string name, string avatar) diff --git a/MessageService.Domain/Entities/Message.cs b/MessageService.Domain/Entities/Message.cs index e0fade0..5680718 100644 --- a/MessageService.Domain/Entities/Message.cs +++ b/MessageService.Domain/Entities/Message.cs @@ -91,10 +91,10 @@ namespace MessageService.Domain.Entities /// 预览图 /// /// - public static Message BuildImg(MessageCreateContext ctx, string url, - int width, int height, string thumb, long sequenceId) + public static Message BuildImg(MessageCreateContext ctx, string? url, + int width, int height, string thumb, long sequenceId, Guid? fileId = null) { - var content = new MessageContent("[图片]", new ImageBody(url, width, height, thumb)); + var content = new MessageContent("[图片]", new ImageBody(url, width, height, thumb, fileId)); return new Message(ctx, sequenceId, MessageType.Image, content); } /// @@ -107,10 +107,10 @@ namespace MessageService.Domain.Entities /// /// /// - public static Message BuildVideo(MessageCreateContext ctx, string url, - int width, int height, string thumb, long sequenceId) + public static Message BuildVideo(MessageCreateContext ctx, string? url, + int width, int height, string thumb, long sequenceId, Guid? fileId = null) { - var content = new MessageContent("[视频]", new VideoBody(url, width, height, thumb)); + var content = new MessageContent("[视频]", new VideoBody(url, width, height, thumb, fileId)); return new Message(ctx, sequenceId, MessageType.Video, content); } /// @@ -121,10 +121,10 @@ namespace MessageService.Domain.Entities /// 持续时间 /// /// - public static Message BuildVoice(MessageCreateContext ctx, string url, - int duration, long sequenceId) + public static Message BuildVoice(MessageCreateContext ctx, string? url, + int duration, long sequenceId, Guid? fileId = null) { - var content = new MessageContent("[音频]", new VoiceBody(url, duration)); + var content = new MessageContent("[音频]", new VoiceBody(url, duration, fileId)); return new Message(ctx, sequenceId, MessageType.Voice, content); } /// @@ -137,11 +137,11 @@ namespace MessageService.Domain.Entities /// 文件格式 /// /// - public static Message BuildFile(MessageCreateContext ctx, string url, + public static Message BuildFile(MessageCreateContext ctx, Guid fileId, string? url, string name, long size, string format, long sequenceId) { - var content = new MessageContent("[文件]", new FileBody(url, name, size, format)); + var content = new MessageContent("[文件]", new FileBody(fileId, url, name, size, format)); return new Message(ctx, sequenceId, MessageType.File, content); } /// 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/KeyObjects/MsgTypeObj.cs b/MessageService.Domain/KeyObjects/MsgTypeObj.cs index 647a895..86bb66f 100644 --- a/MessageService.Domain/KeyObjects/MsgTypeObj.cs +++ b/MessageService.Domain/KeyObjects/MsgTypeObj.cs @@ -1,8 +1,8 @@ namespace MessageService.Domain.KeyObjects { public record TextBody(string Text); - public record ImageBody(string Url, int Width, int Height, string Thumb); - public record VideoBody(string Url, int Width, int Height, string Thumb); - public record VoiceBody(string Url, int Duration); - public record FileBody(string Url, string FileName, long Size, string Format); + public record ImageBody(string? Url, int Width, int Height, string Thumb, Guid? FileId = null); + public record VideoBody(string? Url, int Width, int Height, string Thumb, Guid? FileId = null); + public record VoiceBody(string? Url, int Duration, Guid? FileId = null); + public record FileBody(Guid FileId, string? Url, string FileName, long Size, string Format); } 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 7057e2e..b9dbd9b 100644 --- a/MessageService.Infrastructure/Configs/ConversationConfig.cs +++ b/MessageService.Infrastructure/Configs/ConversationConfig.cs @@ -11,6 +11,15 @@ namespace MessageService.Infrastructure.Configs builder.ToTable("conversations"); 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/20260909000100_ApiAlignmentFixes.cs b/MessageService.Infrastructure/Migrations/20260909000100_ApiAlignmentFixes.cs new file mode 100644 index 0000000..70071b5 --- /dev/null +++ b/MessageService.Infrastructure/Migrations/20260909000100_ApiAlignmentFixes.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace MessageService.Infrastructure.Migrations +{ + [DbContext(typeof(MessageDbContext))] + [Migration("20260909000100_ApiAlignmentFixes")] + public partial class ApiAlignmentFixes : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_conversations_UserId_ChatType_TargetId_IsDeleted", + table: "conversations", + columns: new[] { "UserId", "ChatType", "TargetId", "IsDeleted" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_conversations_UserId_ChatType_TargetId_IsDeleted", + table: "conversations"); + } + } +} 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 3c3daf6..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,8 +83,14 @@ namespace MessageService.Infrastructure.Migrations b.HasKey("Id"); + b.HasIndex("ActiveConversationKey") + .IsUnique() + .HasDatabaseName("UX_conversations_ActiveConversationKey"); + b.HasIndex("UserId"); + b.HasIndex("UserId", "ChatType", "TargetId", "IsDeleted"); + b.ToTable("conversations", (string)null); }); @@ -176,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 fb15cd4..0ba4623 100644 --- a/MessageService.Infrastructure/Reposities/MessageReposity.cs +++ b/MessageService.Infrastructure/Reposities/MessageReposity.cs @@ -18,38 +18,68 @@ 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 messages = []; + List fetched; if (direction == 0) // Before: 找比锚点小的,按倒序排 { if (cusor.HasValue) query = query.Where(m => m.SequenceId < cusor.Value); - var list = await query + fetched = await query .OrderByDescending(m => m.SequenceId) // 最新消息在最前 .Take(limit + 1) - .ToListAsync(); - - messages = [.. list.OrderBy(s => s.SequenceId)]; + .ToListAsync(cancellationToken); } else { if (cusor is null) - return (messages, false); + return (Array.Empty(), false); - messages = await query.OrderBy(o => o.SequenceId) + fetched = await query + .Where(m => m.SequenceId > cusor.Value) + .OrderBy(o => o.SequenceId) .Take(limit + 1) - .ToListAsync(); + .ToListAsync(cancellationToken); } - return (messages, messages.Count > limit); + 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 4d9dd5e..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)) + .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/Dtos/ConversationResponse.cs b/MessageService.WebApi/Application/Dtos/ConversationResponse.cs index 1efcf34..2f2e4e0 100644 --- a/MessageService.WebApi/Application/Dtos/ConversationResponse.cs +++ b/MessageService.WebApi/Application/Dtos/ConversationResponse.cs @@ -31,6 +31,6 @@ namespace MessageService.WebApi.Application.Dtos /// 最后一条最新消息 /// public string LastMessage { get; set; } - public DateTime DateTime { get; set; } + public DateTimeOffset DateTime { get; set; } } } diff --git a/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs b/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs index a2a669f..412d75e 100644 --- a/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs +++ b/MessageService.WebApi/Application/EventHandlers/ConversationAddHandler.cs @@ -2,11 +2,13 @@ using MassTransit; using MessageService.Domain.IReposities; using MessageService.Infrastructure; +using Microsoft.EntityFrameworkCore; +using MySqlConnector; namespace MessageService.WebApi.Application.EventHandlers { public class ConversationAddHandler : IConsumer, - IConsumer + IConsumer, IConsumer { private readonly IConversationReposity reposity; @@ -21,6 +23,11 @@ namespace MessageService.WebApi.Application.EventHandlers public async Task Consume(ConsumeContext context) { var @event = context.Message; + var existing = await reposity.FindActiveAsync(@event.UserId, @event.GroupId, Domain.Enums.ChatType.GROUP, context.CancellationToken); + if (existing is not null) + { + return; + } reposity.Create(new Domain.Entities.Conversation( userId: @event.UserId, targetId: @event.GroupId, @@ -32,12 +39,17 @@ namespace MessageService.WebApi.Application.EventHandlers lastMessage: string.Empty )); - await messageDb.SaveChangesAsync(); + await SaveIdempotentlyAsync(context.CancellationToken); } public async Task Consume(ConsumeContext context) { var @event = context.Message; + var existing = await reposity.FindActiveAsync(@event.OwnerId, @event.TargetId, Domain.Enums.ChatType.PRIVATE, context.CancellationToken); + if (existing is not null) + { + return; + } reposity.Create(new Domain.Entities.Conversation( userId: @event.OwnerId, targetId: @event.TargetId, @@ -49,7 +61,33 @@ namespace MessageService.WebApi.Application.EventHandlers lastMessage: string.Empty )); - await messageDb.SaveChangesAsync(); + await SaveIdempotentlyAsync(context.CancellationToken); + } + + public async Task Consume(ConsumeContext context) + { + 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 f162d1a..d47ee14 100644 --- a/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs +++ b/MessageService.WebApi/Application/EventHandlers/MessageHandler.cs @@ -24,20 +24,24 @@ namespace MessageService.WebApi.Application.EventHandlers { var message = notification.Message; - if(message.ChatType == Domain.Enums.ChatType.PRIVATE) + var conversations = await reposity.FindByStreamKeyAsync(message.StreamKey, cancellationToken); + foreach (var conversation in conversations) { - var list = await reposity.FindByStreamKeyAsync(message.StreamKey); - var owner = list.First(x => x.UserId == message.SenderId); - var target = list.First(x => x.UserId == message.TargetId); - - owner.Update(message.SequenceId, 0, message.Content.Fallback); - target.Update(target.LastReadSequenceId, target.UnreadCount + 1, message.Content.Fallback); - - messageDb.Conversations.UpdateRange(owner,target); - await messageDb.SaveChangesAsync(cancellationToken); + conversation.UpdateLastMessage(message.Content.Fallback, message.CreationTime); + if (conversation.UserId == message.SenderId) + { + conversation.SetLastReadSequence(message.SequenceId); + } + else + { + conversation.IncrementUnread(); + } } - await endpoint.Publish(message.ToIntegrationEvent()); + messageDb.Conversations.UpdateRange(conversations); + await messageDb.SaveChangesAsync(cancellationToken); + + await endpoint.Publish(message.ToIntegrationEvent(), cancellationToken); } public async Task Handle(MessageWithdrawDomainEvent notification, CancellationToken cancellationToken) 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 3f42c72..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); @@ -52,13 +54,16 @@ namespace MessageService.WebApi.Application.Message { MessageType.Text => Domain.Entities.Message.BuildTxt(ctx, command.Text!, sequenceId), - MessageType.Image => Domain.Entities.Message.BuildImg(ctx, command.Url!, - command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId), + MessageType.Image => Domain.Entities.Message.BuildImg(ctx, command.Url, + command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId, command.FileId), - MessageType.Video => Domain.Entities.Message.BuildVideo(ctx, command.Url!, - command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId), + MessageType.Video => Domain.Entities.Message.BuildVideo(ctx, command.Url, + command.Width ?? 0, command.Height ?? 0, command.Thumb!, sequenceId, command.FileId), - MessageType.Voice => Domain.Entities.Message.BuildVoice(ctx, command.Url!, command.Duration ?? 0, sequenceId), + MessageType.Voice => Domain.Entities.Message.BuildVoice(ctx, command.Url, command.Duration ?? 0, sequenceId, command.FileId), + + MessageType.File => Domain.Entities.Message.BuildFile(ctx, command.FileId!.Value, command.Url, + command.FileName!, command.FileSize!.Value, command.FileFormat!, sequenceId), _ => null }; @@ -102,14 +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) { - var conversation = await conversationReposity.FindByIdAsync(command.conversationId); + 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, cancellationToken); if(conversation is null || conversation.UserId != command.userId) { @@ -117,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/Application/Message/SendMsgCommand.cs b/MessageService.WebApi/Application/Message/SendMsgCommand.cs index c37bce4..c4b37af 100644 --- a/MessageService.WebApi/Application/Message/SendMsgCommand.cs +++ b/MessageService.WebApi/Application/Message/SendMsgCommand.cs @@ -22,8 +22,12 @@ namespace MessageService.WebApi.Application.Message public int? Height { get; init; } public string? Thumb { get; init; } public int? Duration { get; init; } + public Guid? FileId { get; init; } + public string? FileName { get; init; } + public long? FileSize { get; init; } + public string? FileFormat { get; init; } - public SendMsgCommand(Guid senderId, Guid targetId, ChatType chatType, MessageType msgType, Guid clientMsgId, Guid? quoteMessageId, Dictionary? ext, string? text, string? url, int? width, int? height, string? thumb, int? duration) + public SendMsgCommand(Guid senderId, Guid targetId, ChatType chatType, MessageType msgType, Guid clientMsgId, Guid? quoteMessageId, Dictionary? ext, string? text, string? url, int? width, int? height, string? thumb, int? duration, Guid? fileId, string? fileName, long? fileSize, string? fileFormat) { SenderId = senderId; TargetId = targetId; @@ -38,6 +42,10 @@ namespace MessageService.WebApi.Application.Message Height = height; Thumb = thumb; Duration = duration; + FileId = fileId; + FileName = fileName; + FileSize = fileSize; + FileFormat = fileFormat; } } } 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/Controllers/Message/MessageSendRequest.cs b/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs index 039a3ec..bc0546a 100644 --- a/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs +++ b/MessageService.WebApi/Controllers/Message/MessageSendRequest.cs @@ -23,6 +23,10 @@ namespace MessageService.WebApi.Controllers.Message public int? Height { get; init; } public string? Thumb { get; init; } public int? Duration { get; init; } + public Guid? FileId { get; init; } + public string? FileName { get; init; } + public long? FileSize { get; init; } + public string? FileFormat { get; init; } public SendMsgCommand ToCommand(Guid senderId) { @@ -39,7 +43,11 @@ namespace MessageService.WebApi.Controllers.Message Width, Height, Thumb, - Duration + Duration, + FileId, + FileName, + FileSize, + FileFormat ); } } @@ -63,9 +71,19 @@ namespace MessageService.WebApi.Controllers.Message // 图片/视频/语音消息:url 必填 When(r => r.MsgType == MessageType.Image || r.MsgType == MessageType.Video - || r.MsgType == MessageType.Voice || r.MsgType == MessageType.File, () => + || r.MsgType == MessageType.Voice, () => { - RuleFor(r => r.Url).NotEmpty().WithMessage("媒体消息的 url 字段不能为空"); + RuleFor(r => r) + .Must(request => !string.IsNullOrWhiteSpace(request.Url) || request.FileId.HasValue) + .WithMessage("媒体消息必须提供 url 或 fileId"); + }); + + When(r => r.MsgType == MessageType.File, () => + { + RuleFor(r => r.FileId).NotNull().NotEmpty().WithMessage("文件消息的 fileId 字段不能为空"); + RuleFor(r => r.FileName).NotEmpty().WithMessage("文件消息的 fileName 字段不能为空"); + RuleFor(r => r.FileSize).NotNull().GreaterThanOrEqualTo(0).WithMessage("文件消息的 fileSize 字段不合法"); + RuleFor(r => r.FileFormat).NotEmpty().WithMessage("文件消息的 fileFormat 字段不能为空"); }); } } diff --git a/MessageService.WebApi/ModuleInit.cs b/MessageService.WebApi/ModuleInit.cs index e8bb4a0..7f11cb5 100644 --- a/MessageService.WebApi/ModuleInit.cs +++ b/MessageService.WebApi/ModuleInit.cs @@ -20,9 +20,16 @@ namespace MessageService.WebApi services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddHttpClient(c => + services.AddHttpClient((sp, c) => { - c.BaseAddress = new Uri("http://im-group-service:8080/"); + var configuration = sp.GetRequiredService(); + c.BaseAddress = new Uri(configuration["InternalServices:GroupServiceBaseUrl"] + ?? "http://im-group-service:8080/"); + var internalApiKey = configuration["InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(internalApiKey)) + { + c.DefaultRequestHeaders.Add("X-Internal-Api-Key", internalApiKey); + } }); services.AddGrpcClient((sp, o) => { 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/MessageService.WebApi/appsettings.Development.json b/MessageService.WebApi/appsettings.Development.json index 0c208ae..69768ca 100644 --- a/MessageService.WebApi/appsettings.Development.json +++ b/MessageService.WebApi/appsettings.Development.json @@ -1,4 +1,8 @@ { + "InternalApiKey": "development-only-change-me", + "InternalServices": { + "GroupServiceBaseUrl": "http://localhost:5070/" + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/MessageService.WebApi/appsettings.json b/MessageService.WebApi/appsettings.json index 10f68b8..5add975 100644 --- a/MessageService.WebApi/appsettings.json +++ b/MessageService.WebApi/appsettings.json @@ -5,5 +5,9 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "InternalApiKey": "", + "InternalServices": { + "GroupServiceBaseUrl": "http://im-group-service:8080/" + } } 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 958017e..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" @@ -145,6 +143,7 @@ services: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 CONSUL_URL: http://consul:8500 + InternalApiKey: ${IM_INTERNAL_API_KEY:?IM_INTERNAL_API_KEY is required} networks: - im-net @@ -173,6 +172,7 @@ services: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 CONSUL_URL: http://consul:8500 + InternalApiKey: ${IM_INTERNAL_API_KEY:?IM_INTERNAL_API_KEY is required} networks: - im-net @@ -189,12 +189,15 @@ services: condition: service_started rabbitmq: condition: service_started + group-service: + condition: service_started ports: - "5005:8080" environment: ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_URLS: http://+:8080 CONSUL_URL: http://consul:8500 + InternalApiKey: ${IM_INTERNAL_API_KEY:?IM_INTERNAL_API_KEY is required} networks: - im-net