/** * NAC案例库归档脚本 * * 功能:将超过1年的已完结审批案例迁移到 approval_cases_archive 集合 * 执行方式: * 1. 手动执行:node -e "import('./dist/archiveApprovalCases.js').then(m => m.runArchive())" * 2. 定时执行:每月1日凌晨2:00(由cron任务调用) * 3. API触发:通过 approval.runArchive tRPC接口(需admin权限) * * 归档策略: * - 状态为 approved 或 rejected 的案例 * - 最后更新时间超过1年(365天) * - 归档后从主集合删除,保留在 approval_cases_archive 集合 * - 归档记录附加 archivedAt 时间戳 * - 法律保留期:7年(归档集合不自动删除) */ import { getMongoDb } from "./mongodb"; import { notifyOwner } from "./_core/notification"; const ARCHIVE_COLLECTION = "approval_cases_archive"; const SOURCE_COLLECTION = "approval_cases"; // 归档阈值:365天 const ARCHIVE_THRESHOLD_DAYS = 365; export interface ArchiveResult { success: boolean; archivedCount: number; failedCount: number; totalEligible: number; executedAt: string; durationMs: number; errors: string[]; } /** * 执行案例归档 * @param dryRun 试运行模式(不实际迁移,只统计数量) */ export async function runArchive(dryRun = false): Promise { const startTime = Date.now(); const executedAt = new Date().toISOString(); const errors: string[] = []; let archivedCount = 0; let failedCount = 0; console.log(`[Archive] 开始案例归档任务 ${dryRun ? "(试运行)" : ""} - ${executedAt}`); const db = await getMongoDb(); if (!db) { const error = "MongoDB连接失败,归档任务中止"; console.error(`[Archive] ${error}`); await notifyOwner({ title: "案例库归档失败", content: error, level: "critical", module: "archive", }); return { success: false, archivedCount: 0, failedCount: 0, totalEligible: 0, executedAt, durationMs: Date.now() - startTime, errors: [error], }; } // 计算归档截止日期 const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - ARCHIVE_THRESHOLD_DAYS); // 查询符合归档条件的案例 const eligibleFilter = { status: { $in: ["approved", "rejected"] }, updatedAt: { $lt: cutoffDate }, }; const totalEligible = await db.collection(SOURCE_COLLECTION).countDocuments(eligibleFilter); console.log(`[Archive] 符合归档条件的案例数量:${totalEligible}(截止日期:${cutoffDate.toISOString()})`); if (totalEligible === 0) { console.log("[Archive] 无需归档的案例,任务完成"); return { success: true, archivedCount: 0, failedCount: 0, totalEligible: 0, executedAt, durationMs: Date.now() - startTime, errors: [], }; } if (dryRun) { console.log(`[Archive] 试运行模式:将归档 ${totalEligible} 个案例(未实际执行)`); return { success: true, archivedCount: 0, failedCount: 0, totalEligible, executedAt, durationMs: Date.now() - startTime, errors: [], }; } // 确保归档集合存在(创建索引) try { await db.collection(ARCHIVE_COLLECTION).createIndex({ caseNumber: 1 }, { unique: true, sparse: true }); await db.collection(ARCHIVE_COLLECTION).createIndex({ archivedAt: 1 }); await db.collection(ARCHIVE_COLLECTION).createIndex({ status: 1 }); await db.collection(ARCHIVE_COLLECTION).createIndex({ jurisdiction: 1 }); } catch (e) { // 索引已存在时忽略错误 } // 分批处理(每批50条,避免内存溢出) const BATCH_SIZE = 50; let processed = 0; while (processed < totalEligible) { const batch = await db.collection(SOURCE_COLLECTION) .find(eligibleFilter) .skip(processed) .limit(BATCH_SIZE) .toArray(); if (batch.length === 0) break; for (const caseDoc of batch) { try { // 添加归档元数据 const archiveDoc = { ...caseDoc, archivedAt: new Date(), archiveReason: `自动归档:案例完结超过${ARCHIVE_THRESHOLD_DAYS}天`, originalCollection: SOURCE_COLLECTION, }; // 插入到归档集合 await db.collection(ARCHIVE_COLLECTION).insertOne(archiveDoc); // 从主集合删除 await db.collection(SOURCE_COLLECTION).deleteOne({ _id: caseDoc._id }); archivedCount++; } catch (e) { const errMsg = `案例 ${caseDoc.caseNumber || caseDoc._id} 归档失败: ${(e as Error).message}`; console.error(`[Archive] ${errMsg}`); errors.push(errMsg); failedCount++; } } processed += batch.length; console.log(`[Archive] 进度:${processed}/${totalEligible}(已归档:${archivedCount},失败:${failedCount})`); } const durationMs = Date.now() - startTime; const success = failedCount === 0; // 记录归档日志到MongoDB try { await db.collection("archive_logs").insertOne({ type: "approval_cases", executedAt: new Date(executedAt), totalEligible, archivedCount, failedCount, durationMs, errors, success, }); } catch (e) { console.warn("[Archive] 归档日志写入失败:", (e as Error).message); } // 发送告警通知 if (!success || archivedCount > 0) { await notifyOwner({ title: success ? `案例库归档完成:归档${archivedCount}个案例` : `案例库归档部分失败`, content: success ? `本次归档任务成功完成。\n\n**归档数量:** ${archivedCount}\n**耗时:** ${(durationMs / 1000).toFixed(1)}秒\n**截止日期:** ${cutoffDate.toLocaleDateString("zh-CN")}` : `归档任务完成但有${failedCount}个案例失败。\n\n**成功:** ${archivedCount}\n**失败:** ${failedCount}\n**错误:** ${errors.slice(0, 3).join("\n")}`, level: success ? "info" : "warning", module: "archive", }); } console.log(`[Archive] 归档任务完成 - 归档:${archivedCount},失败:${failedCount},耗时:${durationMs}ms`); return { success, archivedCount, failedCount, totalEligible, executedAt, durationMs, errors, }; } /** * 查询归档历史 */ export async function getArchiveLogs(limit = 20): Promise> { const db = await getMongoDb(); if (!db) return []; const logs = await db.collection("archive_logs") .find({ type: "approval_cases" }) .sort({ executedAt: -1 }) .limit(limit) .toArray(); return logs.map(log => ({ executedAt: log.executedAt?.toISOString() || "", archivedCount: log.archivedCount || 0, failedCount: log.failedCount || 0, totalEligible: log.totalEligible || 0, durationMs: log.durationMs || 0, success: log.success !== false, })); } /** * 查询归档案例(支持分页) */ export async function getArchivedCases( page = 1, pageSize = 20, filter?: { jurisdiction?: string; status?: string } ): Promise<{ items: unknown[]; total: number }> { const db = await getMongoDb(); if (!db) return { items: [], total: 0 }; const query: Record = {}; if (filter?.jurisdiction) query.jurisdiction = filter.jurisdiction; if (filter?.status) query.status = filter.status; const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ db.collection(ARCHIVE_COLLECTION).find(query).sort({ archivedAt: -1 }).skip(skip).limit(pageSize).toArray(), db.collection(ARCHIVE_COLLECTION).countDocuments(query), ]); return { items, total }; }