using System.Security.Claims; using System.Text; using Microsoft.EntityFrameworkCore; using Rmsys.Domain; using Rmsys.Domain.Entities; using Rmsys.Infrastructure; namespace Rmsys.Api.Endpoints; /// /// 后台管理端数据接口。 /// /api/admin/bootstrap 输出的字段名与形状和现有 demo(admin.html 里的 /// MEETINGS / DEPTS / CADRES / DOCS / PKGS / PADS / USERS / LOGINS / LOGS) /// 完全一致,目的是让前端渲染代码一行不改就能接上真库。 /// public static class AdminEndpoints { private const string DateFmt = "yyyy-MM-dd HH:mm"; private const string TimeFmt = "yyyy-MM-dd HH:mm:ss"; public static void MapAdminEndpoints(this WebApplication app) { // ---------- 导出数据包(生成 SQLite 并下载)---------- // 这是「后台配置真正下发到 PAD」的唯一通道:水印/字号/有效期/设备绑定/清除策略都写在包内 pkg_config app.MapGet("/api/admin/packages/{meetingId:int}/export", async ( int meetingId, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { try { // 数据包=材料离库:仅「全部处室」且非只读账号可导出 var sc = await Auth.ScopeGuard.ResolveAsync(p, db); if (!sc.IsAll) return Results.Json(new { ok = false, message = "「本处室」账号不能导出数据包(包内含全部上会干部材料)" }, statusCode: 403); if (!sc.CanWrite) return Auth.ScopeGuard.ReadOnlyDenied(); var dir = Path.Combine(env.ContentRootPath, "App_Data", "packages"); Directory.CreateDirectory(dir); var name = $"PK{DateTime.Now:yyyyMMdd-HHmmss}.pkg"; var path = Path.Combine(dir, name); // 加密导出:SM4-CBC 加密 + HMAC-SM3 完整性校验 var r = await new PackageExporter(db).ExportEncryptedAsync(meetingId, path, "admin"); // 回写数据包状态,便于后台列表展示 var pkg = await db.DataPackages.Where(x => x.MeetingId == meetingId) .OrderByDescending(x => x.Id).FirstOrDefaultAsync(); if (pkg != null) { pkg.Status = PackageStatus.Delivered; pkg.FilePath = "App_Data/packages/" + name; pkg.FileSize = r.Bytes; pkg.DownloadedAt = DateTime.Now; await db.SaveChangesAsync(); } // 数据包导出=材料离库,必须留痕 await AuditRead(db, p, "导出数据包", pkg?.PkgNo ?? ("会议#" + meetingId), $"{name}({r.Bytes / 1024} KB,{r.Cadres} 人,国密SM4加密)"); PruneOldExports(dir, keep: 3); return Results.File(path, "application/octet-stream", name); } catch (Exception e) { return Results.Problem("导出失败:" + e.Message); } }).RequireAuthorization(); // ---------- 只校验不下载 ---------- app.MapGet("/api/admin/packages/{meetingId:int}/check", async ( int meetingId, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { var dir = Path.Combine(env.ContentRootPath, "App_Data", "packages"); Directory.CreateDirectory(dir); var path = Path.Combine(dir, $"check-{meetingId}-{DateTime.Now:HHmmss}.db"); var r = await new PackageExporter(db).ExportAsync(meetingId, path, "check"); var v = PackageVerifier.Verify(path); try { File.Delete(path); } catch { } await AuditRead(db, p, "校验数据包", v.PkgNo ?? ("会议#" + meetingId), v.Ok ? "校验通过" : ("校验未通过:" + string.Join(";", v.Errors.Take(3)))); return Results.Ok(new { ok = v.Ok, pkgNo = v.PkgNo, meeting = v.MeetingName, expireAt = v.ExpireAt, watermark = v.Watermark, matOrder = v.MatOrder, counts = new { cadres = v.Cadres, resumes = v.Resumes, families = v.Families, notes = v.Notes, materials = v.Materials, docs = v.Docs, padBound = v.PadBound }, missingTables = v.MissingTables, missingConfig = v.MissingConfig, errors = v.Errors, bytes = v.Bytes }); }).RequireAuthorization(); app.MapGet("/api/admin/bootstrap", async (int? meetingId, ClaimsPrincipal p, RmsysDbContext db) => { var depts = await db.Depts.OrderBy(x => x.Sort).ToListAsync(); var meetings = await db.Meetings.Where(x => x.IsDeleted == 0).OrderBy(x => x.Id).ToListAsync(); var cur = meetings.FirstOrDefault(x => x.Id == (meetingId ?? meetings.FirstOrDefault()?.Id)); if (cur == null) return Results.Ok(new { error = "尚无会议数据" }); // 查看留痕:谁在什么时候看了哪场会的全套材料 await AuditRead(db, p, "查看会议数据", cur.Name, "后台管理端加载"); var deptCode = depts.ToDictionary(x => x.Id, x => x.Code); var deptName = depts.ToDictionary(x => x.Id, x => x.Name); var hasDept = cur.Type == MeetingType.BuWu; // ---------- 会议 × 干部 ---------- var mcs = await db.MeetingCadres.Where(x => x.MeetingId == cur.Id) .OrderBy(x => x.SortNo).ToListAsync(); var cadreIds = mcs.Select(x => x.CadreId).ToList(); var cadres = await db.Cadres.Where(x => cadreIds.Contains(x.Id)).ToListAsync(); var mats = await db.Materials.Where(x => x.MeetingId == cur.Id).ToListAsync(); var notes = await db.CadreNotes.Where(x => x.MeetingId == cur.Id || x.MeetingId == null) .GroupBy(x => x.CadreId) .Select(g => new { CadreId = g.Key, N = g.Count() }) .ToListAsync(); var noteMap = notes.ToDictionary(x => x.CadreId, x => x.N); // 已阅回执(PAD 回收后导入的条数,按干部汇总) var readMap = await db.ReadReceipts.Where(x => x.MeetingId == cur.Id) .GroupBy(x => x.CadreId) .Select(g => new { CadreId = g.Key, N = g.Count() }) .ToDictionaryAsync(x => x.CadreId, x => x.N); // ---------- 数据权限过滤(本处室账号只看本处室)---------- // 「全部处室」「只读」不限;「本处室」按 sysusers.DeptId 过滤干部及其材料/批注。 var scope = await Auth.ScopeGuard.ResolveAsync(p, db); if (!scope.IsAll) { var mineIds = cadres.Where(x => x.DeptId == scope.DeptId).Select(x => x.Id).ToHashSet(); mcs = mineIds.Count == 0 ? mcs.Where(_ => false).ToList() : mcs.Where(x => mineIds.Contains(x.CadreId)).ToList(); cadres = cadres.Where(x => mineIds.Contains(x.Id)).ToList(); mats = mats.Where(x => x.CadreId.HasValue && mineIds.Contains(x.CadreId.Value)).ToList(); noteMap = notes.Where(x => mineIds.Contains(x.CadreId)) .ToDictionary(x => x.CadreId, x => x.N); } var cadreOut = new List(); foreach (var mc in mcs) { var c = cadres.FirstOrDefault(x => x.Id == mc.CadreId); if (c == null) continue; var mine = mats.Where(x => x.CadreId == c.Id).ToList(); var missExam = mine.Any(x => x.Type == MaterialType.Inspection && x.Status == MaterialStatus.Missing); var missReport = mine.Any(x => x.Type == MaterialType.Report && x.Status == MaterialStatus.Missing); var comp = missExam ? "缺考察材料" : (missReport ? "缺汇报材料" : "齐全"); var passed = mine.Count > 0 && mine.All(x => x.Status == MaterialStatus.Passed || x.Status == MaterialStatus.Missing); var status = mine.Any(x => x.Status == MaterialStatus.Missing) ? "待补件" : (passed ? "已审核" : "待审核"); cadreOut.Add(new { id = c.Id, dept = c.DeptId.HasValue && deptCode.ContainsKey(c.DeptId.Value) ? deptCode[c.DeptId.Value] : "rsc", name = c.Name, sex = c.Sex ?? "", birth = c.Birth ?? "", nation = c.Nation ?? "", jg = c.NativePlace ?? "", party = c.PartyDate ?? "", work = c.WorkDate ?? "", ef = Join(c.EduFullLevel, c.EduFullDegree), ep = Join(c.EduInLevel, c.EduInDegree), cur = c.CurrentPost ?? "", prop = c.ProposedPost ?? "", dis = c.DismissPost ?? "", comp, status, readCount = readMap.GetValueOrDefault(c.Id, 0), issue = mc.IssueNo ?? "", notes = noteMap.TryGetValue(c.Id, out var n) ? n : 0 }); } // ---------- 任免文件 ---------- var docs = await db.AppointDocs .Where(x => x.MeetingId == cur.Id && x.IsDeleted == 0).ToListAsync(); var docOut = docs.Select(d => (object)new { id = d.Id, meeting = d.MeetingId, dept = d.DeptId.HasValue && deptCode.ContainsKey(d.DeptId.Value) ? deptCode[d.DeptId.Value] : "single", no = d.DocNo ?? "", by = d.UploadedBy ?? "", at = d.UploadedAt?.ToString(DateFmt) ?? "", ver = d.Version, pages = d.Pages, size = Size(d.FileSize), status = MatStatusCn(d.Status) }).ToList(); // ---------- 数据包 ---------- var pkgs = await db.DataPackages.OrderBy(x => x.Id).ToListAsync(); var pkgIds = pkgs.Select(x => x.Id).ToList(); var items = await db.PackageItems.Where(x => pkgIds.Contains(x.PackageId)).ToListAsync(); var pkgDev = await db.PackageDevices.Where(x => pkgIds.Contains(x.PackageId)) .GroupBy(x => x.PackageId).Select(g => new { PackageId = g.Key, N = g.Count() }).ToListAsync(); var pkgDevMap = pkgDev.ToDictionary(x => x.PackageId, x => x.N); var meetName = meetings.ToDictionary(x => x.Id, x => x.Name); var meetType = meetings.ToDictionary(x => x.Id, x => x.Type); var matCn = new Dictionary { [MaterialType.AppointmentForm] = "任免表", [MaterialType.Inspection] = "考察材料", [MaterialType.Report] = "汇报材料", [MaterialType.Note] = "说明材料", [MaterialType.Attachment] = "附件" }; var pkgOut = pkgs.Select(p => { var its = items.Where(x => x.PackageId == p.Id && x.Enabled == 1) .OrderBy(x => x.Sort).Select(x => matCn.GetValueOrDefault(x.MaterialType, x.MaterialType)).ToList(); // 范围前缀取「该数据包所属会议」的类型,不是当前选中的会议 var prefix = meetType.GetValueOrDefault(p.MeetingId) == MeetingType.BuWu ? "全部处室" : "全部干部"; var scope = prefix + " · " + string.Join("+", its.Take(2)) + (its.Count > 2 ? " 等" + its.Count + "类" : ""); return (object)new { id = p.PkgNo, meet = meetName.GetValueOrDefault(p.MeetingId, ""), scope, size = p.FileSize >= 1024 * 1024 ? (p.FileSize / 1024 / 1024) + " MB" : Size(p.FileSize), enc = p.Encrypt == "sm4_pwd" ? "国密SM4 + 口令" : "国密SM4", // 已销毁的历史包显示「已过期」,其余显示具体有效期 exp = p.Status == PackageStatus.Destroyed ? "已过期" : (p.ExpireAt?.ToString(DateFmt) ?? ""), pads = pkgDevMap.GetValueOrDefault(p.Id, 0), status = PkgStatusCn(p.Status), created = p.CreatedAt.ToString(DateFmt) }; }).ToList(); // ---------- PAD 设备 ---------- // 状态口径:Status=0 → 在库;有 LastSeenAt → 在线;否则 未加载 var pads = await db.PadDevices.OrderBy(x => x.PadNo).ToListAsync(); var padOut = pads.Select(p => (object)new { no = p.PadNo, model = p.Model ?? "", owner = p.OwnerLabel ?? (p.OwnerDeptId.HasValue && deptName.ContainsKey(p.OwnerDeptId.Value) ? deptName[p.OwnerDeptId.Value] : "—"), status = p.Status == 0 ? "在库" : (p.LastSeenAt.HasValue ? "在线" : "未加载"), meet = p.Status == 0 ? "—" : (cur.SessionNo.HasValue ? "第" + cur.SessionNo + "次部务会" : cur.Name), last = p.LastSeenAt?.ToString(DateFmt) ?? "—" }).ToList(); // ---------- 用户 / 登录 ---------- var users = await db.SysUsers.OrderBy(x => x.Id).ToListAsync(); var roles = await db.SysRoles.ToListAsync(); var urs = await db.SysUserRoles.ToListAsync(); var roleName = roles.ToDictionary(x => x.Id, x => x.Name); var loginLogs = await db.AuditLogs.Where(x => x.Action == "login") .OrderByDescending(x => x.At).ToListAsync(); var userOut = users.Select(u => { var rn = urs.Where(x => x.UserId == u.Id) .Select(x => roleName.GetValueOrDefault(x.RoleId, "")).Where(s => s != "").ToList(); var lastLogin = loginLogs.FirstOrDefault(l => l.UserName == u.RealName); return (object)new { acc = u.Account, name = u.RealName, role = rn.Count > 0 ? string.Join("、", rn) : "—", dept = u.DeptId.HasValue && deptCode.ContainsKey(u.DeptId.Value) ? deptCode[u.DeptId.Value] : "all", scope = u.DataScope ?? (u.DeptId.HasValue && deptName.ContainsKey(u.DeptId.Value) ? deptName[u.DeptId.Value] : "全部处室"), status = u.Enabled == 1 ? "启用" : "停用", last = u.LastLoginAt?.ToString(DateFmt) ?? "从未登录", ip = lastLogin?.Ip ?? "—" }; }).ToList(); // ---------- 审计日志(操作类,不含登录)---------- var logs = await db.AuditLogs.Where(x => x.Action != "login") .OrderByDescending(x => x.At).Take(200).ToListAsync(); var logOut = logs.Select(l => (object)new { t = l.At.ToString(TimeFmt), u = l.UserName ?? "", r = l.Role ?? "", a = l.Action, o = l.Target ?? l.Detail ?? "", d = l.Device ?? "" }).ToList(); var loginOut = loginLogs.Select(l => (object)new { t = l.At.ToString(TimeFmt), acc = l.UserName ?? "", name = l.UserName ?? "", ip = l.Ip ?? "", dev = l.Device ?? "", res = l.Success == 1 ? "成功" : ("失败" + (string.IsNullOrEmpty(l.Detail) ? "" : "(" + l.Detail + ")")) }).ToList(); // ---------- 会议(形状对齐 demo)---------- var allMc = await db.MeetingCadres.GroupBy(x => x.MeetingId) .Select(g => new { MeetingId = g.Key, N = g.Count() }).ToListAsync(); var mcMap = allMc.ToDictionary(x => x.MeetingId, x => x.N); var meetOut = meetings.Select(m => (object)new { id = m.Id, no = m.SessionNo?.ToString() ?? "—", type = m.Type, name = m.Name, time = m.HeldAt?.ToString(DateFmt) ?? "", place = m.Place ?? "", host = m.Host ?? "", secret = m.Secret, level = m.PwdLevel + (m.PwdLevel == "A级" ? "(仅部领导)" : "(全体参会人员)"), status = MeetStatusCn(m.Status), depts = m.Type == MeetingType.BuWu ? depts.Where(d => d.Enabled == 1).Select(d => d.Code).ToList() : new List() }).ToList(); return Results.Ok(new { depts = depts.Select(d => new { id = d.Id, k = d.Code, n = d.Name, desc = d.Duty ?? "", owner = d.Leader ?? "", order = d.Sort, enabled = d.Enabled == 1 }), types = new[] { new { k = "buwu", n = "部务会", @short = "部务会", depts = true }, new { k = "changwei", n = "常委会", @short = "常委会会议", depts = false }, new { k = "shuji", n = "书记专题会", @short = "书记专题会议", depts = false } }, meetings = meetOut, cadres = cadreOut, docs = docOut, pkgs = pkgOut, pads = padOut, users = userOut, logins = loginOut, logs = logOut, issues = new[] { "议题一", "议题二", "议题三" }, roles = roles.Select(r => r.Name).ToList(), counts = new { cadres = cadreOut.Count, materials = mats.Count, mc = mcMap.GetValueOrDefault(cur.Id, 0) }, currentMeetingId = cur.Id }); }).RequireAuthorization(); // ================= 审计日志检索(分页 + 筛选 + 导出 CSV)================= // 保密检查要求「查看、导出、下载」都有记录,而且要能查全量; // bootstrap 只带最近 200 条,所以单独开了分页检索与 CSV 导出。 app.MapGet("/api/admin/auditlogs", async ( int? page, int? pageSize, string? q, string? action, string? from, string? to, RmsysDbContext db) => { var pg = Math.Max(1, page ?? 1); var ps = Math.Clamp(pageSize ?? 50, 10, 200); var query = BuildAuditQuery(db, q, action, from, to); var total = await query.CountAsync(); var list = await query.OrderByDescending(x => x.At).ThenByDescending(x => x.Id) .Skip((pg - 1) * ps).Take(ps) .Select(x => new { x.Id, at = x.At.ToString("yyyy-MM-dd HH:mm:ss"), user = x.UserName, x.Role, x.Action, x.Target, x.Detail, x.Ip, x.Device, x.Success }).ToListAsync(); return Results.Ok(new { ok = true, total, page = pg, pageSize = ps, pages = (int)Math.Ceiling(total / (double)ps), list }); }).RequireAuthorization(); app.MapGet("/api/admin/auditlogs/export", async ( string? q, string? action, string? from, string? to, ClaimsPrincipal p, RmsysDbContext db) => { var rows = await BuildAuditQuery(db, q, action, from, to) .OrderByDescending(x => x.At).Take(20000).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("时间,操作人,角色,动作,对象,详情,IP,来源设备,结果"); foreach (var r in rows) { sb.AppendLine(string.Join(",", Csv(r.At.ToString("yyyy-MM-dd HH:mm:ss")), Csv(r.UserName), Csv(r.Role), Csv(r.Action), Csv(r.Target), Csv(r.Detail), Csv(r.Ip), Csv(r.Device), r.Success == 1 ? "成功" : "失败")); } // 带 BOM,Excel 直接双击打开不乱码 var body = Encoding.UTF8.GetBytes(sb.ToString()); var bom = Encoding.UTF8.GetPreamble(); var bytes = new byte[bom.Length + body.Length]; Buffer.BlockCopy(bom, 0, bytes, 0, bom.Length); Buffer.BlockCopy(body, 0, bytes, bom.Length, body.Length); await AuditRead(db, p, "导出审计日志", $"{rows.Count} 条", $"筛选:{q ?? "-"} / {action ?? "-"} / {from ?? "-"}~{to ?? "-"}"); return Results.File(bytes, "text/csv; charset=utf-8", $"审计日志_{DateTime.Now:yyyyMMddHHmm}.csv"); }).RequireAuthorization(); } /// 审计日志的公共筛选条件 private static IQueryable BuildAuditQuery( RmsysDbContext db, string? q, string? action, string? from, string? to) { var query = db.AuditLogs.AsNoTracking().AsQueryable(); if (!string.IsNullOrWhiteSpace(q)) { var k = q.Trim(); query = query.Where(x => (x.UserName != null && x.UserName.Contains(k)) || (x.Action != null && x.Action.Contains(k)) || (x.Target != null && x.Target.Contains(k))); } if (!string.IsNullOrWhiteSpace(action)) query = query.Where(x => x.Action == action); if (DateTime.TryParse(from, out var f)) query = query.Where(x => x.At >= f); if (DateTime.TryParse(to, out var t)) query = query.Where(x => x.At < t.AddDays(1)); return query; } /// CSV 字段转义:含逗号/引号/换行时用双引号包裹并把引号加倍 private static string Csv(string? v) { var s = v ?? ""; if (s.Contains(',') || s.Contains('"') || s.Contains('\n') || s.Contains('\r')) return "\"" + s.Replace("\"", "\"\"") + "\""; return s; } /// /// 导出目录清理:只保留最近 N 个包。 /// 每个包都是完整材料副本(100 KB+),不清理会无限堆积,也不符合「材料不留存」的原则。 /// private static void PruneOldExports(string dir, int keep) { try { var files = new DirectoryInfo(dir).GetFiles("*.*") .OrderByDescending(f => f.LastWriteTimeUtc) .Skip(keep) .ToList(); foreach (var f in files) { try { // 先清零再删,避免明文残留在磁盘 var len = f.Length; using (var fs = new FileStream(f.FullName, FileMode.Open, FileAccess.Write, FileShare.None)) { var zeros = new byte[(int)Math.Min(len, 64 * 1024)]; long left = len; while (left > 0) { var n = (int)Math.Min(left, zeros.Length); fs.Write(zeros, 0, n); left -= n; } fs.Flush(true); } f.Delete(); } catch { /* 单个失败不影响导出 */ } } } catch { } } private static async Task AuditRead(RmsysDbContext db, ClaimsPrincipal p, string action, string target, string? detail) { db.AuditLogs.Add(new AuditLog { At = DateTime.Now, UserId = int.TryParse(p.FindFirst("uid")?.Value, out var uid) ? uid : null, UserName = p.FindFirst(ClaimTypes.Name)?.Value, Role = p.FindFirst(ClaimTypes.Role)?.Value, Action = action, Target = target, Detail = detail, Device = "API" }); await db.SaveChangesAsync(); } private static string Join(string? a, string? b) { if (string.IsNullOrEmpty(a)) return b ?? ""; if (string.IsNullOrEmpty(b)) return a; return a + "·" + b; } private static string Size(long b) { if (b <= 0) return "—"; if (b >= 1024 * 1024) return (b / 1024 / 1024) + " MB"; return Math.Max(1, b / 1024) + " KB"; } private static string MatStatusCn(string s) => s switch { MaterialStatus.Missing => "未上传", MaterialStatus.Pending => "待审核", MaterialStatus.Passed => "已审核", MaterialStatus.Rejected => "退回", _ => s }; private static string MeetStatusCn(string s) => s switch { MeetingStatus.Draft => "筹备中", MeetingStatus.Reviewing => "材料已审核", MeetingStatus.Delivered => "已下发", MeetingStatus.Ongoing => "会议召开中", MeetingStatus.Registered => "结果已登记", MeetingStatus.Archived => "已归档", _ => s }; private static string PkgStatusCn(string s) => s switch { PackageStatus.Created => "已生成", PackageStatus.Delivered => "已下发", PackageStatus.Loaded => "PAD已加载", PackageStatus.Destroyed => "已销毁", _ => s }; }