From d90c06688843dd501cb2305e466e7f45990402e0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 25 Oct 2026 12:09:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(cost-center):=20=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=2060=20=E5=A4=A9=E5=BD=92=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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。 --- .env.server.example | 2 + apps/backend/package.json | 1 + .../scripts/archive-cost-exceptions.ts | 92 +++++++++++++++++++ apps/backend/src/config/defaults.ts | 2 + apps/backend/src/config/env-overrides.test.ts | 1 + apps/backend/src/config/env-overrides.ts | 1 + .../src/config/runtime-validation.test.ts | 1 + apps/backend/src/config/runtime-validation.ts | 3 + .../migrations/108_cost_exception_archive.sql | 49 ++++++++++ .../cost-exception-archive-service.ts | 89 ++++++++++++++++++ .../scheduler/data-retention-job.test.ts | 31 +++++++ .../services/scheduler/data-retention-job.ts | 9 ++ apps/backend/src/types/runtime-config.ts | 2 + docs/README.md | 1 + docs/成本中心与履约模型.md | 16 ++++ 15 files changed, 300 insertions(+) create mode 100644 apps/backend/scripts/archive-cost-exceptions.ts create mode 100644 apps/backend/src/db/migrations/108_cost_exception_archive.sql create mode 100644 apps/backend/src/services/cost-center/cost-exception-archive-service.ts diff --git a/.env.server.example b/.env.server.example index e4110f1e..df7f9ce6 100644 --- a/.env.server.example +++ b/.env.server.example @@ -78,6 +78,8 @@ TASK_EVENT_RETENTION_DAYS=180 WEBHOOK_EVENT_RETENTION_DAYS=180 AUDIT_LOG_RETENTION_DAYS=365 WORK_ORDER_EVENT_RETENTION_DAYS=180 +# 已处理成本异常保留天数,超期搬入 cost_exceptions_archive(open 行不归档) +COST_EXCEPTION_RETENTION_DAYS=60 # 文件存储:生产使用阿里云 OSS(生产编排已移除 MinIO,仅本地开发使用)。 # STORAGE_MODE 取值 minio | dual | oss;dual 仅在 MinIO→OSS 迁移/回滚期间使用。 diff --git a/apps/backend/package.json b/apps/backend/package.json index f60f8a00..7f6f950b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -12,6 +12,7 @@ "finance:sync": "tsx scripts/sync-cost-finance.ts", "fulfillment:backfill": "tsx scripts/backfill-fulfillment-mode.ts", "revenue:backfill": "tsx scripts/backfill-revenue-basis.ts", + "cost-exceptions:archive": "tsx scripts/archive-cost-exceptions.ts", "storage:migrate": "tsx scripts/migrate-storage.ts", "storage:migrate:built": "node dist/storage-migrate.js", "check:sql": "tsx scripts/check-sql-guard.ts", diff --git a/apps/backend/scripts/archive-cost-exceptions.ts b/apps/backend/scripts/archive-cost-exceptions.ts new file mode 100644 index 00000000..b029db4a --- /dev/null +++ b/apps/backend/scripts/archive-cost-exceptions.ts @@ -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 = {} + 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)) +} diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 761c2382..d44544a4 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -30,6 +30,8 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { webhookEventDays: 180, auditLogDays: 365, workOrderEventDays: 180, + // 已处理的成本异常保留 60 天,之后搬进 cost_exceptions_archive;open 行不归档。 + costExceptionDays: 60, }, database: { diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index 1e905708..3665609e 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -100,6 +100,7 @@ function createRuntimeConfig(): RuntimeConfig { taskEventDays: 180, webhookEventDays: 180, auditLogDays: 365, + costExceptionDays: 60, }, database: { url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site', diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index a46557fa..e51705d5 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -35,6 +35,7 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ integerEnv('WEBHOOK_EVENT_RETENTION_DAYS', ['retention', 'webhookEventDays']), integerEnv('AUDIT_LOG_RETENTION_DAYS', ['retention', 'auditLogDays']), integerEnv('WORK_ORDER_EVENT_RETENTION_DAYS', ['retention', 'workOrderEventDays']), + integerEnv('COST_EXCEPTION_RETENTION_DAYS', ['retention', 'costExceptionDays']), stringEnv('DATABASE_URL', ['database', 'url']), booleanEnv('DATABASE_SSL', ['database', 'ssl']), integerEnv('DATABASE_MAX_CONNECTIONS', ['database', 'maxConnections']), diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts index 6e677212..71af86d3 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -164,6 +164,7 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo taskEventDays: 180, webhookEventDays: 180, auditLogDays: 365, + costExceptionDays: 60, }, database: { url: 'postgres://postgres:postgres@postgres:5432/order_site', diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 0bff6af6..963acd1d 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -88,6 +88,9 @@ export function validateRuntimeConfig( min: 1, }) requireInteger(issues, 'retention.auditLogDays', config.retention.auditLogDays, { min: 1 }) + requireInteger(issues, 'retention.costExceptionDays', config.retention.costExceptionDays, { + min: 1, + }) } const storageMode = normalizeStorageMode(config.storage?.mode) if (!storageMode) { diff --git a/apps/backend/src/db/migrations/108_cost_exception_archive.sql b/apps/backend/src/db/migrations/108_cost_exception_archive.sql new file mode 100644 index 00000000..08659668 --- /dev/null +++ b/apps/backend/src/db/migrations/108_cost_exception_archive.sql @@ -0,0 +1,49 @@ +-- 108_cost_exception_archive.sql —— 成本异常归档表。 +-- +-- 背景(2026-10-25): +-- cost_exceptions 只增不减。本地快照上 260,253 行(resolved 151,939 / open 108,314), +-- 其中 open 的 product_mapping_missing 与 cost_missing 仍是可操作队列, +-- 但已处理(resolved / ignored)的历史行只是审计痕迹,继续留在热表里拖慢: +-- - 按状态 + 时间分页要扫过大量已完成行; +-- - 唯一索引 uq_cost_exceptions_subject_reason 随行数膨胀。 +-- +-- 策略: +-- 1. 只归档**已处理**(status <> 'open')且超过保留期(默认 60 天)的行; +-- 2. open 行永远留在热表,避免把未解决的问题藏起来; +-- 3. 归档按批(每次 1000 行)在 data-retention 定时任务里执行,不锁全表; +-- 4. 归档表不建外键:历史异常可能指向已删除的订单项/工单,保留原值即可。 +-- +-- 保留期由 COST_EXCEPTION_RETENTION_DAYS 控制(默认 60)。 + +CREATE TABLE IF NOT EXISTS cost_exceptions_archive ( + id BIGINT PRIMARY KEY, + subject_type TEXT NOT NULL, + subject_id BIGINT NOT NULL, + order_item_id BIGINT, + work_order_id BIGINT, + reason TEXT NOT NULL, + status TEXT NOT NULL, + detail_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + resolved_at TIMESTAMPTZ, + resolved_by_admin_user_id BIGINT, + resolution_note TEXT NOT NULL DEFAULT '', + archived_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_archived_at + ON cost_exceptions_archive (archived_at DESC); +CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_subject + ON cost_exceptions_archive (subject_type, subject_id); +CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archive_reason + ON cost_exceptions_archive (reason, archived_at DESC); + +COMMENT ON TABLE cost_exceptions_archive IS + '已处理成本异常的归档:data-retention 任务把超过保留期(默认 60 天)的 resolved/ignored 行搬到这里,open 行不归档'; +COMMENT ON COLUMN cost_exceptions_archive.archived_at IS + '归档时间;原表 created_at / resolved_at 原样保留,便于按原时间追溯'; + +-- 热表上补一个「待归档」用的部分索引,避免每日任务扫全表。 +CREATE INDEX IF NOT EXISTS idx_cost_exceptions_archivable + ON cost_exceptions (COALESCE(resolved_at, created_at)) + WHERE status <> 'open'; diff --git a/apps/backend/src/services/cost-center/cost-exception-archive-service.ts b/apps/backend/src/services/cost-center/cost-exception-archive-service.ts new file mode 100644 index 00000000..3675dee9 --- /dev/null +++ b/apps/backend/src/services/cost-center/cost-exception-archive-service.ts @@ -0,0 +1,89 @@ +/** + * 成本异常归档:把超过保留期的「已处理」异常搬进 cost_exceptions_archive。 + * + * 口径: + * - 只归档 status <> 'open' 的行;open 是待办队列,超期也必须留着; + * - 时钟取 COALESCE(resolved_at, created_at):ignored 行没有 resolved_at 时按创建时间; + * - 每批 1000 行,DELETE ... RETURNING + INSERT 在同一语句内完成,单批原子, + * 不存在「删了但没归档」的窗口; + * - 归档保留 id 与原时间字段,可按原口径追溯。 + * + * 调用方:data-retention 定时任务(每日)、scripts/archive-cost-exceptions.ts(手动)。 + */ +import { query } from '../../db/client.js' + +export type ArchiveCostExceptionsResult = { + archivedRows: number + remainingArchivableRows: number + retentionDays: number +} + +export async function countArchivableCostExceptions(retentionDays: number) { + const { rows } = await query<{ count: number }>( + ` + SELECT COUNT(*)::int AS count + FROM cost_exceptions + WHERE status <> 'open' + AND COALESCE(resolved_at, created_at) < NOW() - make_interval(days => $1::int) + `, + [retentionDays], + ) + return rows[0]?.count ?? 0 +} + +export async function archiveResolvedCostExceptions({ + retentionDays, + batchSize = 1000, + maxBatches = 10_000, +}: { + retentionDays: number + batchSize?: number + maxBatches?: number +}): Promise { + const archiveExists = await query<{ exists: boolean }>( + `SELECT to_regclass('public.cost_exceptions_archive') IS NOT NULL AS exists`, + ) + if (!archiveExists.rows[0]?.exists) { + return { archivedRows: 0, remainingArchivableRows: 0, retentionDays } + } + + let archivedRows = 0 + let batches = 0 + while (batches < maxBatches) { + const result = await query( + ` + WITH moved AS ( + DELETE FROM cost_exceptions + WHERE id IN ( + SELECT id + FROM cost_exceptions + WHERE status <> 'open' + AND COALESCE(resolved_at, created_at) < NOW() - make_interval(days => $1::int) + ORDER BY id + LIMIT $2 + ) + RETURNING id, subject_type, subject_id, order_item_id, work_order_id, + reason, status, detail_json, created_at, resolved_at, + resolved_by_admin_user_id, resolution_note + ) + INSERT INTO cost_exceptions_archive ( + id, subject_type, subject_id, order_item_id, work_order_id, + reason, status, detail_json, created_at, resolved_at, + resolved_by_admin_user_id, resolution_note, archived_at + ) + SELECT id, subject_type, subject_id, order_item_id, work_order_id, + reason, status, detail_json, created_at, resolved_at, + resolved_by_admin_user_id, resolution_note, NOW() + FROM moved + `, + [retentionDays, batchSize], + ) + const movedRows = result.rowCount || 0 + archivedRows += movedRows + batches += 1 + if (movedRows < batchSize) break + } + + const remainingArchivableRows = await countArchivableCostExceptions(retentionDays) + return { archivedRows, remainingArchivableRows, retentionDays } +} diff --git a/apps/backend/src/services/scheduler/data-retention-job.test.ts b/apps/backend/src/services/scheduler/data-retention-job.test.ts index f9a15516..ac70c39c 100644 --- a/apps/backend/src/services/scheduler/data-retention-job.test.ts +++ b/apps/backend/src/services/scheduler/data-retention-job.test.ts @@ -23,6 +23,30 @@ test('保留清理必须覆盖 work_order_events,否则这张只增不减的 ) }) +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( @@ -48,6 +72,7 @@ test('保留清理默认窗口:敏感报文/事件 180 天,审计 365 天, 'webhookEventDays: 180', 'auditLogDays: 365', 'workOrderEventDays: 180', + 'costExceptionDays: 60', ]) { assert.ok(defaults.includes(line), `默认保留策略缺少 ${line}`) } @@ -62,4 +87,10 @@ test('保留清理默认窗口:敏感报文/事件 180 天,审计 365 天, ), 'WORK_ORDER_EVENT_RETENTION_DAYS 需要环境变量出口,否则生产无法调整', ) + assert.ok( + overrides.includes( + "integerEnv('COST_EXCEPTION_RETENTION_DAYS', ['retention', 'costExceptionDays'])", + ), + 'COST_EXCEPTION_RETENTION_DAYS 需要环境变量出口,否则生产无法调整归档窗口', + ) }) diff --git a/apps/backend/src/services/scheduler/data-retention-job.ts b/apps/backend/src/services/scheduler/data-retention-job.ts index 695540d8..f5a9d7ea 100644 --- a/apps/backend/src/services/scheduler/data-retention-job.ts +++ b/apps/backend/src/services/scheduler/data-retention-job.ts @@ -2,6 +2,7 @@ import { query } from '../../db/client.js' import { runtimeConfig } from '../../config/runtime.js' import { logWarn } from '../../utils/logger.js' import { getWorkerProductMatchConfig } from '../worker-platform/worker-product-match-config-service.js' +import { archiveResolvedCostExceptions } from '../cost-center/cost-exception-archive-service.js' import { pruneWorkProductMatchLogs } from '../../repositories/worker-platform/product-match-repo.js' export type DataRetentionResult = { @@ -15,6 +16,7 @@ export type DataRetentionResult = { matchLogRows: number matchLogDeletedRows: number sourceOrderAmountBackfillRows: number + costExceptionArchivedRows: number } /** 清理敏感原始报文并删除已过保留期的事件/审计记录。 */ @@ -26,6 +28,8 @@ export async function runDataRetentionJob(): Promise { const auditLogDays = positiveDays(retention.auditLogDays, 365) // 工单事件表以前完全没纳入保留:717k 行 / 269MB 且只增不减,会一直拖慢历史单检索。 const workOrderEventDays = positiveDays(retention.workOrderEventDays, 180) + // 成本异常只归档「已处理」的行;open 行是待办队列,不能因为超期就藏起来。 + const costExceptionDays = positiveDays(retention.costExceptionDays, 60) // 1. 按配置清理超出上限或过期的打手商品匹配日志 const matchConfig = getWorkerProductMatchConfig() @@ -49,6 +53,10 @@ export async function runDataRetentionJob(): Promise { const workOrderEventRows = await deleteOldRowsInBatches('work_order_events', workOrderEventDays) // 统计热路径已不再读 material_json。若将来有第三个写入点忘了维护派生列, // 金额会静默回退 orders.total_amount。这里把漏写补上并打日志,让口径变化可见。 + const costExceptionArchive = await archiveResolvedCostExceptions({ + retentionDays: costExceptionDays, + }) + const costExceptionArchivedRows = costExceptionArchive.archivedRows const sourceOrderAmountBackfillRows = await backfillMissingSourceOrderAmount() if (sourceOrderAmountBackfillRows > 0) { logWarn('[data-retention]', 'source_order_amount_fen 出现漏写,已按 JSON 回填', { @@ -67,6 +75,7 @@ export async function runDataRetentionJob(): Promise { auditLogRows, workOrderEventRows, sourceOrderAmountBackfillRows, + costExceptionArchivedRows, } } diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 6eed50dc..c72dc79a 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -34,6 +34,8 @@ export type RuntimeConfig = { auditLogDays: number /** 工单事件(work_order_events)保留天数:这张表只增不减,必须纳入清理。 */ workOrderEventDays: number + /** 已处理成本异常(cost_exceptions)保留天数:超期归档,open 行不归档。 */ + costExceptionDays: number } database: { url: string diff --git a/docs/README.md b/docs/README.md index 039b6766..bfeab7f6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -85,6 +85,7 @@ | 履约方式统一 | **已实施 P0** | 迁移 `105`;375,447 项全部回填:接单平台 153,148、人工 115,194、历史平台 53,901、自动发货 53,204 | | 人工商品归一 | **已实施 P1(本次快照恢复执行)** | 人工 115,194 项已归一为 1,195 个 `man:<内部SKU>` 商品;历史人工支出仍不补录 | | 影子总账 | 未实施 | 现有财务页仍是 `UNION ALL` 业务事件拼装,不是复式总账 | +| 成本异常归档 | **已实施** | 迁移 `108`;已处理异常超 60 天搬入 `cost_exceptions_archive`,open 行不归档;`COST_EXCEPTION_RETENTION_DAYS` 可调 | | 退款冲减 / 平台对账 / 期间关账 | 未实施 | 仅方案 | > 分支状态(2026-10-25):`dev @ 465a9f65` 之上完成迁移收敛(10 → 5,`103`–`107`),并已与 `main` 的 worker-hall 改动合并。本地库按「清 `schema_migrations` 103–112 后重跑」对齐,升级结果与全新构建的 schema 完全一致;生产 101 → 107 的路径已验证。**未推送、未部署生产**;生产口径仍以 `main` 为准。 diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index 2f8f1f04..0c25c919 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -468,6 +468,22 @@ npm run revenue:backfill -- --batch-size=2000 --max-batches=100 | `idx_work_orders_rule_cover` | 成本版本页按接单规则聚合 | | `idx_order_items_revenue_cover` | 收入聚合 | | `idx_work_categories_name` | 商品目录按分类名 lateral join | +| `idx_cost_exceptions_archivable` | 归档任务扫描「已处理且超期」的异常(部分索引,`status <> 'open'`) | + +### 9.1 成本异常归档(60 天) + +`cost_exceptions` 只增不减:本地快照 260,253 行(resolved 151,939 / open 108,314)。已处理的行只是审计痕迹,继续留在热表会拖慢按状态+时间的分页,并让唯一索引持续膨胀。 + +| 项 | 规则 | +| --- | --- | +| 归档对象 | `status <> 'open'` 且 `COALESCE(resolved_at, created_at)` 超过保留期 | +| open 行 | **永不归档**——它是待办队列,超期也要留在页面上 | +| 保留期 | `COST_EXCEPTION_RETENTION_DAYS`,默认 **60** 天 | +| 落点 | `cost_exceptions_archive`(保留原 id 与时间字段,加 `archived_at`) | +| 执行 | `data-retention` 每日任务;每批 1000 行,`DELETE ... RETURNING` + `INSERT` 同语句,单批原子 | +| 手动 | `npm run cost-exceptions:archive -- --dry-run` / `-- --days=90` | + +归档不删除审计痕迹,但热表查询与页面上不会再出现这些历史行;需要追溯时查归档表。 ---