feat: 审计动作按落库策略分级写入,系统过程动作改写应用日志
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 12m8s

- 审计注册表为可由系统任务、定时任务发起的动作声明落库策略(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>
This commit is contained in:
邹进
2026-11-05 18:27:24 +09:00
parent 96ff9c1e35
commit b7a7430cc3
21 changed files with 1720 additions and 4 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-05

View File

@@ -0,0 +1,261 @@
## 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)` |
| 停复机记录 | 现有三个索引重建为倒序;新增 `(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
- 后台是否有页面依赖机器动作的审计?实施时间线合并任务前与前端或运营确认。

View File

@@ -0,0 +1,81 @@
## Why
统一审计表被当作过程日志使用。系统任务每次同步、扣减、对接调用开始、失败重试,都会写一条审计事件,外加 2–3 条资源快照。生产近 14 天日均写入约 122 万条,其中 99% 以上由机器发起;测试环境日均约 12.6 万条。两张审计表合计 21 个索引,写入成本高,是 11-03 生产数据库 IO 打满、轮询停摆的主要原因之一。
同时,从审计中移出的过程数据必须仍能查到。现有采集记录、停复机记录等业务表大多只能按单卡查询,筛选条件少。采集失败的具体原因被丢弃,也没有保存运营商原始响应,出了问题无据可查。
方案依据:`docs/2026-11-05-审计日志写入改造技术方案.md`(方案 B)。本 Change 基于 `Iteration/8-11` 实施,只部署到测试环境。
## What Changes
**审计写入**
- 统一审计注册表为每个动作声明落库策略:总是写、只写成功、只写结论、只写应用日志、不写。
- 未声明策略的系统任务、定时任务动作,默认只写应用日志。
- 后台账号、个人客户、OpenAPI、外部系统发起的动作始终写审计。
- 系统任务的过程类动作不再写审计表,改写到所在服务的应用日志(`logs/app.log`,带 `logger=audit` 标记,不含变更前后快照)。涉及的动作包括:流量同步、网络状态同步、流量扣减、停机原因更新、通知投递、优先轮询、流量重置明细等。
- 自动重试类动作只写最终结论:最终成功,或重试用尽转人工。每一次尝试都记录在业务尝试记录表中。
- **BREAKING** 对接日志不再补写 `integration.attempt_started` 审计事件。没有上游审计事件的对接日志,其 `audit_event_id` 允许为空。
- 提供按动作的运行时覆盖配置,不发版即可临时调整某个动作的落库策略。
**退款资产后处理**
- 新增退款后处理尝试记录,每次尝试一条,只增不改。
- 自动重试最多 10 次,指数退避,间隔最长 1 小时。超过上限后标记"需人工处理",不再自动重投。
**停复机记录**
- 停复机指令在调用上游之前就结束时(已是目标状态、条件不再满足、不允许停复机),写一条结果为"跳过"的停复机记录,并写明原因。
**运营商流量采集记录**
- **BREAKING** 成功与失败分表:现有采集记录表只写成功读数,新增采集失败表。
- 失败记录带错误类别、上游返回码与信息、耗时。
- 失败记录不再判断卡有没有套餐,所有轮询到的卡都写。
- 成功表和失败表都保存网关原始请求(加密前明文业务参数)与原始响应。
- **BREAKING** 在线保留由 90 天改为 2 个月(当月加最近 2 个整月)。到期的月分区按"摘出分区、导出 `csv.gz`、校验、删除"四步处理,压缩包和说明文件留在 worker 运行目录,由同步服务器拉走。
**记录查询接口**
- 每类独立记录各提供一个列表接口:传卡标识查单个,不传查全部。
- 都支持分页和条件筛选,时间范围必填。所需索引随筛选条件新建或重建。
- 覆盖的记录:采集成功、采集失败、停复机、套餐日流量详单、卡日流量、流量冲正、退款后处理尝试。退款列表增加后处理状态筛选。
**测试环境部署配置**
- api、worker 各自挂载自己的 `logs/` 目录,不再共用同一个 `app.log`。
- 每个服务保留 40 个压缩备份。
- worker 挂载 `archive/` 目录。
## Capabilities
### New Capabilities
- `operational-record-query`:独立业务记录的统一分页条件查询。覆盖采集成功与失败、停复机、套餐日流量、卡日流量、流量冲正、退款后处理尝试;可查单个,也可查全部。
- `refund-asset-post-processing`:退款资产后处理的逐次尝试记录、重试上限、退避与转人工。
### Modified Capabilities
- `operations-audit`:审计落库策略分级;系统过程动作改写应用日志;结论型写入;对接日志允许无审计关联;轮询审计边界调整。
- `card-traffic-metering`:采集记录成功与失败分表、保存原始请求与响应、失败记录不再判断有无套餐、在线保留 2 个月并到期归档为压缩包。该 capability 来自尚未归档的 `add-card-traffic-queue-and-observed-deduction`,本 Change 须在其归档后再归档。
- `gateway-call-observability`:停复机指令在调用前结束时写"跳过"记录。该 capability 来自尚未归档的 `add-carrier-gateway-rate-limit`,本 Change 须在其归档后再归档。
## Impact
- **审计**:`internal/infrastructure/audit`(注册表、Writer)、`pkg/constants/audit.go`、`internal/governance/auditcoverage`、`internal/query/audit`(时间线合并停复机记录)。
- **对接日志**:`internal/infrastructure/integrationlog/repository.go`。
- **退款**:`internal/service/refund`、`internal/infrastructure/commissiondelivery`(补偿扫描),新增尝试记录表。
- **停复机**:停复机指令执行路径中写入跳过记录。
- **采集**:
- 原始数据收集器:`internal/gateway/client.go`
- 采集写入:`internal/task/polling_carddata_handler.go`、`internal/application/cardobservation`
- 存储与分区维护:`internal/store/postgres/card_traffic_collection_store.go`、`cmd/worker/main.go`
- **接口**:`internal/routes`、`internal/handler/admin`、`internal/model/dto`、`internal/store/postgres`,并同步生成接口文档。
- **数据库迁移**:
- 新增采集失败表、退款后处理尝试表;
- 采集成功表增加 `carrier_id` 与原始数据列;
- 退款单增加后处理状态字段;
- 各记录表新建或重建索引。
- **配置与部署**:`pkg/config`(`audit.persist_overrides`)、`docker-compose.prod.yml`、`.gitea/workflows/deploy.yaml`。
- **与已有 Change 的关系**:`add-audit-integration-write-switches` 的全局写入开关保持不变。全局开关用于应急全停;本 Change 是常态下按动作分级,两者互不替代。
- **不涉及**:生产环境的代码、配置与服务;已有审计数据的删除或迁移。

View File

@@ -0,0 +1,95 @@
## MODIFIED Requirements
### Requirement: 采集记录
系统 SHALL 把每次流量采集按结果分别写入两张表。
**成功表 `tb_card_traffic_collection`**
- 只写读数结果为有效的采集。
- 记录内容:采集时间、采集 ID、来源、场景、运营商账户、运营商原始读数、入队后的队列、写入判断(已写入、建起点、队列未满、读数下降、乱序、激活前结算用已有快照)。
- 写入成功时,还记录换算后的本月累计、是否触发防旧值、本次增量与流量事件 ID。
- 只为采集时刻有套餐的卡写成功记录;没有套餐的卡 MUST NOT 写成功记录。
**失败表 `tb_card_traffic_collection_failure`**
- 写读数结果为失败、限流、响应无效、无读数、读数无效的采集。
- 记录内容:采集时间、采集 ID、来源、场景、运营商账户、ICCID、失败类型、错误类别、上游返回码、上游返回信息(截断)、耗时。
- 失败记录 MUST 为所有被采集的卡写入,不判断卡是否有套餐。
**原始数据**
- 两张表都 SHALL 保存本次网关调用的原始数据:加密前的明文业务请求、HTTP 状态码、网关响应的 `code`、`msg`、`trace_id`、原始响应体,以及网关客户端内部重试次数。
- 原始响应能解析为 JSON 时按 JSON 保存,否则按文本保存。超过 4 KB 时截断,并标记已截断。
- 网关客户端内部发生过重试时,保存最后一次的请求与响应。
- 系统 MUST NOT 保存加密后的报文、签名、应用标识、密钥或请求头。
**通用规则**
- 采集 ID 等于对接日志的 `integration_id`,MUST 随流量事件与扣费审计传递,使扣费可追溯到对应采集记录。
- 采集记录写入失败 MUST 只记录告警,不影响扣减与卡状态。
#### Scenario: 被队列拦下的采集
- **WHEN** 有套餐的卡本次读数使队列出现下降
- **THEN** 系统在成功表写一条采集记录,判断为读数下降,记录队列内容与原始响应,不写快照
#### Scenario: 从扣费追到采集
- **WHEN** 维护者从一笔扣费排查
- **THEN** 可经流量事件的采集 ID 找到成功表中对应的原始读数、队列与原始响应
#### Scenario: 没套餐的卡采集成功
- **WHEN** 没套餐的卡被采集且读数有效
- **THEN** 系统不写成功记录
#### Scenario: 没套餐的卡采集失败
- **WHEN** 没套餐的卡查流量超时
- **THEN** 系统在失败表写一条记录,失败类型为失败,错误类别为超时,HTTP 状态码为空
#### Scenario: 上游返回业务错误
- **WHEN** 网关返回非成功的业务码
- **THEN** 失败表记录上游返回码、返回信息、`trace_id` 与原始响应体
#### Scenario: 原始响应过长
- **WHEN** 网关原始响应体超过 4 KB
- **THEN** 系统保存前 4 KB,并标记已截断
#### Scenario: 不保存敏感报文
- **WHEN** 任一采集记录写入原始数据
- **THEN** 记录中不含加密报文、签名、应用标识与密钥
## ADDED Requirements
### Requirement: 采集记录在线保留与到期归档
采集成功表与失败表 SHALL 按采集时间按月分区,在线保留当月加最近 2 个整月。月分区的月末早于"当前时间减 2 个月"时即为到期。
到期分区 SHALL 由 leader worker 的分区维护任务按以下顺序处理:
1. 把分区从父表摘出,使其不再参与在线查询;
2. 按采集时间与 ID 排序,流式导出为带表头的 CSV,并以 gzip 压缩,写入 worker 运行目录下的 `archive/traffic-collection/`;
3. 重新读取压缩包统计行数,与分区行数比对;同时生成同名说明文件,记录表名、月份、行数、最早与最晚采集时间、文件大小、SHA-256 与导出时间;
4. 行数一致后删除已摘出的分区。
约束:
- 压缩包与说明文件 MUST 先以临时文件名写入,校验通过后再改为正式文件名。
- 导出或校验失败时,系统 MUST 保留已摘出的分区,记录错误日志,并在下一轮维护时重试;MUST NOT 在没有完成校验的压缩包时删除分区。
- 本地 SHALL 只保留最近 3 个月的压缩包,个数可配置。
- 维护任务 SHALL 继续预建未来月份的分区。
#### Scenario: 到期分区归档
- **GIVEN** 当前为 2027-01-01,成功表存在 2026-10 分区
- **WHEN** 分区维护任务运行
- **THEN** 系统生成 `tb_card_traffic_collection_202610.csv.gz` 与说明文件,行数与分区一致后删除该分区
#### Scenario: 导出失败
- **WHEN** 归档过程中写压缩包失败
- **THEN** 已摘出的分区保留,不生成正式文件名的压缩包,下一轮维护重试
#### Scenario: 在线查询不包含已归档月份
- **WHEN** 查询的时间范围落在已归档月份
- **THEN** 接口返回空结果,不报错
#### Scenario: 本地压缩包超过保留个数
- **WHEN** 本地已有压缩包的月份数超过配置的保留个数
- **THEN** 系统删除最早月份的压缩包与说明文件

View File

@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: 停复机指令调用前结束的跳过记录
停复机指令在调用上游之前结束时,系统 SHALL 写一条结果为跳过(`skipped`)的停复机记录,并在原因中写明结束原因。适用情况:
- 执行前判断卡已是目标状态;
- 执行前判断停复机条件不再满足;
- 卡不允许停复机(如关闭轮询、规则不允许)。
被本系统限流推迟、稍后会再次执行的指令 MUST NOT 写跳过记录;限流推迟仍按现有分钟计数观测。跳过记录 MUST NOT 写统一审计。
#### Scenario: 执行前已是目标状态
- **GIVEN** 一条停机指令待执行
- **WHEN** 执行前判断卡已经是停机状态并结束指令
- **THEN** 系统写一条结果为跳过、原因为已是目标状态的停复机记录,不调用上游
#### Scenario: 限流推迟
- **WHEN** 停机指令因所在限流组本秒额度用尽被推迟
- **THEN** 系统不写停复机记录

View File

@@ -0,0 +1,116 @@
## ADDED Requirements
### Requirement: 记录查询公共约定
系统 SHALL 为每类独立业务记录提供一个后台列表接口,接口遵循以下约定。
**查询范围**
- 资产标识选填。传入时只查该卡或该资产,支持虚拟号、ICCID、MSISDN,设备支持 IMEI、SN;不传时查全部。
- 资产标识解析不到资产时,接口 MUST 返回空页,不报错。
- 原有的单卡、单套餐接口 SHALL 保留,与列表接口使用同一查询逻辑。
**时间范围**
- 开始时间与结束时间 MUST 必填,跨度不超过 31 天,按上海时间解析;结束时间不含。
- 单卡、单套餐接口未传时间时,默认取最近 31 天。
- 查全部时除时间范围外,MUST NOT 要求其他筛选条件。
**分页与排序**
- 页码默认 1;每页条数默认 20,最大 100。采集成功记录沿用现有上限 200。
- 返回总数、页码、每页条数与当前页记录。
- 按记录时间倒序排列,时间相同时按 ID 倒序。
**其他**
- 系统 SHALL 为本能力列出的筛选条件建立所需索引,使带时间范围的查询不扫描时间范围外的分区。
- 接口 MUST 需要平台账号登录。
- 接口 MUST 在路由声明中登记输入与输出结构,并同步生成接口文档。
#### Scenario: 查单卡
- **WHEN** 请求带 ICCID 和 7 天时间范围
- **THEN** 系统只返回该卡在范围内的记录,并分页、按时间倒序返回
#### Scenario: 查全部
- **WHEN** 请求只带 7 天时间范围
- **THEN** 系统返回范围内全部记录的第一页与总数
#### Scenario: 缺少时间范围
- **WHEN** 列表接口请求未带开始或结束时间
- **THEN** 系统拒绝请求并提示时间范围必填
#### Scenario: 时间跨度超限
- **WHEN** 请求的时间跨度超过 31 天
- **THEN** 系统拒绝请求并提示跨度上限
#### Scenario: 标识找不到资产
- **WHEN** 请求的资产标识不存在
- **THEN** 系统返回总数为 0 的空页
### Requirement: 流量采集记录查询
系统 SHALL 提供两个采集记录列表接口:
- 采集成功记录:`GET /traffic-collections`。筛选条件:资产标识、运营商账户、写入判断、来源、场景、是否产生增量、是否触发防旧值。
- 采集失败记录:`GET /traffic-collection-failures`。筛选条件:资产标识、运营商账户、失败类型、错误类别、上游返回码、来源、场景。
两者都 MUST 返回原始请求与原始响应字段。单卡采集记录接口 SHALL 支持"包含失败"参数:默认只返回成功记录;开启时合并成功与失败记录,并按采集时间倒序返回。
#### Scenario: 按运营商账户查失败
- **WHEN** 查询某运营商账户近 1 天、错误类别为超时的采集失败记录
- **THEN** 系统返回该账户下所有卡的超时失败记录,含上游返回与原始数据
#### Scenario: 查产生增量的采集
- **WHEN** 查询近 1 天产生增量的采集成功记录
- **THEN** 系统只返回增量大于 0 的记录
#### Scenario: 单卡合并查看
- **WHEN** 单卡采集记录接口开启"包含失败"
- **THEN** 成功与失败记录合并后按采集时间倒序分页返回,每条标明是成功还是失败
### Requirement: 停复机记录查询
停复机记录列表 SHALL 支持以下筛选条件:资产标识(不再只支持 ICCID)、运营商账户、动作、来源、场景、原因、结果(含跳过)、错误类别、指令号。单卡停复机历史 SHALL 支持动作、结果、来源筛选。
#### Scenario: 按场景与原因查
- **WHEN** 查询近 7 天场景为退款、结果为跳过的停复机记录
- **THEN** 系统返回匹配记录,并带跳过原因
#### Scenario: 用虚拟号查
- **WHEN** 用卡的虚拟号查询停复机记录
- **THEN** 系统返回该卡的停复机记录
### Requirement: 流量与冲正记录查询
系统 SHALL 提供以下列表接口:
| 记录 | 筛选条件 |
|---|---|
| 套餐日流量详单 | 资产标识、套餐使用记录、套餐、运营商账户、日期范围 |
| 卡日流量 | 资产标识、运营商账户、日期范围、用量下限 |
| 套餐流量冲正流水 | 资产标识、套餐使用记录、操作人、调整方向(补扣或退回)、时间范围 |
现有套餐日流量详单接口 SHALL 增加分页。现有单套餐冲正流水接口 SHALL 增加时间范围筛选。
#### Scenario: 查异常大流量
- **WHEN** 查询近 3 天、单日用量不低于 10240 MB 的卡日流量
- **THEN** 系统返回所有满足条件的卡与日期,按日期倒序分页
#### Scenario: 查全部冲正
- **WHEN** 查询近 30 天的全部退回类冲正流水
- **THEN** 系统返回所有卡的退回类冲正记录,含操作人与调整前后用量
### Requirement: 退款后处理尝试查询
系统 SHALL 提供退款后处理尝试记录列表接口。筛选条件:退款单 ID、退款单号、资产标识、处理步骤、结果、错误码、触发方式、时间范围。
退款列表 SHALL 增加后处理状态筛选,可筛出"需人工处理"。退款列表与详情 SHALL 返回后处理次数、最后错误与下次重试时间。
#### Scenario: 查需人工处理的退款
- **WHEN** 退款列表按后处理状态"需人工处理"筛选
- **THEN** 系统返回所有已转人工的退款单,并带最后错误
#### Scenario: 查一张退款单的全部尝试
- **WHEN** 按退款单号查询尝试记录
- **THEN** 系统按时间倒序返回该单每一次尝试的步骤、结果、错误码与触发方式

View File

@@ -0,0 +1,160 @@
## MODIFIED Requirements
### Requirement: 审计时间线
系统 SHALL 支持按事件、操作者、资源、请求、关联标识和资金维度查询已记录的审计事实。新建的 `tb_integration_log` 在已取得稳定审计事件时 SHALL 写入其内部 ID 作为 `audit_event_id`;没有上游审计事件时 `audit_event_id` SHALL 为空,系统 MUST NOT 为补齐关联而另写审计事件,并且该日志的非空 `integration_id` SHALL 出现在对应事件列表和事件详情的 `investigation_refs.integration_refs` 中;关联生成失败时系统 MUST 保留 Integration Log 并记录可排查告警,且 MUST NOT 按名称、时间或摘要推断关联。历史 Integration Log 不在本要求的回填范围内。审计事件的构造、校验或持久化失败 MUST 记录可关联的结构化错误日志和二次失败记录,且 MUST NOT 改变已通过业务校验的业务操作结果或接口响应。尚无完成物理清理记录时,审计调查接口 MUST 继续查询在线审计事实,且返回的留存边界不得将数据标记为已归档。
#### Scenario: 审计时间线
- **GIVEN** 审计事实已存在
- **WHEN** 使用对应维度查询
- **THEN** 返回匹配的事实与稳定动作编码,不用访问日志替代
#### Scenario: 事件返回已关联外部交互引用
- **GIVEN** 一个在线审计事件有 `tb_integration_log.audit_event_id` 指向其内部 ID 的外部交互记录
- **WHEN** 查询全局审计事件列表或该事件详情
- **THEN** 该事件的 `investigation_refs.integration_refs` 返回该记录的 `integration_id`
#### Scenario: 事件没有关联外部交互引用
- **GIVEN** 一个在线审计事件没有稳定关联的外部交互记录
- **WHEN** 查询全局审计事件列表或该事件详情
- **THEN** `investigation_refs.integration_refs` 返回空数组
#### Scenario: 新外部交互日志具有稳定审计关联
- **GIVEN** 系统即将记录一次新的外部调用、入站回调或未发送裁决,且该调用由一个已写入的审计事件发起
- **WHEN** 写入对应 Integration Log
- **THEN** 该记录保存非空 `audit_event_id`,且其目标 Audit Event 已存在
#### Scenario: 没有上游审计事件的外部交互日志
- **GIVEN** 一次系统任务发起的外部调用没有上游审计事件
- **WHEN** 写入对应 Integration Log
- **THEN** 该记录的 `audit_event_id` 为空
- **AND** 审计事件表不新增 `integration.attempt_started` 事件
#### Scenario: 缺少审计关联时保留外部交互日志
- **GIVEN** 一次新的外部交互日志没有稳定审计事件关联
- **WHEN** 系统尝试写入该 Integration Log
- **THEN** 系统持久化该 Integration Log、记录可排查告警,并返回空 `integration_refs`
#### Scenario: 审计写入失败不阻断业务
- **GIVEN** 一个业务操作已通过自身输入、权限和状态校验
- **WHEN** 该操作的审计事件构造、校验或持久化失败
- **THEN** 系统提交或返回该业务操作原本的结果,并以请求关联标识、动作编码和资源标识记录审计失败
#### Scenario: 尚无物理清理记录时查询在线审计
- **GIVEN** 审计或外部交互日志尚无完成物理清理的归档运行记录
- **WHEN** 调用任一依赖留存边界的审计调查接口
- **THEN** 系统返回当前在线数据或空列表
- **AND** 响应中的 `archived_before` 为空
- **AND** 系统不得因空留存边界返回服务端错误
### Requirement: 轮询审计事实保留边界
系统 SHALL 仅为人工触发的轮询操作,以及由轮询结果导致的资金或套餐生命周期变化(如套餐到期、激活、退款失效)持久化统一审计事件及资源快照。系统任务发起的轮询同步本身(流量同步、网络状态同步、流量扣减、停复机原因更新)MUST NOT 创建 `tb_audit_event` 或 `tb_audit_event_resource`,无论成功、失败或结果未知;其可查证据由采集记录、采集失败记录、停复机记录、日流量记录与应用日志提供。审计调查接口仅返回实际已保留的历史事实。
#### Scenario: 自动轮询成功但无业务变化
- **GIVEN** 一次自动轮询成功且没有改变任何业务状态、有效流量或风险结论
- **WHEN** 系统结束该轮询处理
- **THEN** 审计事件和审计资源表不新增该次轮询记录
#### Scenario: 自动轮询同步流量并扣减
- **GIVEN** 一次自动轮询读到有效增量并完成扣减
- **WHEN** 系统提交该变化
- **THEN** 审计事件表不新增流量同步或扣减事件
- **AND** 采集记录保存该次读数、扣减判定、增量与原始响应
#### Scenario: 自动轮询调用失败
- **WHEN** 一次自动轮询查流量超时或返回无效响应
- **THEN** 审计事件表不新增记录
- **AND** 采集失败记录保存该次失败的错误类别、上游返回与原始数据
#### Scenario: 轮询导致套餐到期
- **GIVEN** 轮询或定时任务判定一个套餐已到期
- **WHEN** 系统把套餐置为已过期
- **THEN** 系统保留套餐到期的审计事实与资源快照
#### Scenario: 人工触发刷新
- **WHEN** 后台账号手动刷新一张卡
- **THEN** 系统为该操作写审计事件
## ADDED Requirements
### Requirement: 审计动作落库策略
系统 SHALL 在统一审计注册表中为每个动作声明落库策略,取值为:总是写(`always`)、只写成功(`on_success`)、只写结论(`outcome`)、只写应用日志(`log_only`)、不写(`none`)。
- 操作者为后台账号、个人客户、OpenAPI 或外部系统时,系统 MUST 按"总是写"处理,不受动作策略限制。
- 操作者为系统任务或定时任务、且动作未声明策略时,系统 MUST 按"只写应用日志"处理。
- 审计覆盖扫描 MUST 在存在可由系统任务或定时任务发起、但未声明落库策略的动作时失败。
- 系统 SHALL 提供按动作编码覆盖落库策略的运行时配置;覆盖只作用于系统任务与定时任务操作者。
- 是否写入审计 MUST NOT 改变业务操作的结果或接口响应。
#### Scenario: 人工操作始终写审计
- **GIVEN** 动作 `order.create` 对系统任务声明为只写应用日志
- **WHEN** 后台账号创建订单
- **THEN** 系统写入审计事件与资源快照
#### Scenario: 新增机器动作未声明策略
- **WHEN** 系统任务写一个未声明落库策略的动作
- **THEN** 审计事件表不新增记录,应用日志新增一行该动作的结构化日志
#### Scenario: 覆盖扫描发现未声明策略
- **GIVEN** 一个可由系统任务发起的动作没有声明落库策略
- **WHEN** 运行审计覆盖扫描
- **THEN** 扫描失败并列出该动作编码
#### Scenario: 运行时覆盖
- **GIVEN** 运行时配置把 `package_usage.expire` 覆盖为只写应用日志
- **WHEN** 定时任务使一个套餐到期
- **THEN** 审计事件表不新增记录,应用日志新增一行
- **AND** 删除该覆盖后,下一次到期重新写审计事件
### Requirement: 系统过程动作写应用日志
落库策略为"只写应用日志",或"只写成功"时的失败结果,系统 SHALL 在所在服务的应用日志中写一行结构化日志,并 MUST NOT 写入审计事件表与资源表。
- 日志行 MUST 带固定 logger 名 `audit`。
- 日志行 MUST 包含:动作编码、操作者类型与标识、来源、结果、资源类型与资源键、关联标识、请求标识、错误码、摘要。
- 日志行 MUST NOT 包含变更前后快照、主体数据、密钥、令牌或加密报文。
- 成功写 Info 级别,失败写 Warn 级别。
- 应用日志写入失败 MUST NOT 重试,也 MUST NOT 影响业务结果。
#### Scenario: 网络状态同步
- **WHEN** 系统任务同步一张卡的网络状态
- **THEN** 应用日志新增一行 `logger=audit`、动作编码为 `iot_card.worker_network_sync` 的记录
- **AND** 该行不含变更前后快照
#### Scenario: 不新增审计表数据
- **WHEN** 系统任务写任一只写应用日志的动作
- **THEN** `tb_audit_event` 与 `tb_audit_event_resource` 均无新增
### Requirement: 自动重试动作只写结论
落库策略为"只写结论"的动作,系统 SHALL 只在最终结论产生时写一条审计事件。最终结论指最终成功,或重试用尽转人工处理。
- 中间失败 MUST NOT 写审计事件,只写应用日志;日志行 MUST 带尝试序号与对应尝试记录标识。
- 结论事件的元数据 MUST 包含总尝试次数、首次失败时间与最后错误码。
- 同一业务对象的同一结论 MUST 只有一条审计事件。
- 每一次尝试的细节 MUST 由对应业务尝试记录保存,并可分页查询。
#### Scenario: 重试后成功
- **GIVEN** 一张退款单的资产后处理前两次失败、第三次成功
- **WHEN** 第三次处理完成
- **THEN** 审计事件表只有一条结论为成功的事件,元数据中总尝试次数为 3
- **AND** 尝试记录中有三条记录
#### Scenario: 重试用尽转人工
- **GIVEN** 一张退款单的资产后处理连续失败达到上限
- **WHEN** 系统将其标记为需人工处理
- **THEN** 审计事件表写入一条结论为失败的事件,包含最后错误码
#### Scenario: 结论重复写入
- **WHEN** 同一退款单的成功结论被重复提交
- **THEN** 审计事件表仍只有一条该结论事件

View File

@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: 退款资产后处理尝试记录
系统 SHALL 在每次执行退款资产后处理时写一条尝试记录,只新增,不修改、不删除。
- 记录内容:退款单、处理步骤(套餐失效、接续激活、停机评估或停机)、资产类型与资产 ID、ICCID、尝试序号、触发方式(Outbox 投递、补偿扫描、人工重试)、结果、错误码、错误信息(截断)、关联的停复机记录、耗时、记录时间。
- 尝试记录 SHALL 按月分区。
- 尝试记录写入失败 MUST 只记录告警,不影响后处理结果。
#### Scenario: 一次尝试失败
- **WHEN** 退款资产后处理在套餐失效步骤失败
- **THEN** 系统写一条尝试记录,步骤为套餐失效,结果为失败,并带错误码与错误信息
#### Scenario: 一次尝试成功
- **WHEN** 退款资产后处理全部步骤成功
- **THEN** 系统写一条结果为成功的尝试记录
### Requirement: 退款资产后处理重试上限
系统 SHALL 在退款单上维护资产后处理的当前状态、已尝试次数、最后错误与下次重试时间。
- 后处理失败时,系统 SHALL 按指数退避计算下次重试时间,间隔最长 1 小时。
- 已尝试次数达到 10 次仍未成功时,系统 MUST 把状态置为"需人工处理",并停止自动重试。
- Outbox 投递与补偿扫描 MUST 按上述状态与下次重试时间判断是否执行:未到下次重试时间的不执行,"需人工处理"的不执行。
- 后台人工重试 SHALL 允许重新执行"需人工处理"的退款单,并记为人工重试触发;执行前已尝试次数不清零。
- 重复执行 MUST 保持既有幂等:不重复失效套餐,不重复处理资产。
#### Scenario: 退避重试
- **GIVEN** 一张退款单的资产后处理第 3 次失败
- **WHEN** 补偿扫描在下次重试时间之前运行
- **THEN** 系统不执行该退款单的后处理
#### Scenario: 达到上限转人工
- **GIVEN** 一张退款单的资产后处理已失败 9 次
- **WHEN** 第 10 次仍失败
- **THEN** 退款单状态为需人工处理,Outbox 与补偿扫描不再执行它,审计写一条结论为失败的事件
#### Scenario: 人工重试成功
- **GIVEN** 一张需人工处理的退款单
- **WHEN** 运营在后台发起人工重试并成功
- **THEN** 退款单状态为已完成,尝试记录新增一条触发方式为人工重试、结果为成功的记录

View File

@@ -0,0 +1,71 @@
## 1. 审计落库策略(第 1 次提交)
- [x] 1.1 `internal/infrastructure/audit/registry.go`:`ActionDefinition` 新增 `Persist`,取值为 `always`、`on_success`、`outcome`、`log_only`、`none`;按技术方案 4.1、4.2 为全部可由系统任务或定时任务发起的动作声明策略,`integration.attempt_started` 设为 `none`。
- [x] 1.2 `writer.go`:
- 新增 `persistDecision`:人工操作者一律写表;系统任务和定时任务按 `Persist` 处理,未声明时按 `log_only`。
- `AppendInput` 增加 `Final`;`outcome` 策略在 `Final=false` 时只写日志,`Final=true` 时按业务键 `EventID` 写表。
- [x] 1.3 `log_only` 和 `on_success` 的失败结果写 `logger.GetAppLogger().Named("audit")`,`msg=audit_event`。字段按 spec 写入,不含快照;成功记 Info,失败记 Warn。
- [x] 1.4 逐一检查 `AppendAndGet` 的 11 处调用,确保返回 nil 事件时不会出错。
- [x] 1.5 `pkg/config` 新增 `audit.persist_overrides`(动作编码 → 策略),运行时生效,只作用于系统任务和定时任务操作者。
- [x] 1.6 `internal/infrastructure/integrationlog/repository.go`:删除 `Start` 中补写 `integration.attempt_started` 的分支;确认 `auditEventIDForSeries` 的查询走索引。
- [x] 1.7 `internal/governance/auditcoverage`:可由系统任务发起但未声明 `Persist` 的动作,扫描判为失败;更新已移出审计入口的 `AuditEvent` / `DomainLedger` 分类。
- [x] 1.8 `gofmt`、`go build ./cmd/api ./cmd/worker`、`openspec validate refactor-audit-log-persistence`;提交并推送 8-11。
## 2. 退款后处理与停复机跳过记录(第 2 次提交)
- [ ] 2.1 迁移:
- 新建 `tb_refund_post_process_attempt`,按月分区,并建立索引;
- 退款单新增 `asset_post_status`、`asset_post_attempts`、`asset_post_last_error`、`asset_post_next_at`;
- 补齐 down 脚本。
- [ ] 2.2 `internal/service/refund`:
- `processRefundAsset` 每次执行都写尝试记录(步骤、触发方式、结果、错误码与错误信息、耗时、关联停复机记录);
- 失败时按指数退避(最长 1 小时)更新退款单;
- 达到 10 次后置为需人工处理,并写一条 `outcome` 失败结论审计;
- 成功时写 `outcome` 成功结论审计,`asset_reset` 同步置为完成。
- [ ] 2.3 Outbox 处理器与 `RecoverRefundPostProcessing` 在执行前检查状态和 `next_at`:未到期、已完成、需人工处理的都跳过;补偿扫描负责拉起已到期的待重试单。
- [ ] 2.4 新增 `POST /refunds/:id/asset-post-processing/retry` 人工重试接口,只允许在需人工处理状态下调用,记为人工重试;在路由 `RouteSpec` 中登记,并生成接口文档。
- [ ] 2.5 停复机指令执行前的重新判断处,按三种结束原因写 `skipped` 停复机记录(已是目标状态、条件不再满足、不允许停复机);限流推迟不写。
- [ ] 2.6 构建、校验、提交并推送 8-11。
## 3. 流量采集分表与原始数据(第 3 次提交)
- [ ] 3.1 `internal/gateway/client.go`:新增 `WithRawCapture`,`executeWithRetry` 按尝试写入最后一次的明文请求、HTTP 状态、响应体、`code`、`msg`、`trace_id` 和尝试次数;不记录加密报文、签名、`app_id`、密钥和请求头。
- [ ] 3.2 迁移:
- 新建 `tb_card_traffic_collection_failure`,按月分区,并建立索引;
- 采集成功表增加 `carrier_id` 和原始数据列(`raw_request`、`http_status`、`gateway_code`、`gateway_msg`、`gateway_trace_id`、`raw_response`、`raw_response_text`、`raw_truncated`、`gateway_attempts`),新增 D9 中列出的索引。
- [ ] 3.3 `polling_carddata_handler`:
- 调用 `QueryFlow` 前挂上收集器;
- 网关失败时填入错误类别、上游码与信息、耗时;
- 成功和失败都把原始数据带进 `TrafficObservation`。
- [ ] 3.4 `internal/application/cardobservation`:`writeCollectionRecord` 按结果分流。成功写成功表(保持"有套餐才写"),失败直接写失败表(不查套餐);原始响应超过 4 KB 时截断并标记。
- [ ] 3.5 `MaintainPartitions` 改为成功表、失败表共用的四步归档(摘出、导出 `csv.gz.tmp`、校验并写 manifest 后改名、删除),并处理上一轮遗留的已摘出表、清理本地压缩包(默认保留 3 个月);常量改为 `TrafficCollectionRetentionMonths=2`。
## 4. 记录查询接口(第 3 次提交)
- [ ] 4.1 在 `internal/store/postgres` 中为各记录表实现统一的 `List(ctx, Filter)`;资产标识复用现有资产解析,解析不到时返回空页。
- [ ] 4.2 采集成功列表 `GET /traffic-collections`、采集失败列表 `GET /traffic-collection-failures`;单卡采集接口增加筛选条件和"包含失败"参数,成功表查询按 `result='ok'` 过滤。
- [ ] 4.3 停复机记录列表:`iccid` 扩展为资产标识,增加场景、原因、指令号筛选;单卡历史增加动作、结果、来源筛选;新建或重建 D9 中列出的索引。
- [ ] 4.4 新增卡日流量列表;新增套餐日流量详单列表,原单套餐接口改为分页;新增冲正流水列表,原单套餐接口增加时间范围筛选。
- [ ] 4.5 新增退款后处理尝试列表;退款列表增加 `asset_post_status` 筛选,列表和详情返回后处理次数、最后错误、下次重试时间。
- [ ] 4.6 统一校验规则:时间范围必填、跨度不超过 31 天、分页上限;所有接口在 `RouteSpec` 中登记输入输出 DTO,并生成接口文档。
## 5. 时间线与测试环境部署配置(第 3 次提交)
- [ ] 5.1 先与前端或运营确认是否有页面依赖机器动作的审计,再在 `internal/query/audit` 中让卡和设备时间线合并停复机记录。
- [ ] 5.2 `docker-compose.prod.yml`:api 挂载 `./api/logs`,worker 挂载 `./worker/logs` 和 `./worker/archive`;两者增加 `MAX_BACKUPS=40`、`MAX_AGE=14`。`.gitea/workflows/deploy.yaml` 改为创建这三个目录。
- [ ] 5.3 构建、`openspec validate --all`、`./scripts/context-health.sh`;提交并推送 8-11。
## 6. 测试环境验证(AGENTS.md 规定自动化测试为 N/A;按 ENG-TEST-001 在测试环境验证)
- [ ] 6.1 审计:部署后 24 小时,审计日增约 300 条(按小时只读统计);人工操作照常写审计;`app.log` 中有 `logger=audit` 的行且不含快照;新对接日志的 `audit_event_id` 可为空。
- [ ] 6.2 运行时覆盖:把一个机器动作覆盖为 `log_only`,再恢复,确认写入行为随之切换。
- [ ] 6.3 退款后处理:在测试环境造一张后处理会失败的退款单,确认每次尝试都有记录、退避生效、第 10 次后转人工并写结论审计,人工重试成功后状态变为已完成。
- [ ] 6.4 停复机:造一条执行前已是目标状态的停机指令,确认写入 `skipped` 记录。
- [ ] 6.5 采集:成功记录带原始数据,失败记录带错误详情和原始数据,无套餐卡的失败也写入;记录中没有密钥、签名或加密报文。
- [ ] 6.6 归档:在测试库用一个过期的空分区或小分区执行一次维护,确认生成 `csv.gz` 和 manifest、行数一致后分区被删除;模拟目录不可写时,已摘出的分区仍保留。
- [ ] 6.7 查询接口:每个接口分别验证查单个、查全部、分页总数和各筛选条件;用 `EXPLAIN` 确认走索引并做了分区裁剪。
- [ ] 6.8 部署:确认 `/opt/junhong_cmp/api/logs`、`/opt/junhong_cmp/worker/logs`、`/opt/junhong_cmp/worker/archive` 生成且轮换正常,同步服务器能拉到文件。
## 7. 归档顺序
- [ ] 7.1 本 Change 必须在 `add-card-traffic-queue-and-observed-deduction` 和 `add-carrier-gateway-rate-limit` 归档之后再归档。