Files
邹进 7e0e37933e
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m2s
fix: 卡日流量查询改用卡日快照 tb_card_flow_day
tb_card_daily_usage 为已停用旧表(测试库为空,且列名与模型不一致),
卡日流量列表与索引改用采集链路写入的 tb_card_flow_day,当日用量取 settled_mb。

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

14 KiB
Raw Permalink Blame History

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)
卡日流量(卡日快照 tb_card_flow_day;tb_card_daily_usage 为已停用旧表) 新增 (flow_date, settled_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

  • 后台是否有页面依赖机器动作的审计?实施时间线合并任务前与前端或运营确认。