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("user", "/internal/management/summary"); var groups = client.Send("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(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(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(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("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 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); } }