- 网关客户端新增原始数据收集器,记录最后一次尝试的明文请求、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>
262 lines
14 KiB
Markdown
262 lines
14 KiB
Markdown
## 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
|
||
|
||
- 后台是否有页面依赖机器动作的审计?实施时间线合并任务前与前端或运营确认。
|