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
+10 -1
View File
@@ -12,6 +12,9 @@
# User-specific files (MonoDevelop/Xamarin Studio) # User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs *.userprefs
.env
.env.*
!.env.example
# Mono auto generated files # Mono auto generated files
mono_crash.* mono_crash.*
@@ -360,4 +363,10 @@ MigrationBackup/
.ionide/ .ionide/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
.tools/
deploy/.env.local
deploy/data/
artifacts/
**/keyring/
+15
View File
@@ -945,3 +945,18 @@ file: (二进制分片)
| 3000-3099 | 管理后台 | 3000 管理员不存在, 3003 权限不足 | | 3000-3099 | 管理后台 | 3000 管理员不存在, 3003 权限不足 |
| 3100-3199 | 会话 | 3100 会话不存在 | | 3100-3199 | 会话 | 3100 会话不存在 |
| 3200-3299 | 分片 | 3201 分片不存在, 3202 分片合并失败, **3203 分片过小**, **3204 分片数不匹配**, **3205 会话过期**, **3206 分片号无效** | | 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 IConversationIntergrationService conService;
private readonly StackExchange.Redis.IDatabase redis; 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.conService = conService;
this.redis = multiplexer.GetDatabase(); this.redis = multiplexer.GetDatabase();
this.registry = registry;
} }
public async override Task OnConnectedAsync() public async override Task OnConnectedAsync()
@@ -34,6 +36,7 @@ namespace ConnectorService.Hubs
} }
await redis.SetAddAsync(RedisHelper.GetConnectionIdKey(userId), Context.ConnectionId); await redis.SetAddAsync(RedisHelper.GetConnectionIdKey(userId), Context.ConnectionId);
await registry.Add(Context);
await base.OnConnectedAsync(); await base.OnConnectedAsync();
@@ -41,6 +44,7 @@ namespace ConnectorService.Hubs
public async override Task OnDisconnectedAsync(Exception? exception) public async override Task OnDisconnectedAsync(Exception? exception)
{ {
await registry.Remove(Context);
if (Context.User.Identity.IsAuthenticated) if (Context.User.Identity.IsAuthenticated)
{ {
var userId = Context.User.FindFirstValue(ClaimTypes.NameIdentifier); var userId = Context.User.FindFirstValue(ClaimTypes.NameIdentifier);
+8
View File
@@ -1,5 +1,7 @@
using IM.InitCommon.Management;
using ConnectorService.Hubs; using ConnectorService.Hubs;
using ConnectorService.Services;
using IM.InitCommon; using IM.InitCommon;
namespace ConnectorService namespace ConnectorService
@@ -15,6 +17,8 @@ namespace ConnectorService
builder.ConfigureDbConfiguration(); builder.ConfigureDbConfiguration();
builder.Services.AddSignalR(); 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 // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
@@ -23,6 +27,7 @@ namespace ConnectorService
builder.ConfigExtraServices(); builder.ConfigExtraServices();
var app = builder.Build(); var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -32,9 +37,12 @@ namespace ConnectorService
} }
app.UseAppDefault(); app.UseAppDefault();
app.MapManagementHealth();
app.MapHub<ChatHub>("/chat"); 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(); 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.Domain;
using ContactService.WebApi.Application.Dtos; using ContactService.WebApi.Application.Dtos;
using ContactService.WebApi.Application.IntegrationServices; using ContactService.WebApi.Application.IntegrationServices;
using IM.Commons; using IM.Commons;
using Microsoft.EntityFrameworkCore;
namespace ContactService.WebApi.Application.FriendRequest namespace ContactService.WebApi.Application.FriendRequest
{ {
@@ -11,15 +12,15 @@ namespace ContactService.WebApi.Application.FriendRequest
private readonly IFriendRequestReposity reposity; private readonly IFriendRequestReposity reposity;
private readonly FriendRequestDomainService service; private readonly FriendRequestDomainService service;
private readonly IMapper mapper; 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, 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.reposity = reposity;
this.service = service; this.service = service;
this.mapper = mapper; this.mapper = mapper;
this.identityService = identityService; this.identityService = identityService; this.runtime = runtime; this.db = db;
} }
public async Task<Result<FriendRequestResponse>> CreateAsync(CreateFriendRequestCommand command) public async Task<Result<FriendRequestResponse>> CreateAsync(CreateFriendRequestCommand command)
@@ -50,6 +51,9 @@ namespace ContactService.WebApi.Application.FriendRequest
switch (command.Action) switch (command.Action)
{ {
case FriendRequestAction.Accpet: 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); request.Accept(command.RemarkName);
break; break;
case FriendRequestAction.Block: 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; using IM.InitCommon;
@@ -23,6 +24,7 @@ namespace ContactService.WebApi
builder.Services.AddAllGrpcServer(); builder.Services.AddAllGrpcServer();
var app = builder.Build(); var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -32,6 +34,7 @@ namespace ContactService.WebApi
} }
app.UseAppDefault(); app.UseAppDefault();
app.MapManagementHealth();
app.MapControllers(); app.MapControllers();
@@ -6,6 +6,7 @@ namespace FileService.Application.Ports
public interface IObjectStoragePort public interface IObjectStoragePort
{ {
string ProviderCode { get; } 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<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command,CancellationToken token);
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token); public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token);
public Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand 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.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
@@ -46,7 +46,7 @@ namespace FileService.Application.StorageContracts
ObjectKey = objectKey; ObjectKey = objectKey;
FileSize = fileSize; FileSize = fileSize;
TotalPartCount = totalPartCount; TotalPartCount = totalPartCount;
ExpireAt = expireAt ?? DateTime.MaxValue; ExpireAt = expireAt ?? DateTimeOffset.UtcNow.AddHours(24);
CreatedAt = DateTime.Now; CreatedAt = DateTime.Now;
} }
@@ -16,17 +16,17 @@ namespace FileService.Application.UploadFile
private readonly IMapper mapper; private readonly IMapper mapper;
private readonly IObjectStorageRouter router; private readonly IObjectStorageRouter router;
private readonly IOptions<StorageOptions> options; 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, public UploadFileService(IUploadFileReposity reposity, IMapper mapper,
IObjectStorageRouter router, IOptions<StorageOptions> options, IObjectStorageRouter router, IOptionsSnapshot<StorageOptions> options,
IGroupAccessService groupAccessService) IGroupAccessService groupAccessService, IM.InitCommon.Management.RuntimePolicy runtime)
{ {
this.reposity = reposity; this.reposity = reposity;
this.mapper = mapper; this.mapper = mapper;
this.router = router; this.router = router;
this.options = options; this.options = options;
this.groupAccessService = groupAccessService; this.groupAccessService = groupAccessService; this.runtime = runtime;
} }
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id, Guid requesterId) public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id, Guid requesterId)
@@ -54,6 +54,8 @@ namespace FileService.Application.UploadFile
/// </summary> /// </summary>
public async Task<Result<FileResponse>> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default) 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; var checksum = command.CheckSum;
if (string.IsNullOrWhiteSpace(checksum)) if (string.IsNullOrWhiteSpace(checksum))
{ {
@@ -1,4 +1,4 @@
using AutoMapper; using AutoMapper;
using FileService.Application.Ports; using FileService.Application.Ports;
using FileService.Application.StorageContracts; using FileService.Application.StorageContracts;
using FileService.Domain.IReposities; using FileService.Domain.IReposities;
@@ -12,9 +12,9 @@ namespace FileService.Application.UploadFileTask
{ {
public class UploadFileTaskService(IUploadTaskReposity reposity, public class UploadFileTaskService(IUploadTaskReposity reposity,
IMapper mapper, IObjectStorageRouter router, IMapper mapper, IObjectStorageRouter router,
IOptions<StorageOptions> options, IStorageRedisCache redis, IOptionsSnapshot<StorageOptions> options, IStorageRedisCache redis,
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage, IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage,
IUploadFileReposity uploadFileReposity IUploadFileReposity uploadFileReposity, IM.InitCommon.Management.RuntimePolicy runtime, UploadFile.IGroupAccessService groupAccess
) )
{ {
private readonly IUploadTaskReposity reposity = reposity; private readonly IUploadTaskReposity reposity = reposity;
@@ -29,6 +29,9 @@ namespace FileService.Application.UploadFileTask
public async Task<Result<TaskInitResponse>> InitTaskAsync(UploadTaskInitCommand command) 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; CancellationToken cancellationToken = CancellationToken.None;
// 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录 // 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录
@@ -66,7 +69,7 @@ namespace FileService.Application.UploadFileTask
var initUpdateCommand = new StorageContracts.InitiateUploadCommand( var initUpdateCommand = new StorageContracts.InitiateUploadCommand(
ProviderCode: storageOption.ProviderCode, ProviderCode: storageOption.ProviderCode,
Bucket: storageOption.Bucket, 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, ContentType: task.ContentType.Value,
ContentLength: command.FileSize, ContentLength: command.FileSize,
null); null);
@@ -84,14 +87,14 @@ namespace FileService.Application.UploadFileTask
UploadSessionId = initRes.UploadSessionId, UploadSessionId = initRes.UploadSessionId,
StorageLocation = initRes.Location, StorageLocation = initRes.Location,
Instant = false, Instant = false,
UploadMode = string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase) UploadMode = runtime.Enabled || string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)
? "LocalMultipart" ? "ServerMultipart"
: "Presigned", : "Presigned",
TotalPartCount = totalPartCount, TotalPartCount = totalPartCount,
PartSizeBytes = storageOption.DefaultPartSizeBytes PartSizeBytes = storageOption.DefaultPartSizeBytes
}; };
task.StartUpload(); task.StartUpload(new Domain.ValueObjects.StorageLocation(storageOption.ProviderCode, storageOption.Bucket, initUpdateCommand.ObjectKey, storageOption.Region));
reposity.Create(task); reposity.Create(task);
await redis.SetAsync(new StorageContracts.UploadRuntimeCache( await redis.SetAsync(new StorageContracts.UploadRuntimeCache(
@@ -123,18 +126,21 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<PresignedUrl>(ResultCode.PERMISSION_DENIED); 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) if (taskCache.TotalPartCount < partNum || partNum < 1)
{ {
return Result.Fail<PresignedUrl>(ResultCode.INVALID_PART_NUMBER); 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, ProviderCode: taskCache.ProviderCode,
Bucket: taskCache.Bucket, Bucket: taskCache.Bucket,
ObjectKey: taskCache.ObjectKey, ObjectKey: taskCache.ObjectKey,
UploadSessionId: taskCache.UploadSessionId, UploadSessionId: taskCache.UploadSessionId,
PartNumber: partNum, PartNumber: partNum,
ExpiresIn: options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn ExpiresIn: options.Value.Providers[taskCache.ProviderCode].UploadUrlExpiresIn
), token); ), token);
return Result.Success(presignUrl); return Result.Success(presignUrl);
@@ -159,6 +165,9 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<UploadTaskResponse>(ResultCode.PERMISSION_DENIED); 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) if (command.Parts.Count != taskCache.TotalPartCount)
{ {
@@ -172,7 +181,7 @@ namespace FileService.Application.UploadFileTask
} }
// 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 ETag。 // 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 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) foreach (var part in command.Parts)
{ {
@@ -243,7 +252,11 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<CompleteUploadResult>(ResultCode.PERMISSION_DENIED); 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; var isLastPart = command.PartNum == taskCache.TotalPartCount;
@@ -253,13 +266,17 @@ namespace FileService.Application.UploadFileTask
$"分片 {command.PartNum} 大小为 {command.ContentLength} 字节,小于最小值 {minPartSize} 字节"); $"分片 {command.PartNum} 大小为 {command.ContentLength} 字节,小于最小值 {minPartSize} 字节");
} }
StorageContracts.UploadPart uploaded;
if (taskCache.ProviderCode == "Local") {
await localChunkStorage.SavePartAsync(new SaveLocalPartCommand( await localChunkStorage.SavePartAsync(new SaveLocalPartCommand(
UploadSessionId: command.SessionId, UploadSessionId: command.SessionId,
PartNumber: command.PartNum, PartNumber: command.PartNum,
Stream: command.Stream, Stream: command.Stream,
ContentLength: command.ContentLength 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); await redis.SetAsync(taskCache);
var location = new Domain.ValueObjects.StorageLocation( var location = new Domain.ValueObjects.StorageLocation(
storageProvider: taskCache.ProviderCode, storageProvider: taskCache.ProviderCode,
@@ -267,7 +284,7 @@ namespace FileService.Application.UploadFileTask
objectKey: taskCache.ObjectKey, objectKey: taskCache.ObjectKey,
region: taskCache.Region 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> /// <summary>
@@ -302,6 +319,11 @@ namespace FileService.Application.UploadFileTask
return Result.Success(response); 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) private bool CanReuse(Domain.Entities.UploadFile file, UploadTaskInitCommand command)
{ {
var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation); 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 FileService.Domain.ValueObjects;
using IM.DomainCommons; using IM.DomainCommons;
@@ -34,9 +34,9 @@ namespace FileService.Domain.Entities
CheckSum = checkSum; 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) public void StartMerging(StorageLocation location)
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
@@ -7,6 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.511.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" 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.Application.StorageContracts;
using FileService.Domain.ValueObjects; using FileService.Domain.ValueObjects;
using IM.Commons; using IM.Commons;
using IM.InitCommon; using IM.InitCommon;
using Microsoft.Extensions.Options; 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; var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
private readonly IOptions<StorageOptions> options = options; var path = Path.GetFullPath(Path.Combine(new[] { fullRoot }.Concat(segments).ToArray()));
private readonly StorageProviderOptions providerOptions = options.Value.Providers[options.Value.DefaultProviderCode]; 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))
public string ProviderCode => "Local"; 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("不允许使用文件链接");
/// <summary> return path;
/// 单次直传:直接写入 LocalRootPath/{bucket}/{objectKey}。 }
/// bucket 传公开桶名即落到公开目录,可被静态托管直链访问。 public async Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token)
/// </summary> {
public async Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token) var path = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey);
{ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var fullPath = Path.Combine(providerOptions.LocalRootPath!, command.Bucket, command.ObjectKey); await using var stream = new FileStream(path, FileMode.CreateNew);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); await command.Content.CopyToAsync(stream, token);
if (stream.Length != command.ContentLength) throw new InvalidOperationException("文件实际大小与申报大小不符");
await using (var fs = new FileStream(fullPath, FileMode.Create)) return new(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region);
{ }
await command.Content.CopyToAsync(fs, token); public string? GetPublicUrl(StorageLocation location) => !string.IsNullOrEmpty(Provider.PublicBucket) && location.Bucket == Provider.PublicBucket
await fs.FlushAsync(token); ? $"{(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);
} }
if (output.Length != cache.FileSize) throw new InvalidOperationException("合并文件大小不符");
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");
} }
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;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using IM.InitCommon;
using Microsoft.Extensions.Options;
namespace FileService.Infrastructure.Storage namespace FileService.Infrastructure.Storage
{ {
public class ObjectStorageRouter : IObjectStorageRouter public class ObjectStorageRouter : IObjectStorageRouter, IDisposable
{ {
private IReadOnlyDictionary<string, IObjectStoragePort> adpters; 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]; 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 FileService.Infrastructure;
using IM.ASPNETCore; using IM.ASPNETCore;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -71,10 +71,10 @@ namespace FileService.WebApi.Controllers.FileTask
} }
[HttpPost("local/parts/upload")] [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 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)); var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length), Guid.Parse(userId));
return Ok(res); 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 IM.InitCommon;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
@@ -20,6 +21,7 @@ namespace FileService.WebApi
builder.ConfigExtraServices(); builder.ConfigExtraServices();
var app = builder.Build(); var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -29,6 +31,7 @@ namespace FileService.WebApi
} }
app.UseAppDefault(); app.UseAppDefault();
app.MapManagementHealth();
// 仅 FileService 暴露公开桶目录为静态直链,不动共享 UseAppDefault // 仅 FileService 暴露公开桶目录为静态直链,不动共享 UseAppDefault
UsePublicStaticFiles(app); UsePublicStaticFiles(app);
+5 -3
View File
@@ -1,4 +1,4 @@
using GroupService.Domain.Enums; using GroupService.Domain.Enums;
using GroupService.Domain.Events; using GroupService.Domain.Events;
using IM.DomainCommons; using IM.DomainCommons;
@@ -65,13 +65,15 @@ namespace GroupService.Domain.Entities
AddDomainEvent(new AllMembersBannedDomainEvent(this)); AddDomainEvent(new AllMembersBannedDomainEvent(this));
} }
public void Ban() public void Ban(bool notify = true)
{ {
Status = GroupState.Blocked; Status = GroupState.Blocked;
ModificationTime = DateTime.Now; 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) public void Update(string? name, GroupAuthorityType? groupAuthority, string? announcement, string? avatar)
{ {
bool isChanged = false; 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.Domain.IReposities;
using GroupService.WebApi.Application.Dtos; using GroupService.WebApi.Application.Dtos;
using IM.Commons; using IM.Commons;
using Microsoft.EntityFrameworkCore;
namespace GroupService.WebApi.Application.Group namespace GroupService.WebApi.Application.Group
{ {
@@ -9,18 +10,22 @@ namespace GroupService.WebApi.Application.Group
{ {
private readonly IGroupReposity reposity; private readonly IGroupReposity reposity;
private readonly IGroupMemberReposity memberReposity; 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.reposity = reposity;
this.memberReposity = memberReposity; this.memberReposity = memberReposity;
this.mapper = mapper; this.mapper = mapper; this.runtime = runtime; this.db = db;
} }
public async Task<Result<GroupResponse>> CreateAsync(GroupCreateCommand command) 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); var group = new Domain.Entities.Group(command.GroupMasterId, command.Name);
group.Update(null, (Domain.Enums.GroupAuthorityType)policy.DefaultJoinAuthority, null, null);
reposity.Create(group); reposity.Create(group);
return Result<GroupResponse>.Success(mapper.Map<GroupResponse>(group)); return Result<GroupResponse>.Success(mapper.Map<GroupResponse>(group));
} }
@@ -1,4 +1,4 @@
using AutoMapper; using AutoMapper;
using GroupService.Domain.IReposities; using GroupService.Domain.IReposities;
using GroupService.Domain.ValueObjects; using GroupService.Domain.ValueObjects;
using GroupService.WebApi.Application.Dtos; using GroupService.WebApi.Application.Dtos;
@@ -46,7 +46,7 @@ namespace GroupService.WebApi.Application.GroupInvitation
return Result.Success(mapper.Map<GroupInvitationResponse>(existing)); 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() 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) 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) if (group is null)
return Result.Fail(ResultCode.GROUP_NOT_FOUND); return Result.Fail(ResultCode.GROUP_NOT_FOUND);
@@ -1,4 +1,4 @@
using AutoMapper; using AutoMapper;
using GroupService.Domain; using GroupService.Domain;
using GroupService.Domain.IReposities; using GroupService.Domain.IReposities;
using GroupService.WebApi.Application.Dtos; using GroupService.WebApi.Application.Dtos;
@@ -13,15 +13,15 @@ namespace GroupService.WebApi.Application.GroupMember
private readonly IGroupReposity groupReposity; private readonly IGroupReposity groupReposity;
private readonly GroupMemberDomainService service; private readonly GroupMemberDomainService service;
private readonly IIdentityIntegrationService idService; 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.reposity = reposity;
this.groupReposity = groupReposity; this.groupReposity = groupReposity;
this.service = service; this.service = service;
this.idService = idService; this.idService = idService;
this.mapper = mapper; this.mapper = mapper; this.runtime = runtime;
} }
public async Task<Result<List<GroupMemberResponse>>> GetByGroupIdAsync(Guid groupId, Guid userId) 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); 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); var userRes = await idService.FindUserByIdAsync(userId);
if (!userRes.Succeeded) if (!userRes.Succeeded)
{ {
@@ -68,7 +71,7 @@ namespace GroupService.WebApi.Application.GroupMember
public async Task<Result<bool>> CheckMemberAsync(Guid groupId, Guid userId) 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); return Result.Success(exist);
} }
@@ -1,4 +1,4 @@
using AutoMapper; using AutoMapper;
using GroupService.Domain.Entities; using GroupService.Domain.Entities;
using GroupService.Domain.IReposities; using GroupService.Domain.IReposities;
using GroupService.Domain.ValueObjects; using GroupService.Domain.ValueObjects;
@@ -33,6 +33,8 @@ namespace GroupService.WebApi.Application.GroupRequest
return Result.Fail<GroupRequestResponse>(ResultCode.GROUP_NOT_FOUND); 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 user = await idService.FindUserByIdAsync(userId);
var groupProfile = new GroupProfile() 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; using IM.InitCommon;
@@ -19,6 +20,7 @@ namespace GroupService.WebApi
builder.ConfigExtraServices(); builder.ConfigExtraServices();
var app = builder.Build(); var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -28,6 +30,7 @@ namespace GroupService.WebApi
} }
app.UseAppDefault(); app.UseAppDefault();
app.MapManagementHealth();
app.MapControllers(); app.MapControllers();
+1
View File
@@ -25,6 +25,7 @@ namespace IM.InitCommon
//similar to serviceCollection.AddDbContextPool<ECDictDbContext>(opt=>new DbContextOptionsBuilder(dbCtxOpt)); //similar to serviceCollection.AddDbContextPool<ECDictDbContext>(opt=>new DbContextOptionsBuilder(dbCtxOpt));
var methodGenericAddDbContext = methodAddDbContext.MakeGenericMethod(dbCtxType); var methodGenericAddDbContext = methodAddDbContext.MakeGenericMethod(dbCtxType);
methodGenericAddDbContext.Invoke(null, new object[] { services, action, ServiceLifetime.Scoped, ServiceLifetime.Scoped }); methodGenericAddDbContext.Invoke(null, new object[] { services, action, ServiceLifetime.Scoped, ServiceLifetime.Scoped });
services.AddScoped(sp => new Management.ManagementDbProbe((DbContext)sp.GetRequiredService(dbCtxType)));
} }
} }
return services; return services;
@@ -1,5 +1,6 @@
using IM.ASPNETCore; using IM.ASPNETCore;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using IM.InitCommon.Management;
namespace IM.InitCommon namespace IM.InitCommon
{ {
@@ -9,6 +10,7 @@ namespace IM.InitCommon
{ {
app.UseCors(); app.UseCors();
app.UseAuthentication(); app.UseAuthentication();
app.UseManagementRuntime();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<ExceptionMiddleware>(); app.UseMiddleware<ExceptionMiddleware>();
app.UseForwardedHeaders(); 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? 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; } public string? LocalRootPath { get; init; }
@@ -1,4 +1,4 @@
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using IM.ASPNETCore; using IM.ASPNETCore;
using IM.Commons; using IM.Commons;
@@ -14,6 +14,7 @@ using RedLockNet.SERedis.Configuration;
using StackExchange.Redis; using StackExchange.Redis;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
using Winton.Extensions.Configuration.Consul; using Winton.Extensions.Configuration.Consul;
using IM.InitCommon.Management;
namespace IM.InitCommon namespace IM.InitCommon
{ {
@@ -21,7 +22,7 @@ namespace IM.InitCommon
{ {
public static void ConfigureDbConfiguration(this WebApplicationBuilder builder) 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; var env = hostCtx.HostingEnvironment;
@@ -66,6 +67,7 @@ namespace IM.InitCommon
{ {
var services = builder.Services; var services = builder.Services;
var configuration = builder.Configuration; var configuration = builder.Configuration;
services.AddManagementRuntime();
var assemblies = ReflectionHelper.GetAllReferencedAssemblies(); var assemblies = ReflectionHelper.GetAllReferencedAssemblies();
+2 -2
View File
@@ -11,8 +11,8 @@ namespace IM.Jwt
/// <param name="options"></param> /// <param name="options"></param>
/// <returns></returns> /// <returns></returns>
string GetToken(IEnumerable<Claim> claims, JwtOptions options); 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 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); var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes); 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(); 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); string json = JsonConvert.SerializeObject(payload);
//token写入redis //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; return token;
} }
@@ -51,19 +51,19 @@ namespace IM.Jwt
await _redis.KeyDeleteAsync(RedisHelper.GetRefreshTokenKey(refreshToken)); 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)); var json = await _redis.StringGetAsync(RedisHelper.GetRefreshTokenKey(token));
if (json.IsNullOrEmpty) return (false, Guid.Empty); if (json.IsNullOrEmpty) return (false, Guid.Empty, null);
try try
{ {
using var doc = JsonDocument.Parse(json.ToString()); using var doc = JsonDocument.Parse(json.ToString());
var userId = doc.RootElement.GetProperty("UserId").GetGuid(); var userId = doc.RootElement.GetProperty("UserId").GetGuid();
return (true, userId); return (true, userId, doc.RootElement.TryGetProperty("Stamp", out var stamp) ? stamp.GetString() : null);
} }
catch 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 EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IM.Application", "IM.Application\IM.Application.csproj", "{3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "IM.Application", "IM.Application\IM.Application.csproj", "{3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {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|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.ActiveCfg = Release|Any CPU
{A08384EA-AB27-4CE5-A84D-094FCDC36A42}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{DD477B8B-4F7A-4CE3-AE47-000C1243501D}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{C9A6D34A-29A3-44F5-B0BC-11A734B1B0AB}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{148C0E23-8225-4790-A920-6C5DE6C8FF50}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{148C0E23-8225-4790-A920-6C5DE6C8FF50}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{B245AB7B-841A-469E-950D-B08E4C1C8094}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{B245AB7B-841A-469E-950D-B08E4C1C8094}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{E89E5F35-4D54-4FE7-9A47-63752B355DA9}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{6795A287-3488-B0A3-B242-C19526B6A88D}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{6795A287-3488-B0A3-B242-C19526B6A88D}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{096064BE-F09C-40CA-AB54-A78AFE5C88BC}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{2085AC3B-BDF9-4F02-B80A-217685A99CEC}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{11CB06A3-4906-4E66-BDF6-04D9EDB002CD}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{130FE785-7DCA-4609-9E9C-5257198FE36A}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{130FE785-7DCA-4609-9E9C-5257198FE36A}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{EB435E96-1088-49DF-AF28-74098BFEA14D}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{EB435E96-1088-49DF-AF28-74098BFEA14D}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{9D905D4C-8E0B-41D1-AFCA-E0EC110AFDF7}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{80C73C60-EC7D-4FC0-84FF-0D9510F4183A}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{E5017B8A-060E-4C1B-BF2E-B5EBE3D106D6}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{AD01D163-BAD7-4C2B-8E0D-BE2B16FCAD57}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{2154F7AE-A269-4D06-8CA2-B7A5C33FC498}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{432D7A3C-EA35-4AEE-91BF-8F93DC4DA565}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{693CCDD3-FA2D-4A3A-82EA-C460569FAF16}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{DE97FF4D-ADE0-4D1E-9ECD-CF9CD6C3685D}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{4D16531A-25B1-E469-8A71-F3ABFB52FAC8}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{6827DF98-EC6C-4854-8E5A-D3FCC66E0A6D}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{83C58EFA-00FB-443D-8311-2D4664A3FAB7}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{A05B43F3-3391-4ACC-A8BD-B9B7AEABC90B}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
{3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{3B7CAE97-DE5A-48B9-87BC-A44E04BC9A36}.Release|Any CPU.Build.0 = 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 EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
+26
View File
@@ -3,6 +3,7 @@
## 适用迁移 ## 适用迁移
- MessageService`20260909000100_ApiAlignmentFixes` - MessageService`20260909000100_ApiAlignmentFixes`
- MessageService`20260911000100_ConversationUniqueness`
- GroupService`20260909000200_ApiAlignmentFixes` - GroupService`20260909000200_ApiAlignmentFixes`
- FileService`20260909000300_AsyncUploadResult` - FileService`20260909000300_AsyncUploadResult`
@@ -11,6 +12,13 @@
先完成三个库的可恢复备份,并在对应数据库执行: 先完成三个库的可恢复备份,并在对应数据库执行:
```sql ```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 唯一索引前必须无重复。 -- GroupService:新增 Id 唯一索引前必须无重复。
SELECT Id, COUNT(*) AS duplicate_count SELECT Id, COUNT(*) AS duplicate_count
FROM group_join_requests FROM group_join_requests
@@ -49,12 +57,23 @@ dotnet ef database update --project FileService.Infrastructure --startup-project
建议顺序为 Message → Group → File,随后发布后端,再发布最终前端包。 建议顺序为 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`,无法确认作用域的旧文件因此只允许所有者读取。 - 历史文件的 `IsPublic` 默认 `false`,无法确认作用域的旧文件因此只允许所有者读取。
- 新上传文件会写入 `SourceTaskId/ChatType/TargetId/ResultFileId`;不要批量猜测旧文件作用域。 - 新上传文件会写入 `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`;回滚前应另行导出这些新列的数据。 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> /// </summary>
public string LastMessage { get; private set; } public string LastMessage { get; private set; }
/// <summary>
/// 最后一条消息产生的时间。已读状态等会话元数据更新不得改变该值。
/// </summary>
public DateTimeOffset? LastMessageTime { get; private set; }
private Conversation() { } private Conversation() { }
public Conversation(Guid userId, Guid targetId, string targetAvatar, string targetName, long? lastReadSequenceId, int unreadCount, ChatType chatType, string lastMessage) 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; UnreadCount = unreadCount;
ChatType = chatType; ChatType = chatType;
LastMessage = lastMessage; LastMessage = lastMessage;
LastMessageTime = DateTimeOffset.Now;
ModificationTime = DateTime.Now; ModificationTime = DateTime.Now;
StreamKey = ChatType == ChatType.GROUP ? StreamKeyBuilder.Group(targetId) : StreamKeyBuilder.Private(userId, targetId); StreamKey = ChatType == ChatType.GROUP ? StreamKeyBuilder.Group(targetId) : StreamKeyBuilder.Private(userId, targetId);
AddDomainEvent(new ConversationCreatedDomainEvent(this)); AddDomainEvent(new ConversationCreatedDomainEvent(this));
@@ -69,9 +75,10 @@ namespace MessageService.Domain.Entities
NotifyModified(); NotifyModified();
} }
public void UpdateLastMessage(string lastMessage) public void UpdateLastMessage(string lastMessage, DateTimeOffset messageTime)
{ {
LastMessage = lastMessage; LastMessage = lastMessage;
LastMessageTime = messageTime;
NotifyModified(); NotifyModified();
} }
@@ -1,14 +1,17 @@
using MessageService.Domain.Entities; using MessageService.Domain.Entities;
using MessageService.Domain.Models;
namespace MessageService.Domain.IReposities namespace MessageService.Domain.IReposities
{ {
public interface IConversationReposity public interface IConversationReposity
{ {
Task<Conversation?> FindByIdAsync(Guid id); Task<Conversation?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<IEnumerable<Conversation>> FindByUserIdAsync(Guid userId); Task<IReadOnlyList<ConversationSummary>> ListByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
Task<IEnumerable<Conversation>> FindByTargetIdAsync(Guid targetId); Task<IEnumerable<Conversation>> FindByTargetIdAsync(Guid targetId, CancellationToken cancellationToken = default);
Task<IEnumerable<Conversation>> FindByStreamKeyAsync(string streamKey); 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); 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 public interface IMessageReposity
{ {
Task<Message?> FindByIdAsync(Guid id); Task<Message?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
Task<(IEnumerable<Message> messages, bool hasMore)> GetAsync(string streamKey, long? cusor, int direction, int limit); 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); 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.HasKey(x => x.Id);
builder.HasIndex(x => x.UserId); builder.HasIndex(x => x.UserId);
builder.HasIndex(x => new { x.UserId, x.ChatType, x.TargetId, x.IsDeleted }); 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.ToTable("messages");
builder.HasKey(x => x.Id); builder.HasKey(x => x.Id);
builder.HasIndex(x => new { x.StreamKey, x.SequenceId }); 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 => builder.ComplexProperty(x => x.Content, c =>
{ {
// 1. Fallback 是简单字符串,直接映射 // 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 => 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") b.Property<Guid>("Id")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("char(36)"); .HasColumnType("char(36)");
@@ -45,6 +51,9 @@ namespace MessageService.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<DateTimeOffset?>("LastMessageTime")
.HasColumnType("datetime(6)");
b.Property<long?>("LastReadSequenceId") b.Property<long?>("LastReadSequenceId")
.HasColumnType("bigint"); .HasColumnType("bigint");
@@ -74,6 +83,10 @@ namespace MessageService.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("ActiveConversationKey")
.IsUnique()
.HasDatabaseName("UX_conversations_ActiveConversationKey");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.HasIndex("UserId", "ChatType", "TargetId", "IsDeleted"); b.HasIndex("UserId", "ChatType", "TargetId", "IsDeleted");
@@ -178,6 +191,8 @@ namespace MessageService.Infrastructure.Migrations
b.HasIndex("StreamKey", "SequenceId"); b.HasIndex("StreamKey", "SequenceId");
b.HasIndex("StreamKey", "MsgType", "State", "SequenceId");
b.ToTable("messages", (string)null); b.ToTable("messages", (string)null);
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
@@ -1,5 +1,6 @@
using MessageService.Domain.Entities; using MessageService.Domain.Entities;
using MessageService.Domain.IReposities; using MessageService.Domain.IReposities;
using MessageService.Domain.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace MessageService.Infrastructure.Reposities namespace MessageService.Infrastructure.Reposities
@@ -18,31 +19,54 @@ namespace MessageService.Infrastructure.Reposities
db.Conversations.Add(conversation); 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) return await db.Conversations.Where(x => x.UserId == userId)
.AsNoTracking()
.Select(s => s.StreamKey) .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); 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); var query = db.Messages.Where(x => x.StreamKey == streamKey);
List<Message> fetched; List<Message> fetched;
@@ -35,7 +35,7 @@ namespace MessageService.Infrastructure.Reposities
fetched = await query fetched = await query
.OrderByDescending(m => m.SequenceId) // 最新消息在最前 .OrderByDescending(m => m.SequenceId) // 最新消息在最前
.Take(limit + 1) .Take(limit + 1)
.ToListAsync(); .ToListAsync(cancellationToken);
} }
else else
{ {
@@ -46,12 +46,40 @@ namespace MessageService.Infrastructure.Reposities
.Where(m => m.SequenceId > cusor.Value) .Where(m => m.SequenceId > cusor.Value)
.OrderBy(o => o.SequenceId) .OrderBy(o => o.SequenceId)
.Take(limit + 1) .Take(limit + 1)
.ToListAsync(); .ToListAsync(cancellationToken);
} }
var hasMore = fetched.Count > limit; var hasMore = fetched.Count > limit;
var messages = fetched.Take(limit).OrderBy(s => s.SequenceId).ToList(); var messages = fetched.Take(limit).OrderBy(s => s.SequenceId).ToList();
return (messages, hasMore); 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() public ConversationMapperConfig()
{ {
CreateMap<Domain.Entities.Conversation, ConversationResponse>() 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 AutoMapper;
using IM.Commons; using IM.Commons;
using System.Diagnostics;
using MessageService.Domain.IReposities; using MessageService.Domain.IReposities;
using MessageService.WebApi.Application.Dtos; using MessageService.WebApi.Application.Dtos;
@@ -9,22 +10,53 @@ namespace MessageService.WebApi.Application.Conversation
{ {
private readonly IConversationReposity reposity; private readonly IConversationReposity reposity;
private readonly IMapper mapper; 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.reposity = reposity;
this.mapper = mapper; 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); var stopwatch = Stopwatch.StartNew();
return Result.Success(mapper.Map<List<ConversationResponse>>(list.ToList())); 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) if (conversation is null || conversation.UserId != userId)
{ {
@@ -34,21 +66,21 @@ namespace MessageService.WebApi.Application.Conversation
return Result.Success(mapper.Map<ConversationResponse>(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()); 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) if (conversation is null || conversation.UserId != userId)
{ {
return Result.Fail<object>(ResultCode.CONVERSATION_NOT_FOUND); return Result.Fail<object>(ResultCode.CONVERSATION_NOT_FOUND);
} }
conversation.MarkAsRead(lastReadSequenceId: null); conversation.MarkAsRead(lastReadSequenceId);
return Result.Success(); return Result.Success();
} }
} }
@@ -2,6 +2,8 @@
using MassTransit; using MassTransit;
using MessageService.Domain.IReposities; using MessageService.Domain.IReposities;
using MessageService.Infrastructure; using MessageService.Infrastructure;
using Microsoft.EntityFrameworkCore;
using MySqlConnector;
namespace MessageService.WebApi.Application.EventHandlers namespace MessageService.WebApi.Application.EventHandlers
{ {
@@ -21,8 +23,8 @@ namespace MessageService.WebApi.Application.EventHandlers
public async Task Consume(ConsumeContext<GroupMemberJoinedEvent> context) public async Task Consume(ConsumeContext<GroupMemberJoinedEvent> context)
{ {
var @event = context.Message; var @event = context.Message;
var existing = await reposity.FindByTargetIdAsync(@event.GroupId); var existing = await reposity.FindActiveAsync(@event.UserId, @event.GroupId, Domain.Enums.ChatType.GROUP, context.CancellationToken);
if (existing.Any(x => x.UserId == @event.UserId && x.ChatType == Domain.Enums.ChatType.GROUP)) if (existing is not null)
{ {
return; return;
} }
@@ -37,14 +39,14 @@ namespace MessageService.WebApi.Application.EventHandlers
lastMessage: string.Empty lastMessage: string.Empty
)); ));
await messageDb.SaveChangesAsync(context.CancellationToken); await SaveIdempotentlyAsync(context.CancellationToken);
} }
public async Task Consume(ConsumeContext<FriendAddedEvent> context) public async Task Consume(ConsumeContext<FriendAddedEvent> context)
{ {
var @event = context.Message; var @event = context.Message;
var existing = await reposity.FindByUserIdAsync(@event.OwnerId); var existing = await reposity.FindActiveAsync(@event.OwnerId, @event.TargetId, Domain.Enums.ChatType.PRIVATE, context.CancellationToken);
if (existing.Any(x => x.TargetId == @event.TargetId && x.ChatType == Domain.Enums.ChatType.PRIVATE)) if (existing is not null)
{ {
return; return;
} }
@@ -59,18 +61,33 @@ namespace MessageService.WebApi.Application.EventHandlers
lastMessage: string.Empty lastMessage: string.Empty
)); ));
await messageDb.SaveChangesAsync(context.CancellationToken); await SaveIdempotentlyAsync(context.CancellationToken);
} }
public async Task Consume(ConsumeContext<GroupMemberLeftEvent> context) public async Task Consume(ConsumeContext<GroupMemberLeftEvent> context)
{ {
var conversations = await reposity.FindByTargetIdAsync(context.Message.GroupId); var conversation = await reposity.FindActiveAsync(
foreach (var conversation in conversations.Where(x => context.Message.UserId,
x.UserId == context.Message.UserId && x.ChatType == Domain.Enums.ChatType.GROUP)) context.Message.GroupId,
Domain.Enums.ChatType.GROUP,
context.CancellationToken);
if (conversation is not null)
{ {
conversation.SoftDelete(); conversation.SoftDelete();
} }
await messageDb.SaveChangesAsync(context.CancellationToken); 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 message = notification.Message;
var conversations = await reposity.FindByStreamKeyAsync(message.StreamKey); var conversations = await reposity.FindByStreamKeyAsync(message.StreamKey, cancellationToken);
foreach (var conversation in conversations) foreach (var conversation in conversations)
{ {
conversation.UpdateLastMessage(message.Content.Fallback); conversation.UpdateLastMessage(message.Content.Fallback, message.CreationTime);
if (conversation.UserId == message.SenderId) if (conversation.UserId == message.SenderId)
{ {
conversation.SetLastReadSequence(message.SequenceId); conversation.SetLastReadSequence(message.SequenceId);
@@ -19,12 +19,12 @@ namespace MessageService.WebApi.Application.EventHandlers
public async Task Consume(ConsumeContext<UserProfileUpdateEvent> context) public async Task Consume(ConsumeContext<UserProfileUpdateEvent> context)
{ {
var @event = context.Message; 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) foreach (var conversation in conversations)
{ {
conversation.UpdateProfile(@event.NickName, @event.Avatar); 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 IM.Commons;
using MessageService.Domain.Enums; using MessageService.Domain.Enums;
using MessageService.Domain.IReposities; using MessageService.Domain.IReposities;
@@ -16,20 +16,22 @@ namespace MessageService.WebApi.Application.Message
private readonly IMapper mapper; private readonly IMapper mapper;
private readonly IGroupMemberIntegrationService memberService; private readonly IGroupMemberIntegrationService memberService;
private readonly IContactIntegrationService contactService; 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.reposity = reposity;
this.conversationReposity = conversationReposity; this.conversationReposity = conversationReposity;
this.mapper = mapper; this.mapper = mapper;
this.memberService = memberService; this.memberService = memberService;
this.contactService = contactService; this.contactService = contactService;
this.squenceService = squenceService; this.squenceService = squenceService; this.runtime = runtime;
} }
public async Task<Result<MessageResponse>> SendMsgAsync(SendMsgCommand command) 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) if (command.ChatType == Domain.Enums.ChatType.PRIVATE)
{ {
bool passed = await contactService.CheckContactAsync(command.SenderId, command.TargetId); 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); 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(); msg.Withdraw();
return Result.Success(); 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) if (command.direction is not 0 and not 1 || command.limit is < 1 or > 100)
{ {
return Result.Fail<GetMessagesResponse>(ResultCode.PARAMETER_ERROR); 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) 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)); 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] [HttpGet]
public async Task<IActionResult> List() public async Task<IActionResult> List(CancellationToken cancellationToken)
{ {
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetByOwnerIdAsync(Guid.Parse(userId))); return Ok(await service.GetByOwnerIdAsync(Guid.Parse(userId!), cancellationToken));
} }
[HttpGet] [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); 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] [HttpPost]
[UnitOfWork(typeof(MessageDbContext))] [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); 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] [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 userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var command = new GetMessageCommand(conversationId, Guid.Parse(userId), cursor, direction, limit); 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; using IM.InitCommon;
@@ -19,6 +20,7 @@ namespace MessageService.WebApi
builder.Services.AddAllGrpcServer(); builder.Services.AddAllGrpcServer();
var app = builder.Build(); var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -28,6 +30,7 @@ namespace MessageService.WebApi
} }
app.UseAppDefault(); app.UseAppDefault();
app.MapManagementHealth();
app.MapControllers(); app.MapControllers();
+2
View File
@@ -69,6 +69,8 @@ namespace IdentityService.Domain.Entities
AddDomainEvent(new UserBannedDomainEvent(this.Id, reason)); 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) public void Update(string? nickName, string? region, string? avatar, string? desc)
{ {
if (nickName != null) 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 IdentityService.WebApi.Applications.Dtos.Common;
using IM.Commons; using IM.Commons;
using IM.Jwt; using IM.Jwt;
using IM.InitCommon.Management;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using System.Security.Claims; using System.Security.Claims;
@@ -16,10 +17,11 @@ namespace IdentityService.WebApi.Applications.Auth
private readonly IOptions<JwtOptions> jwtOptions; private readonly IOptions<JwtOptions> jwtOptions;
private readonly IdDomainService idDomainService; private readonly IdDomainService idDomainService;
private readonly IMapper mapper; private readonly IMapper mapper;
private readonly RuntimePolicy runtime;
public AuthService(ITokenService tokenService, IIdRepository idRepository, public AuthService(ITokenService tokenService, IIdRepository idRepository,
IOptions<JwtOptions> options, IdDomainService idDomainService, IOptions<JwtOptions> options, IdDomainService idDomainService,
IMapper mapper IMapper mapper, RuntimePolicy runtime
) )
{ {
this.tokenService = tokenService; this.tokenService = tokenService;
@@ -27,6 +29,7 @@ namespace IdentityService.WebApi.Applications.Auth
jwtOptions = options; jwtOptions = options;
this.idDomainService = idDomainService; this.idDomainService = idDomainService;
this.mapper = mapper; this.mapper = mapper;
this.runtime = runtime;
} }
public async Task<Result<LoginResponse>> LoginAsync(string username, string password) 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); 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); var idResult = await idRepository.CheckForSignInAsync(user, password, true);
if (!idResult.Succeeded) if (!idResult.Succeeded)
{ {
return Result<LoginResponse>.Fail(ResultCode.PASSWORD_ERROR); return Result<LoginResponse>.Fail(ResultCode.PASSWORD_ERROR);
} }
var token = await BuildTokenAsync(user); 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)); 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) 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); var userResult = await idDomainService.CreateUserAsync(userName, password, nickName);
if (!userResult.Succeeded) if (!userResult.Succeeded)
return Result<UserResponse?>.Fail(userResult); return Result<UserResponse?>.Fail(userResult);
@@ -78,20 +84,24 @@ namespace IdentityService.WebApi.Applications.Auth
return Result<LoginResponse>.Fail(ResultCode.USER_NOT_FOUND); 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); var token = await BuildTokenAsync(user);
await tokenService.RevokeRefreshTokenAsync(refreshToken);
return Result<LoginResponse>.Success(new LoginResponse(user.Id, token, refreshToken, null, user.UserName, user.NickName, user.Avatar, user.CreationTime)); 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) private async Task<string> BuildTokenAsync(Domain.Entities.User user)
{ {
var roles = await idRepository.GetRolesAsync(user); var roles = await idRepository.GetRolesAsync(user);
List<Claim> claims = new List<Claim>(); List<Claim> claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())); claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
claims.Add(new Claim("session_stamp", user.SecurityStamp ?? ""));
foreach (string role in roles) foreach (string role in roles)
{ {
claims.Add(new Claim(ClaimTypes.Role, role)); 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.Domain.Entities;
using IdentityService.Infrastructure; using IdentityService.Infrastructure;
using IM.InitCommon; using IM.InitCommon;
@@ -38,6 +39,7 @@ namespace IdentityService.WebApi
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
var app = builder.Build(); var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
@@ -47,6 +49,7 @@ namespace IdentityService.WebApi
} }
app.UseAppDefault(); app.UseAppDefault();
app.MapManagementHealth();
app.MapControllers(); app.MapControllers();
app.MapAllGrpcServer(); 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: services:
nginx: nginx:
image: nginx:alpine image: nginx:alpine
@@ -36,10 +34,10 @@ services:
container_name: im-mysql container_name: im-mysql
restart: always restart: always
environment: environment:
MYSQL_ROOT_PASSWORD: root123456 MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required}
MYSQL_DATABASE: im_db MYSQL_DATABASE: im_db
MYSQL_USER: im MYSQL_USER: im
MYSQL_PASSWORD: im123456 MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD is required}
ports: ports:
- "3307:3306" - "3307:3306"
command: command:
@@ -66,8 +64,8 @@ services:
container_name: im-rabbitmq container_name: im-rabbitmq
restart: always restart: always
environment: environment:
RABBITMQ_DEFAULT_USER: im RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER:?RABBITMQ_DEFAULT_USER is required}
RABBITMQ_DEFAULT_PASS: im123456 RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS:?RABBITMQ_DEFAULT_PASS is required}
ports: ports:
- "5673:5672" - "5673:5672"
- "15673:15672" - "15673:15672"