feat(cost-center): 成本异常 60 天归档
cost_exceptions 只增不减:本地快照 260,253 行(resolved 151,939 / open 108,314), 已处理的行只是审计痕迹,留在热表会拖慢「状态 + 时间」分页,并让唯一索引持续膨胀。 - 迁移 108:新增 cost_exceptions_archive(保留原 id 与时间字段,加 archived_at), 热表补部分索引 idx_cost_exceptions_archivable (COALESCE(resolved_at, created_at)) WHERE status <> 'open',让每日任务不扫全表; - cost-exception-archive-service:只归档 status <> 'open' 且超期的行,每批 1000 行, DELETE ... RETURNING + INSERT 在同一语句内完成,单批原子,不存在「删了没归档」; - data-retention 每日任务接入;保留期走 COST_EXCEPTION_RETENTION_DAYS(默认 60); - 新增 npm run cost-exceptions:archive(--dry-run / --days / --batch-size)。 为什么 open 行不归档:open 是待办队列,超期不等于已解决,归档等于把问题藏起来。 验证: - 3 条 resolved 回拨到 90 天前 → 归档 3 条,热表 resolved 151,939 → 151,936; - 1 条 open 回拨到 90 天前 → 仍在热表,未被归档; - 归档表保留原 id 与时间,archived_at 记录归档时刻; - 恢复测试数据后计数回到 151,939 / 108,314。
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 手动归档已处理的成本异常(日常由 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
|
||||
*/
|
||||
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
|
||||
const retentionDays = normalizeInteger(options.days, defaultDays, 1, 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 (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))
|
||||
}
|
||||
Reference in New Issue
Block a user