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
+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);