Files
order_site/apps/backend/scripts/archive-cost-exceptions.ts
T
yml2213 d90c066888 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。
2026-10-25 12:09:20 +08:00

93 lines
2.7 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
*/
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))
}