9.8 KiB
UR#94 卡状态公共写入与运营商回调功能总结
当前完成范围
任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.34、2.35、2.36、2.37 已分别交付实名、流量、网络观测公共写入闭环及三个轮询入口切换。观测序列和运营商回调仍按 2.38~2.45 继续实施,不能因当前能力已落地而提前开放回调入口。
流量观测公共写入闭环
统一 ApplyTrafficObservation 已收口手动 Gateway 刷新、套餐失效前同步和周期流量轮询使用的流量写入规则。
- 应用用例使用 PostgreSQL
FOR UPDATE串行化同一卡观测,并以旧 Gateway 读数作条件更新;检查时间、可信基线、自然月累计、生命周期累计和正增量 Outbox 在同一事务提交。 - 领域规则保留运营商重置日当天及前一天窗口。非重置窗口的下降读数不覆盖可信基线、不累计流量,也不发布扣减事件;零增量只更新时间。
- 自然月切换时保存上月系统累计并初始化本月累计;运营商周期读数与系统自然月累计保持两个独立口径。
- 正增量只发布一次
card.traffic.incrementedv1 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.changedv1 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.changedv1 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 位冲突
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 位冲突
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;
双列异常与不一致
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;
停止条件:任一查询返回记录时不得执行回调路由发布,也不得任取一张卡继续迁移。数据责任人必须核对运营商原始资料、资产归属和历史业务事实,按单独受控方案修复后重新扫描。仅软删除记录与有效卡重复允许存在,但必须单独登记为已确认的非阻塞项。
回滚
down 迁移只把 idx_iot_card_iccid_19/20 恢复为 000131 的普通部分索引,不删除双列、不修改卡数据,也不触碰其他表索引。若回调已经开放并依赖精确唯一语义,应先关闭回调入口并确认没有并发写入,再评估回滚。
验证状态
- 已静态核对 up/down 文件成对、索引名与
000131一致、唯一索引谓词与冲突扫描谓词一致。 - 已静态核对
000179up/down 成对、字段注释和 CHECK 约束一致,领域规则与持久化字段没有 Redis 事务依赖。 - 已静态核对
000180up/down 成对、副作用状态 CHECK 与常量一致,流量用例不再直接覆盖日流量落盘表。 - 已执行
gofmt和go build ./...;构建退出码为 0。 - 按七月测试环境豁免,本轮未连接真实 PostgreSQL、未运行迁移测试;无冲突升级、19/20 位冲突、软删除重复和 down/up 重放验证延期到 6.1、6.3,不能标记为生产验收通过。