NAC_Blockchain/ops/nac-admin/server/archiveApprovalCases.ts

259 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<ArchiveResult> {
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<Array<{
executedAt: string;
archivedCount: number;
failedCount: number;
totalEligible: number;
durationMs: number;
success: boolean;
}>> {
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<string, unknown> = {};
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 };
}