- 审计注册表为可由系统任务、定时任务发起的动作声明落库策略(always/on_success/outcome/log_only/none),未声明按 log_only - 人工、开放接口、个人客户、外部系统发起的动作始终写审计表 - log_only 写所在服务 app.log(logger=audit),不含快照 - 对接日志不再补写 integration.attempt_started,无上游审计时 audit_event_id 为空 - 新增 audit.persist_overrides 按动作覆盖策略 - 审计覆盖扫描要求机器动作必须声明策略 - 新增 OpenSpec 提案 refactor-audit-log-persistence 与技术方案文档 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 KiB
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_requestjsonbhttp_statusintgateway_code、gateway_msg、gateway_trace_idraw_responsejsonb 或raw_response_texttextraw_truncatedboolgateway_attemptsint
分表上线后的查询
- 采集记录表增加
carrier_id冗余列,在采集时写入。 - 新数据写入后,成功表只会有成功记录。历史上写在成功表里的失败数据不迁移,随分区过期。
- 成功表接口按
result='ok'过滤,避免历史失败数据混入。
备选:原始数据只放对接日志。
- 现有规格要求普通轮询查询不逐条写对接日志,而且对接日志不按卡分区,查询成本高,因此不采用。
D8 到期归档:摘出、导出、校验、删除
改写 MaintainPartitions,成功表和失败表共用同一流程,保留期常量改为 TrafficCollectionRetentionMonths = 2。
- 摘出分区:执行
ALTER TABLE ... DETACH PARTITION。 - 导出:通过 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。 - 校验:重新读取压缩包统计行数,与
count(*)比对;计算 SHA-256,写入.manifest.json。校验通过后,把.tmp改名为正式文件名。 - 删除:执行
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) |
| 停复机记录 | 现有三个索引重建为倒序;新增 (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 自动部署,分三次提交:
- 审计落库策略、Writer 分流、对接日志去掉补写审计、覆盖扫描、运行时覆盖配置。
- 退款后处理尝试记录与重试上限、停复机跳过记录。
- 采集分表与原始数据、到期归档、记录查询接口与索引、时间线合并、测试环境部署配置。
每次部署后在测试环境观察 24 小时,确认审计日增、应用日志和各业务记录写入正常。数据库迁移只新增表、列和索引,不改已有数据。
Open Questions
- 后台是否有页面依赖机器动作的审计?实施时间线合并任务前与前端或运营确认。