fix: align backend APIs and upload flow

This commit is contained in:
2026-09-15 14:10:55 +08:00
parent 32177a7293
commit 53e6195938
149 changed files with 4791 additions and 435 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)
*.userprefs
.env
.env.*
!.env.example
# Mono auto generated files
mono_crash.*
@@ -360,4 +363,10 @@ MigrationBackup/
.ionide/
# 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 权限不足 |
| 3100-3199 | 会话 | 3100 会话不存在 |
| 3200-3299 | 分片 | 3201 分片不存在, 3202 分片合并失败, **3203 分片过小**, **3204 分片数不匹配**, **3205 会话过期**, **3206 分片号无效** |
## 会话一致性说明
- `GET /api/Conversation/List` 的请求和响应结构不变,结果按 `ModificationTime ?? CreationTime` 倒序返回。
- 活动会话由 `(UserId, ChatType, TargetId)` 唯一确定;重复的好友或入群事件按幂等成功处理。
- `20260911000100_ConversationUniqueness` 会保留最新活动记录、合并最大未读数和最大已读序号,并软删除其余重复记录。
# 消息历史搜索
`GET /api/Message/Search` 在当前用户拥有的会话中搜索历史文本消息。
- 参数:`conversationId``keyword`(去除首尾空格后 1–50 字符)、可选独占游标 `cursor``limit`150,默认 30)。
- 只返回未撤回、未删除的文本消息,按 `sequenceId` 倒序排列。
- 响应继续使用统一 `Result`,数据结构为 `{ messages, hasmore }`。下一页以本页最后一条消息的 `sequenceId` 作为独占游标。
- `POST /api/Conversation/MarkRead` 新增可选 `lastReadSequenceId` 查询参数,旧客户端不传时仍兼容。
- 会话列表的 `dateTime` 表示最后消息活动时间;标记已读不会改变此时间或会话排序。
+12
View File
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><IsPackable>false</IsPackable><IsTestProject>true</IsTestProject></PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="9.0.13" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.25" />
<PackageReference Include="Testcontainers.MySql" Version="4.13.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
<ItemGroup><ProjectReference Include="../Admin.WebApi/Admin.WebApi.csproj"/><ProjectReference Include="../User.Infrastructure/IdentityService.Infrastructure.csproj"/><ProjectReference Include="../GroupService.Infrastructure/GroupService.Infrastructure.csproj"/><ProjectReference Include="../FileService.Infrastructure/FileService.Infrastructure.csproj"/></ItemGroup>
</Project>
+129
View File
@@ -0,0 +1,129 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Testcontainers.MySql;
using Xunit;
namespace IM.Admin.Tests;
public sealed class AdminFlowTests : IAsyncLifetime
{
readonly MySqlContainer mysql = new MySqlBuilder().WithImage("mysql:8.0").WithDatabase("admin_tests").WithUsername("test_admin").WithPassword(Convert.ToHexString(RandomNumberGenerator.GetBytes(24))).Build();
Factory factory = null!;
readonly DomainHandler domain = new();
const string Password = "Integration-password-456!";
readonly Guid superId = Guid.NewGuid(), reviewerId = Guid.NewGuid();
public async Task InitializeAsync()
{
await mysql.StartAsync(); factory = new Factory(mysql.GetConnectionString(),domain);
using var scope = factory.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AdminDb>(); await db.Database.MigrateAsync();
var hasher = new PasswordHasher<AdminAccount>();
foreach (var (id,name,role) in new[] {(superId,"root_test","super"),(reviewerId,"review_test","reviewer")}) {
var a = new AdminAccount {Id=id,Account=name,Name=name,Role=role}; a.PasswordHash=hasher.HashPassword(a,Password); db.Accounts.Add(a);
}
await db.SaveChangesAsync();
}
public async Task DisposeAsync() { await factory.DisposeAsync(); await mysql.DisposeAsync(); }
HttpClient Client() => factory.CreateClient(new WebApplicationFactoryClientOptions {BaseAddress=new Uri("https://localhost"),AllowAutoRedirect=false,HandleCookies=true});
static async Task<JsonElement> Data(HttpResponseMessage response) { var json=await response.Content.ReadFromJsonAsync<JsonElement>(); return json.GetProperty("data").Clone(); }
static async Task<HttpResponseMessage> Mutate(HttpClient client,string path,object body,HttpMethod? method=null) {
var csrf=await Data(await client.GetAsync("/api/admin/auth/csrf"));
using var request=new HttpRequestMessage(method??HttpMethod.Post,"/api/admin"+path) {Content=JsonContent.Create(body)};
request.Headers.Add("X-CSRF-TOKEN",csrf.GetProperty("token").GetString()); return await client.SendAsync(request);
}
static async Task Login(HttpClient c,string name) => Assert.Equal(HttpStatusCode.OK,(await Mutate(c,"/auth/login",new {account=name,password=Password})).StatusCode);
async Task Process(Guid id) {
using (var scope=factory.Services.CreateScope()) { var db=scope.ServiceProvider.GetRequiredService<AdminDb>(); var op=await db.Operations.FindAsync(id); op!.NextAttemptAt=DateTime.UtcNow; await db.SaveChangesAsync(); }
await new OperationWorker(factory.Services.GetRequiredService<IServiceScopeFactory>(),NullLogger<OperationWorker>.Instance).Process(CancellationToken.None);
}
[Fact]
public async Task Cookie_csrf_permissions_versions_durable_failures_and_revocation_work_together()
{
using var root=Client(); using var reviewer=Client();
Assert.Equal(HttpStatusCode.Unauthorized,(await root.GetAsync("/api/admin/settings")).StatusCode);
Assert.Equal(HttpStatusCode.BadRequest,(await root.PostAsJsonAsync("/api/admin/auth/login",new {account="root_test",password=Password})).StatusCode);
await Login(root,"root_test"); await Login(reviewer,"review_test");
foreach(var route in new[]{"settings","admins","logs","health","storage"}) Assert.Equal(HttpStatusCode.Forbidden,(await reviewer.GetAsync("/api/admin/"+route)).StatusCode);
var me=await Data(await root.GetAsync("/api/admin/auth/me")); Assert.False(me.TryGetProperty("passwordHash",out _));
var settings=await Data(await root.GetAsync("/api/admin/settings")); var account=settings.EnumerateArray().Single(x=>x.GetProperty("id").GetString()=="account");
var input=new {version=account.GetProperty("version").GetInt64(),value=new {registrationEnabled=false,passwordMinLength=10},reason="测试暂停注册"};
Assert.Equal(HttpStatusCode.OK,(await Mutate(root,"/settings/account",input,HttpMethod.Put)).StatusCode);
Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,"/settings/account",input,HttpMethod.Put)).StatusCode);
var platform=await Data(await root.GetAsync("/api/platform")); Assert.False(platform.GetProperty("registrationEnabled").GetBoolean());
using var internalRequest=new HttpRequestMessage(HttpMethod.Post,"/internal/management/reports") {Content=JsonContent.Create(new {reporterId=Guid.NewGuid(),type="user",targetId=domain.Target,reason="骚扰辱骂",description="服务端快照测试",messageIds=Array.Empty<Guid>()})};
internalRequest.Headers.Add("X-IM-Management-Key","integration-internal-key");
var submitted=await root.SendAsync(internalRequest); Assert.Equal(HttpStatusCode.OK,submitted.StatusCode);
var reportId=(await submitted.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetGuid();
Assert.Equal(HttpStatusCode.OK,(await Mutate(reviewer,$"/reports/{reportId}/claim",new{})).StatusCode);
Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,$"/reports/{reportId}/claim",new{})).StatusCode);
var operationId=Guid.NewGuid(); var review=new {operationId,action="封禁",reason="核实违规后处置"};
Assert.Equal(HttpStatusCode.Conflict,(await Mutate(root,$"/reports/{reportId}/review",review)).StatusCode);
domain.Fail=true;
Assert.Equal(HttpStatusCode.Accepted,(await Mutate(reviewer,$"/reports/{reportId}/review",review)).StatusCode);
for(var attempt=0;attempt<3;attempt++) await Process(operationId);
var pending=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("处理中",pending.GetProperty("status").GetString());
var failed=await Data(await reviewer.GetAsync($"/api/admin/operations/{operationId}")); Assert.Equal("failed",failed.GetProperty("status").GetString());
Assert.Equal(0,domain.Applied);
domain.Fail=false; domain.LoseAcknowledgement=true;
Assert.Equal(HttpStatusCode.OK,(await Mutate(reviewer,$"/operations/{operationId}/retry",new{})).StatusCode);
await Process(operationId); Assert.Equal(1,domain.Applied);
pending=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("处理中",pending.GetProperty("status").GetString());
await Process(operationId); Assert.Equal(1,domain.Applied);
var closed=await Data(await reviewer.GetAsync($"/api/admin/reports/{reportId}")); Assert.Equal("已处理",closed.GetProperty("status").GetString());
Assert.Equal(HttpStatusCode.Conflict,(await Mutate(reviewer,$"/reports/{reportId}/review",review)).StatusCode);
using(var scope=factory.Services.CreateScope()) {
var db=scope.ServiceProvider.GetRequiredService<AdminDb>();
Assert.Equal(1,await db.Audit.CountAsync(x=>x.OperationId==operationId));
Assert.True(await db.Audit.AnyAsync(x=>x.Action=="查看举报证据"&&x.ReportId==reportId));
Assert.True(await db.Audit.AnyAsync(x=>x.Result.Contains("执行失败")));
}
var adminEdit=new {account="root_test",name="root_test",email="",password="",role="reviewer",enabled=false,reason="不允许停用最后超级管理员"};
Assert.Equal(HttpStatusCode.BadRequest,(await Mutate(root,$"/admins/{superId}",adminEdit,HttpMethod.Put)).StatusCode);
Assert.Equal(HttpStatusCode.OK,(await Mutate(root,$"/admins/{reviewerId}",new {account="review_test",name="review_test",email="",password="",role="reviewer",enabled=false,reason="停用测试账号"},HttpMethod.Put)).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,(await reviewer.GetAsync("/api/admin/reports")).StatusCode);
Assert.Equal(HttpStatusCode.OK,(await Mutate(root,"/auth/password",new {currentPassword=Password,newPassword="New-integration-password!"})).StatusCode);
Assert.Equal(HttpStatusCode.Unauthorized,(await root.GetAsync("/api/admin/auth/me")).StatusCode);
}
sealed class Factory(string connection,DomainHandler handler) : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder) {
builder.UseEnvironment("Development");
builder.UseSetting("ConnectionStrings:Admin",connection);
builder.UseSetting("Management:KeyRingPath",Path.Combine(Path.GetTempPath(),"im-admin-test-keys",Guid.NewGuid().ToString("N")));
builder.UseSetting("Management:CredentialKey",Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));
builder.UseSetting("Management:InternalKey","integration-internal-key");
foreach(var name in new[]{"user","group","message","contact","file","connector","admin"}) builder.UseSetting($"Management:Services:{name}",$"http://{name}.test");
builder.ConfigureServices(services=> { services.RemoveAll<IHostedService>(); services.AddHttpClient<InternalClient>().ConfigurePrimaryHttpMessageHandler(()=>handler); });
}
}
sealed class DomainHandler : HttpMessageHandler
{
public Guid Target {get;}=Guid.NewGuid(); public bool Fail; public bool LoseAcknowledgement; public int Applied;
readonly Dictionary<Guid,ActionReceipt> receipts=new();
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken ct) {
if(request.RequestUri!.AbsolutePath.EndsWith("/evidence")) return Ok(new SubjectEvidence("测试对象",[]));
if(request.RequestUri.AbsolutePath.EndsWith("/action")) {
if(Fail) return new(HttpStatusCode.ServiceUnavailable);
var command=await request.Content!.ReadFromJsonAsync<InternalAction>(cancellationToken:ct);
if(!receipts.TryGetValue(command!.Id,out var receipt)) { receipt=new("测试对象","正常","封禁"); receipts.Add(command.Id,receipt); Applied++; }
if(LoseAcknowledgement) { LoseAcknowledgement=false; throw new HttpRequestException("Injected response loss after commit"); }
return Ok(receipt);
}
return Ok(new {total=1});
}
static HttpResponseMessage Ok(object value)=>new(HttpStatusCode.OK) {Content=JsonContent.Create(value)};
}
}
+49
View File
@@ -0,0 +1,49 @@
using IM.Admin.Api;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using FileService.Infrastructure.Storage;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.DependencyInjection;
using System.Text.Json.Nodes;
using Xunit;
namespace IM.Admin.Tests;
public class PolicyTests
{
static readonly IConfiguration Config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Management:CredentialKey"] = Convert.ToBase64String(new byte[32]) }).Build();
[Fact]
public void Defaults_preserve_unlimited_legacy_rules_and_explicit_report_limits()
{
foreach (var id in SettingsService.Fields.Keys) SettingsService.Validate(id, SettingsService.Defaults(id));
var p = new Policy(); Assert.True(p.RegistrationEnabled); Assert.Equal(0,p.FriendLimit); Assert.Equal(0,p.UploadMaxBytes); Assert.Equal(0,p.RecallMinutes); Assert.Equal(20,p.ReportsPerDay); Assert.Equal(10,p.ReportCooldownMinutes);
}
[Fact]
public void Validation_rejects_unknown_fields_negative_limits_and_malformed_extensions()
{
var p = SettingsService.Defaults("social"); p["friendLimit"] = -1; Assert.Throws<ApiError>(() => SettingsService.Validate("social",p));
p = SettingsService.Defaults("account"); p["password"] = "must not be persisted"; Assert.Throws<ApiError>(() => SettingsService.Validate("account",p));
p = SettingsService.Defaults("messaging"); p["allowedFileTypes"] = new JsonArray("*.exe"); Assert.Throws<ApiError>(() => SettingsService.Validate("messaging",p));
}
[Fact]
public void Credentials_are_authenticated_encrypted_and_require_the_original_deployment_key()
{
using var db = new AdminDb(new DbContextOptionsBuilder<AdminDb>().Options);
var service = new SettingsService(db,new EphemeralDataProtectionProvider(),Config);
var one = service.Protect("private-secret"); var two = service.Protect("private-secret");
Assert.NotEqual(one,two); Assert.DoesNotContain("private-secret",one); Assert.Equal("private-secret",service.Unprotect(one));
var wrong = new SettingsService(db,new EphemeralDataProtectionProvider(),new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string,string?> { ["Management:CredentialKey"] = Convert.ToBase64String(Enumerable.Repeat((byte)1,32).ToArray()) }).Build());
Assert.ThrowsAny<System.Security.Cryptography.CryptographicException>(() => wrong.Unprotect(one));
}
[Fact]
public void Local_storage_paths_cannot_escape_the_managed_root()
{
var root = Path.Combine(Path.GetTempPath(),"im-storage-test");
Assert.StartsWith(Path.GetFullPath(root),LocalStorageAdapter.SafePath(root,"private","2026/file.txt"));
Assert.Throws<InvalidOperationException>(() => LocalStorageAdapter.SafePath(root,"..","outside.txt"));
Assert.Throws<InvalidOperationException>(() => LocalStorageAdapter.SafePath(root,Path.GetFullPath(Path.Combine(root,"..","outside.txt"))));
}
}
+8
View File
@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup><TargetFramework>net8.0</TargetFramework><Nullable>enable</Nullable><ImplicitUsings>enable</ImplicitUsings></PropertyGroup>
<ItemGroup>
<ProjectReference Include="../IM.InitCommon/IM.InitCommon.csproj" />
<PackageReference Include="MailKit" Version="4.18.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
using System.Security.Claims;
using IM.Admin.Data;
using IM.InitCommon.Management;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Api;
public sealed class ApiError(int status, string message) : Exception(message) { public int Status { get; } = status; }
public static class ApiSupport
{
public static Guid Actor(this HttpContext c) => Guid.Parse(c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);
public static string ActorName(this HttpContext c) => c.User.Identity?.Name ?? "";
public static bool IsSuper(this HttpContext c) => c.User.IsInRole("super");
public static void Reason(string? reason) { if (string.IsNullOrWhiteSpace(reason) || reason.Length > 500) throw new ApiError(400, "请填写 1500 字的操作原因"); }
public static void Audit(this AdminDb db, HttpContext c, string action, string target, string before, string after, string reason, Guid? reportId = null)
=> db.Audit.Add(new AuditRecord { ActorId = c.Actor(), ActorName = c.ActorName(), Action = action, TargetId = target, TargetName = target, Before = before, After = after, Reason = reason, ReportId = reportId });
public static async Task<PageResult<T>> Page<T>(IQueryable<T> query, int page, int size, CancellationToken ct)
{ page = Math.Max(1, page); size = Math.Clamp(size, 1, 100); return new(await query.Skip((page - 1) * size).Take(size).ToListAsync(ct), await query.CountAsync(ct), page, size); }
}
+101
View File
@@ -0,0 +1,101 @@
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Api;
public record LoginInput(string Account, string Password);
public record PasswordInput(string CurrentPassword, string NewPassword);
public record ResetRequest(string Account);
public record ResetInput(string Token, string Password);
public record AccountInput(string Account, string Name, string Email, string Password, string Role, bool Enabled, string Reason);
public static class AuthEndpoints
{
public static object Public(AdminAccount a) => new { a.Id, a.Account, a.Name, a.Email, a.Role, status = a.Enabled ? "启用" : "停用", a.CreatedAt };
public static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
public static void CheckPassword(string p) { if (p.Length is < 12 or > 128) throw new ApiError(400, "管理员密码长度应为 12128 位"); }
public static void MapAuth(this WebApplication app)
{
var api = app.MapGroup("/api/admin/auth");
api.MapGet("/csrf", (HttpContext c, IAntiforgery af) => ManagementResult.Ok(new { token = af.GetAndStoreTokens(c).RequestToken }));
api.MapPost("/login", async (LoginInput input, AdminDb db, IPasswordHasher<AdminAccount> hasher, SettingsService settings, HttpContext c) => {
if (string.IsNullOrWhiteSpace(input.Account) || input.Account.Length > 100 || string.IsNullOrEmpty(input.Password) || input.Password.Length > 128) throw new ApiError(400, "账号或密码格式不正确");
var name = input.Account.Trim().ToLowerInvariant();
await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var a = await db.Accounts.FromSqlInterpolated($"SELECT * FROM admin_accounts WHERE Account = {name} FOR UPDATE").SingleOrDefaultAsync();
var policy = await settings.Policy();
if (a is null || !a.Enabled || a.LockedUntil > DateTime.UtcNow) throw new ApiError(401, "账号或密码错误,或账号暂不可用");
if (hasher.VerifyHashedPassword(a, a.PasswordHash, input.Password) == PasswordVerificationResult.Failed) {
a.FailedAttempts++; if (a.FailedAttempts >= policy.AdminLockThreshold) a.LockedUntil = DateTime.UtcNow.AddMinutes(policy.AdminLockMinutes);
db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "后台登录", TargetId = a.Id.ToString(), Result = "失败", Reason = "密码验证未通过", After = a.LockedUntil.HasValue ? "临时锁定" : "登录失败" });
await db.SaveChangesAsync(); await tx.CommitAsync(); throw new ApiError(401, "账号或密码错误,或账号暂不可用");
}
a.FailedAttempts = 0; a.LockedUntil = null;
db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "后台登录", TargetId = a.Id.ToString(), Reason = "独立管理员登录", After = "登录成功" });
await db.SaveChangesAsync(); await tx.CommitAsync();
var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, a.Id.ToString()), new Claim(ClaimTypes.Name, a.Name), new Claim(ClaimTypes.Role, a.Role), new Claim("stamp", a.Stamp)], "Admin"));
await c.SignInAsync("Admin", principal, new AuthenticationProperties { IsPersistent = true, ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(policy.AdminSessionMinutes) });
return ManagementResult.Ok(Public(a));
}).RequireRateLimiting("login");
api.MapGet("/me", async (HttpContext c, AdminDb db) => ManagementResult.Ok(Public(await db.Accounts.SingleAsync(x => x.Id == c.Actor())))).RequireAuthorization();
api.MapPost("/logout", async (HttpContext c) => { await c.SignOutAsync("Admin"); return ManagementResult.Ok(); }).RequireAuthorization();
api.MapPost("/password", async (PasswordInput input, HttpContext c, AdminDb db, IPasswordHasher<AdminAccount> hasher) => {
CheckPassword(input.NewPassword); var a = await db.Accounts.SingleAsync(x => x.Id == c.Actor());
if (hasher.VerifyHashedPassword(a, a.PasswordHash, input.CurrentPassword) == PasswordVerificationResult.Failed) throw new ApiError(400, "当前密码不正确");
a.PasswordHash = hasher.HashPassword(a, input.NewPassword); a.Stamp = Guid.NewGuid().ToString("N");
db.Audit(c, "修改密码", a.Id.ToString(), "", "已更新", "管理员修改本人密码"); await db.SaveChangesAsync(); await c.SignOutAsync("Admin"); return ManagementResult.Ok();
}).RequireAuthorization();
api.MapPost("/forgot", async (ResetRequest input, AdminDb db, InfrastructureService mail, IConfiguration config) => {
var a = await db.Accounts.SingleOrDefaultAsync(x => x.Account == input.Account.Trim().ToLowerInvariant() && x.Enabled);
if (a is not null && !string.IsNullOrWhiteSpace(a.Email) && await mail.MailEnabled()) {
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
db.Resets.Add(new PasswordReset { Id = Hash(token), AccountId = a.Id, ExpiresAt = DateTime.UtcNow.AddMinutes(20) }); await db.SaveChangesAsync();
var origin = config["Management:AdminPublicUrl"] ?? throw new ApiError(503, "未配置后台访问地址");
await mail.Send(a.Email, "IM 后台密码重置", $"请在 20 分钟内打开以下地址重置密码:{origin.TrimEnd('/')}/#/reset-password?token={token}\n如果不是你发起的请求,请忽略此邮件。");
}
return ManagementResult.Ok(new { message = "如果账号可用且已配置邮件,将收到重置说明。" });
}).RequireRateLimiting("login");
api.MapPost("/reset-password", async (ResetInput input, AdminDb db, IPasswordHasher<AdminAccount> hasher) => {
CheckPassword(input.Password); await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var reset = await db.Resets.SingleOrDefaultAsync(x => x.Id == Hash(input.Token) && x.ExpiresAt > DateTime.UtcNow);
if (reset is null) throw new ApiError(400, "链接无效或已过期");
var a = await db.Accounts.SingleAsync(x => x.Id == reset.AccountId); if (!a.Enabled) throw new ApiError(400, "账号已停用");
a.PasswordHash = hasher.HashPassword(a, input.Password); a.Stamp = Guid.NewGuid().ToString("N"); a.FailedAttempts = 0; a.LockedUntil = null;
db.Resets.RemoveRange(await db.Resets.Where(x => x.AccountId == a.Id).ToListAsync()); db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = a.Name, Action = "重置密码", TargetId = a.Id.ToString(), After = "已更新", Reason = "通过邮件重置密码" });
await db.SaveChangesAsync(); await tx.CommitAsync(); return ManagementResult.Ok();
}).RequireRateLimiting("login");
var accounts = app.MapGroup("/api/admin/admins").RequireAuthorization("super");
accounts.MapGet("", async (AdminDb db, string? q, string? status, string? role, int? page, int? size, CancellationToken ct) => {
var query = db.Accounts.AsNoTracking().Where(x => q == null || x.Account.Contains(q) || x.Name.Contains(q));
if (!string.IsNullOrEmpty(status)) query = query.Where(x => x.Enabled == (status == "启用"));
if (!string.IsNullOrEmpty(role)) query = query.Where(x => x.Role == role);
var result = await ApiSupport.Page(query.OrderBy(x => x.CreatedAt).Select(x => new { x.Id, x.Name, x.Account, x.Email, x.Role, status = x.Enabled ? "启用" : "停用", x.CreatedAt }), page ?? 1, size ?? 8, ct);
return ManagementResult.Ok(result);
});
accounts.MapPost("", async (AccountInput input, AdminDb db, HttpContext c, IPasswordHasher<AdminAccount> hasher) => {
Validate(input); CheckPassword(input.Password); var account = input.Account.Trim().ToLowerInvariant();
if (await db.Accounts.AnyAsync(x => x.Account == account)) throw new ApiError(409, "管理员账号已存在");
var a = new AdminAccount { Account = account, Name = input.Name.Trim(), Email = input.Email.Trim(), Role = input.Role, Enabled = input.Enabled }; a.PasswordHash = hasher.HashPassword(a, input.Password);
db.Accounts.Add(a); db.Audit(c, "创建管理员", a.Id.ToString(), "", input.Role, input.Reason); await db.SaveChangesAsync(); return ManagementResult.Ok(Public(a));
});
accounts.MapPut("/{id:guid}", async (Guid id, AccountInput input, AdminDb db, HttpContext c) => {
Validate(input); await using var tx = await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable);
var a = await db.Accounts.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "管理员不存在");
if (a.Id == c.Actor() && (input.Role != a.Role || !input.Enabled)) throw new ApiError(400, "不能停用或变更自己的角色");
if (a.Role == "super" && a.Enabled && (input.Role != "super" || !input.Enabled) && await db.Accounts.CountAsync(x => x.Role == "super" && x.Enabled) <= 1) throw new ApiError(400, "必须保留一名启用的超级管理员");
var before = $"{a.Role}/{a.Enabled}"; a.Name = input.Name.Trim(); a.Email = input.Email.Trim(); a.Role = input.Role; a.Enabled = input.Enabled; a.Stamp = Guid.NewGuid().ToString("N");
db.Audit(c, "修改管理员", id.ToString(), before, $"{a.Role}/{a.Enabled}", input.Reason); await db.SaveChangesAsync(); await tx.CommitAsync(); return ManagementResult.Ok(Public(a));
});
}
static void Validate(AccountInput i) {
ApiSupport.Reason(i.Reason);
if (i.Account.Length is < 3 or > 100 || string.IsNullOrWhiteSpace(i.Name) || i.Name.Length > 50 || !new[] { "super", "operator", "reviewer" }.Contains(i.Role)) throw new ApiError(400, "管理员资料不正确");
if (!string.IsNullOrEmpty(i.Email) && !System.Net.Mail.MailAddress.TryCreate(i.Email, out _)) throw new ApiError(400, "邮箱格式不正确");
}
}
+105
View File
@@ -0,0 +1,105 @@
using System.Data;
using System.Text.Json;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Api;
public record SubmitReport(Guid ReporterId, string Type, Guid TargetId, string Reason, string Description, Guid[] MessageIds);
public record ReviewInput(Guid OperationId, string Action, string Reason);
public static class BusinessEndpoints
{
public static void MapAdminBusiness(this WebApplication app)
{
var api = app.MapGroup("/api/admin").RequireAuthorization();
api.MapGet("/dashboard", async (AdminDb db, InternalClient client) => {
var users = client.Send<JsonElement>("user", "/internal/management/summary");
var groups = client.Send<JsonElement>("group", "/internal/management/summary");
await Task.WhenAll(users, groups);
return ManagementResult.Ok(new { users = users.Result.GetProperty("total").GetInt32(), groups = groups.Result.GetProperty("total").GetInt32(), pending = await db.Reports.CountAsync(x => x.Status == "待处理"), disposals = await db.Operations.CountAsync(x => x.Status == "completed" && x.CompletedAt >= DateTime.UtcNow.Date) });
});
foreach (var resource in new[] { "users", "groups" }) {
var service = resource == "users" ? "user" : "group";
api.MapGet($"/{resource}", async (HttpContext c, InternalClient client) => ManagementResult.Ok(await client.Send<JsonElement>(service, "/internal/management/list" + c.Request.QueryString)));
api.MapGet($"/{resource}/{{id:guid}}", async (Guid id, HttpContext c, InternalClient client, AdminDb db) => {
var item = await client.Send<JsonElement>(service, $"/internal/management/detail/{id}");
var reports = await db.Reports.AsNoTracking().Where(x => x.TargetId == id).OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.Reason, x.Status, x.Result, x.CreatedAt }).Take(100).ToListAsync();
var logs = c.User.IsInRole("reviewer") ? [] : await db.Audit.AsNoTracking().Where(x => x.TargetId == id.ToString()).OrderByDescending(x => x.CreatedAt).Take(100).ToListAsync();
return ManagementResult.Ok(new { item, reports, logs });
});
api.MapPost($"/{resource}/{{id:guid}}/actions", async (Guid id, ReviewInput input, AdminDb db, HttpContext c) => {
if (input.Action is not "封禁" and not "解封") throw new ApiError(400, "操作不受支持");
return await Enqueue(db, c, service, id, null, input);
}).RequireAuthorization("operate");
}
api.MapGet("/reports", async (AdminDb db, string? q, string? status, string? type, int? page, int? size, CancellationToken ct) => {
var query = db.Reports.AsNoTracking().Where(x => q == null || x.TargetName.Contains(q) || x.Reason.Contains(q) || x.Id.ToString() == q);
if (!string.IsNullOrEmpty(status)) query = query.Where(x => x.Status == status);
if (!string.IsNullOrEmpty(type)) query = query.Where(x => x.Type == type);
return ManagementResult.Ok(await ApiSupport.Page(query.OrderByDescending(x => x.CreatedAt).Select(x => new { x.Id, x.TargetId, x.TargetName, x.Type, x.Reason, x.Status, x.AssigneeId, x.CreatedAt, x.Result, x.Version }), page ?? 1, size ?? 8, ct));
});
api.MapGet("/reports/{id:guid}", async (Guid id, AdminDb db, HttpContext c) => {
var r = await db.Reports.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "举报不存在");
db.Audit(c, "查看举报证据", id.ToString(), "", "已访问", "审核证据读取", id); await db.SaveChangesAsync();
var operations = await db.Operations.AsNoTracking().Where(x => x.ReportId == id).OrderByDescending(x => x.CreatedAt).ToListAsync();
var history = await db.Reports.AsNoTracking().Where(x => x.TargetId == r.TargetId && x.Id != id && x.ClosedAt != null).OrderByDescending(x => x.ClosedAt).Select(x => new { x.Id, x.Result, x.Status, x.ClosedAt }).Take(30).ToListAsync();
var name = r.AssigneeId is null ? null : await db.Accounts.Where(x => x.Id == r.AssigneeId).Select(x => x.Name).SingleOrDefaultAsync();
return ManagementResult.Ok(new { r.Id, r.Type, r.TargetId, r.TargetName, r.ReporterId, r.Reason, r.Description, r.Status, r.AssigneeId, assigneeName = name, r.CreatedAt, r.ClosedAt, r.Result, r.Version, evidence = JsonSerializer.Deserialize<JsonElement>(r.Evidence), operations, history });
});
api.MapPost("/reports/{id:guid}/claim", async (Guid id, AdminDb db, HttpContext c) => {
var r = await db.Reports.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "举报不存在");
if (r.Status != "待处理") throw new ApiError(409, "举报已领取或已结案");
r.Status = "处理中"; r.AssigneeId = c.Actor(); r.Version++;
db.Audit(c, "领取举报", id.ToString(), "待处理", r.Status, "领取并核实举报", id); await db.SaveChangesAsync(); return ManagementResult.Ok();
});
api.MapPost("/reports/{id:guid}/review", async (Guid id, ReviewInput input, AdminDb db, HttpContext c) => {
if (input.Action is not "警告" and not "驳回" and not "封禁") throw new ApiError(400, "操作不受支持");
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
var r = await db.Reports.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "举报不存在");
if (r.Status != "处理中" || r.AssigneeId != c.Actor()) throw new ApiError(409, "仅能处置本人领取且尚未结案的举报");
if (await db.Operations.AnyAsync(x => x.ReportId == id && x.Status != "completed" && x.Id != input.OperationId)) throw new ApiError(409, "处置正在执行,请等待或重试原任务");
var result = await Enqueue(db, c, r.Type, r.TargetId, id, input); await tx.CommitAsync(); return result;
});
api.MapGet("/operations/{id:guid}", async (Guid id, AdminDb db, HttpContext c) => {
var op = await db.Operations.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "任务不存在");
if (c.User.IsInRole("reviewer") && op.ActorId != c.Actor()) throw new ApiError(403, "没有此任务权限"); return ManagementResult.Ok(op);
});
api.MapPost("/operations/{id:guid}/retry", async (Guid id, AdminDb db, HttpContext c) => {
var op = await db.Operations.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "任务不存在");
if (c.User.IsInRole("reviewer") && op.ActorId != c.Actor()) throw new ApiError(403, "没有此任务权限");
if (op.Status != "failed") throw new ApiError(409, "仅失败任务可重试");
op.Status = "pending"; op.Attempts = 0; op.Error = null; op.NextAttemptAt = DateTime.UtcNow; await db.SaveChangesAsync(); return ManagementResult.Ok(op);
});
api.MapGet("/logs", async (AdminDb db, string? q, Guid? actor, string? action, DateTime? from, DateTime? to, int? page, int? size, CancellationToken ct) => {
var query = db.Audit.AsNoTracking().Where(x => q == null || x.TargetId.Contains(q) || x.TargetName.Contains(q) || x.Reason.Contains(q));
if (actor.HasValue) query = query.Where(x => x.ActorId == actor);
if (!string.IsNullOrEmpty(action)) query = query.Where(x => x.Action == action);
if (from.HasValue) query = query.Where(x => x.CreatedAt >= from.Value);
if (to.HasValue) { var end = to.Value.Date.AddDays(1); query = query.Where(x => x.CreatedAt < end); }
return ManagementResult.Ok(await ApiSupport.Page(query.OrderByDescending(x => x.CreatedAt), page ?? 1, size ?? 8, ct));
}).RequireAuthorization("operate");
app.MapPost("/internal/management/reports", async (SubmitReport input, AdminDb db, SettingsService settings, InternalClient client) => {
if (input.Type is not "user" and not "group" || input.Description.Length > 1000 || input.MessageIds.Length > 20) throw new ApiError(400, "举报参数不正确");
var policy = await settings.Policy(); if (!policy.ReportCategories.Contains(input.Reason)) throw new ApiError(400, "请选择有效举报分类");
var verified = await client.Send<SubjectEvidence>("message", "/internal/management/evidence", new EvidenceRequest(input.ReporterId, input.Type, input.TargetId, input.MessageIds));
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
if (await db.Reports.CountAsync(x => x.ReporterId == input.ReporterId && x.CreatedAt >= DateTime.UtcNow.Date) >= policy.ReportsPerDay) throw new ApiError(429, "已达到今日举报上限");
var cutoff = DateTime.UtcNow.AddMinutes(-policy.ReportCooldownMinutes);
if (await db.Reports.AnyAsync(x => x.ReporterId == input.ReporterId && x.TargetId == input.TargetId && x.Type == input.Type && x.CreatedAt > cutoff)) throw new ApiError(429, "请勿重复举报同一对象");
var r = new Report { ReporterId = input.ReporterId, TargetId = input.TargetId, TargetName = verified.TargetName, Type = input.Type, Reason = input.Reason, Description = input.Description, Evidence = JsonSerializer.Serialize(verified.Evidence, SettingsService.Json) };
db.Reports.Add(r); await db.SaveChangesAsync(); await tx.CommitAsync(); return new { r.Id };
});
}
static async Task<IResult> Enqueue(AdminDb db, HttpContext c, string type, Guid target, Guid? report, ReviewInput input)
{
ApiSupport.Reason(input.Reason); if (input.OperationId == Guid.Empty) throw new ApiError(400, "缺少操作 ID");
var existing = await db.Operations.FindAsync(input.OperationId);
if (existing is not null) {
if (existing.ActorId != c.Actor() || existing.TargetId != target || existing.Action != input.Action || existing.ReportId != report || existing.Reason != input.Reason) throw new ApiError(409, "操作 ID 与已有请求冲突");
return Results.Json(ManagementResult.Ok(existing), statusCode: 202);
}
var op = new Operation { Id = input.OperationId, ActorId = c.Actor(), ActorName = c.ActorName(), TargetId = target, Type = type, Action = input.Action, Reason = input.Reason, ReportId = report };
db.Operations.Add(op); await db.SaveChangesAsync(); return Results.Json(ManagementResult.Ok(op), statusCode: 202);
}
}
+19
View File
@@ -0,0 +1,19 @@
using System.Text.Json;
using IM.Admin.Services;
using IM.InitCommon.Management;
namespace IM.Admin.Api;
public static class MonitoringEndpoints
{
public static void MapMonitoring(this WebApplication app)
{
var api = app.MapGroup("/api/admin").RequireAuthorization("operate");
api.MapGet("/health", async (HealthSampler sampler, InternalClient client, CancellationToken ct) => {
var health = await sampler.Read(ct); JsonElement? connections = null;
try { connections = await client.Send<JsonElement>("connector", "/internal/management/connections", ct: ct); } catch { }
return ManagementResult.Ok(new { services = health, connections, sampledAt = DateTime.UtcNow });
});
api.MapGet("/storage", async (HttpContext c, InternalClient client) => ManagementResult.Ok(await client.Send<JsonElement>("file", "/internal/management/storage/summary" + c.Request.QueryString, ct: c.RequestAborted)));
app.MapGet("/api/admin/groups/{id:guid}/members", async (Guid id, HttpContext c, InternalClient client) => ManagementResult.Ok(await client.Send<JsonElement>("group", $"/internal/management/members/{id}" + c.Request.QueryString, ct: c.RequestAborted))).RequireAuthorization();
}
}
+28
View File
@@ -0,0 +1,28 @@
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.EntityFrameworkCore;
using System.Text.Json.Nodes;
namespace IM.Admin.Api;
public static class SettingsEndpoints
{
public static void MapSettings(this WebApplication app)
{
app.MapGet("/api/platform", async (SettingsService s) => { var p = await s.Policy(); return ManagementResult.Ok(new { p.PlatformName, p.Description, p.SupportEmail, p.RegistrationEnabled, p.PasswordMinLength, p.ReportCategories }); });
app.MapGet("/internal/management/policy", async (SettingsService s) => await s.Policy());
app.MapGet("/internal/management/infrastructure/{id}", async (string id, AdminDb db, SettingsService s) => {
if (id != "storage") throw new ApiError(404, "不存在");
var row = await db.Settings.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id);
return new { version = row?.Version ?? 0, value = JsonNode.Parse(row?.Value ?? "{}"), secret = s.Unprotect(row?.Secret) };
});
var api = app.MapGroup("/api/admin/settings").RequireAuthorization("super");
api.MapGet("", async (SettingsService s) => ManagementResult.Ok(await s.List()));
api.MapPut("/{id}", async (string id, SettingInput input, SettingsService s, HttpContext c) => { await s.Save(id, input, c); return ManagementResult.Ok(await s.List()); });
api.MapPost("/{id}/defaults", async (string id, SettingInput input, SettingsService s, HttpContext c) => { await s.Save(id, input with { Value = SettingsService.Defaults(id) }, c); return ManagementResult.Ok(await s.List()); });
api.MapPost("/{id}/draft", async (string id, SettingInput input, InfrastructureService s, HttpContext c) => { await s.Draft(id, input, c); return ManagementResult.Ok(); });
api.MapPost("/{id}/test", async (string id, InfraTest input, InfrastructureService s, HttpContext c) => { await s.Test(id, input, c); return ManagementResult.Ok(); });
api.MapPost("/{id}/activate", async (string id, SettingInput input, InfrastructureService s, HttpContext c) => { await s.Activate(id, input, c); return ManagementResult.Ok(); });
}
}
public record InfraTest(long Version, string? Recipient);
+112
View File
@@ -0,0 +1,112 @@
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Data;
public sealed class AdminAccount
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Account { get; set; } = "";
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public string PasswordHash { get; set; } = "";
public string Role { get; set; } = "reviewer";
public bool Enabled { get; set; } = true;
public string Stamp { get; set; } = Guid.NewGuid().ToString("N");
public int FailedAttempts { get; set; }
public DateTime? LockedUntil { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public sealed class SystemSetting
{
public string Id { get; set; } = "";
public long Version { get; set; }
public string Value { get; set; } = "{}";
public string? Draft { get; set; }
public long? TestedVersion { get; set; }
public string Secret { get; set; } = "";
public string? DraftSecret { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
public sealed class Report
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ReporterId { get; set; }
public Guid TargetId { get; set; }
public string TargetName { get; set; } = "";
public string Type { get; set; } = "user";
public string Reason { get; set; } = "";
public string Description { get; set; } = "";
public string Evidence { get; set; } = "[]";
public string Status { get; set; } = "待处理";
public Guid? AssigneeId { get; set; }
public string? Result { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ClosedAt { get; set; }
public long Version { get; set; }
}
// A durable outbox command: both the request and the pending report update commit together.
public sealed class Operation
{
public Guid Id { get; set; }
public Guid ActorId { get; set; }
public string ActorName { get; set; } = "";
public Guid TargetId { get; set; }
public string Type { get; set; } = "";
public string Action { get; set; } = "";
public string Reason { get; set; } = "";
public Guid? ReportId { get; set; }
public string Status { get; set; } = "pending";
public int Attempts { get; set; }
public string? Error { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime NextAttemptAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
}
public sealed class AuditRecord
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ActorId { get; set; }
public string ActorName { get; set; } = "";
public string Action { get; set; } = "";
public string TargetId { get; set; } = "";
public string TargetName { get; set; } = "";
public string Before { get; set; } = "";
public string After { get; set; } = "";
public string Reason { get; set; } = "";
public Guid? ReportId { get; set; }
public Guid? OperationId { get; set; }
public string Result { get; set; } = "成功";
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
public sealed class PasswordReset
{
public string Id { get; set; } = "";
public Guid AccountId { get; set; }
public DateTime ExpiresAt { get; set; }
}
public sealed class AdminDb(DbContextOptions<AdminDb> options) : DbContext(options)
{
public DbSet<AdminAccount> Accounts => Set<AdminAccount>();
public DbSet<SystemSetting> Settings => Set<SystemSetting>();
public DbSet<Report> Reports => Set<Report>();
public DbSet<Operation> Operations => Set<Operation>();
public DbSet<AuditRecord> Audit => Set<AuditRecord>();
public DbSet<PasswordReset> Resets => Set<PasswordReset>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<AdminAccount>().ToTable("admin_accounts").HasIndex(x => x.Account).IsUnique();
b.Entity<AdminAccount>().Property(x => x.Account).HasMaxLength(100);
b.Entity<AdminAccount>().Property(x => x.Stamp).IsConcurrencyToken();
b.Entity<SystemSetting>().ToTable("admin_settings").Property(x => x.Id).HasMaxLength(64);
b.Entity<SystemSetting>().Property(x => x.Version).IsConcurrencyToken();
b.Entity<Report>().ToTable("admin_reports").HasIndex(x => new { x.ReporterId, x.CreatedAt });
b.Entity<Report>().HasIndex(x => new { x.Status, x.CreatedAt });
b.Entity<Report>().Property(x => x.Status).HasMaxLength(30);
b.Entity<Report>().Property(x => x.Version).IsConcurrencyToken();
b.Entity<Operation>().ToTable("admin_operations").HasIndex(x => new { x.Status, x.NextAttemptAt });
b.Entity<Operation>().Property(x => x.Status).HasMaxLength(30);
b.Entity<AuditRecord>().ToTable("admin_audit").HasIndex(x => x.CreatedAt);
b.Entity<AuditRecord>().HasIndex(x => x.OperationId).IsUnique();
b.Entity<PasswordReset>().ToTable("admin_password_resets").Property(x => x.Id).HasMaxLength(64);
}
}
+9
View File
@@ -0,0 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace IM.Admin.Data;
public sealed class DesignTimeFactory : IDesignTimeDbContextFactory<AdminDb>
{
public AdminDb CreateDbContext(string[] args) => new(new DbContextOptionsBuilder<AdminDb>()
.UseMySql("Server=localhost;Database=im_admin;User=migration;Password=design-time-only", new MySqlServerVersion(new Version(8, 0, 0))).Options);
}
@@ -0,0 +1,314 @@
// <auto-generated />
using System;
using IM.Admin.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Admin.WebApi.Data.Migrations
{
[DbContext(typeof(AdminDb))]
[Migration("20260915004233_InitialAdmin")]
partial class InitialAdmin
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IM.Admin.Data.AdminAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Account")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("Enabled")
.HasColumnType("tinyint(1)");
b.Property<int>("FailedAttempts")
.HasColumnType("int");
b.Property<DateTime?>("LockedUntil")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Stamp")
.IsConcurrencyToken()
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Account")
.IsUnique();
b.ToTable("admin_accounts", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.AuditRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("After")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Before")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("OperationId")
.HasColumnType("char(36)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetId")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("OperationId")
.IsUnique();
b.ToTable("admin_audit", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Operation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Attempts")
.HasColumnType("int");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Error")
.HasColumnType("longtext");
b.Property<DateTime>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Status", "NextAttemptAt");
b.ToTable("admin_operations", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.PasswordReset", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid>("AccountId")
.HasColumnType("char(36)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("admin_password_resets", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Report", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("AssigneeId")
.HasColumnType("char(36)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Evidence")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ReporterId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.HasColumnType("longtext");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ReporterId", "CreatedAt");
b.HasIndex("Status", "CreatedAt");
b.ToTable("admin_reports", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.SystemSetting", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Draft")
.HasColumnType("longtext");
b.Property<string>("DraftSecret")
.HasColumnType("longtext");
b.Property<string>("Secret")
.IsRequired()
.HasColumnType("longtext");
b.Property<long?>("TestedVersion")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("admin_settings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,234 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Admin.WebApi.Data.Migrations
{
/// <inheritdoc />
public partial class InitialAdmin : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_accounts",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Account = table.Column<string>(type: "varchar(100)", maxLength: 100, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Email = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
PasswordHash = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Role = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Enabled = table.Column<bool>(type: "tinyint(1)", nullable: false),
Stamp = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
FailedAttempts = table.Column<int>(type: "int", nullable: false),
LockedUntil = table.Column<DateTime>(type: "datetime(6)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_accounts", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_audit",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Action = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetId = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Before = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
After = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ReportId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
OperationId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Result = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_audit", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_operations",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ActorName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
TargetId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Type = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Action = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
ReportId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Status = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Attempts = table.Column<int>(type: "int", nullable: false),
Error = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
NextAttemptAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
CompletedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_operations", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_password_resets",
columns: table => new
{
Id = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AccountId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ExpiresAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_password_resets", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_reports",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ReporterId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
TargetId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
TargetName = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Type = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Evidence = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Status = table.Column<string>(type: "varchar(30)", maxLength: 30, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
AssigneeId = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Result = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
ClosedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
Version = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_reports", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "admin_settings",
columns: table => new
{
Id = table.Column<string>(type: "varchar(64)", maxLength: 64, nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Version = table.Column<long>(type: "bigint", nullable: false),
Value = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Draft = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
TestedVersion = table.Column<long>(type: "bigint", nullable: true),
Secret = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
DraftSecret = table.Column<string>(type: "longtext", nullable: true)
.Annotation("MySql:CharSet", "utf8mb4"),
UpdatedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_admin_settings", x => x.Id);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_admin_accounts_Account",
table: "admin_accounts",
column: "Account",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_admin_audit_CreatedAt",
table: "admin_audit",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_admin_audit_OperationId",
table: "admin_audit",
column: "OperationId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_admin_operations_Status_NextAttemptAt",
table: "admin_operations",
columns: new[] { "Status", "NextAttemptAt" });
migrationBuilder.CreateIndex(
name: "IX_admin_reports_ReporterId_CreatedAt",
table: "admin_reports",
columns: new[] { "ReporterId", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_admin_reports_Status_CreatedAt",
table: "admin_reports",
columns: new[] { "Status", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "admin_accounts");
migrationBuilder.DropTable(
name: "admin_audit");
migrationBuilder.DropTable(
name: "admin_operations");
migrationBuilder.DropTable(
name: "admin_password_resets");
migrationBuilder.DropTable(
name: "admin_reports");
migrationBuilder.DropTable(
name: "admin_settings");
}
}
}
@@ -0,0 +1,311 @@
// <auto-generated />
using System;
using IM.Admin.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Admin.WebApi.Data.Migrations
{
[DbContext(typeof(AdminDb))]
partial class AdminDbModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
modelBuilder.Entity("IM.Admin.Data.AdminAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Account")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("longtext");
b.Property<bool>("Enabled")
.HasColumnType("tinyint(1)");
b.Property<int>("FailedAttempts")
.HasColumnType("int");
b.Property<DateTime?>("LockedUntil")
.HasColumnType("datetime(6)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Role")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Stamp")
.IsConcurrencyToken()
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Account")
.IsUnique();
b.ToTable("admin_accounts", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.AuditRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("After")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Before")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("OperationId")
.HasColumnType("char(36)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetId")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("OperationId")
.IsUnique();
b.ToTable("admin_audit", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Operation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("Action")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ActorId")
.HasColumnType("char(36)");
b.Property<string>("ActorName")
.IsRequired()
.HasColumnType("longtext");
b.Property<int>("Attempts")
.HasColumnType("int");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Error")
.HasColumnType("longtext");
b.Property<DateTime>("NextAttemptAt")
.HasColumnType("datetime(6)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid?>("ReportId")
.HasColumnType("char(36)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("Id");
b.HasIndex("Status", "NextAttemptAt");
b.ToTable("admin_operations", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.PasswordReset", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<Guid>("AccountId")
.HasColumnType("char(36)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)");
b.HasKey("Id");
b.ToTable("admin_password_resets", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.Report", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<Guid?>("AssigneeId")
.HasColumnType("char(36)");
b.Property<DateTime?>("ClosedAt")
.HasColumnType("datetime(6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Evidence")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<Guid>("ReporterId")
.HasColumnType("char(36)");
b.Property<string>("Result")
.HasColumnType("longtext");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(30)
.HasColumnType("varchar(30)");
b.Property<Guid>("TargetId")
.HasColumnType("char(36)");
b.Property<string>("TargetName")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ReporterId", "CreatedAt");
b.HasIndex("Status", "CreatedAt");
b.ToTable("admin_reports", (string)null);
});
modelBuilder.Entity("IM.Admin.Data.SystemSetting", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("varchar(64)");
b.Property<string>("Draft")
.HasColumnType("longtext");
b.Property<string>("DraftSecret")
.HasColumnType("longtext");
b.Property<string>("Secret")
.IsRequired()
.HasColumnType("longtext");
b.Property<long?>("TestedVersion")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext");
b.Property<long>("Version")
.IsConcurrencyToken()
.HasColumnType("bigint");
b.HasKey("Id");
b.ToTable("admin_settings", (string)null);
});
#pragma warning restore 612, 618
}
}
}
+59
View File
@@ -0,0 +1,59 @@
using System.Security.Claims;
using IM.Admin.Api;
using IM.Admin.Data;
using IM.Admin.Services;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using System.Threading.RateLimiting;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AdminDb>(o => o.UseMySql(builder.Configuration.GetConnectionString("Admin") ?? throw new InvalidOperationException("ConnectionStrings:Admin is required"), new MySqlServerVersion(new Version(8, 0, 0))));
builder.Services.AddSingleton<IPasswordHasher<AdminAccount>, PasswordHasher<AdminAccount>>();
var keyPath = builder.Configuration["Management:KeyRingPath"] ?? throw new InvalidOperationException("Management:KeyRingPath is required");
builder.Services.AddDataProtection().SetApplicationName("IM.Admin").PersistKeysToFileSystem(new DirectoryInfo(keyPath));
builder.Services.AddHttpClient<InternalClient>(c => c.Timeout = TimeSpan.FromSeconds(5));
builder.Services.AddScoped<SettingsService>();
builder.Services.AddScoped<InfrastructureService>();
builder.Services.AddSingleton<HealthSampler>();
builder.Services.AddHostedService<OperationWorker>();
builder.Services.AddAntiforgery(o => { o.HeaderName = "X-CSRF-TOKEN"; o.Cookie.Name = "im.admin.csrf"; o.Cookie.SameSite = SameSiteMode.Strict; o.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; });
builder.Services.AddAuthentication("Admin").AddCookie("Admin", o => {
o.Cookie.Name = "im.admin.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Strict;
o.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
o.SlidingExpiration = false;
o.Events.OnRedirectToLogin = c => { c.Response.StatusCode = 401; return c.Response.WriteAsJsonAsync(ManagementResult.Fail("请登录管理后台")); };
o.Events.OnRedirectToAccessDenied = c => { c.Response.StatusCode = 403; return c.Response.WriteAsJsonAsync(ManagementResult.Fail("没有此操作权限")); };
o.Events.OnValidatePrincipal = async c => {
if (!Guid.TryParse(c.Principal?.FindFirstValue(ClaimTypes.NameIdentifier), out var id)) { c.RejectPrincipal(); return; }
var db = c.HttpContext.RequestServices.GetRequiredService<AdminDb>();
var a = await db.Accounts.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id);
if (a is null || !a.Enabled || a.Stamp != c.Principal!.FindFirstValue("stamp") || a.Role != c.Principal.FindFirstValue(ClaimTypes.Role)) c.RejectPrincipal();
};
});
builder.Services.AddAuthorization(o => { o.AddPolicy("super", p => p.RequireRole("super")); o.AddPolicy("operate", p => p.RequireRole("super", "operator")); });
builder.Services.AddRateLimiter(o => { o.RejectionStatusCode = 429; o.AddPolicy("login", c => RateLimitPartition.GetFixedWindowLimiter(c.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 })); });
var app = builder.Build();
if (args.Contains("--migrate") || args.Contains("--init-admin") || args.Contains("--reset-admin")) { await AdminBootstrap.Run(app.Services, args); return; }
app.Use(async (c, next) => {
try { await next(); }
catch (ApiError e) { c.Response.StatusCode = e.Status; await c.Response.WriteAsJsonAsync(ManagementResult.Fail(e.Message)); }
catch (DbUpdateConcurrencyException) { c.Response.StatusCode = 409; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("内容已被其他管理员修改,请刷新后重试")); }
catch (InternalServiceException e) { c.Response.StatusCode = e.Status is >= 400 and < 500 ? e.Status : 503; await c.Response.WriteAsJsonAsync(ManagementResult.Fail(e.Status switch { 400 => "业务校验未通过,请检查对象、配置值和已有引用", 403 => "没有访问关联对象或消息的权限", 404 => "关联对象或资源不存在", 409 => "对象已变化,请刷新重试", _ => "业务服务暂不可用,请稍后重试" })); }
catch (Exception e) { app.Logger.LogError("Admin request failed: {Type}", e.GetType().Name); c.Response.StatusCode = 503; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("服务暂不可用,请稍后重试")); }
});
app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization();
app.Use(async (c, next) => {
if (c.Request.Path.StartsWithSegments("/internal") && !InternalClient.Authorized(c)) { c.Response.StatusCode = 403; return; }
if (c.Request.Path.StartsWithSegments("/api/admin") && !HttpMethods.IsGet(c.Request.Method)) {
try { await c.RequestServices.GetRequiredService<Microsoft.AspNetCore.Antiforgery.IAntiforgery>().ValidateRequestAsync(c); }
catch (Microsoft.AspNetCore.Antiforgery.AntiforgeryValidationException) { c.Response.StatusCode = 400; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("请求校验失败,请刷新页面")); return; }
}
await next();
});
app.MapAuth(); app.MapAdminBusiness(); app.MapSettings(); app.MapMonitoring();
app.MapGet("/internal/management/health", async (AdminDb db) => { if (!await db.Database.CanConnectAsync()) throw new ApiError(503, "数据库不可用"); return new { status = "healthy", service = "admin" }; });
app.Run();
public partial class Program { }
@@ -0,0 +1,12 @@
{
"profiles": {
"Admin.WebApi": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:60538;http://localhost:60539"
}
}
}
+28
View File
@@ -0,0 +1,28 @@
using IM.Admin.Api;
using IM.Admin.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Services;
public static class AdminBootstrap
{
public static async Task Run(IServiceProvider services, string[] args)
{
using var scope = services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AdminDb>();
if (args.Contains("--migrate")) { await db.Database.MigrateAsync(); Console.WriteLine("管理数据库迁移完成"); }
if (!args.Contains("--init-admin") && !args.Contains("--reset-admin")) return;
var name = Environment.GetEnvironmentVariable("IM_ADMIN_ACCOUNT")?.Trim().ToLowerInvariant() ?? throw new InvalidOperationException("IM_ADMIN_ACCOUNT is required");
var password = Environment.GetEnvironmentVariable("IM_ADMIN_PASSWORD") ?? throw new InvalidOperationException("IM_ADMIN_PASSWORD is required");
AuthEndpoints.CheckPassword(password);
var a = await db.Accounts.SingleOrDefaultAsync(x => x.Account == name);
if (args.Contains("--init-admin")) {
if (await db.Accounts.AnyAsync()) throw new InvalidOperationException("已存在管理员,禁止重复初始化");
a = new AdminAccount { Account = name, Name = name, Role = "super", Email = Environment.GetEnvironmentVariable("IM_ADMIN_EMAIL") ?? "" }; db.Accounts.Add(a);
}
if (a is null) throw new InvalidOperationException("管理员不存在");
a.PasswordHash = scope.ServiceProvider.GetRequiredService<IPasswordHasher<AdminAccount>>().HashPassword(a, password);
a.Stamp = Guid.NewGuid().ToString("N"); a.FailedAttempts = 0; a.LockedUntil = null;
db.Audit.Add(new AuditRecord { ActorId = a.Id, ActorName = "部署初始化命令", Action = "初始化或重置管理员", TargetId = a.Id.ToString(), Reason = "部署命令操作", After = "凭据已更新" });
await db.SaveChangesAsync(); Console.WriteLine("管理员凭据已更新;请清除临时密码环境变量。");
}
}
+33
View File
@@ -0,0 +1,33 @@
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Net.Sockets;
using System.Text.Json;
using IM.InitCommon.Management;
using MySqlConnector;
using StackExchange.Redis;
namespace IM.Admin.Services;
public sealed class HealthSampler(IConfiguration config, IServiceScopeFactory scopes)
{
private readonly ConcurrentDictionary<string, DateTime> successes = new();
public async Task<ServiceHealth[]> Read(CancellationToken ct)
{
using var scope = scopes.CreateScope(); var client = scope.ServiceProvider.GetRequiredService<InternalClient>();
var tasks = new[] { "admin", "user", "contact", "group", "message", "file", "connector" }.Select(name => Check(name, async token => {
var response = await client.Send<JsonElement>(name, "/internal/management/health", ct: token);
if (response.GetProperty("status").GetString() != "healthy") throw new InvalidOperationException();
return response.TryGetProperty("configVersion", out var version) ? version.GetInt64() : (long?)null;
}, ct)).ToList();
tasks.Add(Check("MySQL", async token => { await using var db = new MySqlConnection(config.GetConnectionString("Admin")); await db.OpenAsync(token); return null; }, ct));
tasks.Add(Check("Redis", async token => { var options = ConfigurationOptions.Parse(config.GetConnectionString("Redis") ?? throw new InvalidOperationException()); options.ConnectTimeout = 2000; options.AbortOnConnectFail = true; using var redis = await ConnectionMultiplexer.ConnectAsync(options).WaitAsync(token); await redis.GetDatabase().PingAsync().WaitAsync(token); return null; }, ct));
tasks.Add(Check("RabbitMQTCP", async token => { using var tcp = new TcpClient(); await tcp.ConnectAsync(config["Management:RabbitHost"] ?? "rabbitmq", config.GetValue("Management:RabbitPort", 5672), token); return null; }, ct));
tasks.Add(Check("Consul", async token => { using var http = new HttpClient(); using var response = await http.GetAsync((config["Management:ConsulUrl"] ?? throw new InvalidOperationException()).TrimEnd('/') + "/v1/status/leader", token); response.EnsureSuccessStatusCode(); return null; }, ct));
return await Task.WhenAll(tasks);
}
async Task<ServiceHealth> Check(string name, Func<CancellationToken, Task<long?>> check, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct); timeout.CancelAfter(TimeSpan.FromSeconds(3)); var sw = Stopwatch.StartNew();
try { var version = await check(timeout.Token); var now = DateTime.UtcNow; successes[name] = now; return new(name, "healthy", Math.Round(sw.Elapsed.TotalMilliseconds), now, now, null, version); }
catch { return new(name, "unavailable", null, DateTime.UtcNow, successes.TryGetValue(name, out var time) ? time : null, "连接或就绪检查失败,请检查服务日志"); }
}
}
@@ -0,0 +1,93 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using IM.Admin.Api;
using IM.Admin.Data;
using IM.InitCommon.Management;
using MailKit.Security;
using MimeKit;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Services;
public sealed class InfrastructureService(AdminDb db, SettingsService settings, InternalClient client, IConfiguration config, IHostEnvironment environment)
{
public async Task Draft(string id, SettingInput input, HttpContext c)
{
ApiSupport.Reason(input.Reason); Validate(id, input.Value);
var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id);
if ((row?.Version ?? 0) != input.Version) throw new ApiError(409, "配置版本已变化,请刷新");
if (row is null) { row = new SystemSetting { Id = id }; db.Settings.Add(row); }
row.Draft = input.Value.ToJsonString(); row.TestedVersion = null; row.Version++; row.UpdatedAt = DateTime.UtcNow;
var prior = settings.Unprotect(row.DraftSecret ?? row.Secret);
var secret = string.IsNullOrWhiteSpace(input.Secret) ? prior : id == "storage" ? MergeSecrets(prior, input.Secret) : input.Secret;
row.DraftSecret = settings.Protect(secret);
db.Audit(c, "保存基础设施草稿", id, "", "草稿已更新;凭据" + (string.IsNullOrEmpty(input.Secret) ? "保持" : "已更换"), input.Reason); await db.SaveChangesAsync();
}
static string MergeSecrets(string previous, string next)
{
var old = JsonNode.Parse(string.IsNullOrEmpty(previous) ? "{}" : previous)!.AsObject(); var update = JsonNode.Parse(next)!.AsObject();
foreach (var p in update) { var entry = old[p.Key]?.AsObject() ?? new JsonObject(); foreach (var value in p.Value!.AsObject()) if (!string.IsNullOrWhiteSpace(value.Value?.GetValue<string>())) entry[value.Key] = value.Value!.DeepClone(); old[p.Key] = entry.DeepClone(); }
return old.ToJsonString();
}
public async Task Test(string id, InfraTest input, HttpContext c)
{
var row = await Row(id, input.Version); var value = JsonNode.Parse(row.Draft!)!.AsObject();
Validate(id, value);
if (id == "smtp") {
using var smtp = await Connect(value, settings.Unprotect(row.DraftSecret));
if (!System.Net.Mail.MailAddress.TryCreate(input.Recipient, out _)) throw new ApiError(400, "请填写有效测试收件人");
await SendMessage(smtp, value, input.Recipient!, "IM SMTP 连接测试", "这是一封由后台管理员主动发起的连接测试邮件。");
} else await client.Send<JsonElement>("file", "/internal/management/storage/test", new InfrastructureEnvelope(row.Version, value, settings.Unprotect(row.DraftSecret)));
row.TestedVersion = row.Version; db.Audit(c, "测试基础设施连接", id, "", "测试成功", "管理员主动执行连通性测试"); await db.SaveChangesAsync();
}
public async Task Activate(string id, SettingInput input, HttpContext c)
{
ApiSupport.Reason(input.Reason); var row = await Row(id, input.Version);
if (row.TestedVersion != row.Version) throw new ApiError(409, "请先测试当前草稿的连接");
if (id == "storage") await client.Send<JsonElement>("file", "/internal/management/storage/validate", new InfrastructureEnvelope(row.Version, JsonNode.Parse(row.Draft!)!.AsObject(), settings.Unprotect(row.DraftSecret)));
var before = row.Value; row.Value = row.Draft!; row.Secret = row.DraftSecret!; row.Draft = null; row.DraftSecret = null; row.Version++; row.UpdatedAt = DateTime.UtcNow;
db.Audit(c, "启用基础设施配置", id, before, row.Value, input.Reason); await db.SaveChangesAsync();
}
async Task<SystemSetting> Row(string id, long version)
{
if (id is not "smtp" and not "storage") throw new ApiError(404, "分组不存在");
var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id) ?? throw new ApiError(404, "请先保存草稿");
if (row.Draft is null || row.Version != version) throw new ApiError(409, "草稿不存在或版本已变化"); return row;
}
void Validate(string id, JsonObject value)
{
if (id == "smtp") {
var allowed = new[] { "host", "port", "username", "from", "tls", "enabled" };
if (value.Any(x => !allowed.Contains(x.Key))) throw new ApiError(400, "不支持的邮件字段");
ValidateHost(value["host"]?.GetValue<string>() ?? "", config);
if (value["port"]?.GetValue<int>() is not (>= 1 and <= 65535) || value["tls"]?.GetValue<string>() is not ("starttls" or "ssl") || !System.Net.Mail.MailAddress.TryCreate(value["from"]?.GetValue<string>(), out _)) throw new ApiError(400, "邮件端口、TLS 或发件人无效");
} else if (id == "storage") {
if (value["providers"] is not JsonObject providers || providers.Count == 0 || value["defaultProviderCode"] is null || !providers.ContainsKey(value["defaultProviderCode"]!.GetValue<string>())) throw new ApiError(400, "请配置有效的默认存储提供商");
var allowed = new[] { "providerCode", "providerType", "enabled", "bucket", "publicBucket", "region", "endpoint", "publicBaseUrl", "localRootPath", "localUploadApiBaseUrl", "uploadUrlExpiresIn", "downloadUrlExpiresIn", "maxObjectSizeBytes", "minPartSizeBytes", "defaultPartSizeBytes", "maxPartCount" };
if (value.Any(x => x.Key != "providers" && x.Key != "defaultProviderCode")) throw new ApiError(400, "未知存储字段");
foreach (var (code, node) in providers) {
var p = node!.AsObject(); if (p.Any(x => !allowed.Contains(x.Key)) || p["providerCode"]?.GetValue<string>() != code) throw new ApiError(400, "存储字段不正确,凭据需独立提交");
var type = p["providerType"]?.GetValue<int>(); if (type is not 1 and not 2 and not 5) throw new ApiError(400, "仅支持本地或 S3 兼容存储");
if (type != 1) { if (!Uri.TryCreate(p["endpoint"]?.GetValue<string>(), UriKind.Absolute, out var uri) || uri.Scheme is not ("https" or "http") || !string.IsNullOrEmpty(uri.UserInfo)) throw new ApiError(400, "存储端点无效"); ValidateHost(uri.Host, config); }
}
} else throw new ApiError(404, "分组不存在");
}
public static void ValidateHost(string host, IConfiguration config)
{
var allowed = config.GetSection("Management:AllowedInfrastructureHosts").Get<string[]>() ?? [];
if (string.IsNullOrWhiteSpace(host) || !allowed.Contains(host, StringComparer.OrdinalIgnoreCase)) throw new ApiError(400, "该目标不在部署允许列表中");
}
async Task<MailKit.Net.Smtp.SmtpClient> Connect(JsonObject value, string secret)
{
Validate("smtp", value); var smtp = new MailKit.Net.Smtp.SmtpClient { Timeout = 10000 };
if (environment.IsDevelopment() && config["Management:DevelopmentSmtpCertificateSha256"] is { Length: > 0 } pin)
smtp.ServerCertificateValidationCallback = (_, certificate, _, errors) => errors == System.Net.Security.SslPolicyErrors.None || certificate is not null && string.Equals(certificate.GetCertHashString(System.Security.Cryptography.HashAlgorithmName.SHA256), pin, StringComparison.OrdinalIgnoreCase);
try { await smtp.ConnectAsync(value["host"]!.GetValue<string>(), value["port"]!.GetValue<int>(), value["tls"]!.GetValue<string>() == "ssl" ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTls);
if (!string.IsNullOrWhiteSpace(value["username"]?.GetValue<string>())) await smtp.AuthenticateAsync(value["username"]!.GetValue<string>(), secret);
return smtp;
} catch { smtp.Dispose(); throw new ApiError(503, "邮件连接失败,请检查允许列表、TLS 和凭据"); }
}
static async Task SendMessage(MailKit.Net.Smtp.SmtpClient smtp, JsonObject v, string recipient, string title, string text)
{ var msg = new MimeMessage(); msg.From.Add(MailboxAddress.Parse(v["from"]!.GetValue<string>())); msg.To.Add(MailboxAddress.Parse(recipient)); msg.Subject = title; msg.Body = new TextPart("plain") { Text = text }; await smtp.SendAsync(msg); await smtp.DisconnectAsync(true); }
public async Task<bool> MailEnabled() { var row = await db.Settings.AsNoTracking().SingleOrDefaultAsync(x => x.Id == "smtp"); return row is not null && JsonNode.Parse(row.Value)?["enabled"]?.GetValue<bool>() == true; }
public async Task Send(string recipient, string title, string text) { var row = await db.Settings.AsNoTracking().SingleAsync(x => x.Id == "smtp"); var value = JsonNode.Parse(row.Value)!.AsObject(); using var smtp = await Connect(value, settings.Unprotect(row.Secret)); await SendMessage(smtp, value, recipient, title, text); }
}
+40
View File
@@ -0,0 +1,40 @@
using System.Data;
using IM.Admin.Data;
using IM.InitCommon.Management;
using Microsoft.EntityFrameworkCore;
namespace IM.Admin.Services;
public sealed class OperationWorker(IServiceScopeFactory scopes, ILogger<OperationWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(2));
while (await timer.WaitForNextTickAsync(ct)) {
try { await Process(ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception e) { logger.LogWarning("Operation dispatcher unavailable: {Type}", e.GetType().Name); }
}
}
public async Task Process(CancellationToken ct)
{
using var scope = scopes.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AdminDb>();
var id = await db.Operations.Where(x => x.Status == "pending" && x.NextAttemptAt <= DateTime.UtcNow).OrderBy(x => x.CreatedAt).Select(x => (Guid?)x.Id).FirstOrDefaultAsync(ct);
if (id is null) return;
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
// A row lock serializes dispatchers. Receipt idempotency covers response loss after the domain commit.
var op = await db.Operations.FromSqlInterpolated($"SELECT * FROM admin_operations WHERE Id = {id.Value} FOR UPDATE").SingleAsync(ct);
if (op.Status != "pending" || op.NextAttemptAt > DateTime.UtcNow) return;
try {
var receipt = op.Action is "警告" or "驳回" ? new ActionReceipt(op.TargetId.ToString(), "处理中", op.Action == "驳回" ? "已驳回" : "已处理") : await scope.ServiceProvider.GetRequiredService<InternalClient>().Send<ActionReceipt>(op.Type, "/internal/management/action", new InternalAction(op.Id, op.ActorId, op.TargetId, op.Action, op.Reason), ct);
if (op.ReportId.HasValue) {
var r = await db.Reports.SingleAsync(x => x.Id == op.ReportId, ct);
r.Status = op.Action == "驳回" ? "已驳回" : "已处理"; r.Result = op.Action + "" + op.Reason; r.ClosedAt = DateTime.UtcNow; r.Version++;
}
op.Status = "completed"; op.CompletedAt = DateTime.UtcNow; op.Error = null;
db.Audit.Add(new AuditRecord { ActorId = op.ActorId, ActorName = op.ActorName, Action = op.Action, TargetId = op.TargetId.ToString(), TargetName = receipt.TargetName, Before = receipt.Before, After = receipt.After, Reason = op.Reason, ReportId = op.ReportId, OperationId = op.Id });
} catch (Exception e) when (e is not OperationCanceledException || !ct.IsCancellationRequested) {
op.Attempts++; op.Error = e is InternalServiceException se ? se.Message : "业务服务暂不可用,可重试原任务";
op.Status = op.Attempts >= 3 ? "failed" : "pending"; op.NextAttemptAt = DateTime.UtcNow.AddSeconds(10 * op.Attempts);
db.Audit.Add(new AuditRecord { ActorId = op.ActorId, ActorName = op.ActorName, Action = op.Action, TargetId = op.TargetId.ToString(), TargetName = op.TargetId.ToString(), Before = "待确认", After = "未确认", Reason = op.Reason, ReportId = op.ReportId, Result = $"第 {op.Attempts} 次执行失败;任务 {op.Id}" });
}
await db.SaveChangesAsync(ct); await tx.CommitAsync(ct);
}
}
+82
View File
@@ -0,0 +1,82 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using IM.Admin.Api;
using IM.Admin.Data;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace IM.Admin.Services;
public sealed class SettingsService(AdminDb db, IDataProtectionProvider protection, IConfiguration config)
{
public static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
public static readonly Dictionary<string, string[]> Fields = new() {
["platform"] = ["platformName", "description", "supportEmail"], ["account"] = ["registrationEnabled", "passwordMinLength"],
["social"] = ["friendLimit", "createdGroupLimit", "groupMemberLimit", "defaultJoinAuthority"],
["messaging"] = ["textLimit", "recallMinutes", "uploadMaxBytes", "allowedFileTypes"],
["reports"] = ["reportCategories", "reportsPerDay", "reportCooldownMinutes"],
["security"] = ["clientAccessMinutes", "clientRefreshDays", "adminSessionMinutes", "adminLockThreshold", "adminLockMinutes"],
};
byte[] Key() { var key = Convert.FromBase64String(config["Management:CredentialKey"] ?? throw new InvalidOperationException("Management:CredentialKey is required")); if (key.Length != 32) throw new InvalidOperationException("CredentialKey must be 32 bytes"); return key; }
public string Protect(string secret) {
if (string.IsNullOrEmpty(secret)) return "";
var nonce = RandomNumberGenerator.GetBytes(12); var plain = Encoding.UTF8.GetBytes(secret); var cipher = new byte[plain.Length]; var tag = new byte[16];
using var aes = new AesGcm(Key(), 16); aes.Encrypt(nonce, plain, cipher, tag, Encoding.UTF8.GetBytes("IM.Admin.Config.v1"));
return "v1:" + Convert.ToBase64String(nonce.Concat(tag).Concat(cipher).ToArray());
}
public string Unprotect(string? secret) {
if (string.IsNullOrEmpty(secret)) return "";
if (!secret.StartsWith("v1:")) throw new InvalidOperationException("Unsupported credential encryption version");
var data = Convert.FromBase64String(secret[3..]); var plain = new byte[data.Length - 28];
using var aes = new AesGcm(Key(), 16); aes.Decrypt(data.AsSpan(0,12), data.AsSpan(28), data.AsSpan(12,16), plain, Encoding.UTF8.GetBytes("IM.Admin.Config.v1"));
return Encoding.UTF8.GetString(plain);
}
public static JsonObject Defaults(string id)
{
if (!Fields.TryGetValue(id, out var fields)) throw new ApiError(404, "配置分组不存在");
var all = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject();
return new JsonObject(fields.Select(k => new KeyValuePair<string, JsonNode?>(k, all[k]?.DeepClone())));
}
public async Task<Policy> Policy()
{
var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject();
var rows = await db.Settings.AsNoTracking().Where(x => x.Id != "storage" && x.Id != "smtp").ToListAsync();
foreach (var row in rows) foreach (var p in JsonNode.Parse(row.Value)!.AsObject()) merged[p.Key] = p.Value?.DeepClone();
var policy = merged.Deserialize<Policy>(Json)!; policy.Version = rows.Sum(x => x.Version); return policy;
}
public async Task<object> List()
{
var rows = await db.Settings.AsNoTracking().ToListAsync();
return Fields.Keys.Concat(["storage", "smtp"]).Select(id => {
var row = rows.SingleOrDefault(x => x.Id == id);
return new { id, version = row?.Version ?? 0, value = row is null ? (Fields.ContainsKey(id) ? Defaults(id) : new JsonObject()) : JsonNode.Parse(row.Value), draft = row?.Draft is null ? null : JsonNode.Parse(row.Draft), secretSet = !string.IsNullOrEmpty(row?.Secret), draftSecretSet = !string.IsNullOrEmpty(row?.DraftSecret), tested = row?.TestedVersion == row?.Version && row is not null, updatedAt = row?.UpdatedAt };
}).ToArray();
}
public async Task Save(string id, SettingInput input, HttpContext c)
{
ApiSupport.Reason(input.Reason);
if (!Fields.ContainsKey(id)) throw new ApiError(400, "基础设施配置需走草稿发布流程");
Validate(id, input.Value);
var row = await db.Settings.SingleOrDefaultAsync(x => x.Id == id);
if ((row?.Version ?? 0) != input.Version) throw new ApiError(409, "配置版本已变化,请刷新后重试");
if (row is null) { row = new SystemSetting { Id = id }; db.Settings.Add(row); }
var before = row.Value; row.Value = input.Value.ToJsonString(); row.Version++; row.UpdatedAt = DateTime.UtcNow;
db.Audit(c, "修改系统配置", id, before, row.Value, input.Reason); await db.SaveChangesAsync();
}
public static void Validate(string id, JsonObject value)
{
if (!Fields.TryGetValue(id, out var fields) || value.Count != fields.Length || fields.Any(x => !value.ContainsKey(x))) throw new ApiError(400, "配置字段不完整或包含未知字段");
try {
var merged = JsonSerializer.SerializeToNode(new Policy(), Json)!.AsObject(); foreach (var p in value) merged[p.Key] = p.Value?.DeepClone();
var p1 = merged.Deserialize<Policy>(Json)!;
if (string.IsNullOrWhiteSpace(p1.PlatformName) || p1.PlatformName.Length > 60 || p1.Description.Length > 500 || p1.PasswordMinLength is < 6 or > 50 || p1.AdminSessionMinutes is < 5 or > 10080 || p1.AdminLockThreshold is < 1 or > 20 || p1.AdminLockMinutes is < 1 or > 1440 || p1.DefaultJoinAuthority is < 0 or > 2 || p1.ReportsPerDay is < 1 or > 1000 || p1.ReportCooldownMinutes is < 0 or > 1440) throw new Exception();
if (new[] { p1.FriendLimit, p1.CreatedGroupLimit, p1.GroupMemberLimit, p1.TextLimit, p1.RecallMinutes, p1.ClientAccessMinutes, p1.ClientRefreshDays }.Any(x => x < 0 || x > 1000000) || p1.UploadMaxBytes < 0 || p1.UploadMaxBytes > 1099511627776) throw new Exception();
if (p1.ReportCategories is null || p1.ReportCategories.Length is < 1 or > 30 || p1.ReportCategories.Any(x => string.IsNullOrWhiteSpace(x) || x.Length > 40) || p1.ReportCategories.Distinct().Count() != p1.ReportCategories.Length) throw new Exception();
if (p1.AllowedFileTypes is null || p1.AllowedFileTypes.Any(x => !System.Text.RegularExpressions.Regex.IsMatch(x, "^\\.[a-z0-9]{1,15}$"))) throw new Exception();
if (p1.SupportEmail.Length > 0 && !System.Net.Mail.MailAddress.TryCreate(p1.SupportEmail, out _)) throw new Exception();
} catch { throw new ApiError(400, "配置值无效,请检查类型、范围、邮箱和文件扩展名"); }
}
}
public record SettingInput(long Version, JsonObject Value, string Reason, string? Secret = null);
+5 -1
View File
@@ -10,11 +10,13 @@ namespace ConnectorService.Hubs
{
private readonly IConversationIntergrationService conService;
private readonly StackExchange.Redis.IDatabase redis;
private readonly ConnectionRegistry registry;
public ChatHub(IConversationIntergrationService conService, IConnectionMultiplexer multiplexer)
public ChatHub(IConversationIntergrationService conService, IConnectionMultiplexer multiplexer, ConnectionRegistry registry)
{
this.conService = conService;
this.redis = multiplexer.GetDatabase();
this.registry = registry;
}
public async override Task OnConnectedAsync()
@@ -34,6 +36,7 @@ namespace ConnectorService.Hubs
}
await redis.SetAddAsync(RedisHelper.GetConnectionIdKey(userId), Context.ConnectionId);
await registry.Add(Context);
await base.OnConnectedAsync();
@@ -41,6 +44,7 @@ namespace ConnectorService.Hubs
public async override Task OnDisconnectedAsync(Exception? exception)
{
await registry.Remove(Context);
if (Context.User.Identity.IsAuthenticated)
{
var userId = Context.User.FindFirstValue(ClaimTypes.NameIdentifier);
+8
View File
@@ -1,5 +1,7 @@
using IM.InitCommon.Management;
using ConnectorService.Hubs;
using ConnectorService.Services;
using IM.InitCommon;
namespace ConnectorService
@@ -15,6 +17,8 @@ namespace ConnectorService
builder.ConfigureDbConfiguration();
builder.Services.AddSignalR();
builder.Services.AddSingleton<ConnectionRegistry>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ConnectionRegistry>());
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
@@ -23,6 +27,7 @@ namespace ConnectorService
builder.ConfigExtraServices();
var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
@@ -32,9 +37,12 @@ namespace ConnectorService
}
app.UseAppDefault();
app.MapManagementHealth();
app.MapHub<ChatHub>("/chat");
app.MapGet("/internal/management/connections", async (ConnectionRegistry registry) => await registry.Counts());
app.MapPost("/internal/management/disconnect/{id:guid}", async (Guid id, ConnectionRegistry registry) => { await registry.Disconnect(id); return new { disconnected = true }; });
app.Run();
}
@@ -0,0 +1,38 @@
using System.Collections.Concurrent;
using System.Security.Claims;
using Microsoft.AspNetCore.SignalR;
using StackExchange.Redis;
namespace ConnectorService.Services;
public sealed class ConnectionRegistry(IConnectionMultiplexer redis, ILogger<ConnectionRegistry> logger) : BackgroundService
{
const string Active = "im:management:connections";
static readonly RedisChannel Revocations = RedisChannel.Literal("im:management:disconnect");
readonly ConcurrentDictionary<string, HubCallerContext> contexts = new();
public async Task Add(HubCallerContext c) { contexts[c.ConnectionId] = c; await Beat(c); }
public async Task Remove(HubCallerContext c) { contexts.TryRemove(c.ConnectionId, out _); await redis.GetDatabase().SortedSetRemoveAsync(Active, Key(c)); }
static string Key(HubCallerContext c) => c.User?.FindFirstValue(ClaimTypes.NameIdentifier) + ":" + c.ConnectionId;
Task Beat(HubCallerContext c) => redis.GetDatabase().SortedSetAddAsync(Active, Key(c), DateTimeOffset.UtcNow.AddSeconds(90).ToUnixTimeSeconds());
public async Task Disconnect(Guid id) { Abort(id.ToString()); await redis.GetSubscriber().PublishAsync(Revocations, id.ToString()); }
void Abort(string userId) { foreach (var c in contexts.Values.Where(c => c.User!.FindFirstValue(ClaimTypes.NameIdentifier) == userId)) c.Abort(); }
public async Task<object> Counts() {
var db = redis.GetDatabase(); await db.SortedSetRemoveRangeByScoreAsync(Active, double.NegativeInfinity, DateTimeOffset.UtcNow.ToUnixTimeSeconds());
var active = await db.SortedSetRangeByScoreAsync(Active, DateTimeOffset.UtcNow.ToUnixTimeSeconds(), double.PositiveInfinity);
return new { connections = active.Length, users = active.Select(x => x.ToString().Split(':')[0]).Distinct().Count(), sampledAt = DateTime.UtcNow, expiresAfterSeconds = 90 };
}
protected override async Task ExecuteAsync(CancellationToken ct) {
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
var subscribed = false;
try { do {
try {
if (!subscribed) { await redis.GetSubscriber().SubscribeAsync(Revocations, (_, value) => Abort(value.ToString())); subscribed = true; }
foreach (var c in contexts.Values) { if (c.ConnectionAborted.IsCancellationRequested) await Remove(c); else await Beat(c); }
} catch (Exception e) when (!ct.IsCancellationRequested) {
// Fail closed while the revocation channel is unavailable.
foreach (var c in contexts.Values) c.Abort();
logger.LogWarning("Connection heartbeat unavailable: {Type}",e.GetType().Name);
}
} while (await timer.WaitForNextTickAsync(ct)); }
finally { try { await redis.GetSubscriber().UnsubscribeAsync(Revocations); } catch { } }
}
}
@@ -1,8 +1,9 @@
using AutoMapper;
using AutoMapper;
using ContactService.Domain;
using ContactService.WebApi.Application.Dtos;
using ContactService.WebApi.Application.IntegrationServices;
using IM.Commons;
using Microsoft.EntityFrameworkCore;
namespace ContactService.WebApi.Application.FriendRequest
{
@@ -11,15 +12,15 @@ namespace ContactService.WebApi.Application.FriendRequest
private readonly IFriendRequestReposity reposity;
private readonly FriendRequestDomainService service;
private readonly IMapper mapper;
private readonly IIdentityIntegrationService identityService;
private readonly IIdentityIntegrationService identityService; private readonly IM.InitCommon.Management.RuntimePolicy runtime; private readonly ContactService.Infrastructure.ContactDbContext db;
public FriendRequestService(IFriendRequestReposity reposity, FriendRequestDomainService service,
IMapper mapper, IIdentityIntegrationService identityService)
IMapper mapper, IIdentityIntegrationService identityService, IM.InitCommon.Management.RuntimePolicy runtime, ContactService.Infrastructure.ContactDbContext db)
{
this.reposity = reposity;
this.service = service;
this.mapper = mapper;
this.identityService = identityService;
this.identityService = identityService; this.runtime = runtime; this.db = db;
}
public async Task<Result<FriendRequestResponse>> CreateAsync(CreateFriendRequestCommand command)
@@ -50,6 +51,9 @@ namespace ContactService.WebApi.Application.FriendRequest
switch (command.Action)
{
case FriendRequestAction.Accpet:
var limit = runtime.Current.FriendLimit;
if (limit > 0 && (await db.Friends.CountAsync(x => x.Owner.Id == request.OwnerId) >= limit || await db.Friends.CountAsync(x => x.Owner.Id == request.TargetId) >= limit))
return Result<FriendRequestResponse>.Fail(ResultCode.PERMISSION_DENIED, "好友数量已达到平台上限");
request.Accept(command.RemarkName);
break;
case FriendRequestAction.Block:
@@ -0,0 +1,11 @@
using ContactService.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace ContactService.WebApi.Controllers;
[ApiController, Route("internal/management")]
public sealed class ManagementController(ContactDbContext db) : ControllerBase
{
[HttpGet("relation/{owner:guid}/{target:guid}")]
public async Task<object> Relation(Guid owner, Guid target) => new { related = await db.Friends.AnyAsync(x => x.Owner.Id == owner && x.Target.Id == target) };
}
+3
View File
@@ -1,3 +1,4 @@
using IM.InitCommon.Management;
using IM.InitCommon;
@@ -23,6 +24,7 @@ namespace ContactService.WebApi
builder.Services.AddAllGrpcServer();
var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
@@ -32,6 +34,7 @@ namespace ContactService.WebApi
}
app.UseAppDefault();
app.MapManagementHealth();
app.MapControllers();
@@ -16,22 +16,33 @@ namespace FileService.Application.EventHandler
private readonly IObjectStorageRouter router;
private readonly IUploadFileReposity uploadFile;
private readonly IUnitOfWork uwork;
private readonly IStorageRedisCache storageCache;
private readonly IUploadTaskReposity uploadTask;
public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUnitOfWork uwork, IStorageRedisCache storageCache)
public UploadTaskCompleteEventHandler(IObjectStorageRouter router, IUploadFileReposity uploadFile, IUploadTaskReposity uploadTask, IUnitOfWork uwork)
{
this.router = router;
this.uploadFile = uploadFile;
this.uwork = uwork;
this.storageCache = storageCache;
this.uploadTask = uploadTask;
}
public async Task Consume(ConsumeContext<UploadTaskCompleteEvent> context)
{
var @event = context.Message;
var existingFile = await uploadFile.FindBySourceTaskIdAsync(@event.TaskId);
if (existingFile != null)
{
return;
}
var task = await uploadTask.FindByIdAsync(@event.TaskId);
if (task is null)
{
throw new InvalidOperationException($"Upload task {@event.TaskId} has not been committed yet.");
}
var storage = router.Route(@event.ProviderCode);
var taskCache = await storageCache.GetAsync(@event.SessionId);
if(@event.ProviderCode == "Local")
try
{
await storage.CompleteUploadAsync(new StorageContracts.CompleteUploadCommand(
ProviderCode: @event.ProviderCode,
@@ -46,14 +57,27 @@ namespace FileService.Application.EventHandler
Checksum: s.Checksum
)).ToList()
), context.CancellationToken);
uploadFile.Create(new Domain.Entities.UploadFile(
var file = new Domain.Entities.UploadFile(
ownerId: @event.OperatorId,
fileName: @event.FileName,
fileSize: taskCache.FileSize,
fileSize: @event.FileSize,
contentType: @event.ContentType,
new Domain.ValueObjects.StorageLocation(taskCache.ProviderCode, taskCache.Bucket, taskCache.ObjectKey, taskCache.Region),
checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun)
));
new Domain.ValueObjects.StorageLocation(@event.ProviderCode, @event.Bucket, @event.ObjectKey, @event.Region),
checkSum: new Domain.ValueObjects.CheckSum("md5", @event.CheckSun),
sourceTaskId: @event.TaskId,
chatType: @event.ChatType,
targetId: @event.TargetId,
isPublic: false
);
uploadFile.Create(file);
task.CompleteUpload(file.Id);
await uwork.SaveChangesAsync(context.CancellationToken);
}
catch (Exception ex)
{
task.Fail(ex.Message);
await uwork.SaveChangesAsync(context.CancellationToken);
throw;
}
}
}
@@ -6,6 +6,7 @@ namespace FileService.Application.Ports
public interface IObjectStoragePort
{
string ProviderCode { get; }
Task<UploadPart> WritePartAsync(UploadRuntimeCache task, int partNumber, Stream content, long size, CancellationToken ct) => throw new NotSupportedException();
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command,CancellationToken token);
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token);
public Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token);
@@ -60,6 +60,7 @@ namespace FileService.Application.StorageContracts
public sealed record PresignedUrl(
string Url,
string Method,
IReadOnlyDictionary<string, string> Headers,
DateTimeOffset ExpiresAt);
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
@@ -46,7 +46,7 @@ namespace FileService.Application.StorageContracts
ObjectKey = objectKey;
FileSize = fileSize;
TotalPartCount = totalPartCount;
ExpireAt = expireAt ?? DateTime.MaxValue;
ExpireAt = expireAt ?? DateTimeOffset.UtcNow.AddHours(24);
CreatedAt = DateTime.Now;
}
@@ -12,12 +12,14 @@ namespace FileService.Application.UploadFile
{
public Guid Id { get; set; }
public Guid OwnerId { get; set; }
public FileName FileName { get; set; }
public string FileName { get; set; }
public long FileSize { get; set; }
public ContentType ContentType { get; set; }
public string ContentType { get; set; }
public FileState State { get; set; }
public StorageLocation StorageLocation { get; set; }
public CheckSum CheckSum { get; set; }
public string CheckSum { get; set; }
public string? ChatType { get; set; }
public Guid? TargetId { get; set; }
public bool IsPublic { get; set; }
public DateTimeOffset Created { get; set; }
public DateTimeOffset Updated { get; set; }
@@ -0,0 +1,19 @@
using System.Net.Http.Json;
namespace FileService.Application.UploadFile
{
public interface IGroupAccessService
{
Task<bool> CheckMemberAsync(Guid userId, Guid groupId);
}
public class GroupAccessService(HttpClient httpClient) : IGroupAccessService
{
public async Task<bool> CheckMemberAsync(Guid userId, Guid groupId)
{
var result = await httpClient.GetFromJsonAsync<IM.Commons.Result<bool>>(
$"api/groupmember/checkmember?userId={userId}&groupId={groupId}");
return result?.Succeeded == true && result.Data;
}
}
}
@@ -12,8 +12,11 @@ namespace FileService.Application.UploadFile
public UploadFileMapperConfig()
{
CreateMap<Domain.Entities.UploadFile, FileResponse>()
.ForMember(dest => dest.FileName, opt => opt.MapFrom(src => src.FileName.Value))
.ForMember(dest => dest.ContentType, opt => opt.MapFrom(src => src.ContentType.Value))
.ForMember(dest => dest.CheckSum, opt => opt.MapFrom(src => src.CheckSum.Value))
.ForMember(dest => dest.Created, opt => opt.MapFrom(src => src.CreationTime))
.ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime))
.ForMember(dest => dest.Updated, opt => opt.MapFrom(src => src.ModificationTime ?? src.CreationTime))
;
}
}
@@ -6,6 +6,7 @@ using FileService.Domain.ValueObjects;
using IM.Commons;
using IM.InitCommon;
using Microsoft.Extensions.Options;
using System.Security.Cryptography;
namespace FileService.Application.UploadFile
{
@@ -15,27 +16,35 @@ namespace FileService.Application.UploadFile
private readonly IMapper mapper;
private readonly IObjectStorageRouter router;
private readonly IOptions<StorageOptions> options;
private readonly IGroupAccessService groupAccessService; private readonly IM.InitCommon.Management.RuntimePolicy runtime;
public UploadFileService(IUploadFileReposity reposity, IMapper mapper,
IObjectStorageRouter router, IOptions<StorageOptions> options)
IObjectStorageRouter router, IOptionsSnapshot<StorageOptions> options,
IGroupAccessService groupAccessService, IM.InitCommon.Management.RuntimePolicy runtime)
{
this.reposity = reposity;
this.mapper = mapper;
this.router = router;
this.options = options;
this.groupAccessService = groupAccessService; this.runtime = runtime;
}
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id)
public async Task<Result<FileResponse>> GetFileInfoAsync(Guid id, Guid requesterId)
{
var file = await reposity.FindByIdAsync(id);
if (file == null)
{
return Result.Fail<FileResponse>(ResultCode.FILE_NOT_FOUND);
}
if (!await CanAccessAsync(file, requesterId))
{
return Result.Fail<FileResponse>(ResultCode.PERMISSION_DENIED);
}
var response = mapper.Map<FileResponse>(file);
response.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
response.IsPublic = response.IsPublic || response.Url != null;
return Result.Success(response);
}
@@ -45,15 +54,31 @@ namespace FileService.Application.UploadFile
/// </summary>
public async Task<Result<FileResponse>> SimpleUploadAsync(SimpleUploadCommand command, CancellationToken token = default)
{
// 秒传:相同 checksum 的文件已存在则直接返回已有记录
if (!string.IsNullOrEmpty(command.CheckSum))
runtime.CheckFile(command.FileName, command.FileSize);
if (command.FileSize <= 0 || command.FileSize > options.Value.Providers[options.Value.DefaultProviderCode].MaxObjectSizeBytes) return Result.Fail<FileResponse>(ResultCode.FILE_TOO_LARGE);
var checksum = command.CheckSum;
if (string.IsNullOrWhiteSpace(checksum))
{
var existing = await reposity.FindByCheckSumGlobalAsync("md5", command.CheckSum);
if (existing != null)
var hash = await MD5.HashDataAsync(command.Content, token);
checksum = Convert.ToHexString(hash).ToLowerInvariant();
if (command.Content.CanSeek)
{
command.Content.Position = 0;
}
}
// 秒传:相同 checksum 的文件已存在则直接返回已有记录
if (!string.IsNullOrEmpty(checksum))
{
var existing = await reposity.FindByCheckSumGlobalAsync("md5", checksum);
var existingPublicUrl = existing == null
? null
: router.Route(existing.StorageLocation.StorageProvider).GetPublicUrl(existing.StorageLocation);
if (existing != null && (existingPublicUrl != null ||
(!command.IsPublic && existing.OwnerId == command.OwnerId)))
{
var hit = mapper.Map<FileResponse>(existing);
var hitStorage = router.Route(existing.StorageLocation.StorageProvider);
hit.Url = hitStorage.GetPublicUrl(existing.StorageLocation);
hit.Url = existingPublicUrl;
hit.IsPublic = hit.IsPublic || hit.Url != null;
return Result.Success(hit);
}
}
@@ -84,7 +109,8 @@ namespace FileService.Application.UploadFile
fileSize: command.FileSize,
contentType: command.ContentType,
storageLocation: location,
checkSum: new CheckSum("md5", command.CheckSum ?? string.Empty));
checkSum: new CheckSum("md5", checksum),
isPublic: command.IsPublic);
reposity.Create(file);
@@ -103,6 +129,10 @@ namespace FileService.Application.UploadFile
{
return Result.Fail<FileDownload>(ResultCode.FILE_NOT_FOUND);
}
if (!await CanAccessAsync(file, requesterId))
{
return Result.Fail<FileDownload>(ResultCode.PERMISSION_DENIED);
}
var stream = await router.Route(file.StorageLocation.StorageProvider)
.OpenReadAsync(file.StorageLocation, token);
@@ -113,17 +143,33 @@ namespace FileService.Application.UploadFile
file.FileName.Value));
}
// FileName 值对象限制 20 字符;原始名超长时安全截断(保留扩展名),真实文件名由 objectKey 保证唯一。
private async Task<bool> CanAccessAsync(Domain.Entities.UploadFile file, Guid requesterId)
{
var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation);
if (file.IsPublic || publicUrl != null || file.OwnerId == requesterId) return true;
if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase))
{
return file.TargetId == requesterId;
}
if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) && file.TargetId.HasValue)
{
return await groupAccessService.CheckMemberAsync(requesterId, file.TargetId.Value);
}
return false;
}
// 文件名超长时安全截断(保留扩展名),真实存储键由 objectKey 保证唯一。
private static FileName SafeFileName(string fileName)
{
if (fileName.Length <= 20)
const int maxFileNameLength = 255;
if (fileName.Length <= maxFileNameLength)
{
return new FileName(fileName);
}
var ext = Path.GetExtension(fileName);
var stem = Path.GetFileNameWithoutExtension(fileName);
var keep = Math.Max(0, 20 - ext.Length);
var keep = Math.Max(0, maxFileNameLength - ext.Length);
return new FileName(stem[..Math.Min(stem.Length, keep)] + ext);
}
}
@@ -13,5 +13,10 @@ namespace FileService.Application.UploadFileTask
public string UploadSessionId { get; init; }
public StorageLocation StorageLocation { get; init; }
public bool Instant { get; init; }
public string UploadMode { get; init; } = "LocalMultipart";
public int TotalPartCount { get; init; }
public long PartSizeBytes { get; init; }
public global::FileService.Application.UploadFile.FileResponse? File { get; init; }
}
}
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using FileService.Application.Ports;
using FileService.Application.StorageContracts;
using FileService.Domain.IReposities;
@@ -12,9 +12,9 @@ namespace FileService.Application.UploadFileTask
{
public class UploadFileTaskService(IUploadTaskReposity reposity,
IMapper mapper, IObjectStorageRouter router,
IOptions<StorageOptions> options, IStorageRedisCache redis,
IOptionsSnapshot<StorageOptions> options, IStorageRedisCache redis,
IPublishEndpoint endpoint, ILocalChunkStorage localChunkStorage,
IUploadFileReposity uploadFileReposity
IUploadFileReposity uploadFileReposity, IM.InitCommon.Management.RuntimePolicy runtime, UploadFile.IGroupAccessService groupAccess
)
{
private readonly IUploadTaskReposity reposity = reposity;
@@ -29,18 +29,29 @@ namespace FileService.Application.UploadFileTask
public async Task<Result<TaskInitResponse>> InitTaskAsync(UploadTaskInitCommand command)
{
runtime.CheckFile(command.FileName, command.FileSize);
if (command.FileSize <= 0) return Result.Fail<TaskInitResponse>(ResultCode.PARAMETER_ERROR);
await CheckGroup(command.ChatType, command.TargetId, command.UploaderId);
CancellationToken cancellationToken = CancellationToken.None;
// 秒传:相同 checksum 的文件若已存在于已完成文件表,直接返回已有记录
var existingFile = await uploadFileReposity.FindByCheckSumGlobalAsync("md5", command.checkSum);
if (existingFile != null)
if (existingFile != null && CanReuse(existingFile, command))
{
var storageForResponse = router.Route(existingFile.StorageLocation.StorageProvider);
var fileResponse = mapper.Map<UploadFile.FileResponse>(existingFile);
fileResponse.Url = storageForResponse.GetPublicUrl(existingFile.StorageLocation);
fileResponse.IsPublic = fileResponse.IsPublic || fileResponse.Url != null;
return Result.Success(new TaskInitResponse
{
TaskId = existingFile.Id,
UploadSessionId = existingFile.Id.ToString(),
StorageLocation = existingFile.StorageLocation
StorageLocation = existingFile.StorageLocation,
Instant = true,
UploadMode = "Instant",
TotalPartCount = 0,
PartSizeBytes = 0,
File = fileResponse
});
}
@@ -58,7 +69,7 @@ namespace FileService.Application.UploadFileTask
var initUpdateCommand = new StorageContracts.InitiateUploadCommand(
ProviderCode: storageOption.ProviderCode,
Bucket: storageOption.Bucket,
ObjectKey: $"{storageOption.LocalRootPath}\\{date.Year}\\{date.Month}\\{date.Day}\\{command.FileName}",
ObjectKey: $"{date:yyyy/MM/dd}/{Guid.NewGuid():N}{Path.GetExtension(command.FileName)}",
ContentType: task.ContentType.Value,
ContentLength: command.FileSize,
null);
@@ -70,8 +81,20 @@ namespace FileService.Application.UploadFileTask
var res = mapper.Map<TaskInitResponse>(initRes);
res.TaskId = task.Id;
res = new TaskInitResponse
{
TaskId = task.Id,
UploadSessionId = initRes.UploadSessionId,
StorageLocation = initRes.Location,
Instant = false,
UploadMode = runtime.Enabled || string.Equals(storage.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase)
? "ServerMultipart"
: "Presigned",
TotalPartCount = totalPartCount,
PartSizeBytes = storageOption.DefaultPartSizeBytes
};
task.StartUpload();
task.StartUpload(new Domain.ValueObjects.StorageLocation(storageOption.ProviderCode, storageOption.Bucket, initUpdateCommand.ObjectKey, storageOption.Region));
reposity.Create(task);
await redis.SetAsync(new StorageContracts.UploadRuntimeCache(
@@ -97,18 +120,27 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<PresignedUrl>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<PresignedUrl>(ResultCode.PERMISSION_DENIED);
}
runtime.CheckFile(task.FileName.Value, task.FileSize);
await CheckGroup(task.ChatType, task.TargetId, userId);
if (taskCache.TotalPartCount < partNum || partNum < 1)
{
return Result.Fail<PresignedUrl>(ResultCode.INVALID_PART_NUMBER);
}
var presignUrl = await storage.GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
if (runtime.Enabled) return Result.Fail<PresignedUrl>(ResultCode.PERMISSION_DENIED, "请通过鉴权分片接口上传,以保证封禁立即生效");
var presignUrl = await router.Route(taskCache.ProviderCode).GenerateUploadUrlAsync(new GenerateUploadUrlCommand(
ProviderCode: taskCache.ProviderCode,
Bucket: taskCache.Bucket,
ObjectKey: taskCache.ObjectKey,
UploadSessionId: taskCache.UploadSessionId,
PartNumber: partNum,
ExpiresIn: options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn
ExpiresIn: options.Value.Providers[taskCache.ProviderCode].UploadUrlExpiresIn
), token);
return Result.Success(presignUrl);
@@ -123,24 +155,61 @@ namespace FileService.Application.UploadFileTask
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if (task.UploaderId != command.userId)
{
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)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PART_COUNT_MISMATCH);
}
// 校验所有分片都已在上传缓存中注册
foreach (var part in command.Parts)
var expectedPartNumbers = Enumerable.Range(1, taskCache.TotalPartCount).ToHashSet();
if (!expectedPartNumbers.SetEquals(command.Parts.Select(x => x.PartNumber)))
{
if (!taskCache.Parts.TryGetValue(part.PartNumber, out _))
return Result.Fail<UploadTaskResponse>(ResultCode.INVALID_PART_NUMBER);
}
// 本地分片必须由本服务接收;预签名模式由对象存储在完成合并时校验 ETag。
if (runtime.Enabled || string.Equals(taskCache.ProviderCode, "Local", StringComparison.OrdinalIgnoreCase))
{
foreach (var part in command.Parts)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
if (!taskCache.Parts.TryGetValue(part.PartNumber, out _))
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
}
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task.State == Domain.UploadTaskState.Completed)
{
var completedResponse = mapper.Map<UploadTaskResponse>(task);
if (task.ResultFileId.HasValue)
{
var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value);
if (file != null)
{
completedResponse.File = mapper.Map<UploadFile.FileResponse>(file);
completedResponse.File.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
completedResponse.File.IsPublic = completedResponse.File.IsPublic || completedResponse.File.Url != null;
}
}
return Result.Success(completedResponse);
}
task.CompleteUpload(new Domain.ValueObjects.StorageLocation(
task.StartMerging(new Domain.ValueObjects.StorageLocation(
taskCache.ProviderCode, taskCache.Bucket,
taskCache.ObjectKey, taskCache.Region
));
@@ -162,21 +231,32 @@ namespace FileService.Application.UploadFileTask
FileSize = task.FileSize,
ContentType = task.ContentType.ToString(),
CheckSun = task.CheckSum.Value
,ChatType = task.ChatType
,TargetId = task.TargetId
}, cancellationToken);
return Result.Success(mapper.Map<UploadTaskResponse>(task));
}
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command)
public async Task<Result<CompleteUploadResult>> UploadPartAsync(UploadPartCommand command, Guid userId)
{
var taskCache = await redis.GetAsync(command.SessionId);
if (taskCache is null)
{
return Result.Fail<CompleteUploadResult>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
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;
@@ -186,13 +266,17 @@ namespace FileService.Application.UploadFileTask
$"分片 {command.PartNum} 大小为 {command.ContentLength} 字节,小于最小值 {minPartSize} 字节");
}
StorageContracts.UploadPart uploaded;
if (taskCache.ProviderCode == "Local") {
await localChunkStorage.SavePartAsync(new SaveLocalPartCommand(
UploadSessionId: command.SessionId,
PartNumber: command.PartNum,
Stream: command.Stream,
ContentLength: command.ContentLength
));
taskCache.AddOrUpdatePart(new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength));
uploaded = new StorageContracts.UploadPart(command.PartNum, command.PartNum.ToString(), command.ContentLength);
} else uploaded = await router.Route(taskCache.ProviderCode).WritePartAsync(taskCache, command.PartNum, command.Stream, command.ContentLength, CancellationToken.None);
taskCache.AddOrUpdatePart(uploaded);
await redis.SetAsync(taskCache);
var location = new Domain.ValueObjects.StorageLocation(
storageProvider: taskCache.ProviderCode,
@@ -200,7 +284,7 @@ namespace FileService.Application.UploadFileTask
objectKey: taskCache.ObjectKey,
region: taskCache.Region
);
return Result.Success(new CompleteUploadResult(location, command.PartNum.ToString(), command.ContentLength));
return Result.Success(new CompleteUploadResult(location, uploaded.ETag, command.ContentLength));
}
/// <summary>
@@ -213,6 +297,11 @@ namespace FileService.Application.UploadFileTask
{
return Result.Fail<UploadProgressResponse>(ResultCode.CHUNK_NOT_FOUND);
}
var task = await reposity.FindByIdAsync(Guid.Parse(taskCache.TaskId));
if (task is null || task.UploaderId != userId)
{
return Result.Fail<UploadProgressResponse>(ResultCode.PERMISSION_DENIED);
}
var response = new UploadProgressResponse
{
@@ -229,5 +318,59 @@ namespace FileService.Application.UploadFileTask
return Result.Success(response);
}
private async Task CheckGroup(string? chatType, Guid? targetId, Guid userId)
{
if (string.Equals(chatType, "GROUP", StringComparison.OrdinalIgnoreCase) && (!targetId.HasValue || !await groupAccess.CheckMemberAsync(userId, targetId.Value)))
throw new IM.DomainCommons.DomainException("群组已被封禁或没有群文件写入权限");
}
private bool CanReuse(Domain.Entities.UploadFile file, UploadTaskInitCommand command)
{
var publicUrl = router.Route(file.StorageLocation.StorageProvider).GetPublicUrl(file.StorageLocation);
if (file.IsPublic || publicUrl != null) return true;
if (file.OwnerId == command.UploaderId)
{
return string.Equals(file.ChatType, command.ChatType, StringComparison.OrdinalIgnoreCase) &&
file.TargetId == command.TargetId;
}
if (string.Equals(file.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase))
{
return string.Equals(command.ChatType, "GROUP", StringComparison.OrdinalIgnoreCase) &&
file.TargetId == command.TargetId;
}
if (string.Equals(file.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase))
{
return string.Equals(command.ChatType, "PRIVATE", StringComparison.OrdinalIgnoreCase) &&
file.TargetId == command.UploaderId && command.TargetId == file.OwnerId;
}
return false;
}
public async Task<Result<UploadTaskResponse>> GetStatusAsync(Guid taskId, Guid userId)
{
var task = await reposity.FindByIdAsync(taskId);
if (task is null)
{
return Result.Fail<UploadTaskResponse>(ResultCode.CHUNK_NOT_FOUND);
}
if (task.UploaderId != userId)
{
return Result.Fail<UploadTaskResponse>(ResultCode.PERMISSION_DENIED);
}
var response = mapper.Map<UploadTaskResponse>(task);
if (task.ResultFileId.HasValue)
{
var file = await uploadFileReposity.FindByIdAsync(task.ResultFileId.Value);
if (file != null)
{
response.File = mapper.Map<UploadFile.FileResponse>(file);
response.File.Url = router.Route(file.StorageLocation.StorageProvider)
.GetPublicUrl(file.StorageLocation);
response.File.IsPublic = response.File.IsPublic || response.File.Url != null;
}
}
return Result.Success(response);
}
}
}
@@ -8,7 +8,7 @@ namespace FileService.Application.UploadFileTask
{
public record UploadTaskInitCommand(
Guid UploaderId,
Guid ConversationId, string FileName,
Guid ConversationId, string? ChatType, Guid? TargetId, string FileName,
long FileSize,string contentType,
string checkSum
)
@@ -17,7 +17,7 @@ namespace FileService.Application.UploadFileTask
{
return new Domain.Entities.UploadTask(
UploaderId,
ConversationId, FileName, FileSize, contentType,
ConversationId, ChatType, TargetId, FileName, FileSize, contentType,
null,new Domain.ValueObjects.CheckSum("md5", checkSum)
);
}
@@ -19,5 +19,8 @@ namespace FileService.Application.UploadFileTask
public StorageLocation StorageLocation { get; set; }
public string State { get; set; }
public string CheckSum { get; set; }
public Guid? ResultFileId { get; set; }
public string? FailureReason { get; set; }
public global::FileService.Application.UploadFile.FileResponse? File { get; set; }
}
}
+9 -1
View File
@@ -12,10 +12,14 @@ namespace FileService.Domain.Entities
public FileState State { get; private set; } = FileState.Uploaded;
public StorageLocation StorageLocation { get; private set; } = new StorageLocation();
public CheckSum CheckSum { get; private set; }
public Guid? SourceTaskId { get; private set; }
public string? ChatType { get; private set; }
public Guid? TargetId { get; private set; }
public bool IsPublic { get; private set; }
private UploadFile() { }
public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum)
public UploadFile(Guid ownerId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum, Guid? sourceTaskId = null, string? chatType = null, Guid? targetId = null, bool isPublic = false)
{
OwnerId = ownerId;
FileName = fileName;
@@ -23,6 +27,10 @@ namespace FileService.Domain.Entities
ContentType = contentType;
StorageLocation = storageLocation ?? new StorageLocation();
CheckSum = checkSum;
SourceTaskId = sourceTaskId;
ChatType = chatType?.ToUpperInvariant();
TargetId = targetId;
IsPublic = isPublic;
State = FileState.Uploaded;
}
+25 -7
View File
@@ -1,4 +1,4 @@
using FileService.Domain.Events;
using FileService.Domain.Events;
using FileService.Domain.ValueObjects;
using IM.DomainCommons;
@@ -8,19 +8,25 @@ namespace FileService.Domain.Entities
{
public Guid UploaderId { get; private set; }
public Guid ConversationId { get; private set; }
public string? ChatType { get; private set; }
public Guid? TargetId { get; private set; }
public FileName FileName { get; private set; }
public long FileSize { get; private set; }
public ContentType ContentType { get; private set; }
public StorageLocation StorageLocation { get; private set; }
public UploadTaskState State { get; private set; }
public CheckSum CheckSum { get; private set; }
public Guid? ResultFileId { get; private set; }
public string? FailureReason { get; private set; }
private UploadTask() { }
public UploadTask(Guid uploaderId, Guid conversationId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum)
public UploadTask(Guid uploaderId, Guid conversationId, string? chatType, Guid? targetId, FileName fileName, long fileSize, ContentType contentType, StorageLocation? storageLocation, CheckSum checkSum)
{
UploaderId = uploaderId;
ConversationId = conversationId;
ChatType = chatType?.ToUpperInvariant();
TargetId = targetId;
FileName = fileName;
FileSize = fileSize;
ContentType = contentType;
@@ -28,21 +34,33 @@ namespace FileService.Domain.Entities
CheckSum = checkSum;
}
public void StartUpload()
public void StartUpload(StorageLocation? location = null)
{
State = UploadTaskState.Uploading;
if (location != null) StorageLocation = location; State = UploadTaskState.Uploading;
}
public void CompleteUpload(StorageLocation location)
public void StartMerging(StorageLocation location)
{
StorageLocation = location;
State = UploadTaskState.Merging;
FailureReason = null;
NotifyModified();
}
public void CompleteUpload(Guid fileId)
{
ResultFileId = fileId;
State = UploadTaskState.Completed;
FailureReason = null;
NotifyModified();
AddDomainEvent(new UploadTaskCompletedDomainEvent(this));
}
public void Fail()
public void Fail(string reason)
{
State = UploadTaskState.Failed;
FailureReason = reason.Length > 500 ? reason[..500] : reason;
NotifyModified();
}
}
}
@@ -19,5 +19,6 @@ namespace FileService.Domain.IReposities
/// 全局按 checksum 查询(用于跨用户秒传去重)
/// </summary>
Task<UploadFile?> FindByCheckSumGlobalAsync(string algorithm, string value);
Task<UploadFile?> FindBySourceTaskIdAsync(Guid taskId);
}
}
+2 -2
View File
@@ -12,9 +12,9 @@ namespace FileService.Domain.ValueObjects
public FileName(string value)
{
if(value.Length > 20)
if (string.IsNullOrWhiteSpace(value) || value.Length > 255)
{
throw new ArgumentException("文件名超出长度");
throw new ArgumentException("文件名不能为空且不能超过 255 个字符");
}
Value = value;
@@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs
{
builder.ToTable("upload_files");
builder.Property(x => x.FileName)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.FileName(b)
);
builder.Property(x => x.ContentType)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.ContentType(b)
@@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs
builder.ComplexProperty(x => x.CheckSum, c =>
{
c.Property(p => p.Value)
.HasMaxLength(128)
.HasColumnName("checksum_value");
c.Property(p => p.Algorithm)
.HasMaxLength(16)
.HasColumnName("checksum_algorithm");
});
builder.HasIndex(x => x.SourceTaskId).IsUnique();
builder.Property(x => x.ChatType).HasMaxLength(16);
builder.ComplexProperty(x => x.StorageLocation, c =>
{
c.Property(p => p.StorageProvider)
.HasMaxLength(64)
.HasColumnName("storage_provider");
c.Property(p => p.ObjectKey)
.HasMaxLength(1024)
.HasColumnName("storage_key");
c.Property(p => p.Region)
.HasMaxLength(128)
.HasColumnName("storage_region");
c.Property(p => p.Bucket)
.HasMaxLength(255)
.HasColumnName("storage_bucket");
});
}
@@ -15,12 +15,14 @@ namespace FileService.Infrastructure.Configs
{
builder.ToTable("upload_tasks");
builder.Property(x => x.FileName)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.FileName(b)
);
builder.Property(x => x.ContentType)
.HasMaxLength(255)
.HasConversion(
a => a.Value,
b => new Domain.ValueObjects.ContentType(b)
@@ -29,24 +31,32 @@ namespace FileService.Infrastructure.Configs
builder.ComplexProperty(x => x.CheckSum, c =>
{
c.Property(p => p.Value)
.HasMaxLength(128)
.HasColumnName("checksum_value");
c.Property(p => p.Algorithm)
.HasMaxLength(16)
.HasColumnName("checksum_algorithm");
});
builder.Property(x => x.FailureReason).HasMaxLength(500);
builder.Property(x => x.ChatType).HasMaxLength(16);
builder.ComplexProperty(x => x.StorageLocation, c =>
{
c.Property(p => p.StorageProvider)
.HasMaxLength(64)
.HasColumnName("storage_provider");
c.Property(p => p.ObjectKey)
.HasMaxLength(1024)
.HasColumnName("storage_key");
c.Property(p => p.Region)
.HasMaxLength(128)
.HasColumnName("storage_region");
c.Property(p => p.Bucket)
.HasMaxLength(255)
.HasColumnName("storage_bucket");
});
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.511.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
@@ -0,0 +1,131 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace FileService.Infrastructure.Migrations
{
[DbContext(typeof(FileDbContext))]
[Migration("20260909000300_AsyncUploadResult")]
public partial class AsyncUploadResult : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
AlterStringColumns(migrationBuilder, narrowing: true);
migrationBuilder.AddColumn<string>(
name: "ChatType",
table: "upload_files",
type: "varchar(16)",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsPublic",
table: "upload_files",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Guid>(
name: "SourceTaskId",
table: "upload_files",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "TargetId",
table: "upload_files",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ChatType",
table: "upload_tasks",
type: "varchar(16)",
maxLength: 16,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FailureReason",
table: "upload_tasks",
type: "varchar(500)",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "ResultFileId",
table: "upload_tasks",
type: "char(36)",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "TargetId",
table: "upload_tasks",
type: "char(36)",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_upload_files_SourceTaskId",
table: "upload_files",
column: "SourceTaskId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_upload_files_checksum",
table: "upload_files",
columns: new[] { "checksum_algorithm", "checksum_value", "IsDeleted" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex("IX_upload_files_checksum", "upload_files");
migrationBuilder.DropIndex("IX_upload_files_SourceTaskId", "upload_files");
migrationBuilder.DropColumn("ChatType", "upload_files");
migrationBuilder.DropColumn("IsPublic", "upload_files");
migrationBuilder.DropColumn("SourceTaskId", "upload_files");
migrationBuilder.DropColumn("TargetId", "upload_files");
migrationBuilder.DropColumn("ChatType", "upload_tasks");
migrationBuilder.DropColumn("FailureReason", "upload_tasks");
migrationBuilder.DropColumn("ResultFileId", "upload_tasks");
migrationBuilder.DropColumn("TargetId", "upload_tasks");
AlterStringColumns(migrationBuilder, narrowing: false);
}
private static void AlterStringColumns(MigrationBuilder migrationBuilder, bool narrowing)
{
var columns = new (string Table, string Column, int Length, bool Nullable)[]
{
("upload_files", "FileName", 255, false),
("upload_files", "ContentType", 255, false),
("upload_files", "checksum_algorithm", 16, false),
("upload_files", "checksum_value", 128, false),
("upload_files", "storage_provider", 64, false),
("upload_files", "storage_bucket", 255, false),
("upload_files", "storage_key", 1024, false),
("upload_files", "storage_region", 128, true),
("upload_tasks", "FileName", 255, false),
("upload_tasks", "ContentType", 255, false),
("upload_tasks", "checksum_algorithm", 16, false),
("upload_tasks", "checksum_value", 128, false),
("upload_tasks", "storage_provider", 64, false),
("upload_tasks", "storage_bucket", 255, false),
("upload_tasks", "storage_key", 1024, false),
("upload_tasks", "storage_region", 128, true)
};
foreach (var (table, column, length, nullable) in columns)
{
migrationBuilder.AlterColumn<string>(
name: column,
table: table,
type: narrowing ? $"varchar({length})" : "longtext",
maxLength: narrowing ? length : null,
nullable: nullable,
oldClrType: typeof(string),
oldType: narrowing ? "longtext" : $"varchar({length})",
oldMaxLength: narrowing ? null : length,
oldNullable: nullable);
}
}
}
}
@@ -25,13 +25,18 @@ namespace FileService.Infrastructure.Migrations
modelBuilder.Entity("FileService.Domain.Entities.UploadFile", b =>
{
b.Property<string>("ChatType")
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<DateTimeOffset>("CreationTime")
.HasColumnType("datetime(6)");
@@ -41,7 +46,8 @@ namespace FileService.Infrastructure.Migrations
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
@@ -49,27 +55,38 @@ namespace FileService.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<bool>("IsPublic")
.HasColumnType("tinyint(1)");
b.Property<DateTimeOffset?>("ModificationTime")
.HasColumnType("datetime(6)");
b.Property<Guid>("OwnerId")
.HasColumnType("char(36)");
b.Property<Guid?>("SourceTaskId")
.HasColumnType("char(36)");
b.Property<int>("State")
.HasColumnType("int");
b.Property<Guid?>("TargetId")
.HasColumnType("char(36)");
b.ComplexProperty<Dictionary<string, object>>("CheckSum", "FileService.Domain.Entities.UploadFile.CheckSum#CheckSum", b1 =>
{
b1.IsRequired();
b1.Property<string>("Algorithm")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(16)
.HasColumnType("varchar(16)")
.HasColumnName("checksum_algorithm");
b1.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("checksum_value");
});
@@ -79,38 +96,50 @@ namespace FileService.Infrastructure.Migrations
b1.Property<string>("Bucket")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(255)
.HasColumnType("varchar(255)")
.HasColumnName("storage_bucket");
b1.Property<string>("ObjectKey")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("storage_key");
b1.Property<string>("Region")
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("storage_region");
b1.Property<string>("StorageProvider")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("storage_provider");
});
b.HasKey("Id");
b.HasIndex("SourceTaskId")
.IsUnique();
b.ToTable("upload_files", (string)null);
});
modelBuilder.Entity("FileService.Domain.Entities.UploadTask", b =>
{
b.Property<string>("ChatType")
.HasMaxLength(16)
.HasColumnType("varchar(16)");
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<Guid>("ConversationId")
.HasColumnType("char(36)");
@@ -123,20 +152,31 @@ namespace FileService.Infrastructure.Migrations
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("longtext");
.HasMaxLength(255)
.HasColumnType("varchar(255)");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<string>("FailureReason")
.HasMaxLength(500)
.HasColumnType("varchar(500)");
b.Property<bool>("IsDeleted")
.HasColumnType("tinyint(1)");
b.Property<DateTimeOffset?>("ModificationTime")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ResultFileId")
.HasColumnType("char(36)");
b.Property<int>("State")
.HasColumnType("int");
b.Property<Guid?>("TargetId")
.HasColumnType("char(36)");
b.Property<Guid>("UploaderId")
.HasColumnType("char(36)");
@@ -146,12 +186,14 @@ namespace FileService.Infrastructure.Migrations
b1.Property<string>("Algorithm")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(16)
.HasColumnType("varchar(16)")
.HasColumnName("checksum_algorithm");
b1.Property<string>("Value")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("checksum_value");
});
@@ -161,21 +203,25 @@ namespace FileService.Infrastructure.Migrations
b1.Property<string>("Bucket")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(255)
.HasColumnType("varchar(255)")
.HasColumnName("storage_bucket");
b1.Property<string>("ObjectKey")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("storage_key");
b1.Property<string>("Region")
.HasColumnType("longtext")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("storage_region");
b1.Property<string>("StorageProvider")
.IsRequired()
.HasColumnType("longtext")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("storage_provider");
});
@@ -42,5 +42,10 @@ namespace FileService.Infrastructure.Reposites
x.CheckSum.Value == value
);
}
public Task<UploadFile?> FindBySourceTaskIdAsync(Guid taskId)
{
return db.Files.FirstOrDefaultAsync(x => x.SourceTaskId == taskId);
}
}
}
@@ -1,173 +1,70 @@
using FileService.Application.Ports;
using FileService.Application.Ports;
using FileService.Application.StorageContracts;
using FileService.Domain.ValueObjects;
using IM.Commons;
using IM.InitCommon;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace FileService.Infrastructure.Storage
namespace FileService.Infrastructure.Storage;
public class LocalStorageAdapter(IStorageRedisCache redis, IOptionsSnapshot<StorageOptions> options) : IObjectStoragePort, ILocalChunkStorage
{
public class LocalStorageAdapter(IStorageRedisCache redis, IOptions<StorageOptions> options) : IObjectStoragePort, ILocalChunkStorage
private StorageProviderOptions Provider => options.Value.Providers["Local"];
public string ProviderCode => "Local";
public static string SafePath(string root, params string[] segments)
{
private readonly IStorageRedisCache redis = redis;
private readonly IOptions<StorageOptions> options = options;
private readonly StorageProviderOptions providerOptions = options.Value.Providers[options.Value.DefaultProviderCode];
public string ProviderCode => "Local";
/// <summary>
/// 单次直传:直接写入 LocalRootPath/{bucket}/{objectKey}。
/// bucket 传公开桶名即落到公开目录,可被静态托管直链访问。
/// </summary>
public async Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token)
{
var fullPath = Path.Combine(providerOptions.LocalRootPath!, command.Bucket, command.ObjectKey);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
await using (var fs = new FileStream(fullPath, FileMode.Create))
{
await command.Content.CopyToAsync(fs, token);
await fs.FlushAsync(token);
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var path = Path.GetFullPath(Path.Combine(new[] { fullRoot }.Concat(segments).ToArray()));
if (!path.StartsWith(fullRoot, OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) throw new InvalidOperationException("存储路径超出允许根目录");
for (var dir = Path.GetDirectoryName(path); dir != null && dir.Length >= fullRoot.Length; dir = Path.GetDirectoryName(dir))
if (Directory.Exists(dir) && File.GetAttributes(dir).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用存储目录链接");
if (File.Exists(path) && File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("不允许使用文件链接");
return path;
}
public async Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token)
{
var path = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var stream = new FileStream(path, FileMode.CreateNew);
await command.Content.CopyToAsync(stream, token);
if (stream.Length != command.ContentLength) throw new InvalidOperationException("文件实际大小与申报大小不符");
return new(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region);
}
public string? GetPublicUrl(StorageLocation location) => !string.IsNullOrEmpty(Provider.PublicBucket) && location.Bucket == Provider.PublicBucket
? $"{(Provider.PublicBaseUrl ?? Provider.LocalUploadApiBaseUrl ?? "").TrimEnd('/')}/static/{string.Join('/', location.ObjectKey.Replace('\\', '/').Split('/').Select(Uri.EscapeDataString))}" : null;
public Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token) => Task.FromResult<Stream>(File.OpenRead(SafePath(Provider.LocalRootPath!, location.Bucket, location.ObjectKey)));
public Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command, CancellationToken token) => Task.FromResult(new InitiateUploadResult(Guid.NewGuid().ToString(), new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, Provider.Region)));
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) => Task.FromResult(new PresignedUrl(
Provider.LocalUploadApiBaseUrl!.TrimEnd('/') + $"/local/parts/upload?sessionId={Uri.EscapeDataString(command.UploadSessionId)}&partNumber={command.PartNumber}", "POST", new Dictionary<string, string>(), DateTimeOffset.UtcNow.Add(command.ExpiresIn)));
public async Task SavePartAsync(SaveLocalPartCommand command)
{
if (!Guid.TryParse(command.UploadSessionId, out _) || command.PartNumber < 1) throw new InvalidOperationException("分片参数无效");
var path = SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{command.PartNumber}.part");
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
await using var stream = File.Create(path);
await command.Stream.CopyToAsync(stream);
if (stream.Length != command.ContentLength) throw new InvalidOperationException("分片实际大小不符");
}
public async Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token)
{
var cache = await redis.GetAsync(command.UploadSessionId) ?? throw new InvalidOperationException("上传任务已过期");
var final = SafePath(Provider.LocalRootPath!, command.Bucket, command.ObjectKey);
Directory.CreateDirectory(Path.GetDirectoryName(final)!);
var temporary = final + ".merging";
await using (var output = File.Create(temporary)) {
foreach (var part in command.Parts.OrderBy(x => x.PartNumber)) {
await using var input = File.OpenRead(SafePath(Provider.LocalRootPath!, "staging", command.UploadSessionId, $"{part.PartNumber}.part"));
await input.CopyToAsync(output, token);
}
return new StorageLocation(
storageProvider: ProviderCode,
bucket: command.Bucket,
objectKey: command.ObjectKey,
region: providerOptions.Region);
}
/// <summary>
/// 公开桶文件返回静态托管直链;私有文件返回 null。
/// </summary>
public string? GetPublicUrl(StorageLocation location)
{
if (string.IsNullOrEmpty(providerOptions.PublicBucket) ||
!string.Equals(location.Bucket, providerOptions.PublicBucket, StringComparison.OrdinalIgnoreCase))
{
return null;
}
var baseUrl = (providerOptions.PublicBaseUrl ?? providerOptions.LocalUploadApiBaseUrl ?? string.Empty)
.TrimEnd('/');
var key = location.ObjectKey.Replace('\\', '/').TrimStart('/');
return $"{baseUrl}/static/{key}";
}
/// <summary>
/// 打开本地文件读取流:LocalRootPath/{bucket}/{objectKey}。
/// </summary>
public Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token)
{
var fullPath = Path.Combine(providerOptions.LocalRootPath!, location.Bucket, location.ObjectKey);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException(fullPath);
}
Stream stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
return Task.FromResult(stream);
}
public async Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token)
{
var res = await MergeAsync(command.UploadSessionId, command.ObjectKey, command.Parts);
return new CompleteUploadResult(new StorageLocation(
storageProvider: command.ProviderCode,
bucket: command.Bucket,
objectKey: command.ObjectKey,
region: command.Region
), null, command.Parts.Sum(x => x.Size).Value);
}
public async Task<Result<object>> MergeAsync(string sessionId, string objectKey, IReadOnlyList<UploadPart> parts)
{
var rootPath = options.Value.Providers[options.Value.DefaultProviderCode].LocalRootPath;
var tempPath = Path.Combine(rootPath, sessionId, "parts"); // 项目根目录下 uploads // 最终文件存储路径(这里可以用你之前 ObjectNameGenerator 生成的名字)
var finalPath = Path.Combine(rootPath, objectKey);
var finalDir = Path.GetDirectoryName(finalPath);
Directory.CreateDirectory(finalDir);
var storageCache = await redis.GetAsync(sessionId);
var totalChunks = storageCache.TotalPartCount;
try
{
using (var finalStream = new FileStream(finalPath, FileMode.Create))
{
for (var i = 1; i <= totalChunks; i++)
{
var progress = (i * 100.0 / totalChunks);
if (i % 5 == 0 || i == totalChunks)
{
//await _redis.HashSetAsync(RedisKeys.MergeStatus(taskId), new HashEntry[]
//{
// new("status", "processing"),
// new("progress", progress.ToString("F2"))
//});
}
var chunkPath = Path.Combine(tempPath, $"{i}.part");
if (!File.Exists(chunkPath))
return Result.Fail(ResultCode.CHUNK_NOT_FOUND);
using (var chunkStream = new FileStream(chunkPath, FileMode.Open))
{
await chunkStream.CopyToAsync(finalStream);
}
}
Directory.Delete(tempPath, true);
await redis.DeleteAsync(sessionId);
}
return Result.Success();
}
catch (Exception e)
{
//_logger.LogError(e, e.Message);
throw;
}
}
public async Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token)
{
var baseUrl = options.Value.Providers[options.Value.DefaultProviderCode].LocalUploadApiBaseUrl;
return new PresignedUrl(
baseUrl + $"local/parts/upload?sessionId={command.UploadSessionId}&partNumber={command.PartNumber}",
new Dictionary<string, string>(),
ExpiresAt: DateTimeOffset.Now.Add(options.Value.Providers[options.Value.DefaultProviderCode].UploadUrlExpiresIn)
);
}
public async Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command, CancellationToken token)
{
var sessionId = Guid.NewGuid();
var location = new StorageLocation();
return new InitiateUploadResult(sessionId.ToString(),location);
}
public async Task SavePartAsync(SaveLocalPartCommand command)
{
var path = BuildPartPath(
command.UploadSessionId,
command.PartNumber);
Directory.CreateDirectory(
Path.GetDirectoryName(path)!);
await using var fs = File.Create(path);
await command.Stream.CopyToAsync(fs);
await fs.FlushAsync();
}
private string BuildPartPath(
string uploadSessionId,
int partNumber)
{
return Path.Combine(
providerOptions.LocalRootPath,
uploadSessionId,
"parts",
$"{partNumber}.part");
if (output.Length != cache.FileSize) throw new InvalidOperationException("合并文件大小不符");
}
File.Move(temporary, final, true);
// Keep parts available for idempotent retry after response loss.
return new(new StorageLocation(ProviderCode, command.Bucket, command.ObjectKey, command.Region), null, cache.FileSize);
}
public async Task<Result<object>> MergeAsync(string sessionId, string objectKey, IReadOnlyList<UploadPart> parts)
{
var cache = await redis.GetAsync(sessionId) ?? throw new InvalidOperationException("上传任务已过期");
await CompleteUploadAsync(new CompleteUploadCommand(ProviderCode: cache.ProviderCode, Bucket: cache.Bucket, Region: cache.Region, ObjectKey: objectKey, UploadSessionId: sessionId, Parts: parts), CancellationToken.None);
return Result.Success();
}
}
@@ -1,22 +1,26 @@
using FileService.Application.Ports;
using FileService.Application.Ports;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using IM.InitCommon;
using Microsoft.Extensions.Options;
namespace FileService.Infrastructure.Storage
{
public class ObjectStorageRouter : IObjectStorageRouter
public class ObjectStorageRouter : IObjectStorageRouter, IDisposable
{
private IReadOnlyDictionary<string, IObjectStoragePort> adpters;
public ObjectStorageRouter(IEnumerable<IObjectStoragePort> storages)
public ObjectStorageRouter(IEnumerable<IObjectStoragePort> storages, IOptionsSnapshot<StorageOptions> options)
{
this.adpters = storages.ToDictionary(x => x.ProviderCode, StringComparer.OrdinalIgnoreCase);
var adapters = storages.ToDictionary(x => x.ProviderCode, StringComparer.OrdinalIgnoreCase);
foreach (var (code, provider) in options.Value.Providers.Where(x => x.Value.ProviderType is StorageProviderType.AwsS3 or StorageProviderType.Minio)) adapters[code] = new S3StorageAdapter(code, provider);
this.adpters = adapters;
}
public IObjectStoragePort Route(string providerCode)
public void Dispose() { foreach (var adapter in adpters.Values.OfType<S3StorageAdapter>()) adapter.Dispose(); } public IObjectStoragePort Route(string providerCode)
{
return this.adpters[providerCode];
}
@@ -0,0 +1,53 @@
using Amazon.S3;
using Amazon.S3.Model;
using FileService.Application.Ports;
using FileService.Application.StorageContracts;
using FileService.Domain.ValueObjects;
using IM.InitCommon;
namespace FileService.Infrastructure.Storage;
public sealed class S3StorageAdapter(string code, StorageProviderOptions options) : IObjectStoragePort, IDisposable
{
private readonly AmazonS3Client client = new(options.AccessKeyId, options.AccessKeySecret, new AmazonS3Config { ServiceURL = options.Endpoint, AuthenticationRegion = string.IsNullOrWhiteSpace(options.Region) ? "us-east-1" : options.Region, ForcePathStyle = true });
public string ProviderCode => code;
public async Task<UploadPart> WritePartAsync(UploadRuntimeCache task, int partNumber, Stream content, long size, CancellationToken ct) {
var part = await client.UploadPartAsync(new UploadPartRequest { BucketName = task.Bucket, Key = task.ObjectKey, UploadId = task.UploadSessionId, PartNumber = partNumber, InputStream = content, PartSize = size }, ct);
return new(partNumber, part.ETag, size);
}
public async Task<InitiateUploadResult> InitUploadAsync(InitiateUploadCommand command, CancellationToken token) {
var r = await client.InitiateMultipartUploadAsync(new InitiateMultipartUploadRequest { BucketName = command.Bucket, Key = command.ObjectKey, ContentType = command.ContentType }, token);
return new(r.UploadId, new StorageLocation(code, command.Bucket, command.ObjectKey, options.Region));
}
public Task<PresignedUrl> GenerateUploadUrlAsync(GenerateUploadUrlCommand command, CancellationToken token) {
var expires = DateTimeOffset.UtcNow.Add(command.ExpiresIn);
var request = new GetPreSignedUrlRequest { BucketName = command.Bucket, Key = command.ObjectKey, Verb = HttpVerb.PUT, Expires = expires.UtcDateTime, UploadId = command.UploadSessionId, PartNumber = command.PartNumber ?? 1 };
return Task.FromResult(new PresignedUrl(client.GetPreSignedURL(request), "PUT", new Dictionary<string, string>(), expires));
}
public async Task<CompleteUploadResult> CompleteUploadAsync(CompleteUploadCommand command, CancellationToken token) {
var response = await client.CompleteMultipartUploadAsync(new CompleteMultipartUploadRequest { BucketName = command.Bucket, Key = command.ObjectKey, UploadId = command.UploadSessionId, PartETags = command.Parts.OrderBy(x => x.PartNumber).Select(x => new PartETag(x.PartNumber, x.ETag)).ToList() }, token);
var meta = await client.GetObjectMetadataAsync(command.Bucket, command.ObjectKey, token);
return new(new StorageLocation(code, command.Bucket, command.ObjectKey, options.Region), response.ETag, meta.ContentLength, VersionId: response.VersionId);
}
public async Task<StorageLocation> PutObjectAsync(PutObjectCommand command, CancellationToken token) {
await client.PutObjectAsync(new PutObjectRequest { BucketName = command.Bucket, Key = command.ObjectKey, ContentType = command.ContentType, InputStream = command.Content, AutoCloseStream = false }, token);
return new(code, command.Bucket, command.ObjectKey, options.Region);
}
// Public rendering also passes through FileService. Private access is always authorized there.
public string? GetPublicUrl(StorageLocation location) => null;
public async Task<Stream> OpenReadAsync(StorageLocation location, CancellationToken token) { var response = await client.GetObjectAsync(location.Bucket, location.ObjectKey, token); return new ResponseStream(response); }
public async Task Test(CancellationToken ct) {
var key = "im-admin-connectivity/" + Guid.NewGuid().ToString("N");
try { await client.PutObjectAsync(new PutObjectRequest { BucketName = options.Bucket, Key = key, ContentBody = "IM connectivity test" }, ct); using var read = await client.GetObjectAsync(options.Bucket, key, ct); }
finally { await client.DeleteObjectAsync(options.Bucket, key, CancellationToken.None); }
}
public void Dispose() => client.Dispose();
sealed class ResponseStream(GetObjectResponse response) : Stream {
readonly Stream inner = response.ResponseStream;
public override bool CanRead => inner.CanRead; public override bool CanSeek => inner.CanSeek; public override bool CanWrite => false;
public override long Length => response.ContentLength; public override long Position { get => inner.Position; set => inner.Position = value; }
public override void Flush() => inner.Flush(); public override int Read(byte[] b, int o, int c) => inner.Read(b, o, c);
public override ValueTask<int> ReadAsync(Memory<byte> b, CancellationToken ct = default) => inner.ReadAsync(b, ct);
public override long Seek(long o, SeekOrigin origin) => inner.Seek(o, origin); public override void SetLength(long v) => throw new NotSupportedException(); public override void Write(byte[] b, int o, int c) => throw new NotSupportedException();
protected override void Dispose(bool disposing) { if (disposing) response.Dispose(); base.Dispose(disposing); }
}
}
@@ -1,6 +1,7 @@
using FileService.Application.UploadFile;
using FileService.Infrastructure;
using IM.ASPNETCore;
using IM.Commons;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -51,7 +52,8 @@ namespace FileService.WebApi.Controllers.File
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid id)
{
var res = await service.GetFileInfoAsync(id);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.GetFileInfoAsync(id, Guid.Parse(userId));
return Ok(res);
}
@@ -65,11 +67,15 @@ namespace FileService.WebApi.Controllers.File
var res = await service.OpenDownloadAsync(id, Guid.Parse(userId));
if (res.Data == null)
{
if (res.Code == (int)ResultCode.PERMISSION_DENIED)
{
return StatusCode(StatusCodes.Status403Forbidden, res);
}
return NotFound(res);
}
Response.Headers["Cache-Control"] = "private,max-age=86400";
return File(res.Data.Content, res.Data.ContentType);
return File(res.Data.Content, res.Data.ContentType, enableRangeProcessing: true);
}
}
}
@@ -5,8 +5,8 @@ namespace FileService.WebApi.Controllers.FileTask
{
public class CompleteTaskRequest
{
public string SessionId { get; set; }
public List<UploadPart> Parts { get; set; }
public string SessionId { get; set; } = string.Empty;
public List<UploadPart> Parts { get; set; } = [];
}
public class CompleteTaskRequestValidator : AbstractValidator<CompleteTaskRequest>
@@ -1,4 +1,4 @@
using FileService.Application.UploadFileTask;
using FileService.Application.UploadFileTask;
using FileService.Infrastructure;
using IM.ASPNETCore;
using Microsoft.AspNetCore.Authorization;
@@ -28,6 +28,8 @@ namespace FileService.WebApi.Controllers.FileTask
var res = await service.InitTaskAsync(new UploadTaskInitCommand(
UploaderId: Guid.Parse(userId),
ConversationId: request.ConversationId,
ChatType: request.ChatType,
TargetId: request.TargetId,
FileName: request.FileName,
FileSize: request.FileSize,
contentType: request.ContentType,
@@ -44,6 +46,13 @@ namespace FileService.WebApi.Controllers.FileTask
return Ok(res);
}
[HttpGet("status")]
public async Task<IActionResult> Status(Guid taskId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetStatusAsync(taskId, Guid.Parse(userId)));
}
[HttpGet("Getuploadurl")]
public async Task<IActionResult> GetUploadUrl(string sessionId, int partNum)
{
@@ -53,19 +62,20 @@ namespace FileService.WebApi.Controllers.FileTask
}
[HttpPost("complete")]
[UnitOfWork(typeof(FileDbContext))]
public async Task<IActionResult> Complete([FromBody] CompleteTaskRequest request)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var res = await service.CompleteTaskAsync(new UploadTaskCompleteCommand(request.SessionId, Guid.Parse(userId), request.Parts));
return Ok(res);
return res.Succeeded ? Accepted(res) : Ok(res);
}
[HttpPost("local/parts/upload")]
public async Task<IActionResult> LocalUpload(string sessionId, int partNumber, IFormFile file)
public async Task<IActionResult> LocalUpload([FromForm] string sessionId, [FromForm] int partNumber, IFormFile file)
{
//var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
var stream = file.OpenReadStream();
var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length));
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
await using var stream = file.OpenReadStream();
var res = await service.UploadPartAsync(new UploadPartCommand(stream, sessionId, partNumber, file.Length), Guid.Parse(userId));
return Ok(res);
}
}
@@ -5,14 +5,30 @@ namespace FileService.WebApi.Controllers.FileTask
public class FileTaskInitRequest
{
public Guid ConversationId { get; set; }
public string FileName { get; set; }
public string? ChatType { get; set; }
public Guid? TargetId { get; set; }
public string FileName { get; set; } = string.Empty;
public long FileSize { get; set; }
public string ContentType { get; set; }
public string CheckSum { get; set; }
public string ContentType { get; set; } = string.Empty;
public string CheckSum { get; set; } = string.Empty;
}
public class FileTaskInitRequestValidator: AbstractValidator<FileTaskInitRequest>
{
public FileTaskInitRequestValidator()
{
RuleFor(x => x.FileName).NotEmpty().MaximumLength(255);
RuleFor(x => x.FileSize).GreaterThan(0);
RuleFor(x => x.ContentType).NotEmpty().MaximumLength(255);
RuleFor(x => x.CheckSum).NotEmpty().MaximumLength(128);
RuleFor(x => x.ChatType)
.Must(value => string.IsNullOrWhiteSpace(value) ||
value.Equals("PRIVATE", StringComparison.OrdinalIgnoreCase) ||
value.Equals("GROUP", StringComparison.OrdinalIgnoreCase))
.WithMessage("chatType 必须为 PRIVATE 或 GROUP");
RuleFor(x => x.TargetId)
.NotEmpty()
.When(x => !string.IsNullOrWhiteSpace(x.ChatType));
}
}
}
@@ -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;
}
}
+11
View File
@@ -9,6 +9,17 @@ namespace FileService.WebApi
public void Initialize(IServiceCollection services)
{
services.AddScoped<UploadFileService>();
services.AddHttpClient<IGroupAccessService, GroupAccessService>((sp, client) =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
client.BaseAddress = new Uri(configuration["InternalServices:GroupServiceBaseUrl"]
?? "http://im-group-service:8080/");
var internalApiKey = configuration["InternalApiKey"];
if (!string.IsNullOrWhiteSpace(internalApiKey))
{
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", internalApiKey);
}
});
}
}
}
+3
View File
@@ -1,3 +1,4 @@
using IM.InitCommon.Management;
using IM.InitCommon;
using Microsoft.Extensions.FileProviders;
@@ -20,6 +21,7 @@ namespace FileService.WebApi
builder.ConfigExtraServices();
var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
@@ -29,6 +31,7 @@ namespace FileService.WebApi
}
app.UseAppDefault();
app.MapManagementHealth();
// 仅 FileService 暴露公开桶目录为静态直链,不动共享 UseAppDefault
UsePublicStaticFiles(app);
@@ -1,4 +1,8 @@
{
"InternalApiKey": "development-only-change-me",
"InternalServices": {
"GroupServiceBaseUrl": "http://localhost:5070/"
},
"Logging": {
"LogLevel": {
"Default": "Information",
+5 -1
View File
@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"InternalApiKey": "",
"InternalServices": {
"GroupServiceBaseUrl": "http://im-group-service:8080/"
}
}
+5 -3
View File
@@ -1,4 +1,4 @@
using GroupService.Domain.Enums;
using GroupService.Domain.Enums;
using GroupService.Domain.Events;
using IM.DomainCommons;
@@ -65,13 +65,15 @@ namespace GroupService.Domain.Entities
AddDomainEvent(new AllMembersBannedDomainEvent(this));
}
public void Ban()
public void Ban(bool notify = true)
{
Status = GroupState.Blocked;
ModificationTime = DateTime.Now;
AddDomainEvent(new GroupBlockedDomainEvent(this));
if (notify) AddDomainEvent(new GroupBlockedDomainEvent(this));
}
public void Unban() { Status = GroupState.Normal; ModificationTime = DateTime.Now; }
public void Update(string? name, GroupAuthorityType? groupAuthority, string? announcement, string? avatar)
{
bool isChanged = false;
@@ -49,5 +49,12 @@ namespace GroupService.Domain.Entities
{
GroupNickName = nickname;
}
public void Leave()
{
if (IsDeleted) return;
SoftDelete();
AddDomainEvent(new GroupMemberLeftDomainEvent(this));
}
}
}
@@ -0,0 +1,7 @@
using GroupService.Domain.Entities;
using MediatR;
namespace GroupService.Domain.Events
{
public record GroupMemberLeftDomainEvent(GroupMember Member) : INotification;
}
@@ -22,6 +22,7 @@ namespace GroupService.Domain.IReposities
/// <param name="userId"></param>
/// <returns></returns>
Task<IEnumerable<Group>> FindByMasterIdAsync(Guid userId);
Task<IEnumerable<Group>> FindByMemberIdAsync(Guid userId);
/// <summary>
/// 创建群聊
/// </summary>
@@ -8,5 +8,6 @@ namespace GroupService.Domain.IReposities
Task<GroupJoinRequest> FindByIdAsync(Guid id);
Task<IEnumerable<GroupJoinRequest?>> FindByGroupIdAsync(Guid groupId);
Task<IEnumerable<GroupJoinRequest>> FindByUserIdAsync(Guid userId);
Task<IEnumerable<GroupJoinRequest>> FindVisibleToUserAsync(Guid userId);
}
}
@@ -11,6 +11,9 @@ namespace GroupService.Infrastructure.Configs
builder.ToTable("group_join_requests");
builder.HasKey(x => x.Id);
builder.HasKey(x => new { x.GroupId, x.UserId });
builder.HasIndex(x => x.Id).IsUnique();
builder.HasIndex(x => new { x.GroupId, x.State, x.CreationTime });
builder.HasIndex(x => new { x.UserId, x.CreationTime });
builder.ComplexProperty(x => x.UserProfile, u =>
{
@@ -9,6 +9,8 @@ namespace GroupService.Infrastructure.Configs
public void Configure(EntityTypeBuilder<GroupMember> builder)
{
builder.ToTable("group_members");
builder.HasIndex(x => new { x.UserId, x.IsDeleted, x.GroupId });
builder.HasIndex(x => new { x.GroupId, x.IsDeleted, x.Role });
}
}
}
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace GroupService.Infrastructure.Migrations
{
[DbContext(typeof(GroupDbContext))]
[Migration("20260909000200_ApiAlignmentFixes")]
public partial class ApiAlignmentFixes : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_group_members_UserId_IsDeleted_GroupId",
table: "group_members",
columns: new[] { "UserId", "IsDeleted", "GroupId" });
migrationBuilder.CreateIndex(
name: "IX_group_members_GroupId_IsDeleted_Role",
table: "group_members",
columns: new[] { "GroupId", "IsDeleted", "Role" });
migrationBuilder.CreateIndex(
name: "IX_group_join_requests_Id",
table: "group_join_requests",
column: "Id",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_group_join_requests_GroupId_State_CreationTime",
table: "group_join_requests",
columns: new[] { "GroupId", "State", "CreationTime" });
migrationBuilder.CreateIndex(
name: "IX_group_join_requests_UserId_CreationTime",
table: "group_join_requests",
columns: new[] { "UserId", "CreationTime" });
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex("IX_group_members_UserId_IsDeleted_GroupId", "group_members");
migrationBuilder.DropIndex("IX_group_members_GroupId_IsDeleted_Role", "group_members");
migrationBuilder.DropIndex("IX_group_join_requests_Id", "group_join_requests");
migrationBuilder.DropIndex("IX_group_join_requests_GroupId_State_CreationTime", "group_join_requests");
migrationBuilder.DropIndex("IX_group_join_requests_UserId_CreationTime", "group_join_requests");
}
}
}
@@ -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) { }
}
@@ -227,6 +227,13 @@ namespace GroupService.Infrastructure.Migrations
b.HasKey("GroupId", "UserId");
b.HasIndex("Id")
.IsUnique();
b.HasIndex("GroupId", "State", "CreationTime");
b.HasIndex("UserId", "CreationTime");
b.ToTable("group_join_requests", (string)null);
});
@@ -266,6 +273,10 @@ namespace GroupService.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("GroupId", "IsDeleted", "Role");
b.HasIndex("UserId", "IsDeleted", "GroupId");
b.ToTable("group_members", (string)null);
});
#pragma warning restore 612, 618
@@ -36,5 +36,19 @@ namespace GroupService.Infrastructure.Reposities
x.UserId == userId || x.OperatorId == userId
).ToListAsync();
}
public async Task<IEnumerable<GroupJoinRequest>> FindVisibleToUserAsync(Guid userId)
{
var managedGroupIds = db.GroupMembers
.Where(member => member.UserId == userId &&
(member.Role == Domain.Enums.GroupMemberRole.Administrator ||
member.Role == Domain.Enums.GroupMemberRole.Master))
.Select(member => member.GroupId);
return await db.GroupJoinRequests
.Where(request => request.UserId == userId || managedGroupIds.Contains(request.GroupId))
.OrderByDescending(request => request.CreationTime)
.ToListAsync();
}
}
}
@@ -29,6 +29,18 @@ namespace GroupService.Infrastructure.Reposities
return await db.Groups.Where(x => x.GroupMaster == userId).ToListAsync();
}
public async Task<IEnumerable<Group>> FindByMemberIdAsync(Guid userId)
{
var groupIds = db.GroupMembers
.Where(member => member.UserId == userId)
.Select(member => member.GroupId);
return await db.Groups
.Where(group => groupIds.Contains(group.Id))
.OrderByDescending(group => group.ModificationTime ?? group.CreationTime)
.ToListAsync();
}
public async Task<IEnumerable<Group>> FindByNameAsync(string name)
{
return await db.Groups.Where(x => x.Name == name).ToListAsync();
@@ -0,0 +1,18 @@
using GroupService.Domain.Events;
using IM.Commons.IntegrationEvents;
using MassTransit;
using MediatR;
namespace GroupService.WebApi.Application.EventHandler
{
public class GroupMemberLeftHandler(IPublishEndpoint endpoint)
: INotificationHandler<GroupMemberLeftDomainEvent>
{
public Task Handle(GroupMemberLeftDomainEvent notification, CancellationToken cancellationToken)
{
return endpoint.Publish(
new GroupMemberLeftEvent(notification.Member.UserId, notification.Member.GroupId),
cancellationToken);
}
}
}
@@ -1,7 +1,8 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain.IReposities;
using GroupService.WebApi.Application.Dtos;
using IM.Commons;
using Microsoft.EntityFrameworkCore;
namespace GroupService.WebApi.Application.Group
{
@@ -9,29 +10,33 @@ namespace GroupService.WebApi.Application.Group
{
private readonly IGroupReposity reposity;
private readonly IGroupMemberReposity memberReposity;
private readonly IMapper mapper;
private readonly IMapper mapper; private readonly IM.InitCommon.Management.RuntimePolicy runtime; private readonly global::GroupService.Infrastructure.GroupDbContext db;
public GroupService(IGroupReposity reposity, IGroupMemberReposity memberReposity, IMapper mapper)
public GroupService(IGroupReposity reposity, IGroupMemberReposity memberReposity, IMapper mapper, IM.InitCommon.Management.RuntimePolicy runtime, global::GroupService.Infrastructure.GroupDbContext db)
{
this.reposity = reposity;
this.memberReposity = memberReposity;
this.mapper = mapper;
this.mapper = mapper; this.runtime = runtime; this.db = db;
}
public async Task<Result<GroupResponse>> CreateAsync(GroupCreateCommand command)
{
var policy = runtime.Current;
if (policy.CreatedGroupLimit > 0 && await db.Groups.CountAsync(x => x.GroupMaster == command.GroupMasterId) >= policy.CreatedGroupLimit)
return Result.Fail<GroupResponse>(ResultCode.PERMISSION_DENIED, "创建群组数量已达到平台上限");
var group = new Domain.Entities.Group(command.GroupMasterId, command.Name);
group.Update(null, (Domain.Enums.GroupAuthorityType)policy.DefaultJoinAuthority, null, null);
reposity.Create(group);
return Result<GroupResponse>.Success(mapper.Map<GroupResponse>(group));
}
public async Task<Result<List<GroupResponse>>> GetAllAsync(Guid userId)
{
var groups = await reposity.FindByMasterIdAsync(userId);
var groups = await reposity.FindByMemberIdAsync(userId);
return Result<List<GroupResponse>>.Success(mapper.Map<List<GroupResponse>>(groups));
}
public async Task<Result<GroupResponse>> GetByIdAsync(Guid groupId)
public async Task<Result<GroupResponse>> GetByIdAsync(Guid groupId, Guid userId)
{
var group = await reposity.FindByIdAsync(groupId);
if (group is null)
@@ -39,9 +44,36 @@ namespace GroupService.WebApi.Application.Group
return Result<GroupResponse>.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (!await memberReposity.CheckMemberExistAsync(groupId, userId))
{
return Result<GroupResponse>.Fail(ResultCode.PERMISSION_DENIED);
}
return Result<GroupResponse>.Success(mapper.Map<GroupResponse>(group));
}
public async Task<Result<object>> DissolveAsync(Guid groupId, Guid userId)
{
var group = await reposity.FindByIdAsync(groupId);
if (group is null)
{
return Result.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (group.GroupMaster != userId)
{
return Result.Fail(ResultCode.PERMISSION_DENIED);
}
var members = await memberReposity.FindByGroupIdAsync(groupId);
foreach (var member in members)
{
member.Leave();
}
group.SoftDelete();
return Result.Success();
}
public async Task<Result<GroupResponse>> UpdateAsync(GroupUpdateCommand command)
{
var group = await reposity.FindByIdAsync(command.GroupId);
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain.IReposities;
using GroupService.Domain.ValueObjects;
using GroupService.WebApi.Application.Dtos;
@@ -46,7 +46,7 @@ namespace GroupService.WebApi.Application.GroupInvitation
return Result.Success(mapper.Map<GroupInvitationResponse>(existing));
}
var group = await groupReposity.FindByIdAsync(groupId);
var group = await groupReposity.FindByIdAsync(groupId); if (group?.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组不可用或已被封禁");
var userProfile = new UserProfile()
{
@@ -75,7 +75,7 @@ namespace GroupService.WebApi.Application.GroupInvitation
public async Task<Result<object>> CreateBatchAsync(Guid operatorId, List<Guid> userIds, Guid groupId)
{
var group = await groupReposity.FindByIdAsync(groupId);
var group = await groupReposity.FindByIdAsync(groupId); if (group?.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组不可用或已被封禁");
if (group is null)
return Result.Fail(ResultCode.GROUP_NOT_FOUND);
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain;
using GroupService.Domain.IReposities;
using GroupService.WebApi.Application.Dtos;
@@ -13,24 +13,28 @@ namespace GroupService.WebApi.Application.GroupMember
private readonly IGroupReposity groupReposity;
private readonly GroupMemberDomainService service;
private readonly IIdentityIntegrationService idService;
private IMapper mapper;
private IMapper mapper; private readonly IM.InitCommon.Management.RuntimePolicy runtime;
public GroupMemberService(IGroupMemberReposity reposity, IGroupReposity groupReposity, GroupMemberDomainService service, IIdentityIntegrationService idService, IMapper mapper)
public GroupMemberService(IGroupMemberReposity reposity, IGroupReposity groupReposity, GroupMemberDomainService service, IIdentityIntegrationService idService, IMapper mapper, IM.InitCommon.Management.RuntimePolicy runtime)
{
this.reposity = reposity;
this.groupReposity = groupReposity;
this.service = service;
this.idService = idService;
this.mapper = mapper;
this.mapper = mapper; this.runtime = runtime;
}
public async Task<Result<List<GroupMemberResponse>>> GetByGroupIdAsync(Guid groupId)
public async Task<Result<List<GroupMemberResponse>>> GetByGroupIdAsync(Guid groupId, Guid userId)
{
var group = await groupReposity.FindByIdAsync(groupId);
if (group is null)
{
return Result<List<GroupMemberResponse>>.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (!await reposity.CheckMemberExistAsync(groupId, userId))
{
return Result<List<GroupMemberResponse>>.Fail(ResultCode.PERMISSION_DENIED);
}
var members = await reposity.FindByGroupIdAsync(groupId);
return Result<List<GroupMemberResponse>>.Success(mapper.Map<List<GroupMemberResponse>>(members.ToList()));
@@ -45,6 +49,9 @@ namespace GroupService.WebApi.Application.GroupMember
return Result<GroupMemberResponse>.Fail(ResultCode.GROUP_NOT_FOUND);
}
if (group.Status != Domain.Enums.GroupState.Normal) throw new IM.DomainCommons.DomainException("群组已被封禁,不能加入");
var limit = runtime.Current.GroupMemberLimit;
if (limit > 0 && (await reposity.FindByGroupIdAsync(groupId)).Count() >= limit) throw new IM.DomainCommons.DomainException("群成员数量已达到平台上限");
var userRes = await idService.FindUserByIdAsync(userId);
if (!userRes.Succeeded)
{
@@ -64,7 +71,7 @@ namespace GroupService.WebApi.Application.GroupMember
public async Task<Result<bool>> CheckMemberAsync(Guid groupId, Guid userId)
{
var exist = await reposity.CheckMemberExistAsync(groupId, userId);
var group = await groupReposity.FindByIdAsync(groupId); var exist = group?.Status == Domain.Enums.GroupState.Normal && await reposity.CheckMemberExistAsync(groupId, userId);
return Result.Success(exist);
}
@@ -78,14 +85,32 @@ namespace GroupService.WebApi.Application.GroupMember
}
var operatorMember = await reposity.FindOneByGroupIdAndUserIdAsync(member.GroupId, operatorId);
if (operatorMember is null || operatorMember.Role == Domain.Enums.GroupMemberRole.Normal)
if (operatorMember is null || operatorMember.Id == member.Id ||
member.Role == Domain.Enums.GroupMemberRole.Master ||
operatorMember.Role <= member.Role)
{
return Result.Fail(ResultCode.PERMISSION_DENIED);
}
member.SoftDelete();
member.Leave();
return Result.Success();
}
public async Task<Result<object>> LeaveAsync(Guid groupId, Guid userId)
{
var member = await reposity.FindOneByGroupIdAndUserIdAsync(groupId, userId);
if (member is null)
{
return Result.Fail(ResultCode.GROUP_MEMBER_NOT_FOUNT);
}
if (member.Role == Domain.Enums.GroupMemberRole.Master)
{
return Result.Fail<object>(ResultCode.PERMISSION_DENIED, "群主不能直接退群,请使用解散群接口");
}
member.Leave();
return Result.Success();
}
}
}
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using GroupService.Domain.Entities;
using GroupService.Domain.IReposities;
using GroupService.Domain.ValueObjects;
@@ -33,6 +33,8 @@ namespace GroupService.WebApi.Application.GroupRequest
return Result.Fail<GroupRequestResponse>(ResultCode.GROUP_NOT_FOUND);
}
if (group.Status != Domain.Enums.GroupState.Normal || group.Authority == Domain.Enums.GroupAuthorityType.NOT_ALLOWED_TO_JOIN)
return Result.Fail<GroupRequestResponse>(ResultCode.PERMISSION_DENIED, "群组当前不允许加入");
var user = await idService.FindUserByIdAsync(userId);
var groupProfile = new GroupProfile()
@@ -98,7 +100,7 @@ namespace GroupService.WebApi.Application.GroupRequest
}
public async Task<Result<List<GroupRequestResponse>>> GetListAsync(Guid userId)
{
var list = await reposity.FindByUserIdAsync(userId);
var list = await reposity.FindVisibleToUserAsync(userId);
return Result.Success(mapper.Map<List<GroupRequestResponse>>(list.ToList()));
}
@@ -32,7 +32,8 @@ namespace GroupService.WebApi.Controllers.Group
[ProducesDefaultResponseType(typeof(Result<GroupResponse>))]
public async Task<IActionResult> GetOne(Guid groupId)
{
return Ok(await service.GetByIdAsync(groupId));
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetByIdAsync(groupId, Guid.Parse(userId)));
}
[HttpPost]
@@ -49,5 +50,13 @@ namespace GroupService.WebApi.Controllers.Group
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.UpdateAsync(new GroupUpdateCommand(Guid.Parse(userId), request.GroupId, request.Avatar, request.GroupName, request.Description)));
}
[HttpPost]
[UnitOfWork(typeof(GroupDbContext))]
public async Task<IActionResult> Dissolve([FromQuery] Guid groupId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.DissolveAsync(groupId, Guid.Parse(userId)));
}
}
}
@@ -1,6 +1,8 @@
using GroupService.Infrastructure;
using GroupService.WebApi.Application.GroupMember;
using IM.ASPNETCore;
using IM.Commons;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
@@ -11,25 +13,36 @@ namespace GroupService.WebApi.Controllers.GroupMember
public class GroupMemberController : ControllerBase
{
private readonly GroupMemberService service;
private readonly IConfiguration configuration;
public GroupMemberController(GroupMemberService service)
public GroupMemberController(GroupMemberService service, IConfiguration configuration)
{
this.service = service;
this.configuration = configuration;
}
[HttpGet]
public async Task<IActionResult> CheckMember(Guid userId, Guid groupId)
{
var expectedKey = configuration["InternalApiKey"];
var suppliedKey = Request.Headers["X-Internal-Api-Key"].ToString();
if (string.IsNullOrWhiteSpace(expectedKey) || suppliedKey != expectedKey)
{
return Unauthorized(Result.Fail(ResultCode.AUTH_FAILED));
}
return Ok(await service.CheckMemberAsync(groupId, userId));
}
[HttpGet]
[Authorize]
public async Task<IActionResult> List(Guid groupId)
{
return Ok(await service.GetByGroupIdAsync(groupId));
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.GetByGroupIdAsync(groupId, Guid.Parse(userId)));
}
[HttpPost]
[Authorize]
[UnitOfWork(typeof(GroupDbContext))]
public async Task<IActionResult> Delete([FromQuery] Guid memberId)
{
@@ -37,6 +50,15 @@ namespace GroupService.WebApi.Controllers.GroupMember
return Ok(await service.DeleteAsync(memberId, Guid.Parse(userId)));
}
[HttpPost]
[Authorize]
[UnitOfWork(typeof(GroupDbContext))]
public async Task<IActionResult> Leave([FromQuery] Guid groupId)
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
return Ok(await service.LeaveAsync(groupId, Guid.Parse(userId)));
}
}
}
@@ -0,0 +1,40 @@
using GroupService.Infrastructure;
using GroupService.Domain.Enums;
using IM.InitCommon.Management;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace GroupService.WebApi.Controllers;
[ApiController, Route("internal/management")]
public sealed class ManagementController(GroupDbContext db) : ControllerBase
{
[HttpGet("summary")] public async Task<object> Summary() => new { total = await db.Groups.CountAsync() };
[HttpGet("list")] public async Task<object> List(string? q, string? status, int page = 1, int size = 8)
{
page = Math.Max(1, page); size = Math.Clamp(size, 1, 100);
var query = db.Groups.AsNoTracking().Where(x => q == null || x.Name.Contains(q) || x.Id.ToString() == q || x.GroupMaster.ToString() == q || db.GroupMembers.Any(m => m.GroupId == x.Id && m.UserId == x.GroupMaster && m.GroupNickName.Contains(q)));
if (!string.IsNullOrEmpty(status)) query = query.Where(x => x.Status == (status == "封禁" ? GroupState.Blocked : GroupState.Normal));
return new { items = await query.OrderByDescending(x => x.CreationTime).Skip((page - 1) * size).Take(size).Select(x => new { x.Id, x.Name, ownerId = x.GroupMaster, ownerName = db.GroupMembers.Where(m => m.GroupId == x.Id && m.UserId == x.GroupMaster).Select(m => m.GroupNickName).FirstOrDefault(), memberCount = db.GroupMembers.Count(m => m.GroupId == x.Id), status = x.Status == GroupState.Blocked ? "封禁" : "正常", createdAt = x.CreationTime }).ToListAsync(), total = await query.CountAsync(), page, size };
}
[HttpGet("detail/{id:guid}")] public async Task<IActionResult> Detail(Guid id)
{
var g = await db.Groups.AsNoTracking().SingleOrDefaultAsync(x => x.Id == id); if (g is null) return NotFound();
return Ok(new { g.Id, g.Name, ownerId = g.GroupMaster, g.Announcement, authority = g.Authority, memberCount = await db.GroupMembers.CountAsync(x => x.GroupId == id), status = g.Status == GroupState.Blocked ? "封禁" : "正常", createdAt = g.CreationTime });
}
[HttpGet("members/{id:guid}")] public async Task<object> Members(Guid id, string? q, int page = 1, int size = 8)
{
page = Math.Max(1, page); size = Math.Clamp(size, 1, 100); var query = db.GroupMembers.AsNoTracking().Where(x => x.GroupId == id && (q == null || x.GroupNickName.Contains(q) || x.UserId.ToString() == q));
return new { items = await query.OrderBy(x => x.UserId).Skip((page - 1) * size).Take(size).Select(x => new { id = x.UserId, name = x.GroupNickName, x.Role }).ToListAsync(), total = await query.CountAsync(), page, size };
}
[HttpGet("user/{id:guid}/groups")] public async Task<object> UserGroups(Guid id) => await db.Groups.Where(x => db.GroupMembers.Any(m => m.GroupId == x.Id && m.UserId == id)).Select(x => new { x.Id, x.Name, status = x.Status == GroupState.Blocked ? "封禁" : "正常" }).Take(100).ToListAsync();
[HttpGet("access/{id:guid}/{userId:guid}")] public async Task<object> Access(Guid id, Guid userId) => new { exists = await db.Groups.AnyAsync(x => x.Id == id), enabled = await db.Groups.AnyAsync(x => x.Id == id && x.Status == GroupState.Normal), member = await db.GroupMembers.AnyAsync(x => x.GroupId == id && x.UserId == userId) };
[HttpPost("action")] public async Task<IActionResult> Action(InternalAction command, CancellationToken ct)
{
if (command.Action is not "封禁" and not "解封") return BadRequest();
return Ok(await ReceiptStore.Execute(db, command, async () => {
var g = await db.Groups.SingleOrDefaultAsync(x => x.Id == command.TargetId, ct) ?? throw new InvalidOperationException("群组不存在");
var before = g.Status == GroupState.Blocked ? "封禁" : "正常"; if (command.Action == "封禁") g.Ban(notify: false); else g.Unban();
return new ActionReceipt(g.Name, before, command.Action == "封禁" ? "封禁" : "正常");
}, ct));
}
}
+3
View File
@@ -1,3 +1,4 @@
using IM.InitCommon.Management;
using IM.InitCommon;
@@ -19,6 +20,7 @@ namespace GroupService.WebApi
builder.ConfigExtraServices();
var app = builder.Build();
if (app.ApplyMigrationsIfRequested(args)) return;
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
@@ -28,6 +30,7 @@ namespace GroupService.WebApi
}
app.UseAppDefault();
app.MapManagementHealth();
app.MapControllers();
@@ -1,4 +1,5 @@
{
"InternalApiKey": "development-only-change-me",
"Logging": {
"LogLevel": {
"Default": "Information",
+2 -1
View File
@@ -5,5 +5,6 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"InternalApiKey": ""
}
+29 -2
View File
@@ -1,15 +1,19 @@
using IM.Commons;
using IM.DomainCommons;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace IM.ASPNETCore
{
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
public ExceptionMiddleware(RequestDelegate next)
private readonly ILogger<ExceptionMiddleware> logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
this.logger = logger;
}
public async Task InvokeAsync(HttpContext context)
@@ -22,12 +26,35 @@ namespace IM.ASPNETCore
{
await DomainExceptionHandlerAsync(context, ex);
}
catch (Exception ex)
{
await UnhandledExceptionHandlerAsync(context, ex);
}
}
public Task DomainExceptionHandlerAsync(HttpContext context, DomainException ex)
{
context.Response.ContentType = "application/json";
var result = Result<object>.Fail(ResultCode.PARAMETER_ERROR, ex.Message); // 包装成你的 Result
context.Response.StatusCode = StatusCodes.Status400BadRequest;
var result = Result<object>.Fail(ResultCode.PARAMETER_ERROR, ex.Message);
return context.Response.WriteAsJsonAsync(result);
}
private Task UnhandledExceptionHandlerAsync(HttpContext context, Exception ex)
{
var correlationId = context.TraceIdentifier;
logger.LogError(ex,
"Unhandled exception. CorrelationId: {CorrelationId}, Path: {Path}",
correlationId,
context.Request.Path);
context.Response.ContentType = "application/json";
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.Headers["X-Correlation-ID"] = correlationId;
var result = Result<object>.Fail(
ResultCode.SYSTEM_ERROR,
$"系统错误,关联编号:{correlationId}");
return context.Response.WriteAsJsonAsync(result);
}
}
@@ -0,0 +1,4 @@
namespace IM.Commons.IntegrationEvents
{
public record GroupMemberLeftEvent(Guid UserId, Guid GroupId);
}
@@ -20,6 +20,8 @@ namespace IM.Commons.IntegrationEvents
public string ContentType { get; set; }
public string CheckSun { get; set; }
public IReadOnlyList<UploadPart> Parts { get; set; }
public string? ChatType { get; set; }
public Guid? TargetId { get; set; }
}
public sealed record UploadPart(
int PartNumber,
+1
View File
@@ -25,6 +25,7 @@ namespace IM.InitCommon
//similar to serviceCollection.AddDbContextPool<ECDictDbContext>(opt=>new DbContextOptionsBuilder(dbCtxOpt));
var methodGenericAddDbContext = methodAddDbContext.MakeGenericMethod(dbCtxType);
methodGenericAddDbContext.Invoke(null, new object[] { services, action, ServiceLifetime.Scoped, ServiceLifetime.Scoped });
services.AddScoped(sp => new Management.ManagementDbProbe((DbContext)sp.GetRequiredService(dbCtxType)));
}
}
return services;
@@ -1,5 +1,6 @@
using IM.ASPNETCore;
using Microsoft.AspNetCore.Builder;
using IM.InitCommon.Management;
namespace IM.InitCommon
{
@@ -9,6 +10,7 @@ namespace IM.InitCommon
{
app.UseCors();
app.UseAuthentication();
app.UseManagementRuntime();
app.UseAuthorization();
app.UseMiddleware<ExceptionMiddleware>();
app.UseForwardedHeaders();
+42
View File
@@ -0,0 +1,42 @@
using System.Text.Json.Nodes;
namespace IM.InitCommon.Management;
public record PageResult<T>(IReadOnlyList<T> Items, int Total, int Page, int Size);
public record InternalAction(Guid Id, Guid ActorId, Guid TargetId, string Action, string Reason);
public record ActionReceipt(string TargetName, string Before, string After);
public record EvidenceRequest(Guid ReporterId, string Type, Guid TargetId, Guid[] MessageIds);
public record EvidenceSnapshot(Guid Id, Guid SenderId, string SenderName, string Text, string Type, Guid? FileId, DateTimeOffset SentAt);
public record SubjectEvidence(string TargetName, IReadOnlyList<EvidenceSnapshot> Evidence);
public record UserAccess(bool Enabled, string Stamp);
public record ServiceHealth(string Service, string Status, double? LatencyMs, DateTime CheckedAt, DateTime? LastSuccessAt, string? Error, long? ConfigVersion = null);
public sealed class Policy
{
public long Version { get; set; }
public string PlatformName { get; set; } = "IM";
public string Description { get; set; } = "";
public string SupportEmail { get; set; } = "";
public bool RegistrationEnabled { get; set; } = true;
public int PasswordMinLength { get; set; } = 6;
public int FriendLimit { get; set; }
public int CreatedGroupLimit { get; set; }
public int GroupMemberLimit { get; set; }
public int DefaultJoinAuthority { get; set; }
public int TextLimit { get; set; }
public int RecallMinutes { get; set; }
public long UploadMaxBytes { get; set; }
public string[] AllowedFileTypes { get; set; } = [];
public string[] ReportCategories { get; set; } = ["推广信息", "骚扰辱骂", "冒充身份", "其他"];
public int ReportsPerDay { get; set; } = 20;
public int ReportCooldownMinutes { get; set; } = 10;
public int ClientAccessMinutes { get; set; }
public int ClientRefreshDays { get; set; }
public int AdminSessionMinutes { get; set; } = 480;
public int AdminLockThreshold { get; set; } = 5;
public int AdminLockMinutes { get; set; } = 15;
}
public static class ManagementResult
{
public static object Ok(object? data = null) => new { code = 0, message = "成功", data };
public static object Fail(string message) => new { code = 1003, message, data = (object?)null };
}
@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.EntityFrameworkCore;
namespace IM.InitCommon.Management;
public static class DeploymentCommands
{
public static bool ApplyMigrationsIfRequested(this WebApplication app, string[] args)
{
if (!args.Contains("--migrate")) return false;
using var scope = app.Services.CreateScope();
foreach (var probe in scope.ServiceProvider.GetServices<ManagementDbProbe>().Where(x => x.Context.Database.GetMigrations().Any()))
probe.Context.Database.Migrate();
Console.WriteLine("服务数据库迁移完成"); return true;
}
}
@@ -0,0 +1,35 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
namespace IM.InitCommon.Management;
public sealed class InternalClient(HttpClient http, IConfiguration config)
{
public async Task<T> Send<T>(string service, string path, object? body = null, CancellationToken ct = default)
{
var address = config[$"Management:Services:{service}"] ?? throw new InvalidOperationException($"未配置服务 {service}");
var key = config["Management:InternalKey"] ?? config["InternalApiKey"];
if (string.IsNullOrWhiteSpace(key)) throw new InvalidOperationException("未配置内部服务密钥");
using var request = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, new Uri(new Uri(address.TrimEnd('/') + "/"), path.TrimStart('/')));
request.Headers.Add("X-IM-Management-Key", key);
if (body is not null) request.Content = JsonContent.Create(body);
using var response = await http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode) throw new InternalServiceException(service, (int)response.StatusCode);
return await response.Content.ReadFromJsonAsync<T>(cancellationToken: ct) ?? throw new InvalidOperationException("服务响应为空");
}
public static bool Authorized(HttpContext context)
{
var c = context.RequestServices.GetService(typeof(IConfiguration)) as IConfiguration;
var expected = c?["Management:InternalKey"] ?? c?["InternalApiKey"];
var actual = context.Request.Headers["X-IM-Management-Key"].ToString();
return !string.IsNullOrWhiteSpace(expected) && CryptographicOperations.FixedTimeEquals(SHA256.HashData(Encoding.UTF8.GetBytes(expected)), SHA256.HashData(Encoding.UTF8.GetBytes(actual)));
}
}
public sealed class InternalServiceException(string service, int status) : Exception($"{service} 服务请求失败({status}")
{
public int Status { get; } = status;
}
+29
View File
@@ -0,0 +1,29 @@
using System.Data;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace IM.InitCommon.Management;
public static class ReceiptStore
{
public static async Task<ActionReceipt> Execute(DbContext db, InternalAction command, Func<Task<ActionReceipt>> apply, CancellationToken ct)
{
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);
var connection = db.Database.GetDbConnection();
await using var read = connection.CreateCommand(); read.Transaction = tx.GetDbTransaction();
var owner = db.GetType().Name;
read.CommandText = "SELECT Payload FROM management_receipts WHERE Owner = @owner AND Id = @id FOR UPDATE";
var ownerParameter = read.CreateParameter(); ownerParameter.ParameterName = "@owner"; ownerParameter.Value = owner; read.Parameters.Add(ownerParameter);
var parameter = read.CreateParameter(); parameter.ParameterName = "@id"; parameter.Value = command.Id.ToString(); read.Parameters.Add(parameter);
var existing = await read.ExecuteScalarAsync(ct);
if (existing is string json) {
var saved = JsonSerializer.Deserialize<SavedReceipt>(json)!;
if (saved.Command != command) throw new InvalidOperationException("操作 ID 已被其他请求使用");
await tx.CommitAsync(ct); return saved.Result;
}
var receipt = await apply(); await db.SaveChangesAsync(ct);
await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO management_receipts (Owner, Id, Payload, CreatedAt) VALUES ({owner}, {command.Id.ToString()}, {JsonSerializer.Serialize(new SavedReceipt(command, receipt))}, {DateTime.UtcNow})", ct);
await tx.CommitAsync(ct); return receipt;
}
private record SavedReceipt(InternalAction Command, ActionReceipt Result);
}
+92
View File
@@ -0,0 +1,92 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
namespace IM.InitCommon.Management;
public record InfrastructureEnvelope(long Version, JsonObject Value, string Secret);
public record ManagementDbProbe(DbContext Context);
public sealed class RuntimePolicy(IServiceProvider services, IConfiguration config, ILogger<RuntimePolicy> logger) : BackgroundService
{
private Policy? current;
public InfrastructureEnvelope? Storage { get; private set; }
public bool Enabled => config.GetValue<bool>("Management:Enabled");
public Policy Current => current ?? (!Enabled ? new Policy() : throw new InvalidOperationException("管理配置尚未加载"));
public long AppliedVersion => current?.Version ?? 0;
public DateTime? LastLoadedAt { get; private set; }
protected override async Task ExecuteAsync(CancellationToken ct)
{
if (!Enabled) return;
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(20));
do {
try {
var client = services.GetRequiredService<InternalClient>();
current = await client.Send<Policy>("admin", "/internal/management/policy", ct: ct);
if (config["Management:ServiceName"] == "file") {
var storage = await client.Send<InfrastructureEnvelope>("admin", "/internal/management/infrastructure/storage", ct: ct);
if (Storage?.Version != storage.Version) { Storage = storage; services.GetService<IOptionsMonitorCache<StorageOptions>>()?.Clear(); }
}
LastLoadedAt = DateTime.UtcNow;
} catch (Exception e) when (!ct.IsCancellationRequested) { logger.LogWarning("Policy refresh failed; keeping last committed version: {Type}", e.GetType().Name); }
} while (await timer.WaitForNextTickAsync(ct));
}
public void CheckFile(string fileName, long size)
{
var p = Current;
if (p.UploadMaxBytes > 0 && size > p.UploadMaxBytes) throw new IM.DomainCommons.DomainException("文件超过平台上传大小限制");
if (p.AllowedFileTypes.Length > 0 && !p.AllowedFileTypes.Contains(Path.GetExtension(fileName).ToLowerInvariant())) throw new IM.DomainCommons.DomainException("平台不允许上传此文件类型");
}
}
public sealed class DynamicStorageOptions(RuntimePolicy runtime) : IPostConfigureOptions<StorageOptions>
{
public void PostConfigure(string? name, StorageOptions options)
{
var envelope = runtime.Storage;
if (envelope is null || !envelope.Value.ContainsKey("providers")) return;
var configured = envelope.Value.Deserialize<StorageOptions>(new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
var secrets = JsonNode.Parse(string.IsNullOrEmpty(envelope.Secret) ? "{}" : envelope.Secret)!.AsObject();
foreach (var (code, provider) in configured.Providers) {
if (secrets[code] is JsonObject credential) {
provider.AccessKeyId = credential["accessKeyId"]?.GetValue<string>();
provider.AccessKeySecret = credential["accessKeySecret"]?.GetValue<string>();
}
}
options.DefaultProviderCode = configured.DefaultProviderCode; options.Providers = configured.Providers;
}
}
public static class RuntimeManagementExtensions
{
public static IServiceCollection AddManagementRuntime(this IServiceCollection services)
{
services.AddHttpClient<InternalClient>(c => c.Timeout = TimeSpan.FromSeconds(4));
services.AddSingleton<RuntimePolicy>(); services.AddHostedService(sp => sp.GetRequiredService<RuntimePolicy>());
services.AddSingleton<IPostConfigureOptions<StorageOptions>, DynamicStorageOptions>();
return services;
}
public static IApplicationBuilder UseManagementRuntime(this IApplicationBuilder app) => app.Use(async (c, next) => {
if (c.Request.Path.StartsWithSegments("/internal/management")) {
if (!InternalClient.Authorized(c)) { c.Response.StatusCode = 403; return; }
await next(); return;
}
var policy = c.RequestServices.GetRequiredService<RuntimePolicy>();
if (policy.Enabled && c.User.Identity?.IsAuthenticated == true && Guid.TryParse(c.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) {
try {
var user = await c.RequestServices.GetRequiredService<InternalClient>().Send<UserAccess>("user", $"/internal/management/access/{userId}", ct: c.RequestAborted);
if (!user.Enabled || user.Stamp != c.User.FindFirstValue("session_stamp")) { c.Response.StatusCode = 401; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("账号已被封禁或会话已撤销,请重新登录")); return; }
} catch { c.Response.StatusCode = 503; await c.Response.WriteAsJsonAsync(ManagementResult.Fail("账号状态验证暂不可用,请稍后重试")); return; }
}
await next();
});
public static void MapManagementHealth(this WebApplication app) => app.MapGet("/internal/management/health", async (RuntimePolicy runtime, IConfiguration config, IEnumerable<ManagementDbProbe> probes, CancellationToken ct) => {
var ready = !runtime.Enabled || runtime.LastLoadedAt is not null;
foreach (var probe in probes) ready &= await probe.Context.Database.CanConnectAsync(ct);
return new { service = config["Management:ServiceName"], status = ready ? "healthy" : "unavailable", configVersion = runtime.AppliedVersion, storageVersion = runtime.Storage?.Version, runtime.LastLoadedAt };
});
}
+4
View File
@@ -28,6 +28,10 @@ namespace IM.InitCommon
c.Password(options.Password);
});
cfg.UseMessageRetry(retry => retry.Intervals(
TimeSpan.FromMilliseconds(200),
TimeSpan.FromSeconds(1),
TimeSpan.FromSeconds(5)));
cfg.ConfigureEndpoints(context);
});
});
+2 -2
View File
@@ -34,9 +34,9 @@ namespace IM.InitCommon
public string? PublicBaseUrl { get; init; }
public string? AccessKeyId { get; init; }
public string? AccessKeyId { get; set; }
public string? AccessKeySecret { get; init; }
public string? AccessKeySecret { get; set; }
public string? LocalRootPath { get; init; }
@@ -1,4 +1,4 @@
using FluentValidation;
using FluentValidation;
using FluentValidation.AspNetCore;
using IM.ASPNETCore;
using IM.Commons;
@@ -14,6 +14,7 @@ using RedLockNet.SERedis.Configuration;
using StackExchange.Redis;
using Swashbuckle.AspNetCore.SwaggerGen;
using Winton.Extensions.Configuration.Consul;
using IM.InitCommon.Management;
namespace IM.InitCommon
{
@@ -21,7 +22,7 @@ namespace IM.InitCommon
{
public static void ConfigureDbConfiguration(this WebApplicationBuilder builder)
{
builder.Host.ConfigureAppConfiguration((hostCtx, configbuilder) =>
if (!builder.Configuration.GetValue("Consul:Enabled", true)) return; builder.Host.ConfigureAppConfiguration((hostCtx, configbuilder) =>
{
var env = hostCtx.HostingEnvironment;
@@ -66,6 +67,7 @@ namespace IM.InitCommon
{
var services = builder.Services;
var configuration = builder.Configuration;
services.AddManagementRuntime();
var assemblies = ReflectionHelper.GetAllReferencedAssemblies();

Some files were not shown because too many files have changed in this diff Show More