using System.Security.Claims; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Rmsys.Domain; using Rmsys.Domain.Entities; using Rmsys.Infrastructure; namespace Rmsys.Api.Endpoints; /// /// 回收站:会议、任免文件、材料三类软删数据可查、可恢复、可彻底删除。 /// 保留期 30 天,超期由 /purge-expired 清理(安装包启动时也会跑一次)。 /// public static class RecycleEndpoints { private const int KeepDays = 30; public static void MapRecycleEndpoints(this WebApplication app) { // ================= 列表 ================= app.MapGet("/api/admin/recycle", async (ClaimsPrincipal p, RmsysDbContext db) => { var now = DateTime.Now; var cut = now.AddDays(-KeepDays); var meetings = await db.Meetings.Where(x => x.IsDeleted == 1) .OrderByDescending(x => x.UpdatedAt).Take(200) .Select(x => new { id = x.Id, title = x.Name, sub = MeetingStatus.Label(x.Status) + " · " + (x.HeldAt == null ? "未定日期" : x.HeldAt.Value.ToString("yyyy-MM-dd HH:mm")), by = "", deletedAt = x.UpdatedAt.ToString("yyyy-MM-dd HH:mm") }).ToListAsync(); var docs = await db.AppointDocs.Where(x => x.IsDeleted == 1) .OrderByDescending(x => x.UploadedAt).Take(200) .Select(x => new { id = x.Id, title = x.Title ?? ("文稿 #" + x.Id), sub = "文号 " + (x.DocNo ?? "—") + " · " + x.Pages + " 页", by = x.UploadedBy ?? "", deletedAt = (x.UploadedAt == null ? "" : x.UploadedAt.Value.ToString("yyyy-MM-dd HH:mm")) }).ToListAsync(); var materials = await db.Materials.Where(x => x.IsDeleted == 1) .OrderByDescending(x => x.DeletedAt).Take(200) .Select(x => new { id = x.Id, title = x.FileName ?? x.Title ?? ("材料 #" + x.Id), sub = x.Type + " · " + (x.PrevStatus ?? ""), by = x.UploadedBy ?? "", deletedAt = (x.DeletedAt == null ? "" : x.DeletedAt.Value.ToString("yyyy-MM-dd HH:mm")) }).ToListAsync(); return Results.Ok(new { keepDays = KeepDays, meetings, docs, materials, expiringSoon = new { meetings = await db.Meetings.CountAsync(x => x.IsDeleted == 1 && x.UpdatedAt < cut), docs = await db.AppointDocs.CountAsync(x => x.IsDeleted == 1 && x.UploadedAt != null && x.UploadedAt < cut), materials = await db.Materials.CountAsync(x => x.IsDeleted == 1 && x.DeletedAt != null && x.DeletedAt < cut) } }); }).RequireAuthorization(); // ================= 恢复 ================= app.MapPost("/api/admin/recycle/{type}/{id:int}/restore", async ( string type, int id, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { type = (type ?? "").ToLowerInvariant(); if (type == "meeting") { var m = await db.Meetings.FirstOrDefaultAsync(x => x.Id == id && x.IsDeleted == 1); if (m == null) return Results.Ok(new { ok = false, message = "回收站里没有这场会议" }); m.IsDeleted = 0; m.UpdatedAt = DateTime.Now; await db.SaveChangesAsync(); await Audit(db, p, "回收站-恢复会议", m.Name, $"#{id}"); return Results.Ok(new { ok = true, message = "会议已恢复" }); } if (type == "doc") { var d = await db.AppointDocs.FirstOrDefaultAsync(x => x.Id == id && x.IsDeleted == 1); if (d == null) return Results.Ok(new { ok = false, message = "回收站里没有这份任免文件" }); d.IsDeleted = 0; await db.SaveChangesAsync(); await Audit(db, p, "回收站-恢复任免文件", d.Title ?? ("#" + d.Id), d.DocNo); return Results.Ok(new { ok = true, message = "任免文件已恢复" }); } if (type == "material") { var m = await db.Materials.FirstOrDefaultAsync(x => x.Id == id && x.IsDeleted == 1); if (m == null) return Results.Ok(new { ok = false, message = "回收站里没有这份材料" }); // 文件从回收目录搬回原位 if (!string.IsNullOrEmpty(m.TrashPath)) { var from = Path.Combine(env.ContentRootPath, "App_Data", m.TrashPath!); var to = string.IsNullOrEmpty(m.FilePath) ? Path.Combine(env.ContentRootPath, "App_Data", "uploads", m.MeetingId.ToString(), $"restored_{m.Id}_{Path.GetFileName(from)}") : Path.Combine(env.ContentRootPath, "App_Data", m.FilePath!); try { if (File.Exists(from)) { Directory.CreateDirectory(Path.GetDirectoryName(to)!); File.Move(from, to, overwrite: true); if (string.IsNullOrEmpty(m.FilePath)) m.FilePath = Path.GetRelativePath(Path.Combine(env.ContentRootPath, "App_Data"), to).Replace('\\', '/'); } } catch { /* 文件搬不动不阻断恢复,元数据照样回位 */ } } m.IsDeleted = 0; m.Status = string.IsNullOrEmpty(m.PrevStatus) ? MaterialStatus.Pending : m.PrevStatus!; m.PrevStatus = null; m.TrashPath = null; m.DeletedAt = null; await db.SaveChangesAsync(); await Audit(db, p, "回收站-恢复材料", m.FileName ?? ("#" + m.Id), m.Type); return Results.Ok(new { ok = true, message = "材料已恢复" }); } return Results.Ok(new { ok = false, message = "类型非法" }); }).RequireAuthorization(); // ================= 彻底删除 ================= app.MapDelete("/api/admin/recycle/{type}/{id:int}", async ( string type, int id, ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { type = (type ?? "").ToLowerInvariant(); if (type == "material") { var m = await db.Materials.FirstOrDefaultAsync(x => x.Id == id && x.IsDeleted == 1); if (m == null) return Results.Ok(new { ok = false, message = "回收站里没有这份材料" }); var name = m.FileName ?? ("#" + m.Id); if (!string.IsNullOrEmpty(m.TrashPath)) { var f = Path.Combine(env.ContentRootPath, "App_Data", m.TrashPath!); try { if (File.Exists(f)) File.Delete(f); } catch { } } db.Materials.Remove(m); await db.SaveChangesAsync(); await Audit(db, p, "回收站-彻底删除材料", name, "不可恢复"); return Results.Ok(new { ok = true, message = "已彻底删除" }); } if (type == "doc") { var d = await db.AppointDocs.FirstOrDefaultAsync(x => x.Id == id && x.IsDeleted == 1); if (d == null) return Results.Ok(new { ok = false, message = "回收站里没有这份任免文件" }); var name = d.Title ?? ("#" + d.Id); if (!string.IsNullOrEmpty(d.FilePath)) { var f = Path.Combine(env.ContentRootPath, "App_Data", d.FilePath!); try { if (File.Exists(f)) File.Delete(f); } catch { } } foreach (var l in await db.AppointDocCadres.Where(x => x.DocId == id).ToListAsync()) db.AppointDocCadres.Remove(l); db.AppointDocs.Remove(d); await db.SaveChangesAsync(); await Audit(db, p, "回收站-彻底删除任免文件", name, "不可恢复"); return Results.Ok(new { ok = true, message = "已彻底删除" }); } if (type == "meeting") { var m = await db.Meetings.FirstOrDefaultAsync(x => x.Id == id && x.IsDeleted == 1); if (m == null) return Results.Ok(new { ok = false, message = "回收站里没有这场会议" }); var name = m.Name; // 级联清理:名单、材料、任免文件、数据包、回执 foreach (var x in await db.MeetingCadres.Where(x => x.MeetingId == id).ToListAsync()) db.MeetingCadres.Remove(x); foreach (var x in await db.Materials.Where(x => x.MeetingId == id).ToListAsync()) db.Materials.Remove(x); foreach (var d in await db.AppointDocs.Where(x => x.MeetingId == id).ToListAsync()) { foreach (var l in await db.AppointDocCadres.Where(x => x.DocId == d.Id).ToListAsync()) db.AppointDocCadres.Remove(l); db.AppointDocs.Remove(d); } var pkgs = await db.DataPackages.Where(x => x.MeetingId == id).ToListAsync(); foreach (var pk in pkgs) { foreach (var it in await db.PackageItems.Where(x => x.PackageId == pk.Id).ToListAsync()) db.PackageItems.Remove(it); foreach (var dv in await db.PackageDevices.Where(x => x.PackageId == pk.Id).ToListAsync()) db.PackageDevices.Remove(dv); db.DataPackages.Remove(pk); } db.Meetings.Remove(m); await db.SaveChangesAsync(); await Audit(db, p, "回收站-彻底删除会议", name, "级联清理名单/材料/文件/数据包,不可恢复"); return Results.Ok(new { ok = true, message = "会议及其数据已彻底删除" }); } return Results.Ok(new { ok = false, message = "类型非法" }); }).RequireAuthorization(); // ================= 清理超期(30 天) ================= app.MapPost("/api/admin/recycle/purge-expired", async (ClaimsPrincipal p, RmsysDbContext db, IWebHostEnvironment env) => { var cut = DateTime.Now.AddDays(-KeepDays); var n = 0; foreach (var m in await db.Materials.Where(x => x.IsDeleted == 1 && x.DeletedAt != null && x.DeletedAt < cut).ToListAsync()) { if (!string.IsNullOrEmpty(m.TrashPath)) { var f = Path.Combine(env.ContentRootPath, "App_Data", m.TrashPath!); try { if (File.Exists(f)) File.Delete(f); } catch { } } db.Materials.Remove(m); n++; } foreach (var d in await db.AppointDocs.Where(x => x.IsDeleted == 1 && x.UploadedAt != null && x.UploadedAt < cut).ToListAsync()) { foreach (var l in await db.AppointDocCadres.Where(x => x.DocId == d.Id).ToListAsync()) db.AppointDocCadres.Remove(l); db.AppointDocs.Remove(d); n++; } await db.SaveChangesAsync(); await Audit(db, p, "回收站-清理超期", $"{KeepDays} 天", $"清理 {n} 条"); return Results.Ok(new { ok = true, removed = n, message = $"已清理 {n} 条超过 {KeepDays} 天的数据" }); }).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(); } }