using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Rmsys.Domain; using Rmsys.Domain.Entities; using Rmsys.Infrastructure; using Rmsys.Api.Compat; namespace Rmsys.Api.Endpoints; /// /// 材料文件上传 / 下载 / 预览 / 删除。 /// 设计要点(政务内网环境): /// · 只信扩展名与大小,落盘名一律由服务端生成,绝不用客户端传来的路径(防目录穿越) /// · 每次上传都累加版本号,旧文件保留在盘上,便于事后追溯 /// · 上传 / 删除均写审计日志 /// · 文件缺失时明确告知「文件不存在」,不返回空内容 /// public static class MaterialEndpoints { /// 允许的扩展名(含国产 OFD) private static readonly HashSet AllowedExt = new(StringComparer.OrdinalIgnoreCase) { ".docx", ".doc", ".pdf", ".xlsx", ".xls", ".ofd", ".wps", ".jpg", ".jpeg", ".png", ".zip" }; private const long MaxBytes = 50L * 1024 * 1024; // 单文件 50 MB public static void MapMaterialEndpoints(this WebApplication app) { // ================= 上传材料(任免表 / 考察材料 / 汇报材料 / 说明材料 / 附件)================= app.MapPost("/api/admin/materials/upload", async (HttpRequest req, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { // net6 不支持 IFormFile 参数绑定,统一手动读表单(见 Compat/NetCompat.cs · FormCompat) var form = await FormCompat.ReadFormAsync(req); if (form == null) return Results.Ok(new { ok = false, message = "请求格式不正确(需 multipart/form-data)" }); var file = form.Files.GetFile("file"); var meetingId = FormCompat.Int(form, "meetingId"); var cadreId = FormCompat.Int(form, "cadreId"); var type = FormCompat.Str(form, "type") ?? string.Empty; var pages = FormCompat.IntOrNull(form, "pages"); if (file == null || file.Length == 0) return Results.Ok(new { ok = false, message = "未收到文件" }); if (file.Length > MaxBytes) return Results.Ok(new { ok = false, message = $"文件超过 50 MB(实际 {file.Length / 1024 / 1024} MB)" }); var ext = Path.GetExtension(file.FileName); if (string.IsNullOrEmpty(ext) || !AllowedExt.Contains(ext)) return Results.Ok(new { ok = false, message = "不支持的文件类型:" + ext }); if (!MaterialType.All.Contains(type)) return Results.Ok(new { ok = false, message = "材料类型非法" }); var meeting = await db.Meetings.FirstOrDefaultAsync(x => x.Id == meetingId && x.IsDeleted == 0); if (meeting == null) return Results.Ok(new { ok = false, message = "会议不存在" }); var cadre = await db.Cadres.FirstOrDefaultAsync(x => x.Id == cadreId); if (cadre == null) return Results.Ok(new { ok = false, message = "干部不存在" }); // 状态守卫:结果已登记 / 已归档的会议不允许再上传材料 var denyUpload = Auth.MeetingGuard.CheckWritable(meeting); if (denyUpload != null) return Results.Json(new { ok = false, message = denyUpload }, statusCode: 409); // 名单校验:该干部必须在本次会议的上会名单内,否则会把材料挂到上不了会的会议里 var onRoster = await db.MeetingCadres .AnyAsync(x => x.MeetingId == meetingId && x.CadreId == cadreId); if (!onRoster) return Results.Json(new { ok = false, message = $"{cadre.Name} 不在本次会议的上会名单内,不能上传材料" }, statusCode: 400); // 落盘:服务端生成文件名,不用客户端路径 var rel = Path.Combine("uploads", meetingId.ToString(), cadreId.ToString(), $"{type}_{DateTime.Now:yyyyMMddHHmmss}{ext.ToLowerInvariant()}"); var full = Path.Combine(env.ContentRootPath, "App_Data", rel); Directory.CreateDirectory(Path.GetDirectoryName(full)!); await using (var fs = new FileStream(full, FileMode.Create, FileAccess.Write, FileShare.None)) { await file.CopyToAsync(fs); } // 版本号:同一 (会议, 干部, 类型) 累加 var old = await db.Materials .Where(x => x.MeetingId == meetingId && x.CadreId == cadreId && x.Type == type) .OrderByDescending(x => x.Id).FirstOrDefaultAsync(); var baseVer = 0; if (old?.Version != null && old.Version.StartsWith("v")) int.TryParse(old.Version[1..], out baseVer); var newVer = "v" + (baseVer + 1); var userName = p.FindFirst(ClaimTypes.Name)?.Value ?? "—"; int matId; if (old != null) { old.FileName = Path.GetFileName(file.FileName); old.FilePath = rel.Replace('\\', '/'); old.FileSize = file.Length; old.Version = newVer; old.UploadedBy = userName; old.UploadedAt = DateTime.Now; old.Status = MaterialStatus.Pending; if (pages.HasValue) old.Pages = pages.Value; matId = old.Id; } else { var mat = new Material { MeetingId = meetingId, DeptId = cadre.DeptId, CadreId = cadreId, Type = type, Title = cadre.Name + "_" + TypeCn(type), FileName = Path.GetFileName(file.FileName), FilePath = rel.Replace('\\', '/'), FileSize = file.Length, Pages = pages ?? 0, Version = newVer, UploadedBy = userName, UploadedAt = DateTime.Now, Status = MaterialStatus.Pending, Seq = 0, CreatedAt = DateTime.Now }; db.Materials.Add(mat); try { await db.SaveChangesAsync(); } catch (DbUpdateException) { // 并发上传撞上唯一索引 (MeetingId,Type,CadreId): // 说明另一个请求刚插入了同一行。清掉本次新增,改为提示重试, // 避免静默产生两条 v2 把「版本可追溯」破掉。 db.ChangeTracker.Clear(); try { File.Delete(full); } catch { } return Results.Json(new { ok = false, message = "该材料正被他人同时上传,请稍后重试" }, statusCode: 409); } matId = mat.Id; } await db.SaveChangesAsync(); await Audit(db, p, "上传材料", cadre.Name + " · " + TypeCn(type), $"{Path.GetFileName(file.FileName)}({file.Length / 1024} KB,{newVer})"); return Results.Ok(new { ok = true, id = matId, fileName = Path.GetFileName(file.FileName), size = file.Length, sizeText = file.Length >= 1024 * 1024 ? (file.Length / 1024.0 / 1024.0).ToString("0.0") + " MB" : (file.Length / 1024) + " KB", version = newVer, status = "待审核", uploadedAt = DateTime.Now.ToString("yyyy-MM-dd HH:mm"), message = $"上传成功({newVer})" }); }).RequireAuthorization().NoAntiforgery(); // ================= 上传任免文件(按处室一份)================= app.MapPost("/api/admin/appointdocs/upload", async (HttpRequest req, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { // net6 不支持 IFormFile 参数绑定,统一手动读表单 var form = await FormCompat.ReadFormAsync(req); if (form == null) return Results.Ok(new { ok = false, message = "请求格式不正确(需 multipart/form-data)" }); var file = form.Files.GetFile("file"); var meetingId = FormCompat.Int(form, "meetingId"); var deptId = FormCompat.IntOrNull(form, "deptId"); var docNo = FormCompat.Str(form, "docNo"); var pages = FormCompat.IntOrNull(form, "pages"); var cadreIds = FormCompat.Str(form, "cadreIds"); if (file == null || file.Length == 0) return Results.Ok(new { ok = false, message = "未收到文件" }); if (file.Length > MaxBytes) return Results.Ok(new { ok = false, message = "文件超过 50 MB" }); var ext = Path.GetExtension(file.FileName); if (string.IsNullOrEmpty(ext) || !AllowedExt.Contains(ext)) return Results.Ok(new { ok = false, message = "不支持的文件类型:" + ext }); var meeting = await db.Meetings.FirstOrDefaultAsync(x => x.Id == meetingId && x.IsDeleted == 0); if (meeting == null) return Results.Ok(new { ok = false, message = "会议不存在" }); // 状态守卫:结果已登记 / 已归档的会议不允许再上传任免文件 var denyDoc = Auth.MeetingGuard.CheckWritable(meeting); if (denyDoc != null) return Results.Json(new { ok = false, message = denyDoc }, statusCode: 409); var deptTag = deptId?.ToString() ?? "single"; var rel = Path.Combine("uploads", "docs", meetingId.ToString(), $"doc_{deptTag}_{DateTime.Now:yyyyMMddHHmmss}{ext.ToLowerInvariant()}"); var full = Path.Combine(env.ContentRootPath, "App_Data", rel); Directory.CreateDirectory(Path.GetDirectoryName(full)!); await using (var fs = new FileStream(full, FileMode.Create, FileAccess.Write, FileShare.None)) { await file.CopyToAsync(fs); } var old = await db.AppointDocs .Where(x => x.MeetingId == meetingId && x.DeptId == deptId && x.IsDeleted == 0) .OrderByDescending(x => x.Id).FirstOrDefaultAsync(); var baseVer = 0; if (old?.Version != null && old.Version.StartsWith("v")) int.TryParse(old.Version[1..], out baseVer); var newVer = "v" + (baseVer + 1); var userName = p.FindFirst(ClaimTypes.Name)?.Value ?? "—"; var persons = await db.MeetingCadres.CountAsync(x => x.MeetingId == meetingId); var title = Path.GetFileNameWithoutExtension(file.FileName); int docId; if (old != null) { old.FilePath = rel.Replace('\\', '/'); old.FileSize = file.Length; old.Version = newVer; old.UploadedBy = userName; old.UploadedAt = DateTime.Now; old.Status = "pending"; if (!string.IsNullOrWhiteSpace(docNo)) old.DocNo = docNo; if (pages.HasValue) old.Pages = pages.Value; docId = old.Id; } else { var doc = new AppointDoc { MeetingId = meetingId, DeptId = deptId, DocNo = docNo, Title = title, Persons = persons, Pages = pages ?? 0, FileSize = file.Length, FilePath = rel.Replace('\\', '/'), Version = newVer, UploadedBy = userName, UploadedAt = DateTime.Now, Status = "pending", IsDeleted = 0, CreatedAt = DateTime.Now }; db.AppointDocs.Add(doc); await db.SaveChangesAsync(); docId = doc.Id; } await db.SaveChangesAsync(); // ---------- 涉及干部(文件↔人 人级关联)---------- // cadreIds 逗号分隔;只接受本场上会名单内的干部,避免脏数据 var rosterIds = await db.MeetingCadres.Where(x => x.MeetingId == meetingId) .Select(x => x.CadreId).ToListAsync(); if (!string.IsNullOrWhiteSpace(cadreIds)) { var want = cadreIds.Split(new[] { ',', ',' }, StringSplitOptions.RemoveEmptyEntries) .Select(x => int.TryParse(x.Trim(), out var v) ? v : 0) .Where(v => v > 0 && rosterIds.Contains(v)) .Distinct().ToList(); var oldLinks = await db.AppointDocCadres.Where(x => x.DocId == docId).ToListAsync(); if (oldLinks.Count > 0) db.AppointDocCadres.RemoveRange(oldLinks); var sortMap = await db.MeetingCadres.Where(x => x.MeetingId == meetingId && want.Contains(x.CadreId)) .ToDictionaryAsync(x => x.CadreId, x => x.SortNo); var k = 1; foreach (var cid in want.OrderBy(c => sortMap.TryGetValue(c, out var sn) ? sn : 9999)) db.AppointDocCadres.Add(new AppointDocCadre { DocId = docId, CadreId = cid, Sort = k++ }); await db.SaveChangesAsync(); } // ---------- 没手工勾选人员时,按姓名从文件正文自动匹配 ---------- DocNameMatcher.Result? autoMatch = null; if (string.IsNullOrWhiteSpace(cadreIds)) { try { var roster = await db.MeetingCadres.Where(x => x.MeetingId == meetingId) .Join(db.Cadres, mc => mc.CadreId, c => c.Id, (mc, c) => new { c.Id, c.Name }) .Select(x => new { x.Id, x.Name }).ToListAsync(); autoMatch = DocNameMatcher.Match( Path.Combine(env.ContentRootPath, "App_Data", rel.Replace('\\', '/')), roster.Select(x => (x.Id, x.Name))); if (autoMatch.TextOk) { /* 标题以文件正文为准;抽不到才保留文件名 */ var text = DocNameMatcher.ExtractText( Path.Combine(env.ContentRootPath, "App_Data", rel.Replace('\\', '/')), out _); var realTitle = text == null ? null : DocNameMatcher.ExtractTitle(text); if (!string.IsNullOrWhiteSpace(realTitle)) { var row0 = await db.AppointDocs.FirstAsync(x => x.Id == docId); row0.Title = realTitle; await db.SaveChangesAsync(); } } if (autoMatch.TextOk && autoMatch.InFile.Count > 0) { var oldLinks2 = await db.AppointDocCadres.Where(x => x.DocId == docId).ToListAsync(); if (oldLinks2.Count > 0) db.AppointDocCadres.RemoveRange(oldLinks2); var k2 = 1; foreach (var x in autoMatch.InFile) db.AppointDocCadres.Add(new AppointDocCadre { DocId = docId, CadreId = x.CadreId, Sort = k2++ }); await db.SaveChangesAsync(); } } catch { /* 匹配失败不影响上传本身 */ } } // 标题与干部人数按「实际关联的人员」回写(不再按处室人数推算) var linkedIds = await db.AppointDocCadres.Where(x => x.DocId == docId) .Select(x => x.CadreId).ToListAsync(); if (linkedIds.Count > 0) { var firstCid = await db.MeetingCadres .Where(x => x.MeetingId == meetingId && linkedIds.Contains(x.CadreId)) .OrderBy(x => x.SortNo).Select(x => x.CadreId).FirstOrDefaultAsync(); var firstName = await db.Cadres.Where(c => c.Id == firstCid).Select(c => c.Name).FirstOrDefaultAsync(); var docRow = await db.AppointDocs.FirstAsync(x => x.Id == docId); docRow.Persons = linkedIds.Count; /* 标题一律以文件正文为准,不再用「关于XX等N名同志任免职的通知」这种系统拼出来的词覆盖 */ await db.SaveChangesAsync(); } string? deptName = deptId.HasValue ? await db.Depts.Where(d => d.Id == deptId).Select(d => d.Name).FirstOrDefaultAsync() : null; await Audit(db, p, "上传任免文件", meeting.Name + " / " + (deptName ?? "整会"), $"{Path.GetFileName(file.FileName)}({newVer})"); return Results.Ok(new { ok = true, id = docId, fileName = Path.GetFileName(file.FileName), version = newVer, sizeText = file.Length >= 1024 * 1024 ? (file.Length / 1024.0 / 1024.0).ToString("0.0") + " MB" : (file.Length / 1024) + " KB", persons = linkedIds.Count, autoMatched = autoMatch != null && autoMatch.TextOk ? autoMatch.InFile.Count : 0, autoMatchNote = autoMatch == null ? "" : (!autoMatch.TextOk ? (autoMatch.Error ?? "无法读取文件内容,请在「人员」里手动勾选") : (autoMatch.InFile.Count > 0 ? $"已按姓名自动匹配 {autoMatch.InFile.Count} 人" : "文件里没有识别到本场上会干部的姓名,请在「人员」里手动勾选")), unknownNames = autoMatch?.UnknownNames ?? new List(), ambiguous = autoMatch?.Ambiguous.Select(x => x.Name).ToList() ?? new List(), message = linkedIds.Count > 0 ? $"上传成功({newVer},已关联 {linkedIds.Count} 名干部)" : $"上传成功({newVer},未关联干部)" }); }).RequireAuthorization().NoAntiforgery(); // ================= 设置/修改某份任免文件涉及的人员(不用重新上传文件)================= app.MapPost("/api/admin/appointdocs/{id:int}/cadres", async ( int id, CadreIdsReq req, ClaimsPrincipal p, RmsysDbContext db) => { var doc = await db.AppointDocs.FirstOrDefaultAsync(x => x.Id == id && x.IsDeleted == 0); if (doc == null) return Results.Ok(new { ok = false, message = "任免文件不存在" }); var meeting = await db.Meetings.FirstOrDefaultAsync(x => x.Id == doc.MeetingId); 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); var rosterIds = await db.MeetingCadres.Where(x => x.MeetingId == doc.MeetingId) .Select(x => x.CadreId).ToListAsync(); var want = (req.cadreIds ?? new List()) .Where(v => rosterIds.Contains(v)).Distinct().ToList(); var old = await db.AppointDocCadres.Where(x => x.DocId == id).ToListAsync(); if (old.Count > 0) db.AppointDocCadres.RemoveRange(old); var sortMap = await db.MeetingCadres .Where(x => x.MeetingId == doc.MeetingId && want.Contains(x.CadreId)) .ToDictionaryAsync(x => x.CadreId, x => x.SortNo); var k = 1; foreach (var cid in want.OrderBy(c => sortMap.TryGetValue(c, out var sn) ? sn : 9999)) db.AppointDocCadres.Add(new AppointDocCadre { DocId = id, CadreId = cid, Sort = k++ }); doc.Persons = want.Count; if (want.Count > 0) { var firstCid = want.OrderBy(c => sortMap.TryGetValue(c, out var s2) ? s2 : 9999).First(); var nm = await db.Cadres.Where(c => c.Id == firstCid).Select(c => c.Name).FirstOrDefaultAsync(); doc.Title = "关于" + nm + "等" + want.Count + "名同志任免职的通知"; } else { doc.Title = null; // 清空关联后回退为按处室名单展示 } await db.SaveChangesAsync(); await Audit(db, p, "设置任免文件涉及人员", doc.DocNo ?? ("#" + id), want.Count + " 人"); return Results.Ok(new { ok = true, persons = want.Count, title = doc.Title, message = want.Count > 0 ? $"已关联 {want.Count} 名干部" : "已清空关联" }); }).RequireAuthorization(); // ================= 下载 / 预览(均需登录;下载留痕)================= app.MapGet("/api/admin/materials/{id:int}/download", async ( int id, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { var m = await db.Materials.FirstOrDefaultAsync(x => x.Id == id); if (m == null) return Results.NotFound(new { ok = false, message = "材料不存在" }); if (string.IsNullOrEmpty(m.FilePath)) return Results.NotFound(new { ok = false, message = "该材料尚无文件(仅登记了元数据)" }); var full = Path.Combine(env.ContentRootPath, "App_Data", m.FilePath); if (!File.Exists(full)) return Results.NotFound(new { ok = false, message = "文件不存在(可能已按会后清除策略销毁)" }); await Audit(db, p, "下载材料", m.Title ?? ("#" + id), $"{m.FileName}({m.Version})"); return Results.File(full, "application/octet-stream", m.FileName ?? Path.GetFileName(full)); }).RequireAuthorization(); /* 材料原件预览(考察材料 / 汇报材料 / 说明材料 / 附件 / 任免表): PDF 与图片直接给原件;Word 等先转 PDF 再内嵌 —— 浏览器无法直接显示 .docx,直接返回原件会是一片空白。 */ app.MapGet("/api/admin/materials/{id:int}/preview", async ( int id, RmsysDbContext db, IWebHostEnvironment env) => { var m = await db.Materials.FirstOrDefaultAsync(x => x.Id == id); if (m == null || string.IsNullOrEmpty(m.FilePath)) return PreviewMsg("该材料还没有上传文件。"); var root = Path.Combine(env.ContentRootPath, "App_Data"); var full = Path.Combine(root, m.FilePath.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(full)) return PreviewMsg("文件不存在(可能已被替换或删除),请重新上传。"); var ext = Path.GetExtension(full).ToLowerInvariant(); if (ext == ".pdf" || ext == ".png" || ext == ".jpg" || ext == ".jpeg") { var ct = ext == ".pdf" ? "application/pdf" : ext == ".png" ? "image/png" : "image/jpeg"; return Results.File(full, ct, enableRangeProcessing: true); } // Word / WPS 等:转 PDF 再内嵌,与任免文件走同一套缓存(_pdfcache) var pdf = PdfConverter.ToPdf(full, Path.Combine(root, "_pdfcache"), $"mat_{m.Id}_{m.Version}"); if (pdf == null) return PreviewMsg("无法转换为 PDF:服务器未安装 LibreOffice,或文件已损坏。可点右上角「下载」查看原件。"); return Results.File(pdf, "application/pdf", enableRangeProcessing: true); }).RequireAuthorization(); /* 任免文件原件预览:Word 先转 PDF 再内嵌显示。 后台预览与 PAD 看到的是同一个 PDF,不再用模板重新排版。 */ app.MapGet("/api/admin/appointdocs/{id:int}/preview", async ( int id, RmsysDbContext db, IWebHostEnvironment env) => { var d = await db.AppointDocs.FirstOrDefaultAsync(x => x.Id == id && x.IsDeleted == 0); if (d == null) return Results.NotFound(new { ok = false, message = "任免文件不存在" }); if (string.IsNullOrEmpty(d.FilePath)) return Results.NotFound(new { ok = false, message = "该处室还没有上传任免文件" }); var root = Path.Combine(env.ContentRootPath, "App_Data"); var full = Path.Combine(root, d.FilePath.Replace('/', Path.DirectorySeparatorChar)); if (!File.Exists(full)) return Results.NotFound(new { ok = false, message = "文件不存在(可能已被替换或删除)" }); var pdf = PdfConverter.ToPdf(full, Path.Combine(root, "_pdfcache"), $"doc_{d.Id}_{d.Version}"); if (pdf == null) return Results.Json( new { ok = false, message = "无法转换为 PDF:服务器未安装 LibreOffice,或文件已损坏。请下载原件查看。" }, statusCode: 500); return Results.File(pdf, "application/pdf"); }).RequireAuthorization(); app.MapGet("/api/admin/appointdocs/{id:int}/download", async ( int id, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { var d = await db.AppointDocs.FirstOrDefaultAsync(x => x.Id == id); if (d == null || string.IsNullOrEmpty(d.FilePath)) return Results.NotFound(new { ok = false, message = "任免文件不存在" }); var full = Path.Combine(env.ContentRootPath, "App_Data", d.FilePath); if (!File.Exists(full)) return Results.NotFound(new { ok = false, message = "文件不存在(可能已销毁)" }); var name = (d.Title ?? "任免文件") + Path.GetExtension(full); await Audit(db, p, "下载任免文件", d.Title ?? ("#" + id), $"{name}({d.Version})"); return Results.File(full, "application/octet-stream", name); }).RequireAuthorization(); // ================= 删除材料(软删:元数据置为未上传,文件移入 _trash)================= app.MapDelete("/api/admin/materials/{id:int}", async (int id, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { var m = await db.Materials.FirstOrDefaultAsync(x => x.Id == id); if (m == null) return Results.Ok(new { ok = false, message = "材料不存在" }); // 状态守卫:结果已登记 / 已归档的会议不允许删除材料 var mtg = await db.Meetings.AsNoTracking().FirstOrDefaultAsync(x => x.Id == m.MeetingId); if (mtg != null) { var denyDel = Auth.MeetingGuard.CheckWritable(mtg); if (denyDel != null) return Results.Json(new { ok = false, message = denyDel }, statusCode: 409); } var name = m.Title ?? ("#" + id); var trashMoved = ""; if (!string.IsNullOrEmpty(m.FilePath)) { var full = Path.Combine(env.ContentRootPath, "App_Data", m.FilePath); if (File.Exists(full)) { var trashDir = Path.Combine(env.ContentRootPath, "App_Data", "_trash", DateTime.Now.ToString("yyyyMMdd")); Directory.CreateDirectory(trashDir); var target = Path.Combine(trashDir, Path.GetFileName(full)); try { File.Move(full, target, overwrite: true); trashMoved = Path.GetRelativePath(Path.Combine(env.ContentRootPath, "App_Data"), target).Replace('\\', '/'); } catch { /* 移动失败不阻断业务 */ } } } // 回收站(软删除):记录保留,文件已移到 _trash 并记住相对路径,恢复时搬回 m.IsDeleted = 1; m.PrevStatus = m.Status; m.Status = MaterialStatus.Missing; m.TrashPath = string.IsNullOrEmpty(trashMoved) ? null : trashMoved; m.DeletedAt = DateTime.Now; await db.SaveChangesAsync(); await Audit(db, p, "删除材料", name, (string.IsNullOrEmpty(trashMoved) ? "无实体文件;" : $"文件移入 {trashMoved};") + "可在回收站恢复"); return Results.Ok(new { ok = true, message = "已移入回收站(30 天内可恢复)" }); }).RequireAuthorization(); // ================= 单份材料信息(供页面核对)================= app.MapGet("/api/admin/materials/{id:int}", async (int id, RmsysDbContext db) => { var m = await db.Materials.Where(x => x.Id == id).Select(x => new { x.Id, x.Type, x.Title, x.FileName, x.FileSize, x.Pages, x.Version, x.UploadedBy, uploadedAt = x.UploadedAt == null ? null : x.UploadedAt.Value.ToString("yyyy-MM-dd HH:mm"), x.Status, hasFile = x.FilePath != null }).FirstOrDefaultAsync(); return m == null ? Results.NotFound(new { ok = false }) : Results.Ok(m); }).RequireAuthorization(); // 已阅回执:导入与查询 app.MapReceiptEndpoints(); } private static string TypeCn(string t) => t switch { MaterialType.AppointmentForm => "任免审批表", MaterialType.Inspection => "考察材料", MaterialType.Report => "汇报材料", MaterialType.Note => "说明材料", MaterialType.Attachment => "附件", _ => t }; // ================= 已阅回执 ================= private static void MapReceiptEndpoints(this WebApplication app) { // 导入:干部科回收 PAD 后,把 receipt_<设备号>.jsonl 传上来入库 app.MapPost("/api/admin/receipts/import", async ( HttpRequest req, ClaimsPrincipal p, RmsysDbContext db) => { // net6 不支持 IFormFile 参数绑定,统一手动读表单 var form = await FormCompat.ReadFormAsync(req); if (form == null) return Results.Ok(new { ok = false, message = "请求格式不正确(需 multipart/form-data)" }); var file = form.Files.GetFile("file"); if (file == null || file.Length == 0) return Results.Ok(new { ok = false, message = "未收到回执文件" }); if (file.Length > 8L * 1024 * 1024) return Results.Ok(new { ok = false, message = "回执文件过大(超过 8 MB)" }); var user = p.FindFirst(ClaimTypes.Name)?.Value ?? "—"; // 预载已有键,避免靠捕获数据库唯一冲突来判断重复 var seen = new HashSet( await db.ReadReceipts.AsNoTracking() .Select(x => x.MeetingId + "|" + x.CadreId + "|" + x.MaterialType + "|" + x.PadNo) .ToListAsync()); int added = 0, dup = 0, bad = 0; using var sr = new StreamReader(file.OpenReadStream(), System.Text.Encoding.UTF8); var text = await sr.ReadToEndAsync(); foreach (var raw in text.Split('\n')) { var line = raw.Trim(); if (line.Length == 0) continue; try { using var doc = System.Text.Json.JsonDocument.Parse(line); var r = doc.RootElement; var mid = r.GetProperty("meetingId").GetInt32(); var cid = r.GetProperty("cadreId").GetInt32(); var type = r.GetProperty("type").GetString() ?? ""; var pad = r.TryGetProperty("padNo", out var pn) ? (pn.GetString() ?? "") : ""; var at = r.TryGetProperty("readAt", out var ra) && DateTime.TryParse(ra.GetString(), out var dt) ? dt : DateTime.Now; if (mid <= 0 || cid <= 0 || type.Length == 0 || pad.Length == 0) { bad++; continue; } var key = mid + "|" + cid + "|" + type + "|" + pad; if (!seen.Add(key)) { dup++; continue; } db.ReadReceipts.Add(new ReadReceipt { MeetingId = mid, CadreId = cid, MaterialType = type, PadNo = pad, ReadAt = at, ImportedAt = DateTime.Now, ImportedBy = user }); added++; } catch { bad++; } } await db.SaveChangesAsync(); await Audit(db, p, "导入已阅回执", file.FileName, $"新增 {added} 条,重复 {dup} 条,无效 {bad} 条"); return Results.Ok(new { ok = true, added, dup, bad, message = $"导入完成:新增 {added} 条,重复 {dup} 条,无效 {bad} 条" }); }).RequireAuthorization() .NoAntiforgery(); // 纯 JWT 鉴权(Bearer 头不被浏览器自动携带),CSRF 不成立; // .NET 8 对 IFormFile 端点默认要求抗伪造令牌,这里显式关掉(见 Compat/NetCompat.cs) app.MapGet("/api/admin/receipts", async (int? meetingId, RmsysDbContext db) => { var q = db.ReadReceipts.AsNoTracking(); if (meetingId.HasValue) q = q.Where(x => x.MeetingId == meetingId.Value); var list = await q.OrderByDescending(x => x.ReadAt).Take(500) .Select(x => new { x.MeetingId, x.CadreId, x.MaterialType, x.PadNo, readAt = x.ReadAt.ToString("yyyy-MM-dd HH:mm"), x.ImportedBy }).ToListAsync(); return Results.Ok(new { ok = true, total = list.Count, list }); }).RequireAuthorization(); } private static async Task Audit(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(); } /// /// 预览失败时的提示页。iframe 里直接显示成一行说明, /// 比返回 404/空响应让用户面对一片空白要好排查。 /// private static IResult PreviewMsg(string msg) => Results.Content( "" + "
" + System.Net.WebUtility.HtmlEncode(msg) + "
", "text/html; charset=utf-8"); } /// 设置任免文件涉及人员 public record CadreIdsReq(List? cadreIds);