Files
order_site/apps/backend/src/services/scheduler/data-retention-job.test.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

97 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 需要环境变量出口,否则生产无法调整归档窗口',
)
})