Files
junhong_cmp_fiber/openspec/changes/refactor-audit-log-persistence/design.md
邹进 94cbf2adab feat: 采集记录成功失败分表并保存网关原始数据,到期归档 csv.gz,新增记录分页查询接口
- 网关客户端新增原始数据收集器,记录最后一次尝试的明文请求、HTTP 状态、响应体与 code/msg/trace_id
- tb_card_traffic_collection 只写成功读数,新增 carrier_id 与原始数据列;新增 tb_card_traffic_collection_failure,失败不判断有无套餐并带错误详情
- 采集记录在线保留当月加最近 2 个整月,到期分区摘出、导出 csv.gz、校验行数与 SHA-256 后删除,本地保留 3 个月
- 新增采集成功/失败、卡日流量、套餐日流量详单、冲正流水、退款后处理尝试列表接口:可查单个也可查全部,分页与条件筛选
- 停复机记录支持资产标识、场景、原因、指令号筛选;单卡历史支持动作、结果、来源筛选
- 单卡采集记录支持筛选与合并失败记录;套餐日流量详单支持分页;退款列表支持后处理状态筛选
- 测试环境 api、worker 日志分目录,保留 40 个压缩备份;worker 挂载 archive 目录
- 迁移 000272、000273 需维护者在测试库执行后再部署

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:47:00 +09:00

262 lines
14 KiB
Markdown
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.
## Context
技术方案见 `docs/2026-11-05-审计日志写入改造技术方案.md`(方案 B)。本设计基于 `Iteration/8-11` 的现状。
**审计写入**
- 写入链路:业务调用 `audit.Writer.Append` / `AppendAndGet`(`internal/infrastructure/audit/writer.go:935`),经注册表 `Registry` 校验动作与操作者来源后,写入 `tb_audit_event` 和 `tb_audit_event_resource`。
- 写入失败已经被吞掉,记为二次失败,不影响业务。
- `AppendAndGet` 共 11 处调用,大多是人工或资金类动作。
**对接日志**
- `integrationlog.Repository.Start`(`internal/infrastructure/integrationlog/repository.go:118`)在没有上游审计事件时,会补写一条 `integration.attempt_started` 审计并回填 `audit_event_id`。
**停复机**
- 8-11 上,退款停机在停复机指令队列开启时只请求停机评估(`internal/service/refund/service.go` 的 `stopAsset`)。
- 停机失败已由指令队列按错误分类处理:终态不重试,限流按退避重试。
- 停复机调用记录写在 `tb_card_stop_resume_record`,每次实际调用上游写一条,结果枚举已含 `skipped`,但目前没有写入点。
**退款后处理**
- `processRefundAsset` 依次执行:套餐失效、接续激活、判断有无生效主套餐、停机评估,最后标记完成。
- 任一步失败都会触发 Outbox 重投和 `RecoverRefundPostProcessing` 补偿扫描,二者都没有次数上限和退避。
**流量采集**
- `polling_carddata_handler` 调用 `gateway.QueryFlow`。失败时 `recordUnreadable` 只传失败类型,具体错误被丢弃。
- 采集记录写在 `tb_card_traffic_collection`:成功和失败同表,只写有套餐的卡。
- 表按月分区,由 leader worker 的 `startTrafficCollectionPartitionMaintainer` 每 12 小时维护一次,到期分区直接 `DROP`。
- 网关客户端 `executeWithRetry` 已经读到完整响应体以及外层的 `code`、`msg`、`trace_id`,但只返回 `data`。
**记录查询**
- 已有的列表接口:采集记录(单卡)、停复机记录(跨卡和单卡)、冲正流水(单套餐)、日流量详单(单套餐,不分页)、对接日志(完整)。
**测试环境**
- 由 `docker-compose.prod.yml` 部署,api 和 worker 都把宿主机 `./logs` 挂载到 `/app/logs`,共用同一个 `app.log`。
- 日志级别为 info,格式为 JSON。
## Goals / Non-Goals
**Goals:**
- 审计表只记人工操作,以及资金、套餐生命周期的状态变化。测试环境日增从约 12.6 万降到约 300 条。
- 每一次失败都有记录可查:采集失败、停复机跳过、退款后处理尝试。
- 运营商原始请求与响应有据可查。
- 独立记录都能按单个或全部、分页、按条件查询。
**Non-Goals:**
- 不修改生产环境的代码、配置与服务。
- 不删除、不迁移已有审计数据。
- 不改 `add-audit-integration-write-switches` 的全局开关语义。
- 不改停复机指令队列的重试与错误分类规则。
- 不调整测试环境的日志级别。
## Decisions
### D1 落库策略放在注册表,按"动作 × 操作者"决定
在 `ActionDefinition` 上新增 `Persist` 字段:`always`、`on_success`、`outcome`、`log_only`、`none`。`AppendAndGet` 完成注册与来源校验后,由 `persistDecision` 决定写表、写日志还是跳过。
- 人工操作者(`account`、`personal_customer`、`openapi`、`external_system`)一律写表。
- 系统任务和定时任务按 `Persist` 处理,未声明时按 `log_only`。
- `persist_overrides` 运行时配置只覆盖系统任务和定时任务操作者。
- `iot_card.auto_start` / `auto_stop` 声明为 `always`:8-11 上系统自动停复机已由 `commandAuditRequired` 改为只写停复机记录,仍会写这两个动作的只剩通道阈值、自动续费等豁免链路,`gateway-call-observability` 要求这些链路保留审计。
**为什么这样做**:
- 调用方不改代码,所有动作的去向集中在一处,评审只需看注册表。
- 默认 `log_only` 能防止新增的机器动作再次写满审计表。
**备选**:在各调用方删除 `Append` 调用。
- 改动面大,散落多处,后续新增动作也容易复发,因此不采用。
### D2 `log_only` 写所在进程的应用日志
使用 `logger.GetAppLogger().Named("audit")`,写一行 JSON,`msg=audit_event`。不新建日志文件,`NewWriter` 签名不变。日志里只放标识类字段,不写快照。
- 轮换沿用 `logging.app_log`,由日志同步服务器归集。
**备选**:单独建 `audit-machine.log`。
- 需要额外配置轮换和同步路径,用户已明确要求写在服务自己的日志目录,因此不采用。
### D3 `outcome` 由调用方标明最终结论
`AppendInput` 增加 `Final bool`:
- `Final=false`:只写应用日志,带尝试序号和尝试记录 ID。
- `Final=true`:写表。`EventID` 使用业务键,例如 `refund-asset:{refund_id}:outcome`,依靠现有的 `ON CONFLICT (event_id) DO NOTHING` 保证同一结论只有一条。
**备选**:Writer 自己按次数判断是否为最终结论。
- Writer 不知道业务上限,判断会出错,因此由调用方决定。
**备选**:只写首次失败。
- 用户否决:只记首次失败很难排查问题。
### D4 去掉对接日志的补写审计
删除 `Start` 中 `AuditEventID == nil` 时调用 `recordAuditEvent` 的分支;注册表中 `integration.attempt_started` 设为 `none` 兜底。
- `auditEventIDForSeries` 保留。实施时确认它按 `trigger_series` 回查审计的 SQL 走索引。
- 同步修改 `operations-audit` 中"新外部交互日志必须有非空 `audit_event_id`"的场景。
### D5 退款后处理:尝试表 + 退款单状态字段
**新表 `tb_refund_post_process_attempt`**
- 按 `created_at` 按月分区,只新增。
- 每个步骤失败或整体成功时各写一条记录。
**退款单新增字段**
- `asset_post_status`:处理中、已完成、需人工处理;
- `asset_post_attempts`;
- `asset_post_last_error`;
- `asset_post_next_at`。
现有的 `asset_reset` 完成标记保留,并与 `asset_post_status=已完成` 同步写入,保证兼容。
**重试与人工处理**
- 失败时 `next_at = now + min(2^(n-1) 分钟, 60 分钟)`。
- `n ≥ 10` 时置为需人工处理,写 `outcome` 失败结论审计。
- Outbox 处理器和 `RecoverRefundPostProcessing` 在执行前检查状态与 `next_at`:
- 未到重试时间:处理器返回成功,不再重投,由补偿扫描在到期后拉起;
- 需人工处理:直接跳过。
- 新增后台人工重试接口 `POST /refunds/:id/asset-post-processing/retry`,记为人工重试,只允许在需人工处理状态下发起。
**备选**:只在退款单上记最后一次错误。
- 每次尝试的细节会丢失,用户要求每次失败都可查,因此不采用。
### D6 停复机跳过记录写在指令执行入口
在指令执行前的重新判断处写 `skipped` 记录,覆盖三种结束原因:已是目标状态、条件不再满足、卡不允许停复机。
- 限流推迟不写记录,沿用现有分钟计数。
- 记录写入失败只告警。
### D7 采集记录分表,并由网关原始数据收集器补齐原始数据
**原始数据收集器**
- 新增 `gateway.WithRawCapture(ctx) (*RawCapture, context.Context)`。
- `executeWithRetry` 在每次尝试后,用最后一次的明文请求、HTTP 状态码、响应体、外层 `code` / `msg` / `trace_id` 和尝试次数覆盖收集器。
- 只记录加密前的业务 JSON。不挂收集器的调用没有额外开销。
**采集写入**
- `polling_carddata_handler` 调用 `QueryFlow` 前挂上收集器。
- `TrafficObservation` 增加 `Raw *gateway.RawCapture` 和 `Failure`(错误类别、上游码与信息、耗时)。
- `writeCollectionRecord` 按结果分流:
- 成功:保持"有套餐才写"的规则;
- 失败:直接写失败表,不查套餐。
**原始数据列**(两张表相同)
- `raw_request` jsonb
- `http_status` int
- `gateway_code`、`gateway_msg`、`gateway_trace_id`
- `raw_response` jsonb 或 `raw_response_text` text
- `raw_truncated` bool
- `gateway_attempts` int
**分表上线后的查询**
- 采集记录表增加 `carrier_id` 冗余列,在采集时写入。
- 新数据写入后,成功表只会有成功记录。历史上写在成功表里的失败数据不迁移,随分区过期。
- 成功表接口按 `result='ok'` 过滤,避免历史失败数据混入。
**备选**:原始数据只放对接日志。
- 现有规格要求普通轮询查询不逐条写对接日志,而且对接日志不按卡分区,查询成本高,因此不采用。
### D8 到期归档:摘出、导出、校验、删除
改写 `MaintainPartitions`,成功表和失败表共用同一流程,保留期常量改为 `TrafficCollectionRetentionMonths = 2`。
1. **摘出分区**:执行 `ALTER TABLE ... DETACH PARTITION`。
2. **导出**:通过 pgx 的 `CopyTo` 执行 `COPY (SELECT * FROM <分区> ORDER BY observed_at, id) TO STDOUT WITH (FORMAT csv, HEADER)`,经 `gzip.Writer` 流式写入 `<工作目录>/archive/traffic-collection/<表名>_<yyyymm>.csv.gz.tmp`。
3. **校验**:重新读取压缩包统计行数,与 `count(*)` 比对;计算 SHA-256,写入 `.manifest.json`。校验通过后,把 `.tmp` 改名为正式文件名。
4. **删除**:执行 `DROP TABLE`。
**失败处理**
- 任一步失败,保留已摘出的表,记录 Error 日志。
- 下一轮维护时,先处理"已摘出但未删除"的表,按命名前缀加 `pg_inherits` 中不存在来识别。
**本地清理**
- 本地压缩包按月份只保留最近 N 个(默认 3),超出的删除。
**备选**:先 DROP 再由外部工具导出。
- 可能丢数据,因此不采用。
**备选**:导出为 JSONL。
- 用 `COPY CSV` 可以在数据库端直接流式输出,效率更高;原始数据作为 JSON 文本保存在 CSV 列中,可读性足够,因此选 CSV。
### D9 记录查询接口共用一套查询骨架
每类记录在 `internal/store/postgres` 中提供 `List(ctx, Filter)`。Filter 统一包含:
- `CardIDs`:由资产标识解析而来;
- `From` / `To`;
- 分页参数;
- 各表自身的条件。
资产标识统一通过现有的资产解析服务转换为卡 ID 或设备 ID;解析不到时直接返回空页。原有的单卡接口复用同一个 `List`。
**索引**(迁移中创建;分区表在父表上建,自动作用到各分区):
| 表 | 索引 |
|---|---|
| 采集成功表 | `(observed_at DESC)`、`(carrier_id, observed_at DESC)`、`(decision, observed_at DESC)`,保留现有 `(iot_card_id, observed_at DESC)` |
| 采集失败表 | `(iot_card_id, observed_at DESC)`、`(observed_at DESC)`、`(carrier_id, observed_at DESC)`、`(error_category, observed_at DESC)`、`(observation_id)` |
| 停复机记录 | 现有三个升序索引保留(B-tree 可反向扫描,直接支持倒序分页);新增 `(created_at DESC)`、`(scene, created_at DESC)`、`(command_id)` |
| 卡日流量 | 新增 `(date, usage_mb DESC)` |
| 套餐日流量详单 | 新增 `(package_usage_id, date DESC)`、`(date)` |
| 冲正流水 | 新增 `(iot_card_id, created_at DESC)`、`(created_at DESC)` |
| 退款后处理尝试 | `(refund_id, created_at DESC)`、`(created_at DESC)`、`(result, created_at DESC)`、`(error_code, created_at DESC)` |
**总数统计**:使用同一组条件执行 `count(*)`。时间范围上限 31 天,配合分区裁剪,成本可控。
### D10 测试环境部署配置
`docker-compose.prod.yml` 的调整:
| 服务 | 调整 |
|---|---|
| api | 挂载改为 `./api/logs:/app/logs` |
| worker | 挂载改为 `./worker/logs:/app/logs`,新增 `./worker/archive:/app/archive` |
| 两者 | 增加 `JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=40`、`JUNHONG_LOGGING_APP_LOG_MAX_AGE=14` |
`.gitea/workflows/deploy.yaml` 改为创建 `api/logs`、`worker/logs`、`worker/archive` 三个目录。
## Risks / Trade-offs
| 风险 | 应对 |
|---|---|
| 后台页面依赖机器动作的审计(例如卡时间线里显示流量同步),改造后数据消失 | 实施时间线合并任务前,先与前端或运营确认;卡和设备时间线合并停复机记录 |
| `AppendAndGet` 在策略为不写时返回 nil 事件,调用方可能解引用空指针 | 逐一检查 11 处调用;这些调用都属于人工或资金动作,策略为 `always`;补充返回 nil 时的单元测试 |
| 采集原始数据使成功表变大 | 在线只保留 2 个月;部署后按实际行大小复核;必要时可把原始数据列的保留期单独缩短 |
| 归档导出期间 IO 升高 | 每月只执行一次,流式读写;放在 leader 的维护循环中串行执行,一次只处理一个分区 |
| 归档文件在同步前被本地清理删除 | 本地保留 3 个月,远大于同步周期;清理只删比保留个数更早的文件 |
| 分表上线前,成功表中混有历史失败数据 | 成功表接口按 `result='ok'` 过滤;历史数据随分区过期 |
| 退款后处理退避期间,Outbox 处理器返回成功导致事件不再重投 | 由补偿扫描按 `next_at` 拉起,并在补偿扫描中覆盖"待重试"状态 |
| 本 Change 修改的 `card-traffic-metering` 和 `gateway-call-observability` 来自未归档的 Change | 本 Change 必须在 `add-card-traffic-queue-and-observed-deduction`、`add-carrier-gateway-rate-limit` 之后归档 |
## Migration Plan
只部署测试环境。推送 `Iteration/8-11` 后由 `.gitea/workflows/deploy.yaml` 自动部署,分三次提交:
1. 审计落库策略、Writer 分流、对接日志去掉补写审计、覆盖扫描、运行时覆盖配置。
2. 退款后处理尝试记录与重试上限、停复机跳过记录。
3. 采集分表与原始数据、到期归档、记录查询接口与索引、时间线合并、测试环境部署配置。
每次部署后在测试环境观察 24 小时,确认审计日增、应用日志和各业务记录写入正常。数据库迁移只新增表、列和索引,不改已有数据。
## Open Questions
- 后台是否有页面依赖机器动作的审计?实施时间线合并任务前与前端或运营确认。