259 lines
7.7 KiB
TypeScript
259 lines
7.7 KiB
TypeScript
/**
|
||
* 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 };
|
||
}
|