fix: align backend APIs and upload flow
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Security.Claims;
|
||||
using IM.Admin.Data;
|
||||
using IM.InitCommon.Management;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM.Admin.Api;
|
||||
|
||||
public sealed class ApiError(int status, string message) : Exception(message) { public int Status { get; } = status; }
|
||||
public static class ApiSupport
|
||||
{
|
||||
public static Guid Actor(this HttpContext c) => Guid.Parse(c.User.FindFirstValue(ClaimTypes.NameIdentifier)!);
|
||||
public static string ActorName(this HttpContext c) => c.User.Identity?.Name ?? "";
|
||||
public static bool IsSuper(this HttpContext c) => c.User.IsInRole("super");
|
||||
public static void Reason(string? reason) { if (string.IsNullOrWhiteSpace(reason) || reason.Length > 500) throw new ApiError(400, "请填写 1–500 字的操作原因"); }
|
||||
public static void Audit(this AdminDb db, HttpContext c, string action, string target, string before, string after, string reason, Guid? reportId = null)
|
||||
=> db.Audit.Add(new AuditRecord { ActorId = c.Actor(), ActorName = c.ActorName(), Action = action, TargetId = target, TargetName = target, Before = before, After = after, Reason = reason, ReportId = reportId });
|
||||
public static async Task<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); }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using IM.Admin.Data;
|
||||
using IM.Admin.Services;
|
||||
using IM.InitCommon.Management;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace IM.Admin.Api;
|
||||
public record LoginInput(string Account, string Password);
|
||||
public record PasswordInput(string CurrentPassword, string NewPassword);
|
||||
public record ResetRequest(string Account);
|
||||
public record ResetInput(string Token, string Password);
|
||||
public record AccountInput(string Account, string Name, string Email, string Password, string Role, bool Enabled, string Reason);
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static object Public(AdminAccount a) => new { a.Id, a.Account, a.Name, a.Email, a.Role, status = a.Enabled ? "启用" : "停用", a.CreatedAt };
|
||||
public static string Hash(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)));
|
||||
public static void CheckPassword(string p) { if (p.Length is < 12 or > 128) throw new ApiError(400, "管理员密码长度应为 12–128 位"); }
|
||||
public static void MapAuth(this WebApplication app)
|
||||
{
|
||||
var api = app.MapGroup("/api/admin/auth");
|
||||
api.MapGet("/csrf", (HttpContext c, IAntiforgery af) => ManagementResult.Ok(new { token = af.GetAndStoreTokens(c).RequestToken }));
|
||||
api.MapPost("/login", async (LoginInput input, AdminDb db, IPasswordHasher<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, "邮箱格式不正确");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("管理员凭据已更新;请清除临时密码环境变量。");
|
||||
}
|
||||
}
|
||||
@@ -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("RabbitMQ(TCP)", async token => { using var tcp = new TcpClient(); await tcp.ConnectAsync(config["Management:RabbitHost"] ?? "rabbitmq", config.GetValue("Management:RabbitPort", 5672), token); return null; }, ct));
|
||||
tasks.Add(Check("Consul", async token => { using var http = new HttpClient(); using var response = await http.GetAsync((config["Management:ConsulUrl"] ?? throw new InvalidOperationException()).TrimEnd('/') + "/v1/status/leader", token); response.EnsureSuccessStatusCode(); return null; }, ct));
|
||||
return await Task.WhenAll(tasks);
|
||||
}
|
||||
async Task<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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user