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。
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
import assert from 'node:assert/strict'
|
||
import { readFile } from 'node:fs/promises'
|
||
import test from 'node:test'
|
||
|
||
async function readJobSource() {
|
||
return readFile(new URL('./data-retention-job.ts', import.meta.url), 'utf8')
|
||
}
|
||
|
||
test('保留清理必须覆盖 work_order_events,否则这张只增不减的表会一直拖慢历史单检索', async () => {
|
||
const src = await readJobSource()
|
||
assert.ok(
|
||
src.includes("deleteOldRowsInBatches('work_order_events'"),
|
||
'work_order_events 未纳入保留清理(生产上 71 万行 / 269MB 且只增不减)',
|
||
)
|
||
assert.ok(
|
||
src.includes("'task_events' | 'webhook_events' | 'admin_audit_logs' | 'work_order_events'"),
|
||
'删除助手需要显式允许 work_order_events(表名是白名单联合类型,避免拼接注入)',
|
||
)
|
||
assert.ok(src.includes('retention.workOrderEventDays'), '保留天数必须走配置项,便于按环境调整')
|
||
assert.ok(
|
||
src.includes('workOrderEventRows: number') && src.includes('workOrderEventRows,'),
|
||
'清理结果要带上工单事件的删除行数,方便定时任务面板核对',
|
||
)
|
||
})
|
||
|
||
test('成本异常只归档已处理的行,open 队列不能被藏起来', async () => {
|
||
const job = await readJobSource()
|
||
assert.ok(
|
||
job.includes('archiveResolvedCostExceptions'),
|
||
'成本异常必须走归档,不能直接 DELETE 掉审计痕迹',
|
||
)
|
||
assert.ok(job.includes('retention.costExceptionDays'), '保留天数必须走配置项')
|
||
|
||
// 归档 SQL 在独立服务里,检查服务源码而不是任务源码。
|
||
const service = await readFile(
|
||
new URL('../cost-center/cost-exception-archive-service.ts', import.meta.url),
|
||
'utf8',
|
||
)
|
||
assert.ok(service.includes("status <> 'open'"), 'open 行是待办队列,不能因超期被归档')
|
||
assert.ok(
|
||
service.includes('COALESCE(resolved_at, created_at)'),
|
||
'归档时钟优先用 resolved_at,ignored 行没有 resolved_at 时回退 created_at',
|
||
)
|
||
assert.ok(
|
||
service.includes('INSERT INTO cost_exceptions_archive'),
|
||
'归档必须落到 cost_exceptions_archive,保持可追溯',
|
||
)
|
||
})
|
||
|
||
test('保留任务要回填漏写的付款派生列,并在有命中时打日志', async () => {
|
||
const src = await readJobSource()
|
||
assert.ok(
|
||
src.includes('source_order_amount_fen IS NULL'),
|
||
'只处理派生列为空的行,避免把合法 0 或已落列的金额改掉',
|
||
)
|
||
assert.ok(
|
||
src.includes("material_json->'source'->>'orderAmountFen'"),
|
||
'回填口径必须与 083 / resolveSourceOrderAmountFen 相同',
|
||
)
|
||
assert.ok(src.includes("~ '^-?[0-9]{1,15}$'"), '超长数字不能 ::bigint,否则整次保留任务失败')
|
||
assert.ok(
|
||
src.includes('source_order_amount_fen 出现漏写'),
|
||
'有命中必须打日志,否则金额静默回退 orders.total_amount',
|
||
)
|
||
})
|
||
|
||
test('保留清理默认窗口:敏感报文/事件 180 天,审计 365 天,工单事件 180 天', async () => {
|
||
const defaults = await readFile(new URL('../../config/defaults.ts', import.meta.url), 'utf8')
|
||
for (const line of [
|
||
'rawPayloadDays: 180',
|
||
'taskEventDays: 180',
|
||
'webhookEventDays: 180',
|
||
'auditLogDays: 365',
|
||
'workOrderEventDays: 180',
|
||
'costExceptionDays: 60',
|
||
]) {
|
||
assert.ok(defaults.includes(line), `默认保留策略缺少 ${line}`)
|
||
}
|
||
|
||
const overrides = await readFile(
|
||
new URL('../../config/env-overrides.ts', import.meta.url),
|
||
'utf8',
|
||
)
|
||
assert.ok(
|
||
overrides.includes(
|
||
"integerEnv('WORK_ORDER_EVENT_RETENTION_DAYS', ['retention', 'workOrderEventDays'])",
|
||
),
|
||
'WORK_ORDER_EVENT_RETENTION_DAYS 需要环境变量出口,否则生产无法调整',
|
||
)
|
||
assert.ok(
|
||
overrides.includes(
|
||
"integerEnv('COST_EXCEPTION_RETENTION_DAYS', ['retention', 'costExceptionDays'])",
|
||
),
|
||
'COST_EXCEPTION_RETENTION_DAYS 需要环境变量出口,否则生产无法调整归档窗口',
|
||
)
|
||
})
|