一次性清理需要「不按时间过滤」的语义,但 CLI 把 days 下限钳在 1: 本地库的 15.2 万条 resolved 异常是 17 小时前重建生成的, --days=1 一条都命中不了,只能手写 SQL。 - --days=0 表示不过滤时间,归档全部已处理行(open 仍不归档); - 执行时打印一次性清理提示,避免日常误用; - 记录 2026-10-25 的实际清理结果:151,939 行 / 152 批 / 3.9 秒, 热表 260,253 → 108,314(全为 open),VACUUM FULL 后 155 MB → 51 MB, 归档表 61 MB,主键重叠 0 条。 验证:--days=0 --dry-run 返回 retentionDays=0、archivableRows=0(已清空)。
98 lines
3.0 KiB
TypeScript
98 lines
3.0 KiB
TypeScript
/**
|
|
* 手动归档已处理的成本异常(日常由 data-retention 定时任务自动执行)。
|
|
*
|
|
* 默认窗口与线上配置一致(60 天),可通过 --days 覆盖;open 行永远不归档。
|
|
*
|
|
* 用法:
|
|
* npm run cost-exceptions:archive -- --dry-run
|
|
* npm run cost-exceptions:archive -- --days=90
|
|
* npm run cost-exceptions:archive -- --days=60 --batch-size=2000
|
|
* npm run cost-exceptions:archive -- --days=0 # 一次性清理:归档全部已处理行
|
|
*/
|
|
import process from 'node:process'
|
|
|
|
import { closeDb } from '../src/db/client.js'
|
|
import { runtimeConfig } from '../src/config/runtime.js'
|
|
import {
|
|
archiveResolvedCostExceptions,
|
|
countArchivableCostExceptions,
|
|
} from '../src/services/cost-center/cost-exception-archive-service.js'
|
|
|
|
const options = parseOptions(process.argv.slice(2))
|
|
const defaultDays = Number(runtimeConfig.retention?.costExceptionDays) || 60
|
|
// 0 表示不按时间过滤:一次性把已处理的历史异常全部归档(日常任务永远 >= 1 天)。
|
|
const retentionDays = normalizeInteger(options.days, defaultDays, 0, 3650)
|
|
const batchSize = normalizeInteger(options.batchSize, 1000, 1, 10_000)
|
|
const dryRun = options.dryRun === 'true'
|
|
|
|
async function main() {
|
|
const startedAt = Date.now()
|
|
const archivable = await countArchivableCostExceptions(retentionDays)
|
|
if (retentionDays === 0) {
|
|
console.warn('[archive] days=0:一次性清理,将归档全部已处理异常(open 行不受影响)')
|
|
}
|
|
|
|
if (dryRun) {
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
dryRun: true,
|
|
retentionDays,
|
|
archivableRows: archivable,
|
|
durationMs: Date.now() - startedAt,
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
|
|
const result = await archiveResolvedCostExceptions({ retentionDays, batchSize })
|
|
console.log(
|
|
JSON.stringify(
|
|
{ dryRun: false, ...result, batchSize, durationMs: Date.now() - startedAt },
|
|
null,
|
|
2,
|
|
),
|
|
)
|
|
|
|
if (result.remainingArchivableRows > 0) {
|
|
console.error(`仍有 ${result.remainingArchivableRows} 行待归档;请重跑(幂等)。`)
|
|
process.exitCode = 2
|
|
}
|
|
}
|
|
|
|
main()
|
|
.catch((error) => {
|
|
console.error(error instanceof Error ? error.message : error)
|
|
process.exitCode = 1
|
|
})
|
|
.finally(async () => {
|
|
await closeDb()
|
|
})
|
|
|
|
function parseOptions(args: string[]) {
|
|
const result: Record<string, string> = {}
|
|
for (const arg of args) {
|
|
if (!arg.startsWith('--')) continue
|
|
const body = arg.slice(2)
|
|
const index = body.indexOf('=')
|
|
const key = index === -1 ? body : body.slice(0, index)
|
|
const value = index === -1 ? 'true' : body.slice(index + 1)
|
|
result[key] = value
|
|
result[toCamelCase(key)] = value
|
|
}
|
|
return result
|
|
}
|
|
|
|
function toCamelCase(value: string) {
|
|
return value.replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())
|
|
}
|
|
|
|
function normalizeInteger(value: unknown, fallback: number, min: number, max: number) {
|
|
const parsed = Number(value)
|
|
if (!Number.isInteger(parsed)) return fallback
|
|
return Math.min(max, Math.max(min, parsed))
|
|
}
|