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)
{
PhotoRoot = Path.Combine(env.ContentRootPath, "App_Data") // 证件照随包下发
}.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();
// ---------- 下载「干部任免表 3.0」.lrmx(任免表编辑器 V3.0 可直接打开编辑)----------
app.MapGet("/api/admin/cadres/{cadreId:int}/lrmx", async (
int cadreId, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) =>
{
var c = await db.Cadres.FirstOrDefaultAsync(x => x.Id == cadreId);
if (c == null) return Results.NotFound(new { ok = false, message = "干部不存在" });
var resumes = await db.CadreResumes.Where(x => x.CadreId == cadreId).ToListAsync();
var fams = await db.CadreFamilies.Where(x => x.CadreId == cadreId).ToListAsync();
var photo = LrmxExporter.LoadPhoto(Path.Combine(env.ContentRootPath, "App_Data"), c.PhotoPath);
string? dept = null;
if (c.DeptId.HasValue)
dept = await db.Depts.Where(d => d.Id == c.DeptId.Value).Select(d => d.Name).FirstOrDefaultAsync();
var bytes = LrmxExporter.BuildBytes(c, resumes, fams, photo, dept,
p.FindFirst(ClaimTypes.Name)?.Value);
var fileName = (string.IsNullOrWhiteSpace(c.Name) ? "任免表" : c.Name) + "_任免审批表.lrmx";
await AuditRead(db, p, "下载任免表lrmx", c.Name, fileName + "(简历 " + resumes.Count + " 段)");
return Results.File(bytes, "application/octet-stream", fileName);
}).RequireAuthorization();
// ---------- PAD端数据设置(全局一套,不跟会议走;导出数据包时写入包内)----------
app.MapGet("/api/admin/pad-settings", async (RmsysDbContext db) =>
{
var row = await db.SysConfigs.FirstOrDefaultAsync(x => x.ConfigKey == "pad.settings");
if (row?.ConfigValue == null || row.ConfigValue.Trim().Length == 0)
{
// 未配置时给一套可用默认值(与前端表单一致)
return Results.Ok(new
{
watermark = "中共海东市委组织部 机密★★ PAD-xx",
fontLevel = "big",
allowFontChange = 1,
copyEnabled = 0,
printEnabled = 0,
clearPolicy = "exit",
matOrder = new[] { "t30", "exam", "report", "note" },
updatedAt = ""
});
}
return Results.Content(row.ConfigValue, "application/json");
}).RequireAuthorization();
app.MapPut("/api/admin/pad-settings", async (
System.Text.Json.JsonElement body, ClaimsPrincipal p, RmsysDbContext db) =>
{
var sc = await Auth.ScopeGuard.ResolveAsync(p, db);
if (!sc.CanWrite) return Auth.ScopeGuard.ReadOnlyDenied();
// 落库时补上保存时间,前端可显示「上次保存」
var dict = System.Text.Json.JsonSerializer.Deserialize>(body.GetRawText())
?? new Dictionary();
var outDict = new Dictionary();
foreach (var kv in dict) outDict[kv.Key] = kv.Value.ValueKind == System.Text.Json.JsonValueKind.String
? kv.Value.GetString() : kv.Value;
outDict["updatedAt"] = DateTime.Now.ToString("yyyy-MM-dd HH:mm");
var json = System.Text.Json.JsonSerializer.Serialize(outDict);
var row = await db.SysConfigs.FirstOrDefaultAsync(x => x.ConfigKey == "pad.settings");
if (row == null)
{
row = new SysConfig { ConfigKey = "pad.settings", IsSecret = 0, Remark = "PAD端数据设置(全局,随数据包下发)" };
db.SysConfigs.Add(row);
}
row.ConfigValue = json;
row.UpdatedAt = DateTime.Now;
row.UpdatedBy = p.FindFirst(ClaimTypes.Name)?.Value ?? "—";
await db.SaveChangesAsync();
await AuditRead(db, p, "保存PAD数据设置", "pad.settings", "全局设置已更新,下次导出数据包生效");
return Results.Ok(new { ok = true, message = "已保存 · 下次导出数据包即生效" });
}).RequireAuthorization();
// ---------- 生成数据包(真实落库 + 真导出;后台「生成数据包」按钮调这里)----------
app.MapPost("/api/admin/packages/generate", async (
GeneratePkgReq req, 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 meeting = await db.Meetings.FirstOrDefaultAsync(x => x.Id == req.meetingId && x.IsDeleted == 0);
if (meeting == null) return Results.Ok(new { ok = false, message = "会议不存在" });
var deny = Auth.MeetingGuard.CheckWritable(meeting);
if (deny != null) return Results.Json(new { ok = false, message = deny }, statusCode: 409);
if (req.matOrder == null || req.matOrder.Count == 0)
return Results.Ok(new { ok = false, message = "请至少选择一种材料类型" });
var bad = req.matOrder.Where(t => !MaterialType.All.Contains(t)).ToList();
if (bad.Count > 0)
return Results.Ok(new { ok = false, message = "材料类型非法:" + string.Join("、", bad) });
if (req.encrypt == "sm4_pwd")
return Results.Ok(new { ok = false, message = "口令模式尚未在 PAD 端实现校验,请选择「国密SM4(不加口令)」" });
// 包号:PK + 会议日期 + 两位序号,自动避让重号
var day = (meeting.HeldAt ?? DateTime.Now).ToString("yyyyMMdd");
var seq = await db.DataPackages.CountAsync(x => x.MeetingId == meeting.Id && x.PkgNo.StartsWith("PK" + day));
var pkgNo = $"PK{day}-{seq + 1:00}";
while (await db.DataPackages.AnyAsync(x => x.PkgNo == pkgNo))
pkgNo = $"PK{day}-{++seq + 1:00}";
DateTime? exp = null;
if (!string.IsNullOrWhiteSpace(req.expireAt))
{
string[] fmts = { "yyyy-MM-dd HH:mm", "yyyy-MM-dd HH:mm:ss", "yyyy/MM/dd HH:mm" };
if (DateTime.TryParseExact(req.expireAt!.Trim(), fmts, null,
System.Globalization.DateTimeStyles.None, out var dt)) exp = dt;
else if (DateTime.TryParse(req.expireAt, out var dt2)) exp = dt2;
}
if (exp.HasValue && exp.Value <= DateTime.Now)
return Results.Ok(new { ok = false, message = "有效期必须晚于当前时间,否则 PAD 会拒绝加载" });
var pkg = new DataPackage
{
MeetingId = meeting.Id,
PkgNo = pkgNo,
Encrypt = string.IsNullOrWhiteSpace(req.encrypt) ? "sm4" : req.encrypt!,
ExpireAt = exp,
ClearPolicy = string.IsNullOrWhiteSpace(req.clearPolicy) ? "expire" : req.clearPolicy!,
// 水印默认「密级+星标」,PAD 端会自动补设备号与当天日期
Watermark = string.IsNullOrWhiteSpace(req.watermark)
? (meeting.Secret + (meeting.SecretStars ?? ""))
: req.watermark,
FontLevel = string.IsNullOrWhiteSpace(req.fontLevel) ? "big" : req.fontLevel!,
AllowFontChange = req.allowFontChange ?? 1,
Status = PackageStatus.Created,
CreatedBy = p.FindFirst(ClaimTypes.Name)?.Value ?? "—",
CreatedAt = DateTime.Now
};
db.DataPackages.Add(pkg);
await db.SaveChangesAsync();
// 下发清单(全 5 类都建行,勾选的置 1,保证后续可改)
var sort = 0;
foreach (var t in MaterialType.All)
db.PackageItems.Add(new PackageItem
{
PackageId = pkg.Id, MaterialType = t,
Enabled = req.matOrder.Contains(t) ? 1 : 0, Sort = ++sort
});
// 绑定设备
if (req.padNos != null && req.padNos.Count > 0)
{
var pads = await db.PadDevices.Where(x => req.padNos.Contains(x.PadNo)).ToListAsync();
foreach (var pd in pads)
db.PackageDevices.Add(new PackageDevice { PackageId = pkg.Id, PadId = pd.Id });
}
await db.SaveChangesAsync();
// 真导出(加密包落盘在 App_Data/packages/)
var dir = Path.Combine(env.ContentRootPath, "App_Data", "packages");
Directory.CreateDirectory(dir);
var fileName = pkgNo + ".pkg";
var path = Path.Combine(dir, fileName);
var r = await new PackageExporter(db)
{
PhotoRoot = Path.Combine(env.ContentRootPath, "App_Data"),
MatOrderOverride = req.matOrder // 生成时勾选的清单优先
}.ExportEncryptedAsync(meeting.Id, path, "admin");
pkg.FilePath = "App_Data/packages/" + fileName;
pkg.FileSize = r.Bytes;
pkg.Status = PackageStatus.Delivered;
await db.SaveChangesAsync();
await AuditRead(db, p, "生成数据包", pkgNo,
$"{fileName}({r.Bytes / 1024} KB,{r.Cadres} 人,有效期 {exp:yyyy-MM-dd HH:mm})");
return Results.Ok(new
{
ok = true,
pkgNo,
size = r.Bytes,
sizeText = (r.Bytes / 1024.0).ToString("F1") + " KB",
cadres = r.Cadres,
exp = exp?.ToString("yyyy-MM-dd HH:mm") ?? "",
pads = req.padNos?.Count ?? 0,
message = $"已生成 {pkgNo}({r.Bytes / 1024} KB,{r.Cadres} 人),可在列表右侧下载"
});
}
catch (Exception e)
{
return Results.Problem("生成数据包失败:" + e.Message);
}
}).RequireAuthorization();
// ---------- 销毁数据包(真实:删除导出文件 + 置为已销毁)----------
app.MapPost("/api/admin/packages/{pkgId:int}/destroy", async (
int pkgId, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) =>
{
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 pkg = await db.DataPackages.FirstOrDefaultAsync(x => x.Id == pkgId);
if (pkg == null) return Results.Ok(new { ok = false, message = "数据包不存在" });
if (pkg.Status == PackageStatus.Destroyed)
return Results.Ok(new { ok = true, message = "该包已销毁,无需重复操作" });
var deleted = false;
if (!string.IsNullOrWhiteSpace(pkg.FilePath))
{
var full = Path.Combine(env.ContentRootPath, pkg.FilePath!.Replace('/', Path.DirectorySeparatorChar));
try { if (File.Exists(full)) { File.Delete(full); deleted = true; } } catch { /* 删不掉也不阻断状态置位 */ }
}
pkg.Status = PackageStatus.Destroyed;
await db.SaveChangesAsync();
await AuditRead(db, p, "销毁数据包", pkg.PkgNo,
deleted ? "已删除导出文件并置为已销毁" : "已置为已销毁(导出文件此前不存在)");
return Results.Ok(new { ok = true, message = "已销毁 " + pkg.PkgNo + (deleted ? "(导出文件已删除)" : "") });
}).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