## 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/<表名>_.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 - 后台是否有页面依赖机器动作的审计?实施时间线合并任务前与前端或运营确认。