# UR#94 卡状态公共写入与运营商回调功能总结

## 当前完成范围

任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁，任务 2.34～2.44A 已交付实名、流量、网络观测公共写入闭环、三个轮询入口切换、通用观测序列、业务入口触发，以及电信实名、移动实名、联通实名成功和联通解除实名四个回调防腐层。UR#94 整体发布检查仍按 2.45 继续实施，不能因四条路由已接入而提前宣称整体生产验收完成。

## 联通实名成功回调

新增来源材料 `docs/7月迭代/来源材料/realname.go` 补齐了此前缺失的联通实名成功报文，因此任务 2.44A 新增 `POST /api/callback/carriers/cucc/realname`。协议与联通解除实名一致：外层 `data` 必须是字符串，再解析内层非空 `iccid/dateChanged`。

- 合法 ICCID 只按 19/20 位对应列精确查询；不复制旧代码的 20 位截 19 位，不跨列降级，也不任取多匹配卡。
- 使用 `ICCID + dateChanged` 的安全摘要作为语义幂等键；解析失败使用正文摘要，重复、冲突和中断恢复沿用 Integration Log 租约规则。
- 唯一命中后调用公共 `ApplyCardObservation(verified=true)`，实名事实变化时由公共用例写 Outbox，并尽力提前完成同卡实名观测序列。
- 不调用旧 `inner_callback`、第三方推送、旧平台登录、`ModifyDate` 或 Gateway 二次确认；`dateChanged` 只作为上游变更时间和幂等语义留痕，不直接改写本地业务时间。
- 无论开关状态、报文结果或内部处理结果，均返回入口时间对应的 HTTP 200 固定 JSON 应答，避免运营商不可控重推。

## 运营商回调独立开关

四条回调复用公共 `system_config`，模块名为 `carrier_callback`，默认全部关闭：

- `carrier_callback.ctcc_realname.enabled`
- `carrier_callback.cmcc_realname.enabled`
- `carrier_callback.cucc_realname.enabled`
- `carrier_callback.cucc_realname_removal.enabled`

路由始终保留。开关关闭时不解析报文、不定位卡、不调用公共观测，只按正文摘要幂等记录 `ignored` Integration Log 并返回固定成功应答；配置缺失、非法或读取失败时失败关闭，同样不执行业务写入。配置读取以 PostgreSQL 为事实来源并复用 Redis 五分钟缓存。

超级管理员可通过 `GET /api/admin/system-configs?module=carrier_callback` 查询四个已注册开关，并通过 `PUT /api/admin/system-configs/:key` 更新布尔值。全局 Audit Event 已按用户决策取消，Audit Writer 改为可选；若未来装配 Writer，审计仍与配置更新同事务执行，未装配时不会再阻塞受控配置更新，也不会用 Integration Log 冒充配置审计。

## 联通解除实名留痕回调

任务 2.44 新增 `POST /api/callback/carriers/cucc/realname/remove`。Adapter 强制要求外层 `data` 为 JSON 字符串，再解析内层 `iccid` 与非空 `dateChanged`；任何情况下都返回入口时间对应的固定 JSON 成功应答。

- 空正文、外层或内层 JSON 失败、`data` 非字符串、ICCID 非法及变更时间为空统一记录 `invalid_payload`。
- 合法 ICCID 仅按 19/20 位对应列精确定位；未找到、多匹配和数据库错误分别记录 `not_found`、`conflict`、`failed`，不会跨列降级或任取卡。
- 合法且唯一识别的解除通知统一终结为 `ignored`；不调用 `ApplyCardObservation`、观测序列、Gateway、旧 `DelRealName` 或第三方推送，不修改实名状态、首次实名时间、检查时间和逆转计数。
- 解析成功时使用 `ICCID + dateChanged` 语义摘要防重，JSON 空白或字段顺序变化保持同一幂等语义；同语义不同正文另记 `conflict`，解析失败则以完整正文摘要防重，pending 记录支持租约恢复。
- Access Log 继续使用运营商回调摘要策略，Integration Log 只保存正文长度、哈希、Content-Type 和哈希资源键；未新增数据库迁移或自动化测试。

## 移动实名成功回调防腐层

任务 2.43 新增 `POST /api/callback/carriers/cmcc/realname`，只解析已知 JSON 结构，并使用请求入口捕获时间返回 `code=200`、`msg=success` 和 `YYYY-MM-DD HH:mm:ss` 格式时间戳。

- 只有 `status="0"`、`message="正确"`、首条 `result.regStatus="00000"` 且 ICCID 合法时生成已实名观测；空 ICCID、失败状态、空结果和无法解析的 JSON 均记录 `invalid_payload`。
- 优先使用 `busiSeq` 的安全摘要作为稳定幂等语义，缺失时使用正文摘要；同事务不同正文另记 `conflict`，pending 处理超过一分钟后允许原子认领恢复。
- ICCID 仅按 19/20 位精确列定位，不按 MSISDN 补查，不登录旧管理平台，不保存账号密码或 Cookie，也不调用旧第三方推送、修改到期时间或 Gateway 二次确认。
- 成功结果复用公共 `ApplyCardObservation` 并尽力提前完成同卡实名序列；未找到、多匹配和内部错误分别终结为 `not_found`、`conflict`、`failed`，不会任取卡写入。
- Access Log 复用运营商回调正文摘要策略；Integration Log 不保存完整 JSON 或明文 ICCID，仅保存长度、摘要、Content-Type 和哈希资源键。
- 新 Handler 已接入生产组合根、真实路由、OpenAPI Handler 构造和两个文档生成器。本任务按测试延期约定未新增或运行自动化测试。

## 电信实名回调防腐层

任务 2.42 新增 `POST /api/callback/carriers/ctcc/realname`，按已知 `ContractRoot` XML 协议解析电信实名结果，并始终返回运营商约定的 JSON 成功应答。

- 只有 `RESULTMSG=成功` 且 `ACCEPTMSG` 包含“已完成实名信息补录”时生成已实名观测；实名信息清除和其他合法业务结果仅记录 `ignored`，不查询或修改卡。
- ICCID 去除首尾空白后复用公共格式校验，仅接受 19 或 20 位值，并分别精确查询 `iccid_19`、`iccid_20`；查询不应用登录账号数据范围，未找到和多匹配分别记录 `not_found`、`conflict`。
- 入站先以 `GROUP_TRANSACTIONID` 的安全摘要建立稳定幂等语义；同一外部事务的重复载荷直接成功返回，不重复首次实名、套餐激活或领域事件，不同载荷另记 `conflict`。缺少外部事务号时以完整正文摘要防重；若首次处理在 `pending` 阶段中断，超过一分钟租约后的重复回调可原子认领并恢复处理。
- 实名成功复用公共 `ApplyCardObservation` 事务闭环，并在成功后尽力提前完成同卡未执行实名观测序列；内部处理失败尽力把 Integration Log 终结为 `failed`。
- Access Log 对 `/api/callback/carriers/` 强制使用敏感正文摘要策略，不记录完整 XML 或明文 ICCID；Integration Log 只保存正文长度、SHA-256 摘要和哈希资源键。
- 迁移 `000181` 为 Integration Log 增加 `conflict` 终态；发布时必须先执行迁移再开放路由，down 迁移会先把已有 `conflict` 归并为 `failed`。
- 新 Handler 已接入真实路由、生产组合根、OpenAPI Handler 构造以及 `cmd/api/docs.go`、`cmd/gendocs/main.go` 两个文档生成器。

## 读取与实名入口的 Best Effort 观测触发

任务 2.39 在不迁移 Query、不新增接口、不改变响应结构的前提下，接入了 C 端资产详情、后台资产实时状态、OpenAPI 卡/设备流量、OpenAPI 卡网络/实名查询，以及 C 端和后台实名链接入口。

- 读取型入口使用稳定且调用方无关的场景码，按实际卡资源和同步类型创建无预期序列；同一卡连续 OpenAPI 查询通过 2.38 的场景合并键复用未结束序列，不会按账号或每次请求追加三任务。
- C 端和后台设备资产详情按绑定卡分别触发实名、流量、网络观测；OpenAPI 设备流量同样按绑定卡触发流量观测，卡标识解析到设备时仍以实际绑定卡为资源。
- C 端实名链接和后台 Gateway 实名链接仅在运营商 `realname_link_type != none` 且原有链接响应成功后触发 `expected=verified` 的实名序列；不支持在线实名的运营商不会建序列。
- 读取入口只把结构化请求放入容量受控的进程内分发队列，不等待 Redis、Asynq 或 Gateway，也不把后台序列结果写入当前响应；队列满载或 Redis/Asynq/Integration Log 失败只输出中文安全日志并保留 scene、resource、sync、series 和 request_id 关联信息。
- 原有后台与 C 端 `refresh` 手动刷新路径未注入分发器，仍直接执行一次同步，不生成 0/3/5 序列。OpenAPI、资产查询和实名入口的现有错误码、响应字段与业务结果保持不变。

## 0/3/5 卡观测事件序列

任务 2.38 已交付内部 `SeriesTrigger`、固定阶梯 Asynq 调度器和 Worker 执行闭环，但未修改任何查询、停复机、购包、套餐或设备控制入口；入口接入仍由 2.39～2.41 分别完成。

- 每个新序列以稳定 `series_id` 创建立即、3 分钟、5 分钟三个结构化任务，任务 ID 使用 `series_id + attempt`，`MaxRetry(0)`，单次失败不会删除或取消后续两个独立任务。
- Redis 合并键严格包含 `scene + resource_type + resource_id + sync_type`，只覆盖最后一次计划任务和短暂缓冲。首触发的预期、来源、请求/关联 ID 与基准时间被原子保存；重复触发不刷新上下文或 TTL，补齐首次局部入队失败时仍使用首触发上下文，同时避免重复生成第二组三任务。缺失请求/关联 ID 时生成同一稳定 UUID 贯穿三任务。
- 尝试执行前读取本地权威快照。明确预期已满足时，当前及剩余尝试立即写 `completed` Integration Log 并标记幂等完成，不再访问 Gateway；`CompleteResourceSeries` 为可信实名回调提前结束同卡实名序列提供扩展点。
- 实际 Gateway 请求使用 `provider + sync_type + resource_id` Redis 互斥，16 分钟 TTL 覆盖配置允许的 300 秒单次超时、两次网络重试和安全余量；流量同步复用既有 `traffic:sync:lock:card:{id}` 卡级锁，避免事件、轮询和手动刷新并发读取同一上游读数。互斥命中只把当前尝试记录为 `ignored`，后续阶梯任务保持不变。
- 运营商接入与同步类型的默认最小请求间隔为 10 秒。任务在持有本次请求互斥期间只等待剩余间隔，不建立全局五分钟冷却，也不创建 30/60/120 秒退避；Gateway 超频只把当前 Integration Log 终结为 `rate_limited`。
- 实名、流量、网络实际响应全部复用 `ApplyCardObservation`、`ApplyTrafficObservation`、`ApplyNetworkObservation`，没有新增第二套状态写入。设备信息同步类型已在 Application 契约中预留，具体执行适配器随 2.41 交付。
- 合并、互斥、最小间隔取消、预期提前完成和每次实际 Gateway 请求统一写 `tb_integration_log`，传播请求 ID、关联 ID、序列 ID、尝试序号、场景、资源、结果、耗时和是否变化；请求摘要只保存卡 ID 与同步类型，不保存完整 ICCID。

本任务按 Change 测试延期约定未新增自动化测试，也未新增数据库迁移或同步运行表。序列运行态、幂等键和短时互斥属于 Redis 协调事实，三次可查询结果继续使用公共 Integration Log。

## 流量观测公共写入闭环

统一 `ApplyTrafficObservation` 已收口手动 Gateway 刷新、套餐失效前同步和周期流量轮询使用的流量写入规则。

- 应用用例使用 PostgreSQL `FOR UPDATE` 串行化同一卡观测，并以旧 Gateway 读数作条件更新；检查时间、可信基线、自然月累计、生命周期累计和正增量 Outbox 在同一事务提交。
- 领域规则保留运营商重置日当天及前一天窗口。非重置窗口的下降读数不覆盖可信基线、不累计流量，也不发布扣减事件；零增量只更新时间。
- 自然月切换时保存上月系统累计并初始化本月累计；运营商周期读数与系统自然月累计保持两个独立口径。
- 正增量只发布一次 `card.traffic.incremented` v1 Outbox，不在请求事务内直接调用套餐服务，避免卡事实成功而扣减失败形成半事务。
- Worker 先将增量写入既有 `traffic:daily:{cardID}:{date}` Redis 缓冲并保留 48 小时，再扣减套餐流量、执行停复机评估；每日落盘任务继续按原覆盖语义写 `tb_card_daily_usage`，不会与请求事务内的增量写互相覆盖。
- `tb_card_observation_effect` 以 `event_id` 唯一记录日流量、套餐扣减和停复机评估阶段。重复投递在已完成阶段直接返回；副作用已发出但结果未知时停在处理中，不盲重试造成重复扣减。
- 卡事实提交后才失效轮询缓存。统一 Audit Event 不在本次 Change 范围内；Outbox、套餐使用记录、流量事实和 Access Log 边界保持不变。

迁移 `000180` 新增无外键的卡观测副作用进度表，状态为 0-待处理、1-处理中或结果未知、2-日流量已记录、3-套餐流量已扣减、4-全部完成。真实 PostgreSQL、Redis/Asynq、并发与结果未知恢复验证按本轮豁免延期。

## 网络状态观测公共写入闭环

统一 `ApplyNetworkObservation` 已收口手动 Gateway 刷新和周期网络轮询使用的网络状态写入。

- 领域层集中维护 Gateway `正常/停机/准备/待激活` 到本地开停机状态的稳定映射；未知状态不以零值覆盖当前网络状态，但仍可安全保存本次扩展原因、IMEI、检查时间和同步时间。
- 已知状态变化、Gateway 风险停机/销户扩展、运营商停机原因和网关卡 IMEI 由同一 PostgreSQL `FOR UPDATE` 事务写入；仅真实网络状态变化发布 `card.network.changed` v1 Outbox。
- 独立卡命中“风险停机/已销户”时在同一事务关闭 `enable_polling`；绑定设备的卡和“机卡分离停机”不触发该独立卡终止规则。
- Worker 消费网络变化事件后读取当前权威卡事实执行停复机评估，避免把 Gateway 成功响应直接当成本地停复机事实；已有 Integration Log、停复机资格和 Gateway 失败重排保持在原边界。
- 真实 Gateway 状态映射、风险卡矩阵、未知状态、IMEI、重复事件和事务回滚验证按 6.4/6.1 延期，不能据此标记生产验收完成。

## 三个轮询入口切换

任务 2.37 已将实名、流量、网络三个 Gateway 轮询 Handler 的成功结果应用统一切换到 `CardObservation` 应用服务。Handler 仍保留原有卡资格判断、Redis 分片并发、卡流量互斥、配置间隔、失败重排和监控统计；旧的直接写库、直接扣套餐、直接停复机、直接缓存和风险卡分支已删除，避免新旧路径双写。每次 Gateway 查询前创建 Integration Log，查询失败或响应缺关键 ICCID 时记录失败并按原策略重排；请求关联使用 Integration Log ID 贯穿观测事件。手动刷新不生成额外 0/3/5 序列，后续序列由 2.38 负责。

## 实名观测公共写入闭环

统一 `ApplyCardObservation` 现在负责实名观测的唯一事务写入规则，手动 Gateway 刷新和后台人工纠偏已接入；现有实名轮询入口将在任务 2.37 与流量、网络轮询一起完成同批切换，期间不改变轮询配置、分片、间隔或失败重排。

- 标准观测使用类型明确的 `RealnameObservation` 和 `ObservationMetadata`，包含来源、场景、观测时间、观测 ID、请求/关联 ID 和脱敏摘要，不把 Gateway、Fiber、GORM、Redis 或 Asynq 类型带入领域层。
- 应用用例使用 PostgreSQL `FOR UPDATE` 锁定卡，并以原实名状态作为条件更新；检查时间、实名状态、首次实名时间、激活派生字段、逆转确认状态和 Outbox 在一个事务内提交。
- `first_realname_at` 仅在历史值为空且本次真实发生未实名到已实名时写入，重复成功或逆转后的再次实名不会覆盖首次时间。
- 已实名卡出现未实名周期观测时，连续三次且处于同一 10 分钟窗口才落为未实名；前两次只更新检查时间和持久化确认窗口。逆转计数保存在 `tb_iot_card`，不再依赖可能与数据库回滚脱节的 Redis 计数。
- 运营商解除实名回调不会增加或清空周期逆转计数，也不会修改本地实名状态；人工纠偏使用独立来源，可立即更正状态。
- 状态真实变化时同事务写 `card.realname.changed` v1 Outbox。Worker 消费者以当前权威卡事实幂等执行首次实名卡/设备套餐激活和停复机评估；有效无变化观测不重复发布副作用。
- 事务提交后才删除轮询卡缓存及遗留 Redis 逆转键；缓存删除失败只记录中文告警，不把已提交事实伪装成回滚。

迁移 `000179` 新增 `realname_reversal_count` 与 `realname_reversal_started_at`，并用 CHECK 约束计数只能保存 0～2；达到第三次时状态变化与计数清零在同一事务完成。down 迁移只删除该约束和两个字段。

统一 Audit Event 已按七月总 Change 的最新范围决策移出本次上线，不是 2.34 或生产上线阻塞项；Integration Log、Outbox、Domain Ledger 和 Access Log 仍分别承担外部交互恢复、可靠投递、状态事实和 HTTP 调试职责。

## ICCID 精确唯一性

现有迁移 `000131` 已建立 `tb_iot_card.iccid_19/iccid_20` 和普通部分索引。迁移 `000178` 在不改变 ICCID 展示、导入、模糊查询和卡识别规则的前提下，将两个索引升级为未删除数据范围内的部分唯一索引：

- `iccid_19`：全部未删除卡精确唯一；迁移前同时阻断空值、非 19 位和双列不一致。
- `iccid_20`：未删除且非空时精确唯一；非空值必须是与原 ICCID 一致的 20 位值。
- 软删除记录不阻塞相同 ICCID 的合法新记录。
- 迁移不截断、不补位、不跨列匹配，也不自动删除、合并或修正冲突卡。

迁移在同一事务内锁定 `tb_iot_card`，先检查原 ICCID 长度、双列空值/长度及回填一致性，再按目标唯一索引相同的谓词扫描 19 位和 20 位冲突组，最后删除普通索引并以原名创建唯一索引。发现任一异常时只输出异常卡数和冲突组数的中文安全摘要并整体回滚，不留下半完成索引。

## 发布前异常清单

发布负责人在维护窗口执行迁移前，必须分别导出以下清单并指定数据修复责任人。查询结果包含完整 ICCID，只能存放在受控运维位置，不得写入应用日志或普通工单正文。

### 19 位冲突

```sql
SELECT iccid_19, array_agg(id ORDER BY id) AS card_ids, COUNT(*) AS card_count
FROM tb_iot_card
WHERE deleted_at IS NULL
  AND iccid_19 IS NOT NULL
GROUP BY iccid_19
HAVING COUNT(*) > 1
ORDER BY card_count DESC, iccid_19;
```

### 20 位冲突

```sql
SELECT iccid_20, array_agg(id ORDER BY id) AS card_ids, COUNT(*) AS card_count
FROM tb_iot_card
WHERE deleted_at IS NULL
  AND iccid_20 IS NOT NULL
  AND iccid_20 <> ''
GROUP BY iccid_20
HAVING COUNT(*) > 1
ORDER BY card_count DESC, iccid_20;
```

### 双列异常与不一致

```sql
SELECT id, iccid, iccid_19, iccid_20, carrier_type
FROM tb_iot_card
WHERE deleted_at IS NULL
  AND (
      LENGTH(iccid) NOT IN (19, 20)
      OR iccid_19 IS NULL
      OR LENGTH(iccid_19) <> 19
      OR (LENGTH(iccid) = 19 AND (iccid_19 IS DISTINCT FROM iccid OR iccid_20 IS NOT NULL))
      OR (LENGTH(iccid) = 20 AND (iccid_19 IS DISTINCT FROM LEFT(iccid, 19) OR iccid_20 IS DISTINCT FROM iccid))
  )
ORDER BY id;
```

停止条件：任一查询返回记录时不得执行回调路由发布，也不得任取一张卡继续迁移。数据责任人必须核对运营商原始资料、资产归属和历史业务事实，按单独受控方案修复后重新扫描。仅软删除记录与有效卡重复允许存在，但必须单独登记为已确认的非阻塞项。

## 回滚

优先通过四个 `system_config` 开关分别停止业务处理，路由继续固定成功应答。down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分索引，不删除双列、不修改卡数据，也不触碰其他表索引。若回调已经开放并依赖精确唯一语义，应先关闭相关开关并确认没有并发写入，再评估回滚。

迁移顺序固定为 `000178`（ICCID 唯一性）→ `000179`（实名逆转窗口）→ `000180`（副作用进度）→ `000181`（Integration Log conflict 终态）。一旦已经产生卡状态、Outbox、Integration Log 或副作用进度事实，不清表、不删除业务事实，也不恢复旧 Writer；暂停对应开关和生产者后采用前向修复。

## 验证状态

- 已静态核对 up/down 文件成对、索引名与 `000131` 一致、唯一索引谓词与冲突扫描谓词一致。
- 已静态核对 `000179` up/down 成对、字段注释和 CHECK 约束一致，领域规则与持久化字段没有 Redis 事务依赖。
- 已静态核对 `000180` up/down 成对、副作用状态 CHECK 与常量一致，流量用例不再直接覆盖日流量落盘表。
- 已执行 `gofmt` 和 `go build ./...`；构建退出码为 0。
- 已静态核对观测序列任务固定为 0/3/5 分钟、`MaxRetry(0)`、结构化载荷、同场景合并键、`series_id + attempt` 幂等键、流量共享锁和 10 秒默认最小间隔；任务 2.38 未修改业务入口。
- 已静态核对任务 2.39 只接入 issue 07 指定读取与实名链接入口，稳定 OpenAPI 场景不包含账号身份，`realname_link_type=none` 不触发，后台/C 端手动刷新路径没有分发调用。
- 已静态核对任务 2.42 的 XML 根结构、19/20 位精确查询、系统级资源定位、稳定外部事务幂等、载荷冲突留痕、固定成功应答、Access Log 摘要策略及 Handler/路由/文档生成器装配。
- 已静态核对新增联通实名成功回调的双层 JSON、非空变更时间、19/20 位精确查询、语义幂等、公共实名观测、固定成功应答及四个受控系统配置开关。
- 任务 2.45 已完成四个回调 Handler、免认证路由、生产组合根、两个文档生成器、OpenAPI 产物、观测 Worker、Outbox Consumer、API/Worker Gateway Client 和迁移顺序的静态装配检查。
- `docs/admin-openapi.yaml` 已生成电信实名、移动实名、联通实名成功和联通解除实名四条路径；四个开关均通过后台受控 `system_config` 注册，默认关闭。
- 已执行 `gofmt`、`git diff --check` 和离线 `go build ./...`，构建退出码为 0。按用户要求保留既有 Gateway 与部署 YAML 配置，本任务未修改其配置来源或部署值。
- 当前状态只能标记为“后端代码与测试环境装配完成、真实验证延期”；真实运营商样例、真实 Gateway、19/20 位命中、逐个启停和异常恢复统一转 6.4。
- 按七月测试环境豁免，本轮未连接真实 PostgreSQL、未运行迁移测试；无冲突升级、19/20 位冲突、软删除重复和 down/up 重放验证延期到 6.1、6.3，不能标记为生产验收通过。
