Merge pull request 'Codex/api alignment fixes' (#1) from codex/api-alignment-fixes into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-09-15 14:15:27 +08:00
98 changed files with 3731 additions and 303 deletions
+5
View File
@@ -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
+9
View File
@@ -12,6 +12,9 @@
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
.env
.env.*
!.env.example
# Mono auto generated files
mono_crash.*
@@ -361,3 +364,9 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
.tools/
deploy/.env.local
deploy/data/
artifacts/
**/keyring/
+15
View File
@@ -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`150,默认 30)。
- 只返回未撤回、未删除的文本消息,按 `sequenceId` 倒序排列。
- 响应继续使用统一 `Result`,数据结构为 `{ messages, hasmore }`。下一页以本页最后一条消息的 `sequenceId` 作为独占游标。
- `POST /api/Conversation/MarkRead` 新增可选 `lastReadSequenceId` 查询参数,旧客户端不传时仍兼容。
- 会话列表的 `dateTime` 表示最后消息活动时间;标记已读不会改变此时间或会话排序。
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="9.0.13" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.25" />
<PackageReference Include="Testcontainers.MySql" Version="4.13.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
<ItemGroup><ProjectReference Include="../Admin.WebApi/Admin.WebApi.csproj"/><ProjectReference Include="../User.Infrastructure/IdentityService.Infrastructure.csproj"/><ProjectReference Include="../GroupService.Infrastructure/GroupService.Infrastructure.csproj"/><ProjectReference Include="../FileService.Infrastructure/FileService.Infrastructure.csproj"/></ItemGroup>
</Project>
+129
View File
@@ -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<AdminDb>(); await db.Database.MigrateAsync();
var hasher = new PasswordHasher<AdminAccount>();
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<JsonElement> Data(HttpResponseMessage response) { var json=await response.Content.ReadFromJsonAsync<JsonElement>(); return json.GetProperty("data").Clone(); }
static async Task<HttpResponseMessage> 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<AdminDb>(); var op=await db.Operations.FindAsync(id); op!.NextAttemptAt=DateTime.UtcNow; await db.SaveChangesAsync(); }
await new OperationWorker(factory.Services.GetRequiredService<IServiceScopeFactory>(),NullLogger<OperationWorker>.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<Guid>()})};
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<JsonElement>()).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<AdminDb>();
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<Program>
{
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<IHostedService>(); services.AddHttpClient<InternalClient>().ConfigurePrimaryHttpMessageHandler(()=>handler); });
}
}
sealed class DomainHandler : HttpMessageHandler
{
public Guid Target {get;}=Guid.NewGuid(); public bool Fail; public bool LoseAcknowledgement; public int Applied;
readonly Dictionary<Guid,ActionReceipt> receipts=new();
protected override async Task<HttpResponseMessage> 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<InternalAction>(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)};
}
}
+49
View File
@@ -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<string,string?> { ["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<ApiError>(() => SettingsService.Validate("social",p));
p = SettingsService.Defaults("account"); p["password"] = "must not be persisted"; Assert.Throws<ApiError>(() => SettingsService.Validate("account",p));
p = SettingsService.Defaults("messaging"); p["allowedFileTypes"] = new JsonArray("*.exe"); Assert.Throws<ApiError>(() => SettingsService.Validate("messaging",p));
}
[Fact]
public void Credentials_are_authenticated_encrypted_and_require_the_original_deployment_key()
{
using var db = new AdminDb(new DbContextOptionsBuilder<AdminDb>().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<string,string?> { ["Management:CredentialKey"] = Convert.ToBase64String(Enumerable.Repeat((byte)1,32).ToArray()) }).Build());
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(() => 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<InvalidOperationException>(() => LocalStorageAdapter.SafePath(root,"..","outside.txt"));
Assert.Throws<InvalidOperationException>(() => LocalStorageAdapter.SafePath(root,Path.GetFullPath(Path.Combine(root,"..","outside.txt"))));
}
}
+8
View File
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup>
<ItemGroup>
<ProjectReference Include="../IM.InitCommon/IM.InitCommon.csproj" />
<PackageReference Include="MailKit" Version="4.18.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
</Project>
+19
View File
@@ -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, "请填写 1500 字的操作原因"); }
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<PageResult<T>> Page<T>(IQueryable<T> 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); }
}
+101
View File
@@ -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, "管理员密码长度应为 12128 位"); }
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<AdminAccount> 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<AdminAccount> 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<AdminAccount> 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<AdminAccount> 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, "邮箱格式不正确");
}
}
+105
View File
@@ -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<JsonElement>("user", "/internal/management/summary");
var groups = client.Send<JsonElement>("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<JsonElement>(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<JsonElement>(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<JsonElement>(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<SubjectEvidence>("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<IResult> 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);
}
}
+19
View File
@@ -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<JsonElement>("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<JsonElement>("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<JsonElement>("group", $"/internal/management/members/{id}" + c.Request.QueryString, ct: c.RequestAborted))).RequireAuthorization();
}
}
+28
View File
@@ -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);
+112
View File
@@ -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<AdminDb> options) : DbContext(options)
{
public DbSet<AdminAccount> Accounts => Set<AdminAccount>();
public DbSet<SystemSetting> Settings => Set<SystemSetting>();
public DbSet<Report> Reports => Set<Report>();
public DbSet<Operation> Operations => Set<Operation>();
public DbSet<AuditRecord> Audit => Set<AuditRecord>();
public DbSet<PasswordReset> Resets => Set<PasswordReset>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<AdminAccount>().ToTable("admin_accounts").HasIndex(x => x.Account).IsUnique();
b.Entity<AdminAccount>().Property(x => x.Account).HasMaxLength(100);
b.Entity<AdminAccount>().Property(x => x.Stamp).IsConcurrencyToken();
b.Entity<SystemSetting>().ToTable("admin_settings").Property(x => x.Id).HasMaxLength(64);
b.Entity<SystemSetting>().Property(x => x.Version).IsConcurrencyToken();
b.Entity<Report>().ToTable("admin_reports").HasIndex(x => new { x.ReporterId, x.CreatedAt });
b.Entity<Report>().HasIndex(x => new { x.Status, x.CreatedAt });
b.Entity<Report>().Property(x => x.Status).HasMaxLength(30);
b.Entity<Report>().Property(x => x.Version).IsConcurrencyToken();
b.Entity<Operation>().ToTable("admin_operations").HasIndex(x => new { x.Status, x.NextAttemptAt });
b.Entity<Operation>().Property(x => x.Status).HasMaxLength(30);
b.Entity<AuditRecord>().ToTable("admin_audit").HasIndex(x => x.CreatedAt);
b.Entity<AuditRecord>().HasIndex(x => x.OperationId).IsUnique();
b.Entity<PasswordReset>().ToTable("admin_password_resets").Property(x => x.Id).HasMaxLength(64);
}
}
+9
View File
@@ -0,0 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace IM.Admin.Data;
public sealed class DesignTimeFactory : IDesignTimeDbContextFactory<AdminDb>
{
public AdminDb CreateDbContext(string[] args) => new(new DbContextOptionsBuilder<AdminDb>()
.UseMySql("Server=localhost;Database=im_admin;User=migration;Password=design-time-only", new MySqlServerVersion(new Version(8, 0, 0))).Options);
}
@@ -0,0 +1,314 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Account")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("Enabled")
.HasColumnType("tinyint(1)");
b.Property<int>("FailedAttempts")
.HasColumnType("int");
b.Property<DateTime?>("LockedUntil")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("After")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Before")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("OperationId")
.HasColumnType("char(36)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetId")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Attempts")
.HasColumnType("int");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Error")
.HasColumnType("longtext");
b.Property<DateTime>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid>("AccountId")
.HasColumnType("char(36)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("admin_password_resets", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Report", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("AssigneeId")
.HasColumnType("char(36)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Evidence")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ReporterId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.HasColumnType("longtext");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Draft")
.HasColumnType("longtext");
b.Property<string>("DraftSecret")
.HasColumnType("longtext");
b.Property<string>("Secret")
.IsRequired()
.HasColumnType("longtext");
b.Property<long?>("TestedVersion")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("admin_settings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,234 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Admin.WebApi.Data.Migrations
{
/// <inheritdoc />
public partial class InitialAdmin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_accounts",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Account = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Email = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
PasswordHash = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Role = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Enabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
Stamp = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FailedAttempts = table.Column<int>(type: "int", nullable: false),
LockedUntil = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(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<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Action = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetId = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Before = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
After = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ReportId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
OperationId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Result = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(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<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Type = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Action = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ReportId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Status = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Attempts = table.Column<int>(type: "int", nullable: false),
Error = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
NextAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CompletedAt = table.Column<DateTime>(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<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AccountId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ExpiresAt = table.Column<DateTime>(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<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ReporterId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
TargetId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
TargetName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Type = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Evidence = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AssigneeId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Result = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
Version = table.Column<long>(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<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Version = table.Column<long>(type: "bigint", nullable: false),
Value = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Draft = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
TestedVersion = table.Column<long>(type: "bigint", nullable: true),
Secret = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DraftSecret = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
UpdatedAt = table.Column<DateTime>(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" });
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,311 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Account")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("Enabled")
.HasColumnType("tinyint(1)");
b.Property<int>("FailedAttempts")
.HasColumnType("int");
b.Property<DateTime?>("LockedUntil")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("After")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Before")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("OperationId")
.HasColumnType("char(36)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetId")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Attempts")
.HasColumnType("int");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Error")
.HasColumnType("longtext");
b.Property<DateTime>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid>("AccountId")
.HasColumnType("char(36)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("admin_password_resets", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Report", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("AssigneeId")
.HasColumnType("char(36)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Evidence")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ReporterId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.HasColumnType("longtext");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Draft")
.HasColumnType("longtext");
b.Property<string>("DraftSecret")
.HasColumnType("longtext");
b.Property<string>("Secret")
.IsRequired()
.HasColumnType("longtext");
b.Property<long?>("TestedVersion")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("admin_settings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
+59
View File
@@ -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<AdminDb>(o => o.UseMySql(builder.Configuration.GetConnectionString("Admin") ?? throw new InvalidOperationException("ConnectionStrings:Admin is required"), new MySqlServerVersion(new Version(8, 0, 0))));
builder.Services.AddSingleton<IPasswordHasher<AdminAccount>, PasswordHasher<AdminAccount>>();
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<InternalClient>(c => c.Timeout = TimeSpan.FromSeconds(5));
builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<InfrastructureService>();
builder.Services.AddSingleton<HealthSampler>();
builder.Services.AddHostedService<OperationWorker>();
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<AdminDb>();
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<Microsoft.AspNetCore.Antiforgery.IAntiforgery>().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 { }
@@ -0,0 +1,12 @@
{
"profiles": {
"Admin.WebApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:60538;http://localhost:60539"
}
}
}
+28
View File
@@ -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<AdminDb>();
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<IPasswordHasher<AdminAccount>>().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("管理员凭据已更新;请清除临时密码环境变量。");
}
}
+33
View File
@@ -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<string, DateTime> successes = new();
public async Task<ServiceHealth[]> Read(CancellationToken ct)
{
using var scope = scopes.CreateScope(); var client = scope.ServiceProvider.GetRequiredService<InternalClient>();
var tasks = new[] { "admin", "user", "contact", "group", "message", "file", "connector" }.Select(name => Check(name, async token => {
var response = await client.Send<JsonElement>(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("RabbitMQTCP", 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<ServiceHealth> Check(string name, Func<CancellationToken, Task<long?>> 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, "连接或就绪检查失败,请检查服务日志"); }
}
}
@@ -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<string>())) 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<JsonElement>("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<JsonElement>("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<SystemSetting> 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<string>() ?? "", config);
if (value["port"]?.GetValue<int>() is not (>= 1 and <= 65535) || value["tls"]?.GetValue<string>() is not ("starttls" or "ssl") || !System.Net.Mail.MailAddress.TryCreate(value["from"]?.GetValue<string>(), 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<string>())) 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<string>() != code) throw new ApiError(400, "存储字段不正确,凭据需独立提交");
var type = p["providerType"]?.GetValue<int>(); 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<string>(), 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<string[]>() ?? [];
if (string.IsNullOrWhiteSpace(host) || !allowed.Contains(host, StringComparer.OrdinalIgnoreCase)) throw new ApiError(400, "该目标不在部署允许列表中");
}
async Task<MailKit.Net.Smtp.SmtpClient> 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<string>(), value["port"]!.GetValue<int>(), value["tls"]!.GetValue<string>() == "ssl" ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls);
if (!string.IsNullOrWhiteSpace(value["username"]?.GetValue<string>())) await smtp.AuthenticateAsync(value["username"]!.GetValue<string>(), 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<string>())); 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<bool> MailEnabled() { var row = await db.Settings.AsNoTracking().SingleOrDefaultAsync(x => x.Id == "smtp"); return row is not null && JsonNode.Parse(row.Value)?["enabled"]?.GetValue<bool>() == 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); }
}
+40
View File
@@ -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<OperationWorker> 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<AdminDb>();
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<InternalClient>().Send<ActionReceipt>(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);
}
}
+82
View File
@@ -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<string, string[]> 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<string, JsonNode?>(k, all[k]?.DeepClone())));
}
public async Task<Policy> 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<Policy>(Json)!; policy.Version = rows.Sum(x => x.Version); return policy;
}
public async Task<object> 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<Policy>(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);
+5 -1
View File
@@ -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);
+8
View File
@@ -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<ConnectionRegistry>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ConnectionRegistry>());
// 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<ChatHub>("/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();
}
@@ -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<ConnectionRegistry> logger) : BackgroundService
{
const string Active = "im:management:connections";
static readonly RedisChannel Revocations = RedisChannel.Literal("im:management:disconnect");
readonly ConcurrentDictionary<string, HubCallerContext> 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<object> 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 { } }
}
}
@@ -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<Result<FriendRequestResponse>> 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<FriendRequestResponse>.Fail(ResultCode.PERMISSION_DENIED, "好友数量已达到平台上限");
request.Accept(command.RemarkName);
break;
case FriendRequestAction.Block:
@@ -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<object> Relation(Guid owner, Guid target) => new { related = await db.Friends.AnyAsync(x => x.Owner.Id == owner && x.Target.Id == target) };
}
+3
View File
@@ -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();
@@ -6,6 +6,7 @@ namespace FileService.Application.Ports
public interface IObjectStoragePort
{
string ProviderCode { get; }
Task<UploadPart> WritePartAsync(UploadRuntimeCache task, int partNumber, Stream content, long size, CancellationToken ct) => throw new NotSupportedException();
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command,CancellationToken token);
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token);
public Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token);
@@ -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;
}
@@ -16,17 +16,17 @@ namespace FileService.Application.UploadFile
private readonly IMapper mapper;
private readonly IObjectStorageRouter router;
private readonly IOptions<StorageOptions> options;
private readonly IGroupAccessService groupAccessService;
private readonly IGroupAccessService groupAccessService; private readonly IM.InitCommon.Management.RuntimePolicy runtime;
public UploadFileService(IUploadFileReposity reposity, IMapper mapper,
IObjectStorageRouter router, IOptions<StorageOptions> options,
IGroupAccessService groupAccessService)
IObjectStorageRouter router, IOptionsSnapshot<StorageOptions> options,
IGroupAccessService groupAccessService, IM.InitCommon.Management.RuntimePolicy runtime)
{
this.reposity = reposity;
this.mapper = mapper;
this.router = router;
this.options = options;
this.groupAccessService = groupAccessService;
this.groupAccessService = groupAccessService; this.runtime = runtime;
}
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id, Guid requesterId)
@@ -54,6 +54,8 @@ namespace FileService.Application.UploadFile
/// </summary>
public async Task<Result<FileResponse>> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default)
{
runtime.CheckFile(command.FileName, command.FileSize);
if (command.FileSize <= 0 || command.FileSize > options.Value.Providers[options.Value.DefaultProviderCode].MaxObjectSizeBytes) return Result.Fail<FileResponse>(ResultCode.FILE_TOO_LARGE);
var checksum = command.CheckSum;
if (string.IsNullOrWhiteSpace(checksum))
{
@@ -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<StorageOptions> options, IStorageRedisCache redis,
IOptionsSnapshot<StorageOptions> options, IStorageRedisCache redis,
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage,
IUploadFileReposity uploadFileReposity
IUploadFileReposity uploadFileReposity, IM.InitCommon.Management.RuntimePolicy runtime, UploadFile.IGroupAccessService groupAccess
)
{
private readonly IUploadTaskReposity reposity = reposity;
@@ -29,6 +29,9 @@ namespace FileService.Application.UploadFileTask
public async Task<Result<TaskInitResponse>> InitTaskAsync(UploadTaskInitCommand command)
{
runtime.CheckFile(command.FileName, command.FileSize);
if (command.FileSize <= 0) return Result.Fail<TaskInitResponse>(ResultCode.PARAMETER_ERROR);
await CheckGroup(command.ChatType, command.TargetId, command.UploaderId);
CancellationToken cancellationToken = CancellationToken.None;
// 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录
@@ -66,7 +69,7 @@ namespace FileService.Application.UploadFileTask
var initUpdateCommand = new StorageContracts.InitiateUploadCommand(
ProviderCode: storageOption.ProviderCode,
Bucket: storageOption.Bucket,
ObjectKey: $"{storageOption.LocalRootPath}\\{date.Year}\\{date.Month}\\{date.Day}\\{command.FileName}",
ObjectKey: $"{date:yyyy/MM/dd}/{Guid.NewGuid():N}{Path.GetExtension(command.FileName)}",
ContentType: task.ContentType.Value,
ContentLength: command.FileSize,
null);
@@ -84,14 +87,14 @@ namespace FileService.Application.UploadFileTask
UploadSessionId = initRes.UploadSessionId,
StorageLocation = initRes.Location,
Instant = false,
UploadMode = string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)
? "LocalMultipart"
UploadMode = runtime.Enabled || string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)
? "ServerMultipart"
: "Presigned",
TotalPartCount = totalPartCount,
PartSizeBytes = storageOption.DefaultPartSizeBytes
};
task.StartUpload();
task.StartUpload(new Domain.ValueObjects.StorageLocation(storageOption.ProviderCode, storageOption.Bucket, initUpdateCommand.ObjectKey, storageOption.Region));
reposity.Create(task);
await redis.SetAsync(new StorageContracts.UploadRuntimeCache(
@@ -123,18 +126,21 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<PresignedUrl>(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<PresignedUrl>(ResultCode.INVALID_PART_NUMBER);
}
var presignUrl = await storage.GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
if (runtime.Enabled) return Result.Fail<PresignedUrl>(ResultCode.PERMISSION_DENIED, "请通过鉴权分片接口上传,以保证封禁立即生效");
var presignUrl = await router.Route(taskCache.ProviderCode).GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
ProviderCode: taskCache.ProviderCode,
Bucket: taskCache.Bucket,
ObjectKey: taskCache.ObjectKey,
UploadSessionId: taskCache.UploadSessionId,
PartNumber: partNum,
ExpiresIn: options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn
ExpiresIn: options.Value.Providers[taskCache.ProviderCode].UploadUrlExpiresIn
), token);
return Result.Success(presignUrl);
@@ -159,6 +165,9 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<UploadTaskResponse>(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<UploadTaskResponse>(task));
// 校验分片数量必须匹配
if (command.Parts.Count != taskCache.TotalPartCount)
{
@@ -172,7 +181,7 @@ namespace FileService.Application.UploadFileTask
}
// 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 ETag。
if (string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase))
if (runtime.Enabled || string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase))
{
foreach (var part in command.Parts)
{
@@ -243,7 +252,11 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<CompleteUploadResult>(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<CompleteUploadResult>(ResultCode.PARAMETER_ERROR);
var minPartSize = options.Value.Providers[taskCache.ProviderCode].MinPartSizeBytes;
// 最后一个分片豁免最小值校验(仅校验非最后一片)
var isLastPart = command.PartNum == taskCache.TotalPartCount;
@@ -253,13 +266,17 @@ namespace FileService.Application.UploadFileTask
$"分片 {command.PartNum} 大小为 {command.ContentLength} 字节,小于最小值 {minPartSize} 字节");
}
StorageContracts.UploadPart uploaded;
if (taskCache.ProviderCode == "Local") {
await localChunkStorage.SavePartAsync(new SaveLocalPartCommand(
UploadSessionId: command.SessionId,
PartNumber: command.PartNum,
Stream: command.Stream,
ContentLength: command.ContentLength
));
taskCache.AddOrUpdatePart(new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength));
uploaded = new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength);
} else uploaded = await router.Route(taskCache.ProviderCode).WritePartAsync(taskCache, command.PartNum, command.Stream, command.ContentLength, CancellationToken.None);
taskCache.AddOrUpdatePart(uploaded);
await redis.SetAsync(taskCache);
var location = new Domain.ValueObjects.StorageLocation(
storageProvider: taskCache.ProviderCode,
@@ -267,7 +284,7 @@ namespace FileService.Application.UploadFileTask
objectKey: taskCache.ObjectKey,
region: taskCache.Region
);
return Result.Success(new CompleteUploadResult(location, command.PartNum.ToString(), command.ContentLength));
return Result.Success(new CompleteUploadResult(location, uploaded.ETag, command.ContentLength));
}
/// <summary>
@@ -302,6 +319,11 @@ namespace FileService.Application.UploadFileTask
return Result.Success(response);
}
private async Task CheckGroup(string? chatType, Guid? targetId, Guid userId)
{
if (string.Equals(chatType, "GROUP", StringComparison.OrdinalIgnoreCase) && (!targetId.HasValue || !await groupAccess.CheckMemberAsync(userId, targetId.Value)))
throw new IM.DomainCommons.DomainException("群组已被封禁或没有群文件写入权限");
}
private bool CanReuse(Domain.Entities.UploadFile file, UploadTaskInitCommand command)
{
var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation);
+3 -3
View File
@@ -1,4 +1,4 @@
using FileService.Domain.Events;
using FileService.Domain.Events;
using FileService.Domain.ValueObjects;
using IM.DomainCommons;
@@ -34,9 +34,9 @@ namespace FileService.Domain.Entities
CheckSum = checkSum;
}
public void StartUpload()
public void StartUpload(StorageLocation? location = null)
{
State = UploadTaskState.Uploading;
if (location != null) StorageLocation = location; State = UploadTaskState.Uploading;
}
public void StartMerging(StorageLocation location)
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.511.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
@@ -1,174 +1,70 @@
using FileService.Application.Ports;
using FileService.Application.Ports;
using FileService.Application.StorageContracts;
using FileService.Domain.ValueObjects;
using IM.Commons;
using IM.InitCommon;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace FileService.Infrastructure.Storage
namespace FileService.Infrastructure.Storage;
public class LocalStorageAdapter(IStorageRedisCache redis, IOptionsSnapshot<StorageOptions> options) : IObjectStoragePort, ILocalChunkStorage
{
public class LocalStorageAdapter(IStorageRedisCache redis, IOptions<StorageOptions> 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<StorageOptions> options = options;
private readonly StorageProviderOptions providerOptions = options.Value.Providers[options.Value.DefaultProviderCode];
public string ProviderCode => "Local";
/// <summary>
/// 单次直传:直接写入 LocalRootPath/{bucket}/{objectKey}。
/// bucket 传公开桶名即落到公开目录,可被静态托管直链访问。
/// </summary>
public async Task<StorageLocation> 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<StorageLocation> 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<Stream> OpenReadAsync(StorageLocation location, CancellationToken token) => Task.FromResult<Stream>(File.OpenRead(SafePath(Provider.LocalRootPath!, location.Bucket, location.ObjectKey)));
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command, CancellationToken token) => Task.FromResult(new InitiateUploadResult(Guid.NewGuid().ToString(), new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region)));
public Task<PresignedUrl> 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<string, string>(), 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<CompleteUploadResult> 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);
}
/// <summary>
/// 公开桶文件返回静态托管直链;私有文件返回 null。
/// </summary>
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}";
}
/// <summary>
/// 打开本地文件读取流:LocalRootPath/{bucket}/{objectKey}。
/// </summary>
public Task<Stream> 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<CompleteUploadResult> 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<Result<object>> MergeAsync(string sessionId, string objectKey, IReadOnlyList<UploadPart> 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<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token)
{
var baseUrl = options.Value.Providers[options.Value.DefaultProviderCode].LocalUploadApiBaseUrl;
return new PresignedUrl(
baseUrl + $"local/parts/upload?sessionId={command.UploadSessionId}&partNumber={command.PartNumber}",
"POST",
new Dictionary<string, string>(),
ExpiresAt: DateTimeOffset.Now.Add(options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn)
);
}
public async Task<InitiateUploadResult> 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<Result<object>> MergeAsync(string sessionId, string objectKey, IReadOnlyList<UploadPart> 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();
}
}
@@ -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<string, IObjectStoragePort> adpters;
public ObjectStorageRouter(IEnumerable<IObjectStoragePort> storages)
public ObjectStorageRouter(IEnumerable<IObjectStoragePort> storages, IOptionsSnapshot<StorageOptions> 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<S3StorageAdapter>()) adapter.Dispose(); } public IObjectStoragePort Route(string providerCode)
{
return this.adpters[providerCode];
}
@@ -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<UploadPart> 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<InitiateUploadResult> 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<PresignedUrl> 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<string, string>(), expires));
}
public async Task<CompleteUploadResult> 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<StorageLocation> 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<Stream> 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<int> ReadAsync(Memory<byte> 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); }
}
}
@@ -1,4 +1,4 @@
using FileService.Application.UploadFileTask;
using FileService.Application.UploadFileTask;
using FileService.Infrastructure;
using IM.ASPNETCore;
using Microsoft.AspNetCore.Authorization;
@@ -71,10 +71,10 @@ namespace FileService.WebApi.Controllers.FileTask
}
[HttpPost("local/parts/upload")]
public async Task<IActionResult> LocalUpload(string sessionId, int partNumber, IFormFile file)
public async Task<IActionResult> LocalUpload([FromForm] string sessionId, [FromForm] int partNumber, IFormFile file)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var stream = file.OpenReadStream();
await using var stream = file.OpenReadStream();
var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length), Guid.Parse(userId));
return Ok(res);
}
@@ -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<StorageOptions> current, IConfiguration config) : ControllerBase
{
[HttpGet("summary")]
public async Task<object> 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<object> 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<object> 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<StorageOptions>(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<string[]>() ?? [];
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<string[]>() ?? [];
if (!allowed.Contains(uri.Host, StringComparer.OrdinalIgnoreCase)) throw new IM.DomainCommons.DomainException("存储主机未被部署允许");
p.AccessKeyId = secrets[code]?["accessKeyId"]?.GetValue<string>(); p.AccessKeySecret = secrets[code]?["accessKeySecret"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(p.AccessKeyId) || string.IsNullOrWhiteSpace(p.AccessKeySecret)) throw new IM.DomainCommons.DomainException("缺少存储凭据");
}
}
return next;
}
}
+3
View File
@@ -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);
+5 -3
View File
@@ -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;
@@ -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) { }
}
@@ -1,7 +1,8 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain.IReposities;
using GroupService.WebApi.Application.Dtos;
using IM.Commons;
using Microsoft.EntityFrameworkCore;
namespace GroupService.WebApi.Application.Group
{
@@ -9,18 +10,22 @@ namespace GroupService.WebApi.Application.Group
{
private readonly IGroupReposity reposity;
private readonly IGroupMemberReposity memberReposity;
private readonly IMapper mapper;
private readonly IMapper mapper; private readonly IM.InitCommon.Management.RuntimePolicy runtime; private readonly global::GroupService.Infrastructure.GroupDbContext db;
public GroupService(IGroupReposity reposity, IGroupMemberReposity memberReposity, IMapper mapper)
public GroupService(IGroupReposity reposity, IGroupMemberReposity memberReposity, IMapper mapper, IM.InitCommon.Management.RuntimePolicy runtime, global::GroupService.Infrastructure.GroupDbContext db)
{
this.reposity = reposity;
this.memberReposity = memberReposity;
this.mapper = mapper;
this.mapper = mapper; this.runtime = runtime; this.db = db;
}
public async Task<Result<GroupResponse>> 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<GroupResponse>(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<GroupResponse>.Success(mapper.Map<GroupResponse>(group));
}
@@ -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<GroupInvitationResponse>(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<Result<object>> CreateBatchAsync(Guid operatorId, List<Guid> 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);
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain;
using GroupService.Domain.IReposities;
using GroupService.WebApi.Application.Dtos;
@@ -13,15 +13,15 @@ namespace GroupService.WebApi.Application.GroupMember
private readonly IGroupReposity groupReposity;
private readonly GroupMemberDomainService service;
private readonly IIdentityIntegrationService idService;
private IMapper mapper;
private IMapper mapper; private readonly IM.InitCommon.Management.RuntimePolicy runtime;
public GroupMemberService(IGroupMemberReposity reposity, IGroupReposity groupReposity, GroupMemberDomainService service, IIdentityIntegrationService idService, IMapper mapper)
public GroupMemberService(IGroupMemberReposity reposity, IGroupReposity groupReposity, GroupMemberDomainService service, IIdentityIntegrationService idService, IMapper mapper, IM.InitCommon.Management.RuntimePolicy runtime)
{
this.reposity = reposity;
this.groupReposity = groupReposity;
this.service = service;
this.idService = idService;
this.mapper = mapper;
this.mapper = mapper; this.runtime = runtime;
}
public async Task<Result<List<GroupMemberResponse>>> GetByGroupIdAsync(Guid groupId, Guid userId)
@@ -49,6 +49,9 @@ namespace GroupService.WebApi.Application.GroupMember
return Result<GroupMemberResponse>.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (group.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组已被封禁,不能加入");
var limit = runtime.Current.GroupMemberLimit;
if (limit > 0 && (await reposity.FindByGroupIdAsync(groupId)).Count() >= limit) throw new IM.DomainCommons.DomainException("群成员数量已达到平台上限");
var userRes = await idService.FindUserByIdAsync(userId);
if (!userRes.Succeeded)
{
@@ -68,7 +71,7 @@ namespace GroupService.WebApi.Application.GroupMember
public async Task<Result<bool>> 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);
}
@@ -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<GroupRequestResponse>(ResultCode.GROUP_NOT_FOUND);
}
if (group.Status != Domain.Enums.GroupState.Normal || group.Authority == Domain.Enums.GroupAuthorityType.NOT_ALLOWED_TO_JOIN)
return Result.Fail<GroupRequestResponse>(ResultCode.PERMISSION_DENIED, "群组当前不允许加入");
var user = await idService.FindUserByIdAsync(userId);
var groupProfile = new GroupProfile()
@@ -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<object> Summary() => new { total = await db.Groups.CountAsync() };
[HttpGet("list")] public async Task<object> 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<IActionResult> 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<object> 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<object> 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<object> 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<IActionResult> 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));
}
}
+3
View File
@@ -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();
+1
View File
@@ -25,6 +25,7 @@ namespace IM.InitCommon
//similar to serviceCollection.AddDbContextPool<ECDictDbContext>(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;
@@ -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<ExceptionMiddleware>();
app.UseForwardedHeaders();
+42
View File
@@ -0,0 +1,42 @@
using System.Text.Json.Nodes;
namespace IM.InitCommon.Management;
public record PageResult<T>(IReadOnlyList<T> 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<EvidenceSnapshot> 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 };
}
@@ -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<ManagementDbProbe>().Where(x => x.Context.Database.GetMigrations().Any()))
probe.Context.Database.Migrate();
Console.WriteLine("服务数据库迁移完成"); return true;
}
}
@@ -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<T> Send<T>(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<T>(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;
}
+29
View File
@@ -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<ActionReceipt> Execute(DbContext db, InternalAction command, Func<Task<ActionReceipt>> 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<SavedReceipt>(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);
}
+92
View File
@@ -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<RuntimePolicy> logger) : BackgroundService
{
private Policy? current;
public InfrastructureEnvelope? Storage { get; private set; }
public bool Enabled => config.GetValue<bool>("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<InternalClient>();
current = await client.Send<Policy>("admin", "/internal/management/policy", ct: ct);
if (config["Management:ServiceName"] == "file") {
var storage = await client.Send<InfrastructureEnvelope>("admin", "/internal/management/infrastructure/storage", ct: ct);
if (Storage?.Version != storage.Version) { Storage = storage; services.GetService<IOptionsMonitorCache<StorageOptions>>()?.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<StorageOptions>
{
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<StorageOptions>(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<string>();
provider.AccessKeySecret = credential["accessKeySecret"]?.GetValue<string>();
}
}
options.DefaultProviderCode = configured.DefaultProviderCode; options.Providers = configured.Providers;
}
}
public static class RuntimeManagementExtensions
{
public static IServiceCollection AddManagementRuntime(this IServiceCollection services)
{
services.AddHttpClient<InternalClient>(c => c.Timeout = TimeSpan.FromSeconds(4));
services.AddSingleton<RuntimePolicy>(); services.AddHostedService(sp => sp.GetRequiredService<RuntimePolicy>());
services.AddSingleton<IPostConfigureOptions<StorageOptions>, 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<RuntimePolicy>();
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<InternalClient>().Send<UserAccess>("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<ManagementDbProbe> 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 };
});
}
+2 -2
View File
@@ -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; }
@@ -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();
+2 -2
View File
@@ -11,8 +11,8 @@ namespace IM.Jwt
/// <param name="options"></param>
/// <returns></returns>
string GetToken(IEnumerable<Claim> claims, JwtOptions options);
Task<string> CreateRefreshTokenAsync(Guid userId, CancellationToken cancellationToken = default);
Task<string> 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);
}
}
+7 -7
View File
@@ -26,13 +26,13 @@ namespace IM.Jwt
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes);
}
public async Task<string> CreateRefreshTokenAsync(Guid userId, CancellationToken cancellationToken = default)
public async Task<string> 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);
}
}
}
+246
View File
@@ -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
+26
View File
@@ -3,6 +3,7 @@
## 适用迁移
- MessageService`20260909000100_ApiAlignmentFixes`
- MessageService`20260911000100_ConversationUniqueness`
- GroupService`20260909000200_ApiAlignmentFixes`
- FileService`20260909000300_AsyncUploadResult`
@@ -11,6 +12,13 @@
先完成三个库的可恢复备份,并在对应数据库执行:
```sql
-- MessageService:记录迁移将处理的活动会话重复项。
SELECT UserId, ChatType, TargetId, COUNT(*) AS duplicate_count
FROM conversations
WHERE IsDeleted = 0
GROUP BY UserId, ChatType, TargetId
HAVING COUNT(*) > 1;
-- GroupService:新增 Id 唯一索引前必须无重复。
SELECT Id, COUNT(*) AS duplicate_count
FROM group_join_requests
@@ -49,12 +57,23 @@ dotnet ef database update --project FileService.Infrastructure --startup-project
建议顺序为 Message → Group → File,随后发布后端,再发布最终前端包。
本地具备 Docker 时执行 MessageService 的 MySQL 8 集成测试:
```powershell
$env:RUN_DOCKER_TESTS = '1'
dotnet test MessageService.Tests/MessageService.Tests.csproj
```
未设置该变量时,`dotnet test IM_API_NEW.sln` 仍会运行领域模型和迁移脚本检查,并明确跳过需要 Docker 的两项测试。
## 数据兼容说明
- 所有新业务列均可空或有安全默认值,不删除历史记录。
- 历史文件的 `IsPublic` 默认 `false`,无法确认作用域的旧文件因此只允许所有者读取。
- 新上传文件会写入 `SourceTaskId/ChatType/TargetId/ResultFileId`;不要批量猜测旧文件作用域。
- 群退出、群解散和会话隐藏使用软删除。
- 会话唯一性迁移不会物理删除记录;它保留更新时间最新的一条活动会话,将其他重复项软删除,并创建只约束活动记录的生成列唯一索引。
- Docker Compose 要求从环境注入 MySQL、RabbitMQ 和内部 API 凭据,可复制 `.env.example` 后填入部署环境的真实值;不得提交 `.env`
## 回滚
@@ -72,3 +91,10 @@ dotnet ef database update 20260509073447_InitFileDb --project FileService.Infras
```
FileService 回滚会删除新作用域和任务结果列,并把收紧的字符串列恢复为 `longtext`;回滚前应另行导出这些新列的数据。
# 20260913000100 会话活动时间与消息搜索
部署消息服务前先备份数据库,并在 MySQL 8 测试库执行 `20260913000100_ConversationActivityAndMessageSearch`
- 迁移新增可空 `conversations.LastMessageTime`,按相同 `StreamKey` 的最新未删除消息时间回填;没有消息时回退到会话创建时间。
- 新增 `messages(StreamKey, MsgType, State, SequenceId)` 复合索引,为会话内文本搜索和独占游标分页提供支持。
- 迁移只更新内部存储结构,不改变现有 DTO;发布后确认旧会话排序未因已读操作变化,并抽查搜索翻页无重复。
@@ -40,6 +40,11 @@ namespace MessageService.Domain.Entities
/// 最后一条最新消息
/// </summary>
public string LastMessage { get; private set; }
/// <summary>
/// 最后一条消息产生的时间。已读状态等会话元数据更新不得改变该值。
/// </summary>
public DateTimeOffset? LastMessageTime { get; private set; }
private Conversation() { }
public Conversation(Guid userId, Guid targetId, string targetAvatar, string targetName, long? lastReadSequenceId, int unreadCount, ChatType chatType, string lastMessage)
@@ -52,6 +57,7 @@ namespace MessageService.Domain.Entities
UnreadCount = unreadCount;
ChatType = chatType;
LastMessage = lastMessage;
LastMessageTime = DateTimeOffset.Now;
ModificationTime = DateTime.Now;
StreamKey = ChatType == ChatType.GROUP ? StreamKeyBuilder.Group(targetId) : StreamKeyBuilder.Private(userId, targetId);
AddDomainEvent(new ConversationCreatedDomainEvent(this));
@@ -69,9 +75,10 @@ namespace MessageService.Domain.Entities
NotifyModified();
}
public void UpdateLastMessage(string lastMessage)
public void UpdateLastMessage(string lastMessage, DateTimeOffset messageTime)
{
LastMessage = lastMessage;
LastMessageTime = messageTime;
NotifyModified();
}
@@ -1,14 +1,17 @@
using MessageService.Domain.Entities;
using MessageService.Domain.Models;
namespace MessageService.Domain.IReposities
{
public interface IConversationReposity
{
Task<Conversation?> FindByIdAsync(Guid id);
Task<IEnumerable<Conversation>> FindByUserIdAsync(Guid userId);
Task<IEnumerable<Conversation>> FindByTargetIdAsync(Guid targetId);
Task<IEnumerable<Conversation>> FindByStreamKeyAsync(string streamKey);
Task<Conversation?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ConversationSummary>> ListByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
Task<IEnumerable<Conversation>> FindByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default);
Task<IEnumerable<Conversation>> FindByStreamKeyAsync(string streamKey, CancellationToken cancellationToken = default);
Task<Conversation?> FindActiveAsync(Guid userId, Guid targetId, Enums.ChatType chatType, CancellationToken cancellationToken = default);
void Create(Conversation conversation);
Task<IEnumerable<string>> FindAllStreamKeyAsync(Guid userId);
Task<IEnumerable<string>> FindAllStreamKeyAsync(Guid userId, CancellationToken cancellationToken = default);
}
}
@@ -4,8 +4,9 @@ namespace MessageService.Domain.IReposities
{
public interface IMessageReposity
{
Task<Message?> FindByIdAsync(Guid id);
Task<(IEnumerable<Message> messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit);
Task<Message?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<(IEnumerable<Message> messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit, CancellationToken cancellationToken = default);
Task<(IEnumerable<Message> messages, bool hasMore)> SearchAsync(string streamKey, string keyword, long? cursor, int limit, CancellationToken cancellationToken = default);
void Create(Message message);
}
}
@@ -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);
}
@@ -12,6 +12,14 @@ namespace MessageService.Infrastructure.Configs
builder.HasKey(x => x.Id);
builder.HasIndex(x => x.UserId);
builder.HasIndex(x => new { x.UserId, x.ChatType, x.TargetId, x.IsDeleted });
builder.Property<string>("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");
}
}
@@ -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 是简单字符串,直接映射
@@ -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<string>(
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");
}
}
}
@@ -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<DateTimeOffset>(
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");
}
}
}
@@ -25,6 +25,12 @@ namespace MessageService.Infrastructure.Migrations
modelBuilder.Entity("MessageService.Domain.Entities.Conversation", b =>
{
b.Property<string>("ActiveConversationKey")
.ValueGeneratedOnAddOrUpdate()
.HasMaxLength(96)
.HasColumnType("varchar(96)")
.HasComputedColumnSql("CASE WHEN `IsDeleted` = 0 THEN CONCAT(`UserId`, ':', `ChatType`, ':', `TargetId`) ELSE NULL END", true);
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
@@ -45,6 +51,9 @@ namespace MessageService.Infrastructure.Migrations
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTimeOffset?>("LastMessageTime")
.HasColumnType("datetime(6)");
b.Property<long?>("LastReadSequenceId")
.HasColumnType("bigint");
@@ -74,6 +83,10 @@ namespace MessageService.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ActiveConversationKey")
.IsUnique()
.HasDatabaseName("UX_conversations_ActiveConversationKey");
b.HasIndex("UserId");
b.HasIndex("UserId", "ChatType", "TargetId", "IsDeleted");
@@ -178,6 +191,8 @@ namespace MessageService.Infrastructure.Migrations
b.HasIndex("StreamKey", "SequenceId");
b.HasIndex("StreamKey", "MsgType", "State", "SequenceId");
b.ToTable("messages", (string)null);
});
#pragma warning restore 612, 618
@@ -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<IEnumerable<string>> FindAllStreamKeyAsync(Guid userId)
public async Task<IEnumerable<string>> 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<Conversation?> FindByIdAsync(Guid id)
public async Task<Conversation?> 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<IEnumerable<Conversation>> FindByStreamKeyAsync(string streamKey)
public async Task<IEnumerable<Conversation>> 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<IEnumerable<Conversation>> FindByTargetIdAsync(Guid targetId)
public async Task<IEnumerable<Conversation>> 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<IEnumerable<Conversation>> FindByUserIdAsync(Guid userId)
public async Task<IReadOnlyList<ConversationSummary>> 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<Conversation?> 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);
}
}
}
@@ -18,12 +18,12 @@ namespace MessageService.Infrastructure.Reposities
db.Messages.Add(message);
}
public async Task<Message?> FindByIdAsync(Guid id)
public async Task<Message?> 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<Message> messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit)
public async Task<(IEnumerable<Message> 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<Message> fetched;
@@ -35,7 +35,7 @@ namespace MessageService.Infrastructure.Reposities
fetched = await query
.OrderByDescending(m => m.SequenceId) // 最新消息在最前
.Take(limit + 1)
.ToListAsync();
.ToListAsync(cancellationToken);
}
else
{
@@ -46,12 +46,40 @@ namespace MessageService.Infrastructure.Reposities
.Where(m => m.SequenceId > cusor.Value)
.OrderBy(o => o.SequenceId)
.Take(limit + 1)
.ToListAsync();
.ToListAsync(cancellationToken);
}
var hasMore = fetched.Count > limit;
var messages = fetched.Take(limit).OrderBy(s => s.SequenceId).ToList();
return (messages, hasMore);
}
public async Task<(IEnumerable<Message> 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);
}
}
}
@@ -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<IMigrator>();
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<IMigrator>();
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<MessageDbContext>()
.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 36)))
.Options;
return new MessageDbContext(options, null!);
}
}
@@ -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<IMigrator>();
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<MySqlException>(() =>
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<MessageDbContext>()
.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.";
}
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Testcontainers.MySql" Version="4.13.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessageService.Infrastructure\MessageService.Infrastructure.csproj" />
</ItemGroup>
</Project>
@@ -8,7 +8,7 @@ namespace MessageService.WebApi.Application.Conversation
public ConversationMapperConfig()
{
CreateMap<Domain.Entities.Conversation, ConversationResponse>()
.ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.ModificationTime ?? src.CreationTime))
.ForMember(dest => dest.DateTime, opt => opt.MapFrom(src => src.LastMessageTime ?? src.CreationTime))
;
}
}
@@ -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<ConversationService> logger;
public ConversationService(IConversationReposity reposity, IMapper mapper)
public ConversationService(IConversationReposity reposity, IMapper mapper, ILogger<ConversationService> logger)
{
this.reposity = reposity;
this.mapper = mapper;
this.logger = logger;
}
public async Task<Result<List<ConversationResponse>>> GetByOwnerIdAsync(Guid userId)
public async Task<Result<List<ConversationResponse>>> GetByOwnerIdAsync(Guid userId, CancellationToken cancellationToken = default)
{
var list = await reposity.FindByUserIdAsync(userId);
return Result.Success(mapper.Map<List<ConversationResponse>>(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<Result<ConversationResponse>> GetByIdAsync(Guid id, Guid userId)
public async Task<Result<ConversationResponse>> 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<ConversationResponse>(conversation));
}
public async Task<Result<List<string>>> GetStreamkeysAsync(Guid userId)
public async Task<Result<List<string>>> 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<Result<object>> MarkAsReadAsync(Guid conversationId, Guid userId)
public async Task<Result<object>> 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<object>(ResultCode.CONVERSATION_NOT_FOUND);
}
conversation.MarkAsRead(lastReadSequenceId: null);
conversation.MarkAsRead(lastReadSequenceId);
return Result.Success();
}
}
@@ -2,6 +2,8 @@
using MassTransit;
using MessageService.Domain.IReposities;
using MessageService.Infrastructure;
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
namespace MessageService.WebApi.Application.EventHandlers
{
@@ -21,8 +23,8 @@ namespace MessageService.WebApi.Application.EventHandlers
public async Task Consume(ConsumeContext<GroupMemberJoinedEvent> context)
{
var @event = context.Message;
var existing = await reposity.FindByTargetIdAsync(@event.GroupId);
if (existing.Any(x => x.UserId == @event.UserId && x.ChatType == Domain.Enums.ChatType.GROUP))
var existing = await reposity.FindActiveAsync(@event.UserId, @event.GroupId, Domain.Enums.ChatType.GROUP, context.CancellationToken);
if (existing is not null)
{
return;
}
@@ -37,14 +39,14 @@ namespace MessageService.WebApi.Application.EventHandlers
lastMessage: string.Empty
));
await messageDb.SaveChangesAsync(context.CancellationToken);
await SaveIdempotentlyAsync(context.CancellationToken);
}
public async Task Consume(ConsumeContext<FriendAddedEvent> context)
{
var @event = context.Message;
var existing = await reposity.FindByUserIdAsync(@event.OwnerId);
if (existing.Any(x => x.TargetId == @event.TargetId && x.ChatType == Domain.Enums.ChatType.PRIVATE))
var existing = await reposity.FindActiveAsync(@event.OwnerId, @event.TargetId, Domain.Enums.ChatType.PRIVATE, context.CancellationToken);
if (existing is not null)
{
return;
}
@@ -59,18 +61,33 @@ namespace MessageService.WebApi.Application.EventHandlers
lastMessage: string.Empty
));
await messageDb.SaveChangesAsync(context.CancellationToken);
await SaveIdempotentlyAsync(context.CancellationToken);
}
public async Task Consume(ConsumeContext<GroupMemberLeftEvent> context)
{
var conversations = await reposity.FindByTargetIdAsync(context.Message.GroupId);
foreach (var conversation in conversations.Where(x =>
x.UserId == context.Message.UserId && x.ChatType == Domain.Enums.ChatType.GROUP))
var conversation = await reposity.FindActiveAsync(
context.Message.UserId,
context.Message.GroupId,
Domain.Enums.ChatType.GROUP,
context.CancellationToken);
if (conversation is not null)
{
conversation.SoftDelete();
}
await messageDb.SaveChangesAsync(context.CancellationToken);
}
private async Task SaveIdempotentlyAsync(CancellationToken cancellationToken)
{
try
{
await messageDb.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException exception) when (exception.InnerException is MySqlException { Number: 1062 })
{
messageDb.ChangeTracker.Clear();
}
}
}
}
@@ -24,10 +24,10 @@ namespace MessageService.WebApi.Application.EventHandlers
{
var message = notification.Message;
var conversations = await reposity.FindByStreamKeyAsync(message.StreamKey);
var conversations = await reposity.FindByStreamKeyAsync(message.StreamKey, cancellationToken);
foreach (var conversation in conversations)
{
conversation.UpdateLastMessage(message.Content.Fallback);
conversation.UpdateLastMessage(message.Content.Fallback, message.CreationTime);
if (conversation.UserId == message.SenderId)
{
conversation.SetLastReadSequence(message.SequenceId);
@@ -19,12 +19,12 @@ namespace MessageService.WebApi.Application.EventHandlers
public async Task Consume(ConsumeContext<UserProfileUpdateEvent> 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);
}
}
}
@@ -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<Result<MessageResponse>> SendMsgAsync(SendMsgCommand command)
{
if (runtime.Current.TextLimit > 0 && command.MsgType == MessageType.Text && command.Text?.Length > runtime.Current.TextLimit)
return Result.Fail<MessageResponse>(ResultCode.PARAMETER_ERROR, "文本超过平台长度限制");
if (command.ChatType == Domain.Enums.ChatType.PRIVATE)
{
bool passed = await contactService.CheckContactAsync(command.SenderId, command.TargetId);
@@ -105,19 +107,21 @@ namespace MessageService.WebApi.Application.Message
return Result.Fail<object>(ResultCode.MESSAGE_NOT_FOUND);
}
if (runtime.Current.RecallMinutes > 0 && DateTimeOffset.UtcNow - msg.CreationTime > TimeSpan.FromMinutes(runtime.Current.RecallMinutes))
return Result.Fail<object>(ResultCode.PERMISSION_DENIED, "已超过平台撤回时限");
msg.Withdraw();
return Result.Success();
}
public async Task<Result<GetMessagesResponse>> GetMessagesAsync(GetMessageCommand command)
public async Task<Result<GetMessagesResponse>> GetMessagesAsync(GetMessageCommand command, CancellationToken cancellationToken = default)
{
if (command.direction is not 0 and not 1 || command.limit is < 1 or > 100)
{
return Result.Fail<GetMessagesResponse>(ResultCode.PARAMETER_ERROR);
}
var conversation = await conversationReposity.FindByIdAsync(command.conversationId);
var conversation = await conversationReposity.FindByIdAsync(command.conversationId, cancellationToken);
if(conversation is null || conversation.UserId != command.userId)
{
@@ -125,9 +129,37 @@ namespace MessageService.WebApi.Application.Message
}
var messages = await reposity.GetAsync(conversation.StreamKey, command.cusor, command.direction, command.limit);
var messages = await reposity.GetAsync(conversation.StreamKey, command.cusor, command.direction, command.limit, cancellationToken);
return Result.Success(new GetMessagesResponse(mapper.Map<List<MessageResponse>>(messages.messages.ToList()),messages.hasMore));
}
public async Task<Result<GetMessagesResponse>> 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<GetMessagesResponse>(ResultCode.PARAMETER_ERROR);
}
var conversation = await conversationReposity.FindByIdAsync(command.ConversationId, cancellationToken);
if (conversation is null || conversation.UserId != command.UserId)
{
return Result.Fail<GetMessagesResponse>(ResultCode.PERMISSION_DENIED);
}
var messages = await reposity.SearchAsync(
conversation.StreamKey,
keyword,
command.Cursor,
command.Limit,
cancellationToken);
return Result.Success(new GetMessagesResponse(
mapper.Map<List<MessageResponse>>(messages.messages.ToList()),
messages.hasMore));
}
}
}
@@ -0,0 +1,9 @@
namespace MessageService.WebApi.Application.Message
{
public record SearchMessageCommand(
Guid ConversationId,
Guid UserId,
string Keyword,
long? Cursor,
int Limit);
}
@@ -20,24 +20,24 @@ namespace MessageService.WebApi.Controllers.Conversation
}
[HttpGet]
public async Task<IActionResult> List()
public async Task<IActionResult> 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<IActionResult> Get([FromQuery]Guid id)
public async Task<IActionResult> 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<IActionResult> MarkRead([FromQuery] Guid conversationId)
public async Task<IActionResult> 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));
}
}
@@ -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<IActionResult> Report(ClientReport input, CancellationToken ct)
{
var reporter = Guid.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier)!);
JsonElement result;
try { result = await client.Send<JsonElement>("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<IActionResult> 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<JsonElement>("group", $"/internal/management/access/{input.TargetId}/{input.ReporterId}", ct: ct);
if (!access.GetProperty("member").GetBoolean()) return Forbid();
var group = await client.Send<JsonElement>("group", $"/internal/management/detail/{input.TargetId}", ct: ct); name = group.GetProperty("name").GetString()!;
} else {
var relation = await client.Send<JsonElement>("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<JsonElement>("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<EvidenceSnapshot>();
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<JsonElement>("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);
@@ -37,11 +37,24 @@ namespace MessageService.WebApi.Controllers.Message
}
[HttpGet]
public async Task<IActionResult> GetMessages([FromQuery]Guid conversationId, long? cursor, int direction, int limit)
public async Task<IActionResult> 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<IActionResult> 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));
}
}
}
+3
View File
@@ -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();
+2
View File
@@ -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)
@@ -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) { }
}
+15 -5
View File
@@ -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> jwtOptions;
private readonly IdDomainService idDomainService;
private readonly IMapper mapper;
private readonly RuntimePolicy runtime;
public AuthService(ITokenService tokenService, IIdRepository idRepository,
IOptions<JwtOptions> 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<Result<LoginResponse>> LoginAsync(string username, string password)
@@ -37,17 +40,20 @@ namespace IdentityService.WebApi.Applications.Auth
{
return Result<LoginResponse>.Fail(ResultCode.USER_NOT_FOUND);
}
if (user.Status != UserState.Normal || user.IsDeleted) return Result<LoginResponse>.Fail(ResultCode.AUTH_FAILED);
var idResult = await idRepository.CheckForSignInAsync(user, password, true);
if (!idResult.Succeeded)
{
return Result<LoginResponse>.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<LoginResponse>.Success(new LoginResponse(user.Id, token, refreshToken, null, user.UserName, user.NickName,user.Avatar, user.CreationTime));
}
public async Task<Result<UserResponse?>> RegisterAsync(string userName, string password, string nickName)
{
if (!runtime.Current.RegistrationEnabled) return Result<UserResponse?>.Fail(ResultCode.PERMISSION_DENIED, "平台暂未开放注册");
if (password.Length < runtime.Current.PasswordMinLength) return Result<UserResponse?>.Fail(ResultCode.PARAMETER_ERROR, "密码不符合当前最小长度要求");
var userResult = await idDomainService.CreateUserAsync(userName, password, nickName);
if (!userResult.Succeeded)
return Result<UserResponse?>.Fail(userResult);
@@ -78,20 +84,24 @@ namespace IdentityService.WebApi.Applications.Auth
return Result<LoginResponse>.Fail(ResultCode.USER_NOT_FOUND);
}
if (user.Status != UserState.Normal || user.IsDeleted || validateRes.stamp != user.SecurityStamp) return Result<LoginResponse>.Fail(ResultCode.AUTH_FAILED);
var token = await BuildTokenAsync(user);
return Result<LoginResponse>.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<LoginResponse>.Success(new LoginResponse(user.Id, token, nextRefresh, null, user.UserName, user.NickName, user.Avatar, user.CreationTime));
}
private async Task<string> BuildTokenAsync(Domain.Entities.User user)
{
var roles = await idRepository.GetRolesAsync(user);
List<Claim> claims = new List<Claim>();
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 });
}
}
}
@@ -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<object> Summary() => new { total = await db.Users.CountAsync() };
[HttpGet("access/{id:guid}")] public async Task<UserAccess> 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<object> 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<IActionResult> 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<System.Text.Json.JsonElement>("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<IActionResult> 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<object>("connector", $"/internal/management/disconnect/{command.TargetId}", new { }, ct);
return Ok(receipt);
}
}
+4 -1
View File
@@ -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();
+39
View File
@@ -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.'
+31
View File
@@ -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.'
+43
View File
@@ -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))."
}
}
+61
View File
@@ -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:
+4 -6
View File
@@ -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"