收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

完成运营商实名回调、业务事件观测序列与受控配置装配,同时恢复 UR43 已交付的 packages[].remove 字段及旧响应兼容,统一更新 OpenSpec、OpenAPI 和交付文档。

Constraint: 七月测试环境里程碑不新增或运行自动化测试

Rejected: 以必填 operation_type 替换 packages[].remove | 会破坏已交付前端契约

Confidence: high

Scope-risk: broad

Directive: 后续修改系列套餐管理接口必须保持 packages[].remove 和 ShopSeriesGrantResponse 兼容

Tested: go run ./cmd/gendocs;go build -buildvcs=false ./...;openspec validate complete-july-iteration-test-release --strict;git diff --check

Not-tested: 按本 Change 约定未运行 go test,真实运营商与 Gateway 联调延期
This commit is contained in:
2026-07-24 19:59:24 +08:00
parent a18ed8bc8d
commit 5c4d17e9fc
79 changed files with 4341 additions and 478 deletions

View File

@@ -38,6 +38,11 @@
| 代理主钱包充值与人工调整正向入账 | 延期(测试环境冻结;生产前由 6.5 为入账补齐同事务 Audit Event | 充值/人工调整业务事实、`tb_agent_wallet` 与唯一成功流水在同一事务形成权威事实 | N/A本接缝不调用支付或审批外部系统 | 同事务写入 `wallet.agent_main.credited`,消费者按成功流水复核;支付/审批 Integration Log 由 UR#34 外部流程负责 | | 代理主钱包充值与人工调整正向入账 | 延期(测试环境冻结;生产前由 6.5 为入账补齐同事务 Audit Event | 充值/人工调整业务事实、`tb_agent_wallet` 与唯一成功流水在同一事务形成权威事实 | N/A本接缝不调用支付或审批外部系统 | 同事务写入 `wallet.agent_main.credited`,消费者按成功流水复核;支付/审批 Integration Log 由 UR#34 外部流程负责 |
| 代理订单主钱包退款回充 | 延期(测试环境冻结;生产前由 6.5 为退款资金变更补齐同事务 Audit Event | 原成功扣款流水定位付款钱包并限定金额;退款审批、`tb_agent_wallet` 与唯一成功退款流水同事务形成权威事实 | N/A本资金接缝不调用渠道或审批外部系统 | 同事务写入 `wallet.agent_main.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 | | 代理订单主钱包退款回充 | 延期(测试环境冻结;生产前由 6.5 为退款资金变更补齐同事务 Audit Event | 原成功扣款流水定位付款钱包并限定金额;退款审批、`tb_agent_wallet` 与唯一成功退款流水同事务形成权威事实 | N/A本资金接缝不调用渠道或审批外部系统 | 同事务写入 `wallet.agent_main.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 |
| 代理商资金概况信用投影 | N/A普通受权读取不返回其他数据范围的资金事实不执行资金或配置变更 | 只读投影 `tb_shop`、主/佣金钱包、提现汇总和主账号;派生金额不另建事实表 | N/A无外部系统调用 | N/A纯 Query 不产生可靠副作用) | | 代理商资金概况信用投影 | N/A普通受权读取不返回其他数据范围的资金事实不执行资金或配置变更 | 只读投影 `tb_shop`、主/佣金钱包、提现汇总和主账号;派生金额不另建事实表 | N/A无外部系统调用 | N/A纯 Query 不产生可靠副作用) |
| 受控系统配置更新 | N/A用户已明确取消全局 Audit Event仅超级管理员可更新代码注册 Key未知 Key、非法类型和值域均拒绝 | `tb_system_config` 是配置值、类型、模块及更新人的 PostgreSQL 权威事实,更新后失效 Redis 缓存 | N/A配置更新不调用外部系统不得写 Integration Log 冒充配置审计) | N/A配置更新不产生可靠异步副作用 |
| 电信实名结果回调 | N/AAudit Event 已移出本 Change运营商来源真实性验证也不在本票边界 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/ignored/not_found/conflict/success/failed` 终态 | 实名事实首次变化时由公共 `ApplyCardObservation` 同事务写入实名状态变化 Outbox重复成功不重复写事件 |
| 移动实名成功回调 | N/AAudit Event 已移出本 Change不接入旧平台登录、MSISDN 补查或来源真实性验证) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed` 终态,并通过 pending 租约恢复中断处理 | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通实名成功回调 | N/AAudit Event 已移出本 Change不复制旧 `inner_callback`、第三方推送或 Gateway 二次确认) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实;上游 `dateChanged` 只用于幂等和留痕 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed`,关闭时记录 `ignored` | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通解除实名回调 | N/A只识别并留痕外部解除通知不把单次回调作为本地实名逆转事实 | `tb_iot_card` 保持原实名状态、首次实名时间、检查时间及逆转计数,回调不写领域事实 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/ignored/failed` 终态并支持 pending 租约恢复 | N/A不调用公共实名观测不产生状态变化、停机或套餐事件 |
## 旧 Writer 与旧表写入口清单 ## 旧 Writer 与旧表写入口清单

View File

@@ -8,23 +8,23 @@ Status: ready-for-agent
代理系列授权及套餐授权的数据结构和接口已经存在,首次授权也已经能够在同一事务中创建系列授权与多条套餐授权。但前端没有把现有系列套餐列表和授权详情正确组合成批量选择视图,后续追加套餐时不容易区分未授权与已授权,也容易混淆上级当前成本、目标代理授权成本和建议零售价。 代理系列授权及套餐授权的数据结构和接口已经存在,首次授权也已经能够在同一事务中创建系列授权与多条套餐授权。但前端没有把现有系列套餐列表和授权详情正确组合成批量选择视图,后续追加套餐时不容易区分未授权与已授权,也容易混淆上级当前成本、目标代理授权成本和建议零售价。
现有 `PUT /api/admin/shop-series-grants/{id}/packages` 还将新增、改价和移除混在同一请求中:已经授权的套餐再次提交不同价格会被直接改价。这会使一个看似“新增授权”的并发请求静默覆盖其他运营人员刚设置的成本价,业务意图和审计语义均不明确 现有 `PUT /api/admin/shop-series-grants/{id}/packages` 已通过每个套餐项的 `remove` 字段支持新增、改价和移除,其中 `remove=true` 的软删除语义已经交付并被前端使用。本需求必须在增强批量原子性、权限和价格边界时保持该字段兼容,不能将它删除或改成必填的新顶层命令字段
## Solution ## Solution
复用现有套餐列表和系列授权详情,不新增候选 API。首次授权直接使用现有套餐列表选择多项并由 `POST /api/admin/shop-series-grants` 在同一事务创建系列及套餐授权;后续管理同时读取现有套餐列表与 `GET /api/admin/shop-series-grants/{id}`,按 `package_id` 合并成同一批量选择视图,已授权项置灰、未授权项可多选。 复用现有套餐列表和系列授权详情,不新增候选 API。首次授权直接使用现有套餐列表选择多项并由 `POST /api/admin/shop-series-grants` 在同一事务创建系列及套餐授权;后续管理同时读取现有套餐列表与 `GET /api/admin/shop-series-grants/{id}`,按 `package_id` 合并成同一批量选择视图,已授权项置灰、未授权项可多选。
保留现有批量写路径,但新增必填 `operation_type=authorize|update_cost|remove`。单次请求只能表达一种命令,并在一个事务内全成全败:`authorize` 只新增并支持同价幂等,不同价重复整批冲突;`update_cost` 只明确修改已授权套餐价格;`remove` 只明确移除授权 保留现有批量写路径`packages:[{package_id,cost_price,remove}]` 契约:`remove=true` 表示移除,未设置或为 `false` 时根据当前授权状态新增或改价。单次最多 100 项并在一个事务内全成全败;既有请求和响应结构必须继续可用
## User Stories ## User Stories
1. 作为平台或上级代理,我希望一次选择多个系列套餐并授权给目标代理。 1. 作为平台或上级代理,我希望一次选择多个系列套餐并授权给目标代理。
2. 作为授权人员,我希望看到公司成本价、目标代理当前授权成本价和建议零售价,不再混淆单一 `cost_price` 的含义。 2. 作为授权人员,我希望看到公司成本价、目标代理当前授权成本价和建议零售价,不再混淆单一 `cost_price` 的含义。
3. 作为授权人员,我希望已经授权的套餐明确置灰,避免重复选择。 3. 作为授权人员,我希望已经授权的套餐明确置灰,避免重复选择。
4. 作为授权人员,我希望新增授权、调价和移除是三个明确动作,避免误操作 4. 作为授权人员,我希望继续使用已交付的每项 `remove` 字段完成移除,避免发布后旧前端失效
5. 作为并发操作人员,我希望重复同价授权安全幂等,不同价并发请求明确冲突而不是覆盖 5. 作为并发操作人员,我希望批量请求全成全败,并在价格或授权状态冲突时得到明确反馈
6. 作为上级代理,我只能把自己有权销售的套餐授权给直属下级,不能借候选或构造请求越权。 6. 作为上级代理,我只能把自己有权销售的套餐授权给直属下级,不能借候选或构造请求越权。
7. 作为审计人员,我希望知道一次批量操作的命令、系列、目标代理、套餐及前后价格 7. 作为维护人员,我希望 OpenAPI 和前端契约准确记录 `remove` 字段,避免后续再次误删
## Implementation Decisions ## Implementation Decisions
@@ -32,7 +32,7 @@ Status: ready-for-agent
- 复用 `tb_shop_series_allocation``tb_shop_package_allocation`,不新建授权关系表。 - 复用 `tb_shop_series_allocation``tb_shop_package_allocation`,不新建授权关系表。
- 系列授权记录是目标店铺获得该系列销售能力的入口;套餐授权记录保存目标店铺对具体套餐的成本价、零售价、状态和上下架状态。 - 系列授权记录是目标店铺获得该系列销售能力的入口;套餐授权记录保存目标店铺对具体套餐的成本价、零售价、状态和上下架状态。
- 本需求不重建系列佣金、强充配置、套餐零售价或价格继承规则,也不新增候选 Query只规范现有列表组合、首次批量授权和后续明确的批量套餐命令 - 本需求不重建系列佣金、强充配置、套餐零售价或价格继承规则,也不新增候选 Query只规范现有列表组合、首次批量授权和兼容既有字段的后续批量管理
- 赠送套餐 `is_gift=true` 不属于代理授权候选,也不能通过构造请求加入。 - 赠送套餐 `is_gift=true` 不属于代理授权候选,也不能通过构造请求加入。
- 金额统一使用分,类型为 `int64`,禁止浮点金额。 - 金额统一使用分,类型为 `int64`,禁止浮点金额。
@@ -58,33 +58,29 @@ Status: ready-for-agent
- 继续使用 `POST /api/admin/shop-series-grants`,在一个事务内创建 `ShopSeriesAllocation` 与请求中的多条 `ShopPackageAllocation`;不拆成两步,不允许留下无套餐的空系列授权。 - 继续使用 `POST /api/admin/shop-series-grants`,在一个事务内创建 `ShopSeriesAllocation` 与请求中的多条 `ShopPackageAllocation`;不拆成两步,不允许留下无套餐的空系列授权。
- `packages` 从当前可选字段收紧为必填,至少 1 项、最多 100 项;每项为 `package_id + cost_price`,同一请求内套餐 ID 必须唯一。 - `packages` 从当前可选字段收紧为必填,至少 1 项、最多 100 项;每项为 `package_id + cost_price`,同一请求内套餐 ID 必须唯一。
- 首次授权中的套餐必须满足与后续 `authorize` 相同的系列、赠送、状态、上级授权、价格和权限规则;任一套餐失败则系列授权、全部套餐授权价格历史和成功审计均不落库。 - 首次授权中的套餐必须满足与后续新增授权相同的系列、赠送、状态、上级授权、价格和权限规则;任一套餐失败则系列授权、全部套餐授权价格历史均不落库。
- 首次授权不需要 `operation_type`,因为 `POST` 的业务语义已经唯一明确为“创建系列授权并首次授权套餐”;该接口不承担后续调价或移除。 - 首次授权不需要 `operation_type`,因为 `POST` 的业务语义已经唯一明确为“创建系列授权并首次授权套餐”;该接口不承担后续调价或移除。
### 批量写契约 ### 批量写兼容契约
- 保留 `PUT /api/admin/shop-series-grants/{id}/packages`,请求固定为: - 保留 `PUT /api/admin/shop-series-grants/{id}/packages`,请求固定为:
```json ```json
{ {
"operation_type": "authorize",
"packages": [ "packages": [
{"package_id": 1001, "cost_price": 6500}, {"package_id": 1001, "cost_price": 6500},
{"package_id": 1002, "cost_price": 7000} {"package_id": 1002, "cost_price": 7000},
{"package_id": 1003, "remove": true}
] ]
} }
``` ```
- `operation_type` 是必填稳定枚举:
- `authorize`:新增套餐授权。
- `update_cost`:明确修改已有授权成本价。
- `remove`:明确移除已有套餐授权。
- `packages` 必填,最少 1 项;单次上限 100 项。同一请求中的 `package_id` 必须唯一,重复 ID 返回参数错误,不能以“最后一个覆盖前一个”处理。 - `packages` 必填,最少 1 项;单次上限 100 项。同一请求中的 `package_id` 必须唯一,重复 ID 返回参数错误,不能以“最后一个覆盖前一个”处理。
- `authorize``update_cost` 每项必须提供大于等于 0 的 `cost_price``remove` 只使用 `package_id`,如携带价格则拒绝,避免无效参数制造歧义 - 每项 `remove=true` 时执行软删除;字段缺失或为 `false` 必须提供大于等于 0 的 `cost_price`,未授权则新增,已授权则按现有规则更新价格
- 不再使用每项 `remove=true` 混合命令;旧模糊请求不得继续触发新增、改价或删除 - `remove` 是已交付稳定字段,不得删除、改名或要求调用方改传必填顶层 `operation_type`
- `operation_type` 缺失返回参数错误。该契约要求前后端同批发布,不提供会延续模糊语义的长期兼容层 - 成功响应继续返回最新 `ShopSeriesGrantResponse`,不得强制旧前端适配新的结构化计数响应
### `authorize` 规则 ### 新增授权规则
- 每个套餐必须未删除、属于当前系列且不是赠送套餐。套餐当前禁用或下架状态原样展示但不阻止授权,保持现有“可以先配置授权、销售时由可售策略拦截”的能力;本需求不把销售状态误当成授权状态。 - 每个套餐必须未删除、属于当前系列且不是赠送套餐。套餐当前禁用或下架状态原样展示但不阻止授权,保持现有“可以先配置授权、销售时由可售策略拦截”的能力;本需求不把销售状态误当成授权状态。
- 代理操作者必须拥有该套餐的有效授权;目标店铺必须仍满足现有直属下级和系列授权管理权限。 - 代理操作者必须拥有该套餐的有效授权;目标店铺必须仍满足现有直属下级和系列授权管理权限。
@@ -93,7 +89,7 @@ Status: ready-for-agent
- 目标代理已经存在有效授权但成本价不同:返回冲突,整批不做任何写入。不得借 `authorize` 静默改价。 - 目标代理已经存在有效授权但成本价不同:返回冲突,整批不做任何写入。不得借 `authorize` 静默改价。
- 已软删除的旧授权是否允许按新授权恢复,应复用项目现有唯一索引和新建授权语义:创建新的有效记录或按明确恢复操作处理,但不能把旧价格静默带回;审计必须标明恢复来源。 - 已软删除的旧授权是否允许按新授权恢复,应复用项目现有唯一索引和新建授权语义:创建新的有效记录或按明确恢复操作处理,但不能把旧价格静默带回;审计必须标明恢复来源。
### `update_cost` 规则 ### 更新成本价规则
- 每个套餐必须已经存在目标代理的当前有效授权;未授权项导致整批失败。 - 每个套餐必须已经存在目标代理的当前有效授权;未授权项导致整批失败。
- 新价格与当前价格相同时按幂等成功,不写重复价格历史。 - 新价格与当前价格相同时按幂等成功,不写重复价格历史。
@@ -101,7 +97,7 @@ Status: ready-for-agent
- 若目标代理已将该套餐继续授权给下级,沿用现有规则禁止修改成本价,返回“存在下级分配记录,请先回收后再修改成本价”;整批失败,不允许部分跳过。 - 若目标代理已将该套餐继续授权给下级,沿用现有规则禁止修改成本价,返回“存在下级分配记录,请先回收后再修改成本价”;整批失败,不允许部分跳过。
- 此命令是按套餐指定绝对目标成本价,与现有 `/shop-package-batch-pricing` 按店铺/系列整体固定或比例调价不同,二者不能互相冒充。 - 此命令是按套餐指定绝对目标成本价,与现有 `/shop-package-batch-pricing` 按店铺/系列整体固定或比例调价不同,二者不能互相冒充。
### `remove` 规则 ### `remove=true` 规则
- 每个套餐必须已经存在目标代理的当前有效授权;同一授权已不存在时按幂等成功,不重复写删除审计。 - 每个套餐必须已经存在目标代理的当前有效授权;同一授权已不存在时按幂等成功,不重复写删除审计。
- 若该套餐已经继续授权给下级、存在会被破坏的销售/授权不变量或项目现有回收前置条件,必须拒绝并要求先回收,不能留下下级拥有而上级无权的悬空链路。 - 若该套餐已经继续授权给下级、存在会被破坏的销售/授权不变量或项目现有回收前置条件,必须拒绝并要求先回收,不能留下下级拥有而上级无权的悬空链路。
@@ -110,7 +106,7 @@ Status: ready-for-agent
### 事务、并发与幂等 ### 事务、并发与幂等
- 批量命令是轻量 Application 事务脚本。Application 负责权限、命令解析、批量加载、规则校验、条件写入价格历史和审计;不创建无业务行为的聚合根。 - 批量管理是轻量 Application 事务脚本。Application 负责权限、逐项语义解析、批量加载、规则校验、条件写入价格历史;不创建无业务行为的聚合根。
- 一批请求在一个 PostgreSQL 事务内全成全败。必须先批量加载和校验所有套餐、目标授权及下级引用,再执行写入。 - 一批请求在一个 PostgreSQL 事务内全成全败。必须先批量加载和校验所有套餐、目标授权及下级引用,再执行写入。
- 数据库保留目标店铺与套餐的有效授权唯一约束;`authorize` 创建应使用条件写入/唯一冲突后的重新读取,正确区分同价幂等与不同价冲突。 - 数据库保留目标店铺与套餐的有效授权唯一约束;`authorize` 创建应使用条件写入/唯一冲突后的重新读取,正确区分同价幂等与不同价冲突。
- 并发 `authorize` 同一批套餐时,一方成功后另一方重新核对当前价格;相同价格成功返回,不同价格冲突,不得将唯一约束错误暴露给客户端。 - 并发 `authorize` 同一批套餐时,一方成功后另一方重新核对当前价格;相同价格成功返回,不同价格冲突,不得将唯一约束错误暴露给客户端。
@@ -126,23 +122,22 @@ Status: ready-for-agent
### 审计与响应 ### 审计与响应
- 每次成功批量命令写统一 Audit Event至少包含 `operation_type`、系列授权 ID、目标店铺、系列 ID、套餐 ID 列表、前后成本价、操作者和幂等项摘要 - Audit Event 已按七月总 Change 决策移出本期,本需求不新增审计 Writer价格历史继续作为调价事实保存
- 拒绝和冲突按公共失败审计规则记录;价格、账号等敏感信息遵循全局审计脱敏规则 - 成功响应保持已交付的最新授权详情结构,不引入破坏性响应变更
- 成功响应返回最新授权详情或结构化结果,至少包括请求数、实际新增/更新/移除数、幂等数及相关套餐 ID不得以“跳过”掩盖不同价格冲突。
### 前端交互 ### 前端交互
- 首次授权和后续管理复用同一个批量选择表格组件;数据来自现有套餐列表,后续管理再与现有授权详情按 `package_id` 合并。 - 首次授权和后续管理复用同一个批量选择表格组件;数据来自现有套餐列表,后续管理再与现有授权详情按 `package_id` 合并。
- 分列展示当前上级成本价、目标代理授权成本价、建议零售价;平台作为上级时当前上级成本就是公司成本。未授权成本价显示“-”,不能显示 0 元造成误解。 - 分列展示当前上级成本价、目标代理授权成本价、建议零售价;平台作为上级时当前上级成本就是公司成本。未授权成本价显示“-”,不能显示 0 元造成误解。
- `is_authorized=true` 显示“已授权”并在新增授权模式置灰;切换到调价或移除模式时只允许选择已授权项。 - `is_authorized=true` 显示“已授权”并在新增授权模式置灰;切换到调价或移除模式时只允许选择已授权项。
- 一个弹窗/提交只能处于授权、调价或移除一种模式,请求提交相应 `operation_type` - 前端可以提供授权、调价或移除操作模式,但提交时继续组装既有套餐项:移除项设置 `remove=true`,新增或调价项提交 `cost_price`
- 提交前展示命令名称、目标代理、系列、套餐数量和价格摘要;提交期间禁止重复提交。 - 提交前展示命令名称、目标代理、系列、套餐数量和价格摘要;提交期间禁止重复提交。
- 并发不同价冲突时保留用户输入,提示刷新候选数据后重新确认,不能自动覆盖。 - 并发不同价冲突时保留用户输入,提示刷新候选数据后重新确认,不能自动覆盖。
### 发布与回滚 ### 发布与回滚
- 不迁移、不回填现有授权数据。发布前核对同一店铺/套餐有效授权重复、孤立下级授权及价格历史异常。 - 不迁移、不回填现有授权数据。发布前核对同一店铺/套餐有效授权重复、孤立下级授权及价格历史异常。
- 后端同批切换必填 `operation_type`;旧前端流量应在发布窗口清空或阻断,不能让缺失命令类型的请求继续按旧逻辑执行 - 后端发布必须向后兼容现有前端请求,无需停机切换新命令字段;发布检查应确认 OpenAPI 仍包含 `packages[].remove`
- 回滚应用时保留新版本产生的授权、价格历史和审计;不得删除业务事实。 - 回滚应用时保留新版本产生的授权、价格历史和审计;不得删除业务事实。
## Testing Decisions ## Testing Decisions
@@ -150,20 +145,19 @@ Status: ready-for-agent
- 读取组合测试覆盖平台与代理视角的现有套餐列表、授权详情、系列过滤、分页、赠送套餐排除、禁用/下架存量项只读展示,以及不同视角下成本价字段的准确文案。 - 读取组合测试覆盖平台与代理视角的现有套餐列表、授权详情、系列过滤、分页、赠送套餐排除、禁用/下架存量项只读展示,以及不同视角下成本价字段的准确文案。
- 字段权限测试验证无成本价查看权限的账号不能读取敏感价格,代理不能查询其他授权记录详情或借套餐列表扩大授权范围。 - 字段权限测试验证无成本价查看权限的账号不能读取敏感价格,代理不能查询其他授权记录详情或借套餐列表扩大授权范围。
- 首次创建测试覆盖套餐数组必填、多项同事务成功、重复 ID、跨系列、赠送、无上级授权、非法价格及任一项失败时系列授权也不落库。 - 首次创建测试覆盖套餐数组必填、多项同事务成功、重复 ID、跨系列、赠送、无上级授权、非法价格及任一项失败时系列授权也不落库。
- `authorize` 测试覆盖多项成功、同价幂等、不同价冲突、同请求重复 ID、跨系列、赠送、禁用/下架、代理自身未授权和并发唯一冲突。 - 新增测试覆盖多项成功、重复请求、同请求重复 ID、跨系列、赠送、禁用/下架、代理自身未授权和并发唯一冲突。
- `update_cost` 测试覆盖未授权、同价幂等、成功调价、价格历史、存在下级分配时整批失败,以及绝对价格不会被误解成固定/比例调整。 - 调价测试覆盖同价提交、成功调价、价格历史、存在下级分配时整批失败,以及绝对价格不会被误解成固定/比例调整。
- `remove` 测试覆盖成功、已不存在幂等、存在下级授权拒绝和不影响客户历史订单/套餐使用。 - `remove=true` 测试覆盖成功、已不存在幂等、存在下级授权拒绝和不影响客户历史订单/套餐使用,并验证缺失 `remove` 时不会误删
- 事务测试验证任一套餐失败时整批没有新增、改价、删除、价格历史或成功审计残留。 - 事务测试验证任一套餐失败时整批没有新增、改价、删除、价格历史或成功审计残留。
- HTTP 集成测试穿过 Fiber 认证、Handler、Application/Query、GORM/PostgreSQL 和统一响应,验证必填枚举、错误码、分页与越权安全语义。 - HTTP 集成测试穿过 Fiber 认证、Handler、Application/Query、GORM/PostgreSQL 和统一响应,验证 `remove` 字段、错误码、分页与越权安全语义。
- 前端验收覆盖首次与后续共用批量表格、两个现有读取接口的合并、已授权置灰、三种明确模式、批量摘要、空态、失败态和并发冲突刷新。 - 前端验收覆盖首次与后续共用批量表格、两个现有读取接口的合并、已授权置灰、`remove=true` 提交、空态、失败态和并发冲突刷新。
## Out of Scope ## Out of Scope
- 不重建系列授权、套餐授权、佣金或强充模型。 - 不重建系列授权、套餐授权、佣金或强充模型。
- 不新增 `package-options` 或其他重复的候选套餐读取接口。 - 不新增 `package-options` 或其他重复的候选套餐读取接口。
- 不允许创建没有任何套餐的空系列授权。 - 不允许创建没有任何套餐的空系列授权。
-允许一个请求混合授权、调价和移除 -删除或替换既有 `packages[].remove` 字段
- 不在 `authorize` 中修改已授权套餐价格。
- 不把赠送套餐授权给代理。 - 不把赠送套餐授权给代理。
- 不自动级联调整或回收下级代理授权。 - 不自动级联调整或回收下级代理授权。
- 不修改存量客户订单、套餐使用记录或零售价配置。 - 不修改存量客户订单、套餐使用记录或零售价配置。
@@ -171,7 +165,7 @@ Status: ready-for-agent
## Further Notes ## Further Notes
- 当前写接口已经接受 `packages:[{package_id,cost_price,remove}]`,并会在重复授权时直接改价;实现必须主动消除这段模糊语义 - 当前写接口已经接受 `packages:[{package_id,cost_price,remove}]`;这是已交付契约,实现和文档必须继续保留
- 仓库已有 `/shop-package-batch-pricing`,但它按整个店铺/系列进行固定或比例调整且允许逐项跳过,不能替代本需求按选中套餐设置绝对成本价、整批原子失败的 `update_cost` - 仓库已有 `/shop-package-batch-pricing`,但它按整个店铺/系列进行固定或比例调整且允许逐项跳过,不能替代本需求按选中套餐设置绝对成本价、整批原子失败的 `update_cost`
- 用户已确认以必填 `operation_type` 将授权、调价和移除拆为明确命令,并接受前后端同批切换 - 用户已于 2026-07-24 明确纠正:`remove` 字段不应移除,必须恢复并保持兼容
- 用户纠正并确认:首次授权保持现有 `POST` 同时授权系列和套餐;后续添加套餐由 `PUT /{id}/packages` 负责;读取复用现有套餐列表和授权详情,不新增候选接口。 - 用户纠正并确认:首次授权保持现有 `POST` 同时授权系列和套餐;后续添加套餐由 `PUT /{id}/packages` 负责;读取复用现有套餐列表和授权详情,不新增候选接口。

View File

@@ -1,6 +1,6 @@
# 02 — 后续套餐授权的原子命令契约 # 02 — 后续套餐授权的原子兼容契约
**What to build:** 后续套餐管理接口以必填的操作类型明确区分新增授权、修改成本价和移除授权。每次请求只执行一种命令且整批原子:同价新增安全幂等、不同价新增明确冲突;调价和移除遇到下级授权等前置条件时整批拒绝成功操作记录价格历史和审计,权限、并发和幂等行为不会泄露底层唯一约束错误。 **What to build:** 后续套餐管理接口保留已交付的 `packages:[{package_id,cost_price,remove}]` 契约,以 `remove=true` 表示软删除,其他项按当前授权状态新增或改价。批量请求保持事务原子性,调价和移除遇到下级授权等前置条件时整批拒绝成功调价记录价格历史,权限、并发和幂等行为不会泄露底层唯一约束错误。
**适用架构通道:** Application 事务脚本。完整业务边界为“系列授权下套餐授权的后续批量命令”,包括命令解析、批量加载、权限与业务校验、条件写入、价格历史及审计;不迁移现有整店/系列批量调价,不自动回收下级授权,也不修改订单、套餐使用或零售价。 **适用架构通道:** Application 事务脚本。完整业务边界为“系列授权下套餐授权的后续批量命令”,包括命令解析、批量加载、权限与业务校验、条件写入、价格历史及审计;不迁移现有整店/系列批量调价,不自动回收下级授权,也不修改订单、套餐使用或零售价。
@@ -8,6 +8,6 @@
**Status:** ready-for-agent **Status:** ready-for-agent
- [ ] 后续管理请求必须携带 `authorize``update_cost``remove` 之一,套餐数量限制为 1 至 100 且 ID 唯一;旧的混合新增、改价、移除语义不再生效 - [ ] 后续管理请求继续接受 `packages[].remove`,套餐数量限制为 1 至 100 且 ID 唯一;不得要求必填顶层 `operation_type`,不得改变既有响应结构
- [ ] 三种命令均在一个 PostgreSQL 事务内完成批量校验与写入,正确处理权限、赠送套餐、跨系列、绝对成本价下级依赖、同价幂等及不同价冲突 - [ ] 新增、改价和 `remove=true` 软删除均在一个 PostgreSQL 事务内完成校验与写入,正确处理权限、赠送套餐、跨系列、绝对成本价下级依赖。
- [ ] 集成测试覆盖并发新增、全量回滚、价格历史、审计、错误码和越权安全语义,并验证响应包含实际操作数、幂等数与相关套餐标识 - [ ] 集成测试覆盖 `remove` 兼容、并发新增、全量回滚、价格历史、错误码和越权安全语义。

View File

@@ -1,6 +1,6 @@
# 03 — 后续授权管理批量界面与发布验收 # 03 — 后续授权管理批量界面与发布验收
**What to build:** 授权人员在同一批量表格中管理既有系列授权:套餐列表与授权详情按套餐标识合并,已授权项在新增模式置灰,授权详情独有的存量项仍只读展示;调价和移除只允许选择已授权套餐。界面提交明确命令,提供批量摘要、空态、失败态及并发冲突后的刷新提示,并具备可安全发布和回滚的验收材料。 **What to build:** 授权人员在同一批量表格中管理既有系列授权:套餐列表与授权详情按套餐标识合并,已授权项在新增模式置灰,授权详情独有的存量项仍只读展示;调价和移除只允许选择已授权套餐。界面继续使用 `packages[].remove` 提交移除,提供空态、失败态及并发冲突后的刷新提示,并具备可安全发布和回滚的验收材料。
**适用架构通道:** 主通道为 Query 与前端;辅助通道为 Application API 集成。完整业务边界为“后续授权管理的读取组合、命令交互与上线验收”;不新增候选读取接口,不扩大现有数据或价格字段权限,不重构未触碰的套餐列表。 **适用架构通道:** 主通道为 Query 与前端;辅助通道为 Application API 集成。完整业务边界为“后续授权管理的读取组合、命令交互与上线验收”;不新增候选读取接口,不扩大现有数据或价格字段权限,不重构未触碰的套餐列表。
@@ -9,5 +9,5 @@
**Status:** ready-for-agent **Status:** ready-for-agent
- [ ] 后续管理页面并行复用现有套餐列表和授权详情,准确展示三类价格、授权状态及受权限变化影响的存量只读项。 - [ ] 后续管理页面并行复用现有套餐列表和授权详情,准确展示三类价格、授权状态及受权限变化影响的存量只读项。
- [ ] 新增、调价、移除三种模式只提交对应命令;提交期间防重,冲突时保留输入并要求刷新确认,且响应与失败状态对用户清晰可见 - [ ] 新增、调价、移除模式继续组装既有套餐项,移除项设置 `remove=true`;提交期间防重,冲突时保留输入并要求刷新确认。
- [ ] 完成 Fiber 到 PostgreSQL 的端到端验收、前后端同批切换核查、上线前数据异常核查和保留业务事实的回滚说明。 - [ ] 完成 Fiber 到 PostgreSQL 的端到端验收、`remove` 契约兼容核查、上线前数据异常核查和保留业务事实的回滚说明。

View File

@@ -242,7 +242,8 @@ default:
- **UR#96 店铺业务员归属**:店铺支持平台业务员设置/清空和创建时一次性继承,列表/详情批量投影业务员名称、手机号摘要及可用状态,并提供通知接收人解析接缝;详见 [功能总结](docs/ur96-shop-business-owner/功能总结.md)。 - **UR#96 店铺业务员归属**:店铺支持平台业务员设置/清空和创建时一次性继承,列表/详情批量投影业务员名称、手机号摘要及可用状态,并提供通知接收人解析接缝;详见 [功能总结](docs/ur96-shop-business-owner/功能总结.md)。
- **渠道无关审批核心**:业务侧统一使用 Approval Port、通用审批实例和五类标准决策不依赖企微等具体渠道字段详见 [功能总结](docs/tech-approval-core/功能总结.md)。 - **渠道无关审批核心**:业务侧统一使用 Approval Port、通用审批实例和五类标准决策不依赖企微等具体渠道字段详见 [功能总结](docs/tech-approval-core/功能总结.md)。
- **UR#38 代理主钱包信用额度**:已建立主钱包信用字段、迁移前置检查、数据库资金约束与 Wallet Domain 统一金额口径,完成订单扣款、资金预占、充值/人工调整入账、退款回充、信用资金投影和测试环境切换收口;旧主钱包 Store 写接缝、非事务店铺创建与直写运维脚本已收缩,自动化和真实依赖验收按七月总台账延期。详见 [功能总结](docs/ur38-agent-main-wallet-credit/功能总结.md)。 - **UR#38 代理主钱包信用额度**:已建立主钱包信用字段、迁移前置检查、数据库资金约束与 Wallet Domain 统一金额口径,完成订单扣款、资金预占、充值/人工调整入账、退款回充、信用资金投影和测试环境切换收口;旧主钱包 Store 写接缝、非事务店铺创建与直写运维脚本已收缩,自动化和真实依赖验收按七月总台账延期。详见 [功能总结](docs/ur38-agent-main-wallet-credit/功能总结.md)。
- **UR#94 卡状态公共写入与运营商回调**:已完成 19/20 位 ICCID 唯一性门禁及实名 `ApplyCardObservation` 事务闭环,包含数据库持久化的三次/10 分钟逆转确认、首次实名 Outbox 和 Worker 消费;流量/网络观测、轮询切换、事件序列及回调入口继续按七月总台账实施。详见 [功能总结](docs/ur94-card-state-events-callbacks/功能总结.md)。 - **UR#94 卡状态公共写入与运营商回调**:已完成 19/20 位 ICCID 唯一性门禁、实名/流量/网络公共观测、轮询与业务入口事件序列,以及电信实名、移动实名、联通实名成功和联通解除实名四条防腐层;四个运行时开关复用受控 `system_config` 且默认关闭,真实运营商与 Gateway 验收按七月总台账延期。详见 [功能总结](docs/ur94-card-state-events-callbacks/功能总结.md)。
- **UR#43 代理系列套餐批量授权**:首次系列授权原子创建 1100 条套餐授权;后续管理继续使用已交付的 `packages[].remove` 字段完成软删除,并兼容既有批量新增、调价和响应结构。详见 [功能总结](docs/ur43-series-package-bulk-authorization/功能总结.md)。
- **UR#86 资产前代与后代换货标识**:统一资产详情返回稳定 `exchange_trace`,展示单节点前代与后代快照,并按关联资产当前权限控制可跳转 ID详见 [功能总结](docs/ur86-asset-exchange-trace/功能总结.md)。 - **UR#86 资产前代与后代换货标识**:统一资产详情返回稳定 `exchange_trace`,展示单节点前代与后代快照,并按关联资产当前权限控制可跳转 ID详见 [功能总结](docs/ur86-asset-exchange-trace/功能总结.md)。
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md) - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md) - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)

View File

@@ -5,6 +5,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi" "github.com/break/junhong_cmp_fiber/pkg/openapi"
) )
@@ -23,6 +24,10 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构) // 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。 // 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers() handlers := openapi.BuildDocHandlers()
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil)
// 4. 注册所有路由到文档生成器 // 4. 注册所有路由到文档生成器
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)

View File

@@ -7,6 +7,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/routes" "github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi" "github.com/break/junhong_cmp_fiber/pkg/openapi"
) )
@@ -32,6 +33,10 @@ func generateAdminDocs(outputPath string) error {
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构) // 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。 // 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers() handlers := openapi.BuildDocHandlers()
handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil)
// 4. 注册所有路由到文档生成器 // 4. 注册所有路由到文档生成器
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)

View File

@@ -14,10 +14,12 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop" shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop"
@@ -330,6 +332,16 @@ func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *za
appLogger.Fatal("注册卡网络状态变化 Outbox 消费者失败", appLogger.Fatal("注册卡网络状态变化 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeCardNetworkChanged), zap.Error(err)) zap.String("event_type", constants.OutboxEventTypeCardNetworkChanged), zap.Error(err))
} }
seriesTrigger := cardObservationApp.NewSeriesTrigger(
cardObservationInfra.NewSeriesCoordinator(runtime.redisClient),
queue.NewCardObservationSeriesScheduler(runtime.outboxQueueClient),
cardObservationInfra.NewSeriesAttemptLogger(integrationlog.NewRepository(runtime.db)),
)
seriesConsumer := cardObservationInfra.NewSeriesRequestedConsumer(seriesTrigger)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardSeriesRequested, seriesConsumer); err != nil {
appLogger.Fatal("注册业务卡观测序列 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeCardSeriesRequested), zap.Error(err))
}
} }
// registerWalletOutboxConsumer 注册代理主钱包资金事实消费者。 // registerWalletOutboxConsumer 注册代理主钱包资金事实消费者。

View File

@@ -1099,7 +1099,7 @@ cash_available = balance - frozen_balance
后续管理并行读取现有套餐列表和 `GET /api/admin/shop-series-grants/{id}`,由前端按 `package_id` 合并:已授权项置灰,未授权项可多选,存量已授权但当前不再可售/可见的项目仍通过授权详情只读展示。 后续管理并行读取现有套餐列表和 `GET /api/admin/shop-series-grants/{id}`,由前端按 `package_id` 合并:已授权项置灰,未授权项可多选,存量已授权但当前不再可售/可见的项目仍通过授权详情只读展示。
保留 `PUT /api/admin/shop-series-grants/{id}/packages`,但请求必须用 `operation_type=authorize|update_cost|remove` 明确表达一种批量命令,单次最多 100 个套餐、事务内全成全败。新增授权同价重复幂等,不同价重复冲突;调价和移除不能伪装成新增授权。前后端同批切换,不保留旧的混合新增/改价/移除语义 保留 `PUT /api/admin/shop-series-grants/{id}/packages` 及已交付的 `packages:[{package_id,cost_price,remove}]` 契约:`remove=true` 表示软删除,未设置或为 `false` 时按当前授权状态新增或改价。单次最多 100 个套餐、事务内全成全败;不得删除 `remove` 字段、要求必填顶层 `operation_type` 或改变既有响应结构
页面按调用视角分别标注上级当前成本价、目标代理授权成本价和建议零售价;平台视角的上级成本才是公司成本。所有系列、套餐、价格和直属下级权限由后端重新校验,不能依赖前端置灰。 页面按调用视角分别标注上级当前成本价、目标代理授权成本价和建议零售价;平台视角的上级成本才是公司成本。所有系列、套餐、价格和直属下级权限由后端重新校验,不能依赖前端置灰。

View File

@@ -1019,7 +1019,7 @@
接口约定: 接口约定:
- 首次授权复用GET /api/admin/packages?series_id=...和POST /api/admin/shop-series-grantsPOST必须同时提交至少一个packages项。 - 首次授权复用GET /api/admin/packages?series_id=...和POST /api/admin/shop-series-grantsPOST必须同时提交至少一个packages项。
- 后续管理复用GET /api/admin/packages?series_id=...与GET /api/admin/shop-series-grants/{id}。 - 后续管理复用GET /api/admin/packages?series_id=...与GET /api/admin/shop-series-grants/{id}。
- PUT /api/admin/shop-series-grants/{id}/packagesbody={operation_type:authorize|update_cost|remove,packages:[{package_id,cost_price?}]} - PUT /api/admin/shop-series-grants/{id}/packagesbody={packages:[{package_id,cost_price?,remove?}]}`remove=true` 表示软删除,必须保持既有字段兼容
交互规则:三类价格按分转元;提交成功后重新加载候选列表;空候选和全部已授权状态有明确提示。 交互规则:三类价格按分转元;提交成功后重新加载候选列表;空候选和全部已授权状态有明确提示。
@@ -1037,7 +1037,7 @@
**描述** **描述**
```markdown ```markdown
目标:首次授权继续同时创建系列与套餐;后续复用现有批量接口,并把新增、调价、移除拆成明确命令不新增候选Query。 目标:首次授权继续同时创建系列与套餐;后续复用现有批量接口并保留 `packages[].remove` 已交付契约不新增候选Query。
预计工时后端0.51小时。 预计工时后端0.51小时。
@@ -1045,11 +1045,11 @@
首次授权POST中的packages改为必填且至少1项系列与套餐在同一事务全成全败不允许空系列授权。 首次授权POST中的packages改为必填且至少1项系列与套餐在同一事务全成全败不允许空系列授权。
后续规则PUT必须提交operation_type=authorize|update_cost|remove一次只执行一种命令、最多100项、事务内全成全败authorize同价重复幂等、不同价重复冲突不能静默改价;所有系列、套餐、直属下级与价格边界由后端校验。 后续规则PUT继续提交packages数组`remove=true` 表示软删除其他项按当前授权状态新增或改价最多100项、事务内全成全败。不得要求必填operation_type或改变既有响应结构;所有系列、套餐、直属下级与价格边界由后端校验。
读取:前端组合现有套餐列表和授权详情;已授权但当前不在普通列表中的存量项仍从详情只读展示。成本字段按操作者视角准确命名。 读取:前端组合现有套餐列表和授权详情;已授权但当前不在普通列表中的存量项仍从详情只读展示。成本字段按操作者视角准确命名。
完成标准:首次授权不会产生空系列;新增、调价和移除无语义混用;重复提交不生成重复关系或静默覆盖并发价格;前后端同批切换 完成标准:首次授权不会产生空系列;`packages[].remove` 兼容保留;重复提交不生成重复关系;旧前端无需切换新命令字段即可继续工作
``` ```
## UR#42 导出功能 ## UR#42 导出功能

View File

@@ -0,0 +1,792 @@
//go:build ignore
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
ranNumLib "math/rand"
"github.com/google/uuid"
)
// XML请求体结构
type ContractRoot struct {
XMLName xml.Name `xml:"ContractRoot"`
Type string `xml:"TYPE"`
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
StatusInfo string `xml:"STATUSINFO"`
AccNbr string `xml:"ACCNBR"`
ICCID string `xml:"ICCID"`
SendDt string `xml:"SENDDT"`
AcceptType string `xml:"ACCEPTTYPE"`
AcceptMsg string `xml:"ACCEPTMSG"`
StatusDt string `xml:"STATUSDT"`
ResultMsg string `xml:"RESULTMSG"`
}
// 第三方推送数据结构
type PushData struct {
Msg string `json:"msg"`
Code int `json:"code"`
Data struct {
RequestID string `json:"requestId"`
RealStatus bool `json:"realStatus"`
ICCID string `json:"iccid"`
} `json:"data"`
}
func parseCallback(jsonStr []byte) (string, string, error) {
// 定义匿名结构体用于解析外层JSON
var cb struct {
Data string `json:"data"`
}
err := json.Unmarshal(jsonStr, &cb)
if err != nil {
return "", "", fmt.Errorf("failed to unmarshal outer JSON: %v", err)
}
// 定义匿名结构体用于解析内层数据
var inner struct {
DateChanged string `json:"dateChanged"`
ICCID string `json:"iccid"`
}
err = json.Unmarshal([]byte(cb.Data), &inner)
if err != nil {
return "", "", fmt.Errorf("failed to unmarshal inner data JSON: %v", err)
}
return inner.ICCID, inner.DateChanged, nil
}
// 5GCMP实名后推送到第三方平台
func pushToThirdParty(iccid string) (string, error) {
// 构建推送数据
pushData := PushData{
Msg: "查询成功",
Code: 200,
}
pushData.Data.RequestID = uuid.New().String()
pushData.Data.RealStatus = true
pushData.Data.ICCID = iccid
// 序列化为JSON
jsonData, err := json.Marshal(pushData)
if err != nil {
return "", fmt.Errorf("序列化推送数据失败: %v", err)
}
// 发送HTTP POST请求
pushURL := "http://jh.whjhft.com/gswlpushapi/recv.do?type=3"
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Post(pushURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("HTTP请求失败: %v", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
}
return string(respBody), nil
}
// 获取日志文件路径
func getLogFilePath() string {
// 创建logs目录
logsDir := "logs"
if err := os.MkdirAll(logsDir, 0755); err != nil {
log.Printf("创建日志目录失败: %v", err)
}
// 按日期生成文件名
dateStr := time.Now().Format("2006-01-02")
fileName := fmt.Sprintf("5gcmp_callback_%s.log", dateStr)
return filepath.Join(logsDir, fileName)
}
func GetQcRandNum() (ranNum string) {
randomFloat := ranNumLib.Float64()
if randomFloat < 0.5 {
randomFloat = 1 - randomFloat
}
ranNum = fmt.Sprintf("%.16f", randomFloat)
return
}
// 写入日志
func writeLog(content string) {
logFile := getLogFilePath()
// 打开或创建日志文件
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
log.Printf("打开日志文件失败: %v", err)
return
}
defer file.Close()
// 写入日志内容
if _, err := file.WriteString(content); err != nil {
log.Printf("写入日志失败: %v", err)
}
}
// 向管理平台发送删除实名请求
func DelRealName(iccid string) (res string) {
account := Account{UserName: "18627991016", Password: "y123456"}
sessionid := account.Login()
realnameId := getRealnameIdByIccid(iccid, sessionid)
if realnameId == "" {
return
}
log.Printf("sessinid:%s,realnameId:%s", sessionid, realnameId)
url := "http://jh.whjhft.com/realnamerecord/deleteById.do?responseFunction=initUpdate&id=" + realnameId + "&rfm=" + GetQcRandNum()
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return ""
}
client := &http.Client{
Timeout: 10 * time.Second,
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
log.Printf("删除实名响应: %s", respBody)
res = string(respBody)
return
}
func getRealnameIdByIccid(iccid, sessionid string) (realnameId string) {
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=0." + GetQcRandNum()
client := &http.Client{
Timeout: 10 * time.Second,
}
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
var JSONData struct {
Code string `json:"code"`
Data struct {
PageNo int `json:"pageNo"`
PageCount int `json:"pageCount"`
PageSize int `json:"pageSize"`
PageStartOffset int `json:"pageStartOffset"`
Total int `json:"total"`
Rows []struct {
MybatisRecordCount int `json:"mybatisRecordCount"`
OrderNo string `json:"orderNo"`
JSONUpdateFlag string `json:"jsonUpdateFlag"`
ID string `json:"id"`
IccidMark string `json:"iccidMark"`
Phone string `json:"phone"`
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
Status int `json:"status"`
CreateName string `json:"createName"`
CreateDate string `json:"createDate"`
StatusStr string `json:"statusStr"`
} `json:"rows"`
Framework string `json:"framework"`
Data string `json:"data"`
Count int `json:"count"`
Limit int `json:"limit"`
Page int `json:"page"`
Layui bool `json:"layui"`
} `json:"data"`
CurrentSessionUserResourceIdsIndex []string `json:"current_session_user_resource_ids_index"`
AppResultKey string `json:"app_result_key"`
SystemResultKey string `json:"system_result_key"`
}
json.Unmarshal(respBody, &JSONData)
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
realnameId = JSONData.Data.Rows[0].ID
}
log.Printf("响应体: %s", respBody)
return
}
func getIccidByMsisdn(msisdn, sessionid string) (iccid string) {
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=" + GetQcRandNum()
client := &http.Client{
Timeout: 10 * time.Second,
}
data := fmt.Sprintf("status=1&phone=%s", msisdn)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
var JSONData struct {
Code string `json:"code"`
Data struct {
Rows []struct {
IccidMark string `json:"iccidMark"`
} `json:"rows"`
Count int `json:"count"`
} `json:"data"`
AppResultKey string `json:"app_result_key"`
SystemResultKey string `json:"system_result_key"`
}
json.Unmarshal(respBody, &JSONData)
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
iccid = JSONData.Data.Rows[0].IccidMark
}
log.Printf("响应体: %s", respBody)
return
}
func ModifyDate(iccid, dateChanged string) {
var jsonData = map[string]interface{}{
"iccid": iccid,
"dateChanged": dateChanged,
}
jsonDataBs, _ := json.Marshal(jsonData)
// 创建请求
req, err := http.NewRequest("POST", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", bytes.NewReader(jsonDataBs))
if err != nil {
writeLog(fmt.Sprintf("创建请求失败:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
// 设置Content-Type为x-www-form-urlencoded
req.Header.Set("Content-Type", "application/json")
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
defer resp.Body.Close()
// 读取响应内容
respBody, err := io.ReadAll(resp.Body)
if err != nil {
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
// 检查响应状态
if resp.StatusCode != http.StatusOK {
fmt.Printf("请求失败,状态码: %d, 响应: %s\n", resp.StatusCode, string(respBody))
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]成功,[%s]\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged, string(respBody)))
}
// 处理回调请求
func handleCallback(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
// 处理XML请求体和第三方推送
var pushResponse string
log.Printf("body:%s\r\n", string(body))
if len(body) > 0 {
// 尝试解析XML
var contractRoot ContractRoot
if err := xml.Unmarshal(body, &contractRoot); err == nil {
logBuilder.WriteString("--- XML解析结果 ---\n")
logBuilder.WriteString(fmt.Sprintf("TYPE: %s\n", contractRoot.Type))
logBuilder.WriteString(fmt.Sprintf("ICCID: %s\n", contractRoot.ICCID))
logBuilder.WriteString(fmt.Sprintf("STATUSINFO: %s\n", contractRoot.StatusInfo))
// 如果TYPE=1表示实名认证成功需要推送到第三方
if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息补录") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("触发条件: TYPE=%s (实名认证成功)\n", contractRoot.Type))
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", contractRoot.ICCID))
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
ModifyDate(contractRoot.ICCID, timestampStr)
// 执行推送
if resp, err := pushToThirdParty(contractRoot.ICCID); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
pushResponse = fmt.Sprintf("推送失败: %v", err)
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
pushResponse = resp
}
} else if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息清除") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
logBuilder.WriteString("--- 第三方推送删除实名 ---\n")
res := DelRealName(contractRoot.ICCID)
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
} else {
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("跳过推送: TYPE=%s,%s,%s (非实名认证成功)\n", contractRoot.Type, contractRoot.AcceptMsg, contractRoot.ResultMsg))
}
} else {
logBuilder.WriteString(fmt.Sprintf("XML解析失败: %v\n", err))
}
}
// 记录处理时间
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 返回成功响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
// 如果有推送响应,添加到响应中
if pushResponse != "" {
responseData["pushResponse"] = pushResponse
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
// 获取管理平台登录凭证
func (ac Account) Login() (sessionid string) {
var password string
password = ac.Password
var requestBody bytes.Buffer
multipartWriter := multipart.NewWriter(&requestBody)
multipartWriter.WriteField("username", ac.UserName)
multipartWriter.WriteField("password", password)
multipartWriter.Close()
req, _ := http.NewRequest("POST", "http://jh.whjhft.com/pages/login.do", &requestBody)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
//client1 := http.DefaultClient
client1 := &http.Client{
CheckRedirect: func(req1 *http.Request, via []*http.Request) error {
strs := strings.Split(req1.URL.Path, ";")
log.Printf("%s\r\n", req1.URL.Path)
if len(strs) == 2 {
strs1 := strings.Split(strs[1], "=")
if len(strs1) == 2 {
sessionid = strs1[1]
}
}
// fmt.Printf("Redirect from '%s' to '%s'\n", via[0].URL, req1.URL.Path)
return nil
},
}
resp, err := client1.Do(req)
if err != nil {
fmt.Println("Failed to send request:", err)
return
}
defer resp.Body.Close()
// 处理响应
_, err = ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println("Failed to read response:", err)
return
}
//fmt.Println("Response:", string(respBody))
return
}
// 移动实名回调
func ChinaMobileCallback(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString("【移动实名回调】\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
var JSONData struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
RegStatus string `json:"regStatus"`
BusiSeq string `json:"busiSeq"`
Msisdn string `json:"msisdn"`
Iccid string `json:"iccid"`
} `json:"result"`
}
json.Unmarshal(body, &JSONData)
if JSONData.Status == "0" && JSONData.Message == "正确" && len(JSONData.Result) > 0 && JSONData.Result[0].RegStatus == "00000" {
iccid := JSONData.Result[0].Iccid
if iccid == "" {
msisdn := JSONData.Result[0].Msisdn //接入号
account := Account{UserName: "18627991016", Password: "y123456"}
sessionid := account.Login()
iccid = getIccidByMsisdn(msisdn, sessionid)
}
if iccid == "" {
return
}
//推送修改过期时间
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
ModifyDate(iccid, timestampStr)
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", iccid))
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
// 执行推送实名状态
if resp, err := pushToThirdParty(iccid); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
}
logBuilder.WriteString("--- 第三方推送 ---\n")
} else {
logBuilder.WriteString("(实名认证失败)\n")
}
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
defer r.Body.Close()
// 记录处理时间
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 返回200 OK响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
// 联通实名回调
func UniRealnameCallback(w http.ResponseWriter, r *http.Request) {
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString("【联通实名回调】\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
defer r.Body.Close()
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
iccid, dateChanged, err := parseCallback(body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("解析回调数据失败: %v\n", err))
} else {
logBuilder.WriteString(fmt.Sprintf("解析回调数据成功: ICCID=%s, DateChanged=%s\n", iccid, dateChanged))
if len(iccid) == 20 {
iccid = iccid[:19]
}
if iccid != "" {
ModifyDate(iccid, dateChanged)
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", iccid))
if resp, err := pushToThirdParty(iccid); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
}
}
}
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
writeLog(logBuilder.String())
fmt.Print(logBuilder.String())
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
respJSON, _ := json.Marshal(map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
})
w.Write(respJSON)
}
// 联通解除实名回调
func UniRealnameRemove(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
defer r.Body.Close()
// 解析回调数据
iccid, dateChanged, err := parseCallback(body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("解析回调数据失败: %v\n", err))
} else {
if len(iccid) == 20 {
//取前面19位
iccid = iccid[:19]
//删除实名
res := DelRealName(iccid)
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
}
logBuilder.WriteString(fmt.Sprintf("解析回调数据成功: ICCID=%s, DateChanged=%s\n", iccid, dateChanged))
}
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
func main() {
// 注册路由
// res := DelRealName("8986112422108176397")
// log.Printf("删除实名响应: %s", res)
http.HandleFunc("/5gcmp/callback/realname", handleCallback)
http.HandleFunc("/unicom/callback/realname", UniRealnameCallback)
http.HandleFunc("/unicom/callback/realname/remove", UniRealnameRemove)
http.HandleFunc("/mobile/callback/realname", ChinaMobileCallback)
// 启动服务器
port := ":16159"
fmt.Printf("5GCMP回调服务器启动成功\n")
fmt.Printf("监听端口: %s\n", port)
fmt.Printf("电信回调地址: %s/5gcmp/callback/realname\n", port)
fmt.Printf("联通实名回调地址: %s/unicom/callback/realname\n", port)
fmt.Printf("联通解除实名回调地址: %s/unicom/callback/realname/remove\n", port)
fmt.Printf("移动回调地址: %s/mobile/callback/realname\n", port)
fmt.Printf("日志目录: logs/\n")
fmt.Printf("按 Ctrl+C 停止服务器\n\n")
if err := http.ListenAndServe(port, nil); err != nil {
log.Fatalf("启动服务器失败: %v", err)
}
}
type Account struct {
UserName string
Password string
}

View File

@@ -719,11 +719,3 @@ type Account struct {
} }
``` ```
优化轮询以及添加事件/触发式 数据同步
目前我们系统过于依赖轮询系统,且轮询系统的黑盒属性过于严重
所以现在想加入事件触发以及实名回调,目前已知的可配置实名回调只有移动,联通,电信三个运营商,广电是没有实名回调的,上方是之前已经实现过的实名回调
需求差不多是这么个需求,主要就是想让数据同步这一块的及时率达到一种很快的地步,看是怎么埋点,而且开放接口的埋点还需要特殊处理,不然的话代理拿着我们的开放接口乱调用的话就等于外置了一个轮询系统了

View File

@@ -3939,18 +3939,25 @@ components:
nullable: true nullable: true
type: integer type: integer
packages: packages:
description: 初始授权套餐列表 description: 初始授权套餐列表1100项套餐ID不可重复
items: items:
$ref: '#/components/schemas/DtoGrantPackageItem' $ref: '#/components/schemas/DtoGrantPackageItem'
maxItems: 100
minItems: 1
nullable: true
type: array type: array
series_id: series_id:
description: 套餐系列ID description: 套餐系列ID
minimum: 0 minimum: 1
type: integer type: integer
shop_id: shop_id:
description: 被授权代理店铺ID description: 被授权代理店铺ID
minimum: 0 minimum: 1
type: integer type: integer
required:
- shop_id
- series_id
- packages
type: object type: object
DtoCreateWechatConfigRequest: DtoCreateWechatConfigRequest:
properties: properties:
@@ -5724,16 +5731,20 @@ components:
DtoGrantPackageItem: DtoGrantPackageItem:
properties: properties:
cost_price: cost_price:
description: 成本价(分) description: 成本价(分),新增或调价时必填
minimum: 0
nullable: true
type: integer type: integer
package_id: package_id:
description: 套餐ID description: 套餐ID
minimum: 0 minimum: 1
type: integer type: integer
remove: remove:
description: 是否删除该套餐授权true=删除) description: 是否删除该套餐授权true=删除)
nullable: true nullable: true
type: boolean type: boolean
required:
- package_id
type: object type: object
DtoImportDeviceRequest: DtoImportDeviceRequest:
properties: properties:
@@ -6239,11 +6250,15 @@ components:
nullable: true nullable: true
type: string type: string
packages: packages:
description: 套餐操作列表 description: 套餐操作列表1100项套餐ID不可重复
items: items:
$ref: '#/components/schemas/DtoGrantPackageItem' $ref: '#/components/schemas/DtoGrantPackageItem'
maxItems: 100
minItems: 1
nullable: true nullable: true
type: array type: array
required:
- packages
type: object type: object
DtoManualTriggerLogListResp: DtoManualTriggerLogListResp:
properties: properties:
@@ -29216,6 +29231,82 @@ paths:
summary: 支付宝回调 summary: 支付宝回调
tags: tags:
- 支付回调 - 支付回调
/api/callback/carriers/cmcc/realname:
post:
description: 接收中国移动 JSON 实名成功结果,固定返回运营商成功应答。
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
summary: 移动实名结果回调
tags:
- 运营商回调
/api/callback/carriers/ctcc/realname:
post:
description: 接收电信 XML 实名补录结果,固定返回运营商成功应答。
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
summary: 电信实名结果回调
tags:
- 运营商回调
/api/callback/carriers/cucc/realname:
post:
description: 接收中国联通嵌套 JSON 实名成功结果,固定返回运营商成功应答。
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
summary: 联通实名结果回调
tags:
- 运营商回调
/api/callback/carriers/cucc/realname/remove:
post:
description: 识别中国联通解除实名 JSON仅留痕且不修改本地实名事实。
responses:
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
summary: 联通解除实名回调
tags:
- 运营商回调
/api/callback/fuiou-pay: /api/callback/fuiou-pay:
post: post:
responses: responses:

View File

@@ -44,6 +44,6 @@
## 现在不需要调整的内容 ## 现在不需要调整的内容
`GET /api/admin/system-configs``PUT /api/admin/system-configs/{key}` 当前只是公共受控配置接缝不代表前端现在要新增系统配置页面。UR#48 注册正式支付配置 Key、全局审计正式 Adapter 接入并通过发布门禁后,再按 UR#48 的页面范围联调 `GET /api/admin/system-configs``PUT /api/admin/system-configs/{key}` 是超级管理员受控配置接口。业务模块注册正式 Key 后即可按 `control` 渲染并更新;全局 Audit Event 已取消,不再作为接口可用前置。是否建设独立系统配置页面仍由实际业务需求决定
Outbox 监控和人工恢复也不在本次前端范围内;公共基础只提供后端运维接缝,未来如需运营页面必须另行评审。 Outbox 监控和人工恢复也不在本次前端范围内;公共基础只提供后端运维接缝,未来如需运营页面必须另行评审。

View File

@@ -4,7 +4,7 @@
本能力提供公共 Outbox 与 Relay、创建命令幂等原语、统一异步任务五态、受控系统配置、数据库发布门禁以及 Access Log 递归脱敏和敏感路由安全摘要。公共基础只定义稳定接缝和基础设施语义,不拥有 Audit Event、Integration Log、站内通知、业务模型、业务状态机、业务唯一键、任务失败明细或业务事件消费者。 本能力提供公共 Outbox 与 Relay、创建命令幂等原语、统一异步任务五态、受控系统配置、数据库发布门禁以及 Access Log 递归脱敏和敏感路由安全摘要。公共基础只定义稳定接缝和基础设施语义,不拥有 Audit Event、Integration Log、站内通知、业务模型、业务状态机、业务唯一键、任务失败明细或业务事件消费者。
各业务 PRD 必须自行实现消费者副作用幂等,并决定使用事件 ID、业务唯一键、状态条件更新或版本号裁决重复消费。审计和通知由对应公共能力实现后注入现有 Port系统配置更新在审计 Port 不可用时失败关闭,不另建临时审计表 各业务 PRD 必须自行实现消费者副作用幂等,并决定使用事件 ID、业务唯一键、状态条件更新或版本号裁决重复消费。通知由对应公共能力实现后注入现有 Port全局 Audit Event 已按七月总 Change 决策取消,系统配置在审计 Port 未装配时仍可由超级管理员更新已注册 Key不另建临时审计表或借用 Integration Log
## 关键流程 ## 关键流程
@@ -62,7 +62,7 @@ Access Log 对 query、请求 JSON 和响应 JSON 复用大小写不敏感的递
## 已登记的后续闭环项 ## 已登记的后续闭环项
- 系统配置更新的正式审计 Adapter 由[全局审计 20 号票](../../.scratch/tech-global-audit/issues/20-system-config-audit-adapter.md)接入Outbox 人工恢复由[全局审计 21 号票](../../.scratch/tech-global-audit/issues/21-outbox-recovery-audit-adapter.md)接入,并统一由[全局审计 19 号发布门禁](../../.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md)验证;不再作为无负责人的待决策项 - 全局 Audit Event 及其系统配置 Adapter 已移出七月总 Change不再阻塞受控配置更新。若未来重新立项可通过现有可选 Audit Writer 在同一事务写入;当前不得用 Access Log 或 Integration Log 冒充配置审计
- 各下游 PRD 必须在自己的最终发布门禁中确认事件类型、载荷版本、消费者幂等键、业务失败明细和通知策略,并引用具体公共票;[INT-08](../7月迭代/7月迭代禅道研发需求逐条录入稿.md#int-08-全链路与停机发布验收) 汇总检查,公共基础仍不猜测业务事件定义。 - 各下游 PRD 必须在自己的最终发布门禁中确认事件类型、载荷版本、消费者幂等键、业务失败明细和通知策略,并引用具体公共票;[INT-08](../7月迭代/7月迭代禅道研发需求逐条录入稿.md#int-08-全链路与停机发布验收) 汇总检查,公共基础仍不猜测业务事件定义。
- Outbox 待投递年龄、积压量、过期租约比例和成功率的生产告警值由发布负责人依据容量基线填写到 INT-08 发布清单。没有数值、负责人和验证记录时不得放量。 - Outbox 待投递年龄、积压量、过期租约比例和成功率的生产告警值由发布负责人依据容量基线填写到 INT-08 发布清单。没有数值、负责人和验证记录时不得放量。

View File

@@ -0,0 +1,72 @@
# UR#43 代理系列套餐批量授权功能总结
## 完成范围
本次复用既有系列授权、套餐授权模型和四个现有接口,不新增候选套餐 API、不新增迁移、不改整店批量调价逻辑
- `GET /api/admin/packages`:按 `series_id` 获取当前操作者可见套餐。
- `POST /api/admin/shop-series-grants`:首次创建系列授权,并原子创建 1100 条套餐授权。
- `GET /api/admin/shop-series-grants/{id}`:读取目标代理已授权套餐及其当前成本价。
- `PUT /api/admin/shop-series-grants/{id}/packages`:通过既有套餐项字段批量新增、修改成本价或软删除授权。
当前仓库不包含前端源码。本次交付框架无关的页面组合与交互契约,前端实现和真实人工验收仍需在对应仓库完成。
## 首次批量授权
创建系列授权时,`packages` 必填且只能包含 1100 项;每项必须提供唯一 `package_id` 和非负 `cost_price`。后端在同一 PostgreSQL 事务内锁定并校验目标店铺、上级有效系列授权、套餐和上级有效套餐授权,全部通过后才写系列授权、套餐授权和价格历史。
- 代理只能授权当前直属下级店铺。
- 套餐必须未删除、属于目标系列且不是赠送套餐;禁用或下架不等于不能预先配置授权。
- 代理必须拥有对应的有效上级套餐授权;目标成本价不得低于当前上级成本价。
- 任一项失败时,系列授权、套餐授权和价格历史全部回滚,不会留下空系列授权。
- `expiry_base_override` 继续遵守 UR#55 契约,必须显式传 `null` 或合法枚举。
## 后续兼容契约
`PUT /api/admin/shop-series-grants/{id}/packages` 继续接受已交付的 `packages[].remove` 字段:
```json
{
"expiry_base_override": null,
"packages": [
{"package_id": 1001, "cost_price": 6500},
{"package_id": 1002, "cost_price": 7000},
{"package_id": 1003, "remove": true}
]
}
```
| 套餐项 | 成本价字段 | 业务语义 |
| --- | --- | --- |
| `remove=true` | 可省略 | 软删除当前授权;已不存在按幂等成功 |
| `remove` 缺失或为 `false`,当前未授权 | 必填,允许 0 | 新增套餐授权 |
| `remove` 缺失或为 `false`,当前已授权 | 必填,允许 0 | 按现有规则更新成本价 |
请求要求 1100 个套餐项,并在一个事务内完成权限校验、价格边界和写入。`remove` 是 2026-03-04 已交付字段,不得删除、改名或替换为必填顶层 `operation_type`
成功响应继续返回刷新后的 `ShopSeriesGrantResponse`,保持旧前端无需适配新的响应结构。
## 前端读取与价格语义
首次授权只读取 `GET /api/admin/packages?series_id={series_id}`;后续管理并行读取套餐列表和授权详情,再按 `package_id` 合并:
1. 套餐列表中的 `cost_price` 表示当前上级成本价。平台视角是公司成本价,代理视角是该代理自己的授权成本价。
2. 授权详情 `packages[].cost_price` 表示目标代理当前授权成本价。
3. 套餐列表 `suggested_retail_price` 表示建议零售价。
4. 详情存在的套餐标记 `is_authorized=true`;只在列表出现的套餐标记 `is_authorized=false`,未授权成本显示“-”,不能用 0 代替。
5. 已授权但因上级权限变化不再出现在普通列表的存量项,仍从详情只读展示,不能静默消失。
页面可以提供授权、调价、移除三种交互模式。新增模式只允许选择未授权项;调价和移除模式只允许选择已授权项;提交移除时设置对应套餐项的 `remove=true`。提交期间禁止重复提交。
## 权限、可靠性与范围裁决
- 超级管理员和平台账号沿用现有管理范围;代理必须仍是目标店铺直属上级,不能仅凭历史 `allocator_shop_id` 操作已转移店铺。
- 资源不存在与越权统一返回安全错误,不暴露其他代理授权或成本价。
- 价格历史与授权写入同事务Audit Event 已由七月总 Change 明确移出本次范围,不接入 Audit Writer也不以 Integration Log 冒充业务审计。
- 本任务不修改订单、客户套餐使用、零售价、佣金、强充或整店批量调价,不自动级联回收下级授权。
## 发布与回滚
后端发布必须兼容旧前端,不要求切换新命令字段。发布前应确认 OpenAPI 仍包含 `packages[].remove`,并核对同一店铺/套餐有效授权重复、孤立下级授权和异常价格;本功能没有新增迁移。
回滚应用时保留新版本已经产生的系列授权、套餐授权和价格历史,不清表、不物理删除业务事实。自动化测试与真实 PostgreSQL 并发验收按七月总 Change 的 6.1/6.3 延期本批只执行格式化、OpenAPI 生成、构建和静态部署检查。

View File

@@ -2,7 +2,87 @@
## 当前完成范围 ## 当前完成范围
任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.342.35、2.36、2.37 已分别交付实名、流量、网络观测公共写入闭环三个轮询入口切换观测序列和运营商回调仍按 2.382.45 继续实施,不能因当前能力已落地而提前开放回调入口 任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.342.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 或明文 ICCIDIntegration 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.392.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。
## 流量观测公共写入闭环 ## 流量观测公共写入闭环
@@ -108,7 +188,9 @@ ORDER BY id;
## 回滚 ## 回滚
down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分索引,不删除双列、不修改卡数据,也不触碰其他表索引。若回调已经开放并依赖精确唯一语义,应先关闭回调入口并确认没有并发写入,再评估回滚。 优先通过四个 `system_config` 开关分别停止业务处理,路由继续固定成功应答。down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分索引,不删除双列、不修改卡数据,也不触碰其他表索引。若回调已经开放并依赖精确唯一语义,应先关闭相关开关并确认没有并发写入,再评估回滚。
迁移顺序固定为 `000178`ICCID 唯一性)→ `000179`(实名逆转窗口)→ `000180`(副作用进度)→ `000181`Integration Log conflict 终态。一旦已经产生卡状态、Outbox、Integration Log 或副作用进度事实,不清表、不删除业务事实,也不恢复旧 Writer暂停对应开关和生产者后采用前向修复。
## 验证状态 ## 验证状态
@@ -116,4 +198,12 @@ down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分
- 已静态核对 `000179` up/down 成对、字段注释和 CHECK 约束一致,领域规则与持久化字段没有 Redis 事务依赖。 - 已静态核对 `000179` up/down 成对、字段注释和 CHECK 约束一致,领域规则与持久化字段没有 Redis 事务依赖。
- 已静态核对 `000180` up/down 成对、副作用状态 CHECK 与常量一致,流量用例不再直接覆盖日流量落盘表。 - 已静态核对 `000180` up/down 成对、副作用状态 CHECK 与常量一致,流量用例不再直接覆盖日流量落盘表。
- 已执行 `gofmt``go build ./...`;构建退出码为 0。 - 已执行 `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,不能标记为生产验收通过。 - 按七月测试环境豁免,本轮未连接真实 PostgreSQL、未运行迁移测试无冲突升级、19/20 位冲突、软删除重复和 down/up 重放验证延期到 6.1、6.3,不能标记为生产验收通过。

View File

@@ -0,0 +1,41 @@
package cardobservation
import (
"context"
"time"
"gorm.io/gorm"
)
type suppressSeriesTriggerKey struct{}
// SuppressSeriesTriggerContext 标记观测结果驱动的业务评估,避免形成反向触发环。
func SuppressSeriesTriggerContext(ctx context.Context) context.Context {
return context.WithValue(ctx, suppressSeriesTriggerKey{}, true)
}
// IsSeriesTriggerSuppressed 判断当前业务调用是否来自观测结果消费。
func IsSeriesTriggerSuppressed(ctx context.Context) bool {
value, _ := ctx.Value(suppressSeriesTriggerKey{}).(bool)
return value
}
// SeriesRequestedEvent 是业务成功边界可靠请求观测序列的事实。
type SeriesRequestedEvent struct {
EventID string `json:"event_id"`
Scene string `json:"scene"`
ResourceType string `json:"resource_type"`
ResourceID uint `json:"resource_id"`
ResourceIDs []uint `json:"resource_ids,omitempty"`
SyncTypes []string `json:"sync_types"`
ExpectedValue string `json:"expected_value,omitempty"`
Source string `json:"source"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// SeriesEventWriter 在原业务事务中追加观测序列请求事件。
type SeriesEventWriter interface {
AppendSeriesRequested(ctx context.Context, tx *gorm.DB, event SeriesRequestedEvent) error
}

View File

@@ -23,6 +23,29 @@ type SeriesRequest struct {
CorrelationID string `json:"correlation_id,omitempty"` CorrelationID string `json:"correlation_id,omitempty"`
} }
// DeviceCardsSeriesRequest 描述需要在后台展开设备有效绑定卡的观测请求。
type DeviceCardsSeriesRequest struct {
DeviceID uint
Request SeriesRequest
}
// DeviceControlSeriesRequest 描述设备控制成功后需要在后台解析的差异化观测资源。
type DeviceControlSeriesRequest struct {
DeviceID uint
TargetICCID string
SourceCardID uint
TargetCardID uint
BoundCardIDs []uint
IncludeTargetTraffic bool
Request SeriesRequest
}
// RealnameCapabilitySeriesRequest 描述需要在后台判断运营商实名能力的观测请求。
type RealnameCapabilitySeriesRequest struct {
CarrierID uint
Request SeriesRequest
}
// SeriesTaskPayload 是固定三次 Asynq 任务的结构化载荷。 // SeriesTaskPayload 是固定三次 Asynq 任务的结构化载荷。
type SeriesTaskPayload struct { type SeriesTaskPayload struct {
SeriesID string `json:"series_id"` SeriesID string `json:"series_id"`
@@ -46,7 +69,10 @@ type RunResult struct {
// SeriesCoordinator 管理活跃序列、尝试幂等和实际请求互斥。 // SeriesCoordinator 管理活跃序列、尝试幂等和实际请求互斥。
type SeriesCoordinator interface { type SeriesCoordinator interface {
Reserve(ctx context.Context, request SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (seriesID string, baseTime time.Time, merged bool, err error) Reserve(ctx context.Context, request SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (seriesID string, baseTime time.Time, originalRequest SeriesRequest, merged bool, err error)
IsScheduled(ctx context.Context, seriesID string) (bool, error)
ClaimSchedule(ctx context.Context, seriesID string, attempt int) (bool, error)
ReleaseSchedule(ctx context.Context, seriesID string, attempt int)
IsCompleted(ctx context.Context, seriesID string) (bool, error) IsCompleted(ctx context.Context, seriesID string) (bool, error)
ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error) ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error)
AcquireRequest(ctx context.Context, payload SeriesTaskPayload, provider string) (release func(), wait time.Duration, acquired bool, err error) AcquireRequest(ctx context.Context, payload SeriesTaskPayload, provider string) (release func(), wait time.Duration, acquired bool, err error)
@@ -60,6 +86,14 @@ type SeriesScheduler interface {
Enqueue(ctx context.Context, payload SeriesTaskPayload) error Enqueue(ctx context.Context, payload SeriesTaskPayload) error
} }
// BestEffortSeriesDispatcher 为读取入口提供不返回业务错误的轻量触发端口。
type BestEffortSeriesDispatcher interface {
Dispatch(ctx context.Context, request SeriesRequest)
DispatchDeviceCards(ctx context.Context, request DeviceCardsSeriesRequest)
DispatchDeviceControl(ctx context.Context, request DeviceControlSeriesRequest)
DispatchRealnameWithCapability(ctx context.Context, request RealnameCapabilitySeriesRequest)
}
// SeriesAttemptLogger 记录未访问 Gateway 的合并、互斥、限频和提前完成结果。 // SeriesAttemptLogger 记录未访问 Gateway 的合并、互斥、限频和提前完成结果。
type SeriesAttemptLogger interface { type SeriesAttemptLogger interface {
Record(ctx context.Context, payload SeriesTaskPayload, result, reason string) error Record(ctx context.Context, payload SeriesTaskPayload, result, reason string) error
@@ -88,27 +122,58 @@ func NewSeriesTrigger(coordinator SeriesCoordinator, scheduler SeriesScheduler,
// Trigger 创建新序列;同场景未结束序列只留合并记录,不延长原序列。 // Trigger 创建新序列;同场景未结束序列只留合并记录,不延长原序列。
func (s *SeriesTrigger) Trigger(ctx context.Context, request SeriesRequest) (string, bool, error) { func (s *SeriesTrigger) Trigger(ctx context.Context, request SeriesRequest) (string, bool, error) {
return s.trigger(ctx, request, uuid.NewString(), false)
}
// TriggerEvent 使用稳定业务事件键触发序列,至少一次重投不会再次创建任务。
func (s *SeriesTrigger) TriggerEvent(ctx context.Context, eventKey string, request SeriesRequest) (string, bool, error) {
seriesID := uuid.NewSHA1(uuid.NameSpaceOID, []byte(eventKey)).String()
return s.trigger(ctx, request, seriesID, true)
}
func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, candidateSeriesID string, stable bool) (string, bool, error) {
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil { if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置") return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
} }
request = normalizeSeriesTrace(request)
if err := validateSeriesRequest(request); err != nil { if err := validateSeriesRequest(request); err != nil {
return "", false, err return "", false, err
} }
if stable {
scheduled, err := s.coordinator.IsScheduled(ctx, candidateSeriesID)
if err != nil {
return "", false, err
}
if scheduled {
if logErr := s.logger.RecordMerged(ctx, request, candidateSeriesID); logErr != nil {
return candidateSeriesID, true, logErr
}
return candidateSeriesID, true, nil
}
}
candidateBaseTime := s.now().UTC() candidateBaseTime := s.now().UTC()
seriesID, baseTime, merged, err := s.coordinator.Reserve(ctx, request, uuid.NewString(), candidateBaseTime) seriesID, baseTime, originalRequest, merged, err := s.coordinator.Reserve(ctx, request, candidateSeriesID, candidateBaseTime)
if err != nil { if err != nil {
return "", false, err return "", false, err
} }
// 重复触发仍以稳定任务 ID 补齐首次入队的局部失败;已存在任务由 Asynq 去重,不会延长原序列。 // 重复触发仍以稳定任务 ID 补齐首次入队的局部失败;已存在任务由 Asynq 去重,不会延长原序列。
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ { for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
claimed, claimErr := s.coordinator.ClaimSchedule(ctx, seriesID, attempt)
if claimErr != nil {
return seriesID, merged, claimErr
}
if !claimed {
continue
}
scheduledAt := baseTime.Add(constants.CardObservationAttemptDelay(attempt)) scheduledAt := baseTime.Add(constants.CardObservationAttemptDelay(attempt))
payload := SeriesTaskPayload{ payload := SeriesTaskPayload{
SeriesID: seriesID, Attempt: attempt, ScheduledAt: scheduledAt, SeriesID: seriesID, Attempt: attempt, ScheduledAt: scheduledAt,
Scene: request.Scene, ResourceType: request.ResourceType, ResourceID: request.ResourceID, Scene: originalRequest.Scene, ResourceType: originalRequest.ResourceType, ResourceID: originalRequest.ResourceID,
SyncType: request.SyncType, ExpectedValue: request.ExpectedValue, Source: request.Source, SyncType: originalRequest.SyncType, ExpectedValue: originalRequest.ExpectedValue, Source: originalRequest.Source,
RequestID: request.RequestID, CorrelationID: request.CorrelationID, RequestID: originalRequest.RequestID, CorrelationID: originalRequest.CorrelationID,
} }
if err := s.scheduler.Enqueue(ctx, payload); err != nil { if err := s.scheduler.Enqueue(ctx, payload); err != nil {
s.coordinator.ReleaseSchedule(ctx, seriesID, attempt)
return seriesID, false, err return seriesID, false, err
} }
} }
@@ -148,25 +213,25 @@ func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPa
completed, err := s.coordinator.IsCompleted(ctx, payload.SeriesID) completed, err := s.coordinator.IsCompleted(ctx, payload.SeriesID)
if err != nil { if err != nil {
return err return s.recordPreGatewayFailure(ctx, payload, "读取序列完成状态失败", err)
} }
if completed { if completed {
return s.completeRemainingAttempts(ctx, payload, "序列已提前完成") return s.completeRemainingAttempts(ctx, payload, "序列已提前完成")
} }
met, err := s.runner.ExpectationMet(ctx, payload) met, err := s.runner.ExpectationMet(ctx, payload)
if err != nil { if err != nil {
return err return s.recordPreGatewayFailure(ctx, payload, "读取本地预期快照失败", err)
} }
if met { if met {
return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期") return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期")
} }
provider, err := s.runner.Provider(ctx, payload) provider, err := s.runner.Provider(ctx, payload)
if err != nil { if err != nil {
return err return s.recordPreGatewayFailure(ctx, payload, "解析运营商接入失败", err)
} }
release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider) release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider)
if err != nil { if err != nil {
return err return s.recordPreGatewayFailure(ctx, payload, "获取实际请求互斥失败", err)
} }
if !acquired { if !acquired {
return s.logger.Record(ctx, payload, constants.IntegrationResultIgnored, "实际 Gateway 请求正在执行") return s.logger.Record(ctx, payload, constants.IntegrationResultIgnored, "实际 Gateway 请求正在执行")
@@ -194,6 +259,13 @@ func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPa
return nil return nil
} }
func (s *SeriesAttemptService) recordPreGatewayFailure(ctx context.Context, payload SeriesTaskPayload, reason string, original error) error {
if logErr := s.logger.Record(ctx, payload, constants.IntegrationResultFailed, reason); logErr != nil {
return errors.Wrap(errors.CodeInternalError, original, reason+",且 Integration Log 写入失败")
}
return original
}
func (s *SeriesAttemptService) completeRemainingAttempts(ctx context.Context, payload SeriesTaskPayload, reason string) error { func (s *SeriesAttemptService) completeRemainingAttempts(ctx context.Context, payload SeriesTaskPayload, reason string) error {
baseTime := payload.ScheduledAt.Add(-constants.CardObservationAttemptDelay(payload.Attempt)) baseTime := payload.ScheduledAt.Add(-constants.CardObservationAttemptDelay(payload.Attempt))
for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ { for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
@@ -217,7 +289,8 @@ func (s *SeriesAttemptService) CompleteResourceSeries(ctx context.Context, resou
func validateSeriesRequest(request SeriesRequest) error { func validateSeriesRequest(request SeriesRequest) error {
if strings.TrimSpace(request.Scene) == "" || strings.TrimSpace(request.ResourceType) == "" || if strings.TrimSpace(request.Scene) == "" || strings.TrimSpace(request.ResourceType) == "" ||
strings.TrimSpace(request.ResourceID) == "" || !validSyncType(request.SyncType) { strings.TrimSpace(request.ResourceID) == "" || !validSyncType(request.SyncType) || !validObservationSource(request.Source) ||
strings.TrimSpace(request.RequestID) == "" || strings.TrimSpace(request.CorrelationID) == "" {
return errors.New(errors.CodeInvalidParam, "卡观测序列参数不完整") return errors.New(errors.CodeInvalidParam, "卡观测序列参数不完整")
} }
return nil return nil
@@ -227,7 +300,36 @@ func validateSeriesPayload(payload SeriesTaskPayload) error {
if payload.SeriesID == "" || payload.Attempt < 1 || payload.Attempt > constants.CardObservationSeriesAttemptCount { if payload.SeriesID == "" || payload.Attempt < 1 || payload.Attempt > constants.CardObservationSeriesAttemptCount {
return errors.New(errors.CodeInvalidParam, "卡观测序列任务载荷无效") return errors.New(errors.CodeInvalidParam, "卡观测序列任务载荷无效")
} }
return validateSeriesRequest(SeriesRequest{Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID, SyncType: payload.SyncType}) return validateSeriesRequest(SeriesRequest{
Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID,
SyncType: payload.SyncType, Source: payload.Source, RequestID: payload.RequestID, CorrelationID: payload.CorrelationID,
})
}
func normalizeSeriesTrace(request SeriesRequest) SeriesRequest {
request.RequestID = strings.TrimSpace(request.RequestID)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
if request.RequestID == "" && request.CorrelationID == "" {
traceID := uuid.NewString()
request.RequestID = traceID
request.CorrelationID = traceID
} else if request.RequestID == "" {
request.RequestID = request.CorrelationID
} else if request.CorrelationID == "" {
request.CorrelationID = request.RequestID
}
return request
}
func validObservationSource(source string) bool {
switch source {
case constants.CardObservationSourcePolling, constants.CardObservationSourceManualSync,
constants.CardObservationSourceManualOverride, constants.CardObservationSourceCarrierCallback,
constants.CardObservationSourceBusinessEvent:
return true
default:
return false
}
} }
func validSyncType(syncType string) bool { func validSyncType(syncType string) bool {

View File

@@ -5,7 +5,6 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
stderrors "errors"
"time" "time"
"gorm.io/gorm" "gorm.io/gorm"
@@ -31,19 +30,11 @@ type ChangeAudit struct {
CorrelationID string CorrelationID string
} }
// AuditWriter 由 tech-global-audit 提供事务内实现 // AuditWriter 可选接收系统配置事务内审计事实
type AuditWriter interface { type AuditWriter interface {
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit ChangeAudit) error WriteConfigChange(ctx context.Context, tx *gorm.DB, audit ChangeAudit) error
} }
// UnavailableAuditWriter 是统一审计尚未注入时的失败关闭策略。
type UnavailableAuditWriter struct{}
// WriteConfigChange 拒绝在缺少统一审计时修改敏感配置。
func (UnavailableAuditWriter) WriteConfigChange(_ context.Context, _ *gorm.DB, _ ChangeAudit) error {
return stderrors.New("统一审计接缝尚未配置")
}
// UpdateService 执行单 Key 校验、事务更新、审计和提交后缓存失效。 // UpdateService 执行单 Key 校验、事务更新、审计和提交后缓存失效。
type UpdateService struct { type UpdateService struct {
db *gorm.DB db *gorm.DB
@@ -63,9 +54,6 @@ func NewUpdateService(
alerts configinfra.AlertSink, alerts configinfra.AlertSink,
now func() time.Time, now func() time.Time,
) *UpdateService { ) *UpdateService {
if audit == nil {
audit = UnavailableAuditWriter{}
}
if now == nil { if now == nil {
now = time.Now now = time.Now
} }
@@ -133,14 +121,16 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
if value := middleware.GetRequestIDFromContext(ctx); value != nil { if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value requestID = *value
} }
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{ if s.audit != nil {
OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置", if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
ConfigKey: key, OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置",
BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)}, ConfigKey: key,
AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)}, BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)},
RequestID: requestID, CorrelationID: requestID, AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)},
}); err != nil { RequestID: requestID, CorrelationID: requestID,
return err }); err != nil {
return err
}
} }
saved = existing saved = existing
return nil return nil

View File

@@ -0,0 +1,37 @@
package bootstrap
import (
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func registerCarrierCallbackConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger) {
if registry == nil {
return
}
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigCarrierCallbackCTCCRealnameEnabled, Module: constants.SystemConfigModuleCarrierCallback, ValueType: constants.SystemConfigTypeBool, DefaultValue: "false", Description: "是否处理中国电信实名回调", Control: "switch"},
{Key: constants.SystemConfigCarrierCallbackCMCCRealnameEnabled, Module: constants.SystemConfigModuleCarrierCallback, ValueType: constants.SystemConfigTypeBool, DefaultValue: "false", Description: "是否处理中国移动实名回调", Control: "switch"},
{Key: constants.SystemConfigCarrierCallbackCUCCRealnameEnabled, Module: constants.SystemConfigModuleCarrierCallback, ValueType: constants.SystemConfigTypeBool, DefaultValue: "false", Description: "是否处理中国联通实名成功回调", Control: "switch"},
{Key: constants.SystemConfigCarrierCallbackCUCCRealnameRemovalEnabled, Module: constants.SystemConfigModuleCarrierCallback, ValueType: constants.SystemConfigTypeBool, DefaultValue: "false", Description: "是否处理中国联通解除实名回调", Control: "switch"},
}
for _, definition := range definitions {
if existing, exists := registry.Get(definition.Key); exists {
if existing.ValueType != definition.ValueType || existing.Module != definition.Module {
logCarrierCallbackConfigRegistrationError(logger, definition.Key, "配置 Key 已被其他类型或模块注册")
}
continue
}
if err := registry.Register(definition); err != nil {
logCarrierCallbackConfigRegistrationError(logger, definition.Key, err.Error())
}
}
}
func logCarrierCallbackConfigRegistrationError(logger *zap.Logger, key, reason string) {
if logger != nil {
logger.Error("注册运营商回调系统配置失败,相关回调将按关闭处理", zap.String("config_key", key), zap.String("reason", reason))
}
}

View File

@@ -28,5 +28,5 @@ type Dependencies struct {
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选) WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选) SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选)
SystemConfigAudit systemConfigApp.AuditWriter // 统一配置变更审计 Port可选缺失时更新失败关闭 SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port可选装配后与配置同事务写入
} }

View File

@@ -11,6 +11,8 @@ import (
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
"github.com/break/junhong_cmp_fiber/internal/handler/callback" "github.com/break/junhong_cmp_fiber/internal/handler/callback"
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi" openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig" systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
@@ -89,6 +91,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
if systemConfigRegistry == nil { if systemConfigRegistry == nil {
systemConfigRegistry = systemConfigInfra.NewRegistry() systemConfigRegistry = systemConfigInfra.NewRegistry()
} }
registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger)
systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger) systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger)
var systemConfigCache systemConfigInfra.Cache var systemConfigCache systemConfigInfra.Cache
if deps.Redis != nil { if deps.Redis != nil {
@@ -108,15 +111,27 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission)) handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission))
return handler return handler
}(), }(),
Permission: admin.NewPermissionHandler(svc.Permission), Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger), PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger), ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
ClientAsset: app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger), ClientAsset: func() *app.ClientAssetHandler {
ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore), handler := app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger)
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger), handler.SetObservationSeriesDispatcher(svc.ObservationSeries)
ClientExchange: app.NewClientExchangeHandler(svc.Exchange), return handler
ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger), }(),
ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger), ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
ClientRealname: func() *app.ClientRealnameHandler {
handler := app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger)
handler.SetObservationSeriesDispatcher(svc.ObservationSeries)
return handler
}(),
ClientDevice: func() *app.ClientDeviceHandler {
handler := app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger)
handler.SetDeviceService(svc.Device)
return handler
}(),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger), ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate), ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
Shop: func() *admin.ShopHandler { Shop: func() *admin.ShopHandler {
@@ -158,12 +173,28 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
AdminOrder: admin.NewOrderHandler(svc.Order, validate), AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate), AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, deps.Logger), PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, deps.Logger),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig), CTCCRealnameCallback: callback.NewCTCCRealnameHandler(
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency), carriercallback.NewCTCCRealnameTranslator(), carriercallback.NewCTCCCardResolver(deps.DB),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring), integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert), ),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup), CMCCRealnameCallback: callback.NewCMCCRealnameHandler(
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger), carriercallback.NewCMCCRealnameTranslator(), carriercallback.NewCMCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
),
CUCCRealnameCallback: callback.NewCUCCRealnameHandler(
carriercallback.NewCUCCRealnameTranslator(), carriercallback.NewCUCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger,
),
CUCCRealnameRemovalCallback: callback.NewCUCCRealnameRemovalHandler(
carriercallback.NewCUCCRealnameRemovalTranslator(), carriercallback.NewCUCCCardResolver(deps.DB),
integrationlog.NewRepository(deps.DB), systemConfigReader, deps.Logger,
),
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
Asset: func() *admin.AssetHandler { Asset: func() *admin.AssetHandler {
pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger) pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
assetPollingSvc := pollingSvcPkg.NewAssetPollingService( assetPollingSvc := pollingSvcPkg.NewAssetPollingService(
@@ -176,6 +207,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
) )
h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger)) h := admin.NewAssetHandler(svc.Asset, svc.AssetAudit, svc.Device, svc.IotCard, svc.StopResumeService, assetPollingSvc, assetQuery.NewExchangeTraceQuery(deps.DB, deps.Logger))
h.SetLifecycleService(svc.AssetLifecycle) h.SetLifecycleService(svc.AssetLifecycle)
h.SetObservationSeriesDispatcher(svc.ObservationSeries)
return h return h
}(), }(),
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle), AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),

View File

@@ -8,6 +8,7 @@ import (
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
"github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/polling"
@@ -27,6 +28,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/payment" "github.com/break/junhong_cmp_fiber/pkg/payment"
"github.com/break/junhong_cmp_fiber/pkg/queue"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet" assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
@@ -118,6 +120,9 @@ type services struct {
AgentOpenAPI *agentOpenAPISvc.Service AgentOpenAPI *agentOpenAPISvc.Service
CustomerBinding *customerBindingSvc.Service CustomerBinding *customerBindingSvc.Service
OrderPackageInvalidate *orderPackageInvalidateSvc.Service OrderPackageInvalidate *orderPackageInvalidateSvc.Service
ObservationSeries cardObservationApp.BestEffortSeriesDispatcher
CardObservation *cardObservationApp.Service
CardObservationSeries *cardObservationApp.SeriesAttemptService
} }
func initServices(s *stores, deps *Dependencies) *services { func initServices(s *stores, deps *Dependencies) *services {
@@ -143,11 +148,27 @@ func initServices(s *stores, deps *Dependencies) *services {
assetAudit, assetAudit,
) )
cardObservationOutbox := outbox.NewRepository() cardObservationOutbox := outbox.NewRepository()
iotCard.SetCardObservationService(cardObservationApp.NewService( observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
cardObservationService := cardObservationApp.NewService(
deps.DB, deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox), cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger), cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)) )
iotCard.SetCardObservationService(cardObservationService)
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
seriesIntegration := integrationlog.NewRepository(deps.DB)
seriesTrigger := cardObservationApp.NewSeriesTrigger(
seriesCoordinator,
queue.NewCardObservationSeriesScheduler(deps.QueueClient),
cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration),
)
cardObservationSeries := cardObservationApp.NewSeriesAttemptService(
seriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, seriesIntegration),
cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration),
)
observationSeries := cardObservationInfra.NewBestEffortSeriesDispatcher(seriesTrigger, deps.Logger, s.DeviceSimBinding, s.Carrier)
iotCard.SetObservationSeriesDispatcher(observationSeries)
// 使用 PollingLifecycleService 替代 APICallback通过分片队列准确操作修复 api_callback.go 遗漏 protect 队列的 Bug3 // 使用 PollingLifecycleService 替代 APICallback通过分片队列准确操作修复 api_callback.go 遗漏 protect 队列的 Bug3
pollingConfigStore := postgres.NewPollingConfigStore(deps.DB) pollingConfigStore := postgres.NewPollingConfigStore(deps.DB)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger) pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger)
@@ -172,6 +193,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageUsageDailyRecord, s.PackageUsageDailyRecord,
deps.Logger, deps.Logger,
) )
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
stopResumeService := iotCardSvc.NewStopResumeService( stopResumeService := iotCardSvc.NewStopResumeService(
deps.Redis, deps.Redis,
@@ -183,6 +205,7 @@ func initServices(s *stores, deps *Dependencies) *services {
assetAudit, assetAudit,
) )
stopResumeService.SetPollingCallback(pollingLifecycleSvc) stopResumeService.SetPollingCallback(pollingLifecycleSvc)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
iotCard.SetRealnameActivator(packageActivation) iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService) iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding) iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
@@ -206,10 +229,13 @@ func initServices(s *stores, deps *Dependencies) *services {
s.EnterpriseDeviceAuthorization, s.EnterpriseDeviceAuthorization,
s.Enterprise, s.Enterprise,
) )
device.SetObservationSeriesEventWriter(observationSeriesEvents)
device.SetObservationSeriesDispatcher(observationSeries)
operationPassword := operationPasswordSvc.New(deps.Redis) operationPassword := operationPasswordSvc.New(deps.Redis)
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger) shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)
packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation) packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation)
orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone) orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone)
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
walletOutbox := outbox.NewRepository() walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox) walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil)) orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
@@ -244,6 +270,7 @@ func initServices(s *stores, deps *Dependencies) *services {
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil)) refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit) assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device) agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
return &services{ return &services{
Account: account, Account: account,
@@ -331,5 +358,8 @@ func initServices(s *stores, deps *Dependencies) *services {
Refund: refundService, Refund: refundService,
CustomerBinding: customerBinding, CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
} }
} }

View File

@@ -53,6 +53,10 @@ type Handlers struct {
AdminOrder *admin.OrderHandler AdminOrder *admin.OrderHandler
AdminExchange *admin.ExchangeHandler AdminExchange *admin.ExchangeHandler
PaymentCallback *callback.PaymentHandler PaymentCallback *callback.PaymentHandler
CTCCRealnameCallback *callback.CTCCRealnameHandler
CMCCRealnameCallback *callback.CMCCRealnameHandler
CUCCRealnameCallback *callback.CUCCRealnameHandler
CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler
PollingConfig *admin.PollingConfigHandler PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler PollingConcurrency *admin.PollingConcurrencyHandler
PollingMonitoring *admin.PollingMonitoringHandler PollingMonitoring *admin.PollingMonitoringHandler

View File

@@ -88,6 +88,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
deps.Logger, deps.Logger,
) )
cardObservationOutbox := outbox.NewRepository() cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
cardObservationService := cardObservationApp.NewService( cardObservationService := cardObservationApp.NewService(
deps.DB, deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox), cardObservationInfra.NewEventWriter(cardObservationOutbox),
@@ -141,22 +142,25 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
deps.Logger, deps.Logger,
assetAudit, assetAudit,
) )
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
usageService.SetStopResumeCallback(stopResumeService) usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService) activationService.SetResumeCallback(stopResumeService)
orderService.SetResumeCallback(stopResumeService) orderService.SetResumeCallback(stopResumeService)
resetService.SetResumeCallback(stopResumeService) resetService.SetResumeCallback(stopResumeService)
return &queue.WorkerServices{ return &queue.WorkerServices{
CardObservation: cardObservationService, CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService, CardObservationSeries: cardObservationSeriesService,
CommissionCalculation: commissionCalculationService, ObservationSeriesEvents: observationSeriesEvents,
CommissionStats: commissionStatsService, CommissionCalculation: commissionCalculationService,
UsageService: usageService, CommissionStats: commissionStatsService,
ActivationService: activationService, UsageService: usageService,
ResetService: resetService, ActivationService: activationService,
AlertService: alertService, ResetService: resetService,
CleanupService: cleanupService, AlertService: alertService,
StopResumeService: stopResumeService, CleanupService: cleanupService,
OrderExpirer: orderService, StopResumeService: stopResumeService,
OrderExpirer: orderService,
} }
} }

View File

@@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
dto "github.com/break/junhong_cmp_fiber/internal/model/dto" dto "github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -31,6 +32,12 @@ type AssetHandler struct {
assetPolling *pollingSvc.AssetPollingService assetPolling *pollingSvc.AssetPollingService
assetLifecycleService AssetLifecycleService assetLifecycleService AssetLifecycleService
exchangeTraceQuery AssetExchangeTraceResolver exchangeTraceQuery AssetExchangeTraceResolver
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。
func (h *AssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
} }
// AssetExchangeTraceResolver 定义资产详情换货链路读取用例。 // AssetExchangeTraceResolver 定义资产详情换货链路读取用例。
@@ -110,10 +117,40 @@ func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
h.dispatchRealtimeObservations(c.UserContext(), result)
return response.Success(c, result) return response.Success(c, result)
} }
func (h *AssetHandler) dispatchRealtimeObservations(ctx context.Context, result *dto.AssetRealtimeStatusResponse) {
if h.observationSeries == nil || result == nil {
return
}
cardIDs := make([]uint, 0, len(result.Cards)+1)
if result.AssetType == "card" {
cardIDs = append(cardIDs, result.AssetID)
} else {
for _, card := range result.Cards {
cardIDs = append(cardIDs, card.CardID)
}
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
for _, cardID := range cardIDs {
resourceID := strconv.FormatUint(uint64(cardID), 10)
for _, syncType := range []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork} {
h.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: constants.CardObservationSceneAdminAssetRead,
ResourceType: constants.CardObservationResourceTypeCard, ResourceID: resourceID,
SyncType: syncType, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
}
}
// Refresh 刷新资产状态(调网关同步) // Refresh 刷新资产状态(调网关同步)
// POST /api/admin/assets/:identifier/refresh // POST /api/admin/assets/:identifier/refresh
func (h *AssetHandler) Refresh(c *fiber.Ctx) error { func (h *AssetHandler) Refresh(c *fiber.Ctx) error {

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -18,6 +19,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
pkgMiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response" "github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"go.uber.org/zap" "go.uber.org/zap"
@@ -36,6 +38,12 @@ type ClientAssetHandler struct {
deviceStore *postgres.DeviceStore deviceStore *postgres.DeviceStore
db *gorm.DB db *gorm.DB
logger *zap.Logger logger *zap.Logger
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesDispatcher 注入读取型后台观测序列端口。
func (h *ClientAssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
} }
// NewClientAssetHandler 创建 C 端资产信息处理器 // NewClientAssetHandler 创建 C 端资产信息处理器
@@ -213,10 +221,47 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime) resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime)
} }
} }
h.dispatchAssetReadObservations(c.UserContext(), resolved.Asset)
return response.Success(c, resp) return response.Success(c, resp)
} }
func (h *ClientAssetHandler) dispatchAssetReadObservations(ctx context.Context, asset *dto.AssetResolveResponse) {
if h.observationSeries == nil || asset == nil {
return
}
cardIDs := make([]uint, 0, len(asset.Cards)+1)
if asset.AssetType == "card" {
cardIDs = append(cardIDs, asset.AssetID)
} else {
for _, card := range asset.Cards {
cardIDs = append(cardIDs, card.CardID)
}
}
dispatchCardReadSeries(ctx, h.observationSeries, constants.CardObservationSceneClientAssetRead, cardIDs)
}
func dispatchCardReadSeries(ctx context.Context, dispatcher cardObservationApp.BestEffortSeriesDispatcher, scene string, cardIDs []uint) {
requestID := ""
if value := pkgMiddleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
for _, cardID := range cardIDs {
resourceID := strconv.FormatUint(uint64(cardID), 10)
for _, syncType := range []string{
constants.CardObservationSyncTypeRealname,
constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork,
} {
dispatcher.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: resourceID, SyncType: syncType, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
}
}
// GetAvailablePackages B2 资产可购套餐列表 // GetAvailablePackages B2 资产可购套餐列表
// GET /api/c/v1/asset/packages // GET /api/c/v1/asset/packages
func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error { func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {

View File

@@ -6,6 +6,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/model/dto"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/logger"
@@ -33,9 +34,15 @@ type ClientDeviceHandler struct {
deviceSimBindingStore *postgres.DeviceSimBindingStore deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore iotCardStore *postgres.IotCardStore
gatewayClient *gateway.Client gatewayClient *gateway.Client
deviceService *deviceSvc.Service
logger *zap.Logger logger *zap.Logger
} }
// SetDeviceService 注入统一设备控制服务,确保各入口共享成功后的观测触发。
func (h *ClientDeviceHandler) SetDeviceService(service *deviceSvc.Service) {
h.deviceService = service
}
// NewClientDeviceHandler 创建 C 端设备能力处理器 // NewClientDeviceHandler 创建 C 端设备能力处理器
func NewClientDeviceHandler( func NewClientDeviceHandler(
assetService *assetSvc.Service, assetService *assetSvc.Service,
@@ -195,10 +202,10 @@ func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error {
return err return err
} }
// 调用 Gateway 重启设备 if h.deviceService == nil {
if err := h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{ return errors.New(errors.CodeInternalError, "设备控制服务未配置")
DeviceID: info.IMEI, }
}); err != nil { if err := h.deviceService.GatewayRebootDevice(c.UserContext(), info.IMEI); err != nil {
h.logger.Error("Gateway重启设备失败", h.logger.Error("Gateway重启设备失败",
zap.String("imei", info.IMEI), zap.String("imei", info.IMEI),
zap.Error(err)) zap.Error(err))
@@ -225,10 +232,10 @@ func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error {
return err return err
} }
// 调用 Gateway 恢复出厂设置 if h.deviceService == nil {
if err := h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{ return errors.New(errors.CodeInternalError, "设备控制服务未配置")
DeviceID: info.IMEI, }
}); err != nil { if err := h.deviceService.GatewayResetDevice(c.UserContext(), info.IMEI); err != nil {
h.logger.Error("Gateway恢复出厂设置失败", h.logger.Error("Gateway恢复出厂设置失败",
zap.String("imei", info.IMEI), zap.String("imei", info.IMEI),
zap.Error(err)) zap.Error(err))
@@ -256,14 +263,11 @@ func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error {
return err return err
} }
// 调用 Gateway 配置 WiFi if h.deviceService == nil {
// CardNo 字段虽名为"卡号",但 Gateway 实际要求传入设备 IMEI return errors.New(errors.CodeInternalError, "设备控制服务未配置")
if err := h.gatewayClient.SetWiFi(c.UserContext(), &gateway.WiFiReq{ }
CardNo: info.IMEI, if err := h.deviceService.GatewaySetWiFi(c.UserContext(), info.IMEI, &dto.SetWiFiRequest{
Params: gateway.WiFiParams{ SSID: req.SSID, Password: req.Password, Enabled: req.Enabled,
SSIDName: req.SSID,
SSIDPassword: req.Password,
},
}); err != nil { }); err != nil {
h.logger.Error("Gateway配置WiFi失败", h.logger.Error("Gateway配置WiFi失败",
zap.String("imei", info.IMEI), zap.String("imei", info.IMEI),
@@ -292,10 +296,11 @@ func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error {
return err return err
} }
// 调用 Gateway 切卡CardNo 传设备 IMEI if h.deviceService == nil {
if err := h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{ return errors.New(errors.CodeInternalError, "设备控制服务未配置")
CardNo: info.IMEI, }
ICCID: req.TargetICCID, if err := h.deviceService.GatewaySwitchCard(c.UserContext(), info.IMEI, &dto.SwitchCardRequest{
TargetICCID: req.TargetICCID,
}); err != nil { }); err != nil {
h.logger.Error("Gateway切卡失败", h.logger.Error("Gateway切卡失败",
zap.String("imei", info.IMEI), zap.String("imei", info.IMEI),

View File

@@ -2,9 +2,10 @@ package app
import ( import (
"context" "context"
"strconv"
"strings" "strings"
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"go.uber.org/zap" "go.uber.org/zap"
@@ -16,7 +17,6 @@ import (
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/logger"
@@ -36,7 +36,7 @@ type ClientRealnameHandler struct {
carrierStore *postgres.CarrierStore carrierStore *postgres.CarrierStore
gatewayClient *gateway.Client gatewayClient *gateway.Client
logger *zap.Logger logger *zap.Logger
manualTriggerSvc *pollingSvc.ManualTriggerService // 手动触发服务可为nilnil时跳过自动触发 observationSeries cardObservationApp.BestEffortSeriesDispatcher
} }
// NewClientRealnameHandler 创建 C 端实名认证处理器 // NewClientRealnameHandler 创建 C 端实名认证处理器
@@ -48,7 +48,7 @@ func NewClientRealnameHandler(
carrierStore *postgres.CarrierStore, carrierStore *postgres.CarrierStore,
gatewayClient *gateway.Client, gatewayClient *gateway.Client,
logger *zap.Logger, logger *zap.Logger,
manualTriggerSvc *pollingSvc.ManualTriggerService, // 可为nil _ *pollingSvc.ManualTriggerService, // 兼容旧构造签名;获取链接改由 0/3/5 观测序列收敛
) *ClientRealnameHandler { ) *ClientRealnameHandler {
return &ClientRealnameHandler{ return &ClientRealnameHandler{
assetService: assetSvc, assetService: assetSvc,
@@ -58,10 +58,14 @@ func NewClientRealnameHandler(
carrierStore: carrierStore, carrierStore: carrierStore,
gatewayClient: gatewayClient, gatewayClient: gatewayClient,
logger: logger, logger: logger,
manualTriggerSvc: manualTriggerSvc,
} }
} }
// SetObservationSeriesDispatcher 注入获取实名链接后的观测序列端口。
func (h *ClientRealnameHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
h.observationSeries = dispatcher
}
// GetRealnameLink E1 获取实名认证链接 // GetRealnameLink E1 获取实名认证链接
// GET /api/c/v1/realname/link // GET /api/c/v1/realname/link
func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error { func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
@@ -138,15 +142,29 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
return err return err
} }
// 异步触发实名检查,提升检测优先级;失败不影响主流程 h.dispatchRealnameObservation(ctx, targetCard.ID)
if h.manualTriggerSvc != nil && config.Get().PollingAutoTrigger.EnableAutoTrigger {
systemUserID := uint(config.Get().PollingAutoTrigger.AutoTriggerSystemUserID)
go h.triggerRealnameCheck(targetCard.ID, customerID, targetCard.ICCID, systemUserID)
}
return response.Success(c, resp) return response.Success(c, resp)
} }
func (h *ClientRealnameHandler) dispatchRealnameObservation(ctx context.Context, cardID uint) {
if h.observationSeries == nil {
return
}
requestID := ""
if value := pkgMiddleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
h.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: constants.CardObservationSceneClientRealnameLink,
ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(cardID), 10),
SyncType: constants.CardObservationSyncTypeRealname,
ExpectedValue: "verified", Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
// resolveTargetCard 根据资产类型和ICCID定位目标卡 // resolveTargetCard 根据资产类型和ICCID定位目标卡
// 支持三条路径:直接卡资产、设备+指定ICCID、设备取第一张绑定卡 // 支持三条路径:直接卡资产、设备+指定ICCID、设备取第一张绑定卡
func (h *ClientRealnameHandler) resolveTargetCard(c *fiber.Ctx, asset *dto.AssetResolveResponse, iccid string) (*model.IotCard, error) { func (h *ClientRealnameHandler) resolveTargetCard(c *fiber.Ctx, asset *dto.AssetResolveResponse, iccid string) (*model.IotCard, error) {
@@ -275,33 +293,3 @@ func (h *ClientRealnameHandler) findFirstBoundCard(c *fiber.Ctx, deviceID uint)
return card, nil return card, nil
} }
// triggerRealnameCheck 异步触发单卡实名检查
// 在独立 goroutine 中调用,使用独立 context 避免 Fiber 请求 context 失效问题
// 参数全部为值类型,不捕获请求相关指针
func (h *ClientRealnameHandler) triggerRealnameCheck(cardID, customerID uint, iccid string, systemUserID uint) {
// 必须使用独立 context禁止复用 Fiber 请求 context请求返回后即失效
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// 使用平台用户身份构建 context绕过卡归属权限检查
// 注意:使用 UserTypePlatform 而非 UserTypeSuperAdminSuperAdmin 不受日限制约束
sysCtx := pkgMiddleware.SetUserContext(ctx, &pkgMiddleware.UserContextInfo{
UserID: systemUserID,
UserType: constants.UserTypePlatform,
})
err := h.manualTriggerSvc.TriggerSingle(sysCtx, cardID, constants.TaskTypePollingRealname, systemUserID)
if err != nil {
h.logger.Warn("自动触发实名检查失败",
zap.Uint("customer_id", customerID),
zap.String("iccid", iccid),
zap.Uint("card_id", cardID),
zap.Error(err))
return
}
h.logger.Info("自动触发实名检查成功",
zap.Uint("customer_id", customerID),
zap.String("iccid", iccid),
zap.Uint("card_id", cardID))
}

View File

@@ -0,0 +1,79 @@
package callback
import (
"context"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// SystemConfigReader 提供运营商回调运行时开关读取能力。
type SystemConfigReader interface {
Get(ctx context.Context, key string) (string, error)
}
func carrierCallbackEnabled(ctx context.Context, reader SystemConfigReader, key string) (bool, error) {
if reader == nil {
return false, apperrors.New(apperrors.CodeInternalError, "运营商回调启停配置未装配")
}
value, err := reader.Get(ctx, key)
if err != nil {
return false, apperrors.Wrap(apperrors.CodeInternalError, err, "读取运营商回调启停配置失败")
}
enabled, err := strconv.ParseBool(value)
if err != nil {
return false, apperrors.Wrap(apperrors.CodeInternalError, err, "运营商回调启停配置值无效")
}
return enabled, nil
}
func recordDisabledCarrierCallback(
ctx context.Context,
repository *integrationlog.Repository,
provider string,
operation string,
integrationPrefix string,
body []byte,
contentType string,
) error {
if repository == nil {
return apperrors.New(apperrors.CodeInternalError, "运营商回调留痕能力未配置")
}
payloadHash := shortHashBytes(body)
key := integrationPrefix + "-disabled-body:" + payloadHash
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := repository.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationPrefix + "-disabled:" + shortHash(key),
IdempotencyKey: key,
Provider: provider,
Operation: operation,
ResourceType: constants.CardObservationResourceTypeCard,
RawPayload: body,
ContentType: contentType,
RequestID: requestID,
CorrelationID: requestID,
})
if err != nil {
return err
}
if !created {
if log == nil || log.Result != constants.IntegrationResultPending {
return nil
}
claimed, claimErr := repository.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
if claimErr != nil || !claimed {
return claimErr
}
}
_, err = repository.Complete(ctx, log.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultIgnored,
HTTPStatus: fiber.StatusOK,
ResponseSummary: map[string]any{"reason": "后台配置已关闭该运营商回调"},
})
return err
}

View File

@@ -0,0 +1,171 @@
package callback
import (
"context"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// CMCCRealnameHandler 处理中国移动实名成功 JSON 回调。
type CMCCRealnameHandler struct {
translator *carriercallback.CMCCRealnameTranslator
resolver *carriercallback.CMCCCardResolver
integration *integrationlog.Repository
observation *cardapp.Service
series ResourceSeriesCompleter
config SystemConfigReader
logger *zap.Logger
}
// NewCMCCRealnameHandler 创建中国移动实名回调 Handler。
func NewCMCCRealnameHandler(translator *carriercallback.CMCCRealnameTranslator, resolver *carriercallback.CMCCCardResolver, integration *integrationlog.Repository, observation *cardapp.Service, series ResourceSeriesCompleter, config SystemConfigReader, logger *zap.Logger) *CMCCRealnameHandler {
return &CMCCRealnameHandler{translator: translator, resolver: resolver, integration: integration, observation: observation, series: series, config: config, logger: logger}
}
// Realname 接收中国移动实名结果并返回固定成功应答。
// POST /api/callback/carriers/cmcc/realname
func (h *CMCCRealnameHandler) Realname(c *fiber.Ctx) error {
startedAt := time.Now()
enabled, err := carrierCallbackEnabled(c.UserContext(), h.config, constants.SystemConfigCarrierCallbackCMCCRealnameEnabled)
if err != nil {
if h.logger != nil {
h.logger.Error("读取移动实名回调启停配置失败,已按关闭处理并返回成功", zap.Error(err))
}
return sendCMCCSuccess(c, startedAt)
}
if !enabled {
if err := recordDisabledCarrierCallback(c.UserContext(), h.integration, constants.IntegrationProviderCMCC, constants.IntegrationOperationCMCCRealnameCallback, "cmcc-realname", c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("移动实名回调已关闭,但留痕失败", zap.Error(err))
}
return sendCMCCSuccess(c, startedAt)
}
if err := h.process(c.UserContext(), c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("处理移动实名回调失败,已按运营商约定返回成功", zap.Error(err))
}
return sendCMCCSuccess(c, startedAt)
}
func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentType string) error {
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "移动实名回调能力未完整配置")
}
translated, translateErr := h.translator.Translate(body)
idempotencyKey := cmccIdempotencyKey(body, translated)
integrationID := "cmcc-realname:" + shortHash(idempotencyKey)
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationID, IdempotencyKey: idempotencyKey,
Provider: constants.IntegrationProviderCMCC, Operation: constants.IntegrationOperationCMCCRealnameCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
ExternalID: translated.BusiSeq, RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
if isAppConflict(err) {
return h.recordConflict(ctx, body, contentType, idempotencyKey, translated, requestID)
}
return err
}
if !created {
if log.Result != constants.IntegrationResultPending {
return nil
}
claimed, claimErr := h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
if claimErr != nil || !claimed {
return claimErr
}
}
if translateErr != nil {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultInvalidPayload, false, "报文或业务结果无效")
}
cards, err := h.resolver.Resolve(ctx, translated.ICCID)
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if len(cards) == 0 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultNotFound, false, "精确列未找到卡")
}
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "精确列匹配多张卡")
}
card := cards[0]
decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: card.ID, Verified: true,
Metadata: carddomain.ObservationMetadata{
ObservationID: log.IntegrationID, Source: constants.CardObservationSourceCarrierCallback,
Scene: constants.CardObservationSceneCarrierCallback, ObservedAt: time.Now().UTC(),
RequestID: optionalStringValue(requestID), CorrelationID: optionalStringValue(requestID), UpstreamSummary: "移动实名成功",
},
})
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if h.series != nil {
if err := h.series.CompleteResourceSeries(ctx, constants.CardObservationResourceTypeCard, uintString(card.ID), constants.CardObservationSyncTypeRealname); err != nil && h.logger != nil {
h.logger.Warn("移动实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
}
func (h *CMCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CMCCRealnameTranslation, requestID *string) error {
key := baseKey + ":conflict:" + shortHashBytes(body)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{IntegrationID: "cmcc-realname-conflict:" + shortHash(key), IdempotencyKey: key, Provider: constants.IntegrationProviderCMCC, Operation: constants.IntegrationOperationCMCCRealnameCallback, ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID), ExternalID: translated.BusiSeq, RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID})
if err != nil {
return err
}
if !created {
if log.Result != constants.IntegrationResultPending {
return nil
}
claimed, claimErr := h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
if claimErr != nil || !claimed {
return claimErr
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "同一幂等语义对应不同载荷")
}
func (h *CMCCRealnameHandler) complete(ctx context.Context, integrationID, result string, changed bool, reason string) error {
_, err := h.integration.Complete(ctx, integrationID, integrationlog.Completion{Result: result, HTTPStatus: fiber.StatusOK, StateChanged: changed, ResponseSummary: map[string]any{"reason": reason}})
return err
}
func (h *CMCCRealnameHandler) fail(ctx context.Context, integrationID string, original error) error {
if err := h.complete(ctx, integrationID, constants.IntegrationResultFailed, false, "内部处理失败"); err != nil && h.logger != nil {
h.logger.Error("移动实名回调失败终态写入失败", zap.String("integration_id", integrationID), zap.Error(err))
}
return original
}
func cmccIdempotencyKey(body []byte, translated carriercallback.CMCCRealnameTranslation) string {
if translated.BusiSeq != "" {
return "cmcc-external:" + shortHash(translated.BusiSeq)
}
return "cmcc-body:" + shortHashBytes(body)
}
type cmccSuccessResponse struct {
Code int `json:"code"`
Message string `json:"msg"`
Timestamp string `json:"timestamp"`
}
func sendCMCCSuccess(c *fiber.Ctx, startedAt time.Time) error {
body, err := sonic.Marshal(cmccSuccessResponse{Code: 200, Message: "success", Timestamp: startedAt.Format("2006-01-02 15:04:05")})
if err != nil {
return apperrors.Wrap(apperrors.CodeInternalError, err, "生成移动回调应答失败")
}
c.Type("json", "utf-8")
return c.Status(fiber.StatusOK).Send(body)
}

View File

@@ -0,0 +1,243 @@
package callback
import (
"context"
"crypto/sha256"
"encoding/hex"
stderrors "errors"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// ResourceSeriesCompleter 提前完成同资源未执行的观测序列。
type ResourceSeriesCompleter interface {
CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error
}
// CTCCRealnameHandler 处理电信实名 XML 回调。
type CTCCRealnameHandler struct {
translator *carriercallback.CTCCRealnameTranslator
resolver *carriercallback.CTCCCardResolver
integration *integrationlog.Repository
observation *cardapp.Service
series ResourceSeriesCompleter
config SystemConfigReader
logger *zap.Logger
}
// NewCTCCRealnameHandler 创建电信实名回调 Handler。
func NewCTCCRealnameHandler(
translator *carriercallback.CTCCRealnameTranslator,
resolver *carriercallback.CTCCCardResolver,
integration *integrationlog.Repository,
observation *cardapp.Service,
series ResourceSeriesCompleter,
config SystemConfigReader,
logger *zap.Logger,
) *CTCCRealnameHandler {
return &CTCCRealnameHandler{
translator: translator, resolver: resolver, integration: integration,
observation: observation, series: series, config: config, logger: logger,
}
}
// Realname 接收电信实名结果并返回固定成功应答。
// POST /api/callback/carriers/ctcc/realname
func (h *CTCCRealnameHandler) Realname(c *fiber.Ctx) error {
startedAt := time.Now()
enabled, err := carrierCallbackEnabled(c.UserContext(), h.config, constants.SystemConfigCarrierCallbackCTCCRealnameEnabled)
if err != nil {
if h.logger != nil {
h.logger.Error("读取电信实名回调启停配置失败,已按关闭处理并返回成功", zap.Error(err))
}
return sendCTCCSuccess(c, startedAt)
}
if !enabled {
if err := recordDisabledCarrierCallback(c.UserContext(), h.integration, constants.IntegrationProviderCTCC, constants.IntegrationOperationCTCCRealnameCallback, "ctcc-realname", c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("电信实名回调已关闭,但留痕失败", zap.Error(err))
}
return sendCTCCSuccess(c, startedAt)
}
if err := h.process(c.UserContext(), c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("处理电信实名回调失败,已按运营商约定返回成功", zap.Error(err))
}
return sendCTCCSuccess(c, startedAt)
}
func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentType string) error {
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "电信实名回调能力未完整配置")
}
translated, translateErr := h.translator.Translate(body)
idempotencyKey := ctccIdempotencyKey(body, translated)
integrationID := "ctcc-realname:" + shortHash(idempotencyKey)
resourceKey := optionalHashedResourceKey(translated.ICCID)
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationID, IdempotencyKey: idempotencyKey,
Provider: constants.IntegrationProviderCTCC, Operation: constants.IntegrationOperationCTCCRealnameCallback,
ExternalID: translated.GroupTransactionID, ResourceType: constants.CardObservationResourceTypeCard,
ResourceKey: resourceKey, RawPayload: body, ContentType: contentType,
RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
if isAppConflict(err) {
return h.recordPayloadConflict(ctx, body, contentType, idempotencyKey, translated, requestID)
}
return err
}
if !created {
claimed, claimErr := h.claimExistingPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
if translateErr != nil {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultInvalidPayload, false, "报文解析或 ICCID 校验失败")
}
if translated.Action != carriercallback.CTCCRealnameActionVerified {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultIgnored, false, "合法业务结果无需更新实名事实")
}
cards, err := h.resolver.Resolve(ctx, translated.ICCID)
if err != nil {
return h.failPending(ctx, log.IntegrationID, err)
}
if len(cards) == 0 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultNotFound, false, "精确列未找到卡")
}
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "精确列匹配多张卡")
}
card := cards[0]
now := time.Now().UTC()
decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: card.ID, Verified: true,
Metadata: carddomain.ObservationMetadata{
ObservationID: log.IntegrationID, Source: constants.CardObservationSourceCarrierCallback,
Scene: constants.CardObservationSceneCarrierCallback, ObservedAt: now,
RequestID: optionalStringValue(requestID), CorrelationID: optionalStringValue(requestID),
UpstreamSummary: "电信实名补录成功",
},
})
if err != nil {
return h.failPending(ctx, log.IntegrationID, err)
}
if h.series != nil {
if err := h.series.CompleteResourceSeries(ctx, constants.CardObservationResourceTypeCard, uintString(card.ID), constants.CardObservationSyncTypeRealname); err != nil && h.logger != nil {
h.logger.Warn("电信实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
}
func (h *CTCCRealnameHandler) failPending(ctx context.Context, integrationID string, original error) error {
if err := h.complete(ctx, integrationID, constants.IntegrationResultFailed, false, "内部处理失败"); err != nil && h.logger != nil {
h.logger.Error("电信实名回调失败终态写入失败", zap.String("integration_id", integrationID), zap.Error(err))
}
return original
}
func (h *CTCCRealnameHandler) recordPayloadConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CTCCRealnameTranslation, requestID *string) error {
conflictKey := baseKey + ":conflict:" + shortHashBytes(body)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: "ctcc-realname-conflict:" + shortHash(conflictKey), IdempotencyKey: conflictKey,
Provider: constants.IntegrationProviderCTCC, Operation: constants.IntegrationOperationCTCCRealnameCallback,
ExternalID: translated.GroupTransactionID, ResourceType: constants.CardObservationResourceTypeCard,
ResourceKey: optionalHashedResourceKey(translated.ICCID), RawPayload: body, ContentType: contentType,
RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return err
}
if !created {
claimed, claimErr := h.claimExistingPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "同一幂等语义对应不同载荷")
}
func (h *CTCCRealnameHandler) claimExistingPending(ctx context.Context, log *model.IntegrationLog) (bool, error) {
if log == nil || log.Result != constants.IntegrationResultPending {
return false, nil
}
return h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
}
func (h *CTCCRealnameHandler) complete(ctx context.Context, integrationID, result string, stateChanged bool, reason string) error {
_, err := h.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: result, HTTPStatus: fiber.StatusOK, StateChanged: stateChanged,
ResponseSummary: map[string]any{"reason": reason},
})
return err
}
func ctccIdempotencyKey(body []byte, translated carriercallback.CTCCRealnameTranslation) string {
if strings.TrimSpace(translated.GroupTransactionID) != "" {
return "ctcc-external:" + shortHash(strings.TrimSpace(translated.GroupTransactionID))
}
return "ctcc-body:" + shortHashBytes(body)
}
func optionalHashedResourceKey(iccid string) *string {
if strings.TrimSpace(iccid) == "" {
return nil
}
value := "iccid-sha256:" + shortHash(strings.TrimSpace(iccid))
return &value
}
func shortHash(value string) string {
return shortHashBytes([]byte(value))
}
func shortHashBytes(value []byte) string {
sum := sha256.Sum256(value)
return hex.EncodeToString(sum[:])[:32]
}
func optionalStringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func isAppConflict(err error) bool {
var appErr *apperrors.AppError
return stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeConflict
}
func uintString(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
type ctccSuccessResponse struct {
Code int `json:"code"`
Message string `json:"msg"`
Timestamp string `json:"timestamp"`
}
func sendCTCCSuccess(c *fiber.Ctx, startedAt time.Time) error {
body, err := sonic.Marshal(ctccSuccessResponse{Code: 200, Message: "success", Timestamp: startedAt.Format("2006-01-02 15:04:05")})
if err != nil {
return apperrors.Wrap(apperrors.CodeInternalError, err, "生成电信回调应答失败")
}
c.Type("json", "utf-8")
return c.Status(fiber.StatusOK).Send(body)
}

View File

@@ -0,0 +1,168 @@
package callback
import (
"context"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// CUCCRealnameHandler 处理中国联通实名成功回调。
type CUCCRealnameHandler struct {
translator *carriercallback.CUCCRealnameTranslator
resolver *carriercallback.CUCCCardResolver
integration *integrationlog.Repository
observation *cardapp.Service
series ResourceSeriesCompleter
config SystemConfigReader
logger *zap.Logger
}
// NewCUCCRealnameHandler 创建中国联通实名成功回调 Handler。
func NewCUCCRealnameHandler(translator *carriercallback.CUCCRealnameTranslator, resolver *carriercallback.CUCCCardResolver, integration *integrationlog.Repository, observation *cardapp.Service, series ResourceSeriesCompleter, config SystemConfigReader, logger *zap.Logger) *CUCCRealnameHandler {
return &CUCCRealnameHandler{translator: translator, resolver: resolver, integration: integration, observation: observation, series: series, config: config, logger: logger}
}
// Realname 接收中国联通实名成功结果并返回固定成功应答。
// POST /api/callback/carriers/cucc/realname
func (h *CUCCRealnameHandler) Realname(c *fiber.Ctx) error {
startedAt := time.Now()
enabled, err := carrierCallbackEnabled(c.UserContext(), h.config, constants.SystemConfigCarrierCallbackCUCCRealnameEnabled)
if err != nil {
if h.logger != nil {
h.logger.Error("读取联通实名回调启停配置失败,已按关闭处理并返回成功", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
if !enabled {
if err := recordDisabledCarrierCallback(c.UserContext(), h.integration, constants.IntegrationProviderCUCC, constants.IntegrationOperationCUCCRealnameCallback, "cucc-realname", c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("联通实名回调已关闭,但留痕失败", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
if err := h.process(c.UserContext(), c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("处理联通实名回调失败,已按运营商约定返回成功", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentType string) error {
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "联通实名回调能力未完整配置")
}
translated, translateErr := h.translator.Translate(body)
key := cuccRealnameIdempotencyKey(body, translated, translateErr == nil)
integrationID := "cucc-realname:" + shortHash(key)
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationID, IdempotencyKey: key,
Provider: constants.IntegrationProviderCUCC, Operation: constants.IntegrationOperationCUCCRealnameCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
if isAppConflict(err) {
return h.recordConflict(ctx, body, contentType, key, translated, requestID)
}
return err
}
if !created {
claimed, claimErr := h.claimPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
if translateErr != nil {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultInvalidPayload, false, "报文、嵌套 data 或字段校验失败")
}
cards, err := h.resolver.Resolve(ctx, translated.ICCID)
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if len(cards) == 0 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultNotFound, false, "精确列未找到卡")
}
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "精确列匹配多张卡")
}
card := cards[0]
decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: card.ID, Verified: true,
Metadata: carddomain.ObservationMetadata{
ObservationID: log.IntegrationID, Source: constants.CardObservationSourceCarrierCallback,
Scene: constants.CardObservationSceneCarrierCallback, ObservedAt: time.Now().UTC(),
RequestID: optionalStringValue(requestID), CorrelationID: optionalStringValue(requestID),
UpstreamSummary: "联通实名成功,变更时间已留痕",
},
})
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if h.series != nil {
if err := h.series.CompleteResourceSeries(ctx, constants.CardObservationResourceTypeCard, uintString(card.ID), constants.CardObservationSyncTypeRealname); err != nil && h.logger != nil {
h.logger.Warn("联通实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
}
func (h *CUCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameTranslation, requestID *string) error {
key := baseKey + ":conflict:" + shortHashBytes(body)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: "cucc-realname-conflict:" + shortHash(key), IdempotencyKey: key,
Provider: constants.IntegrationProviderCUCC, Operation: constants.IntegrationOperationCUCCRealnameCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return err
}
if !created {
claimed, claimErr := h.claimPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, false, "同一实名语义对应不同载荷")
}
func (h *CUCCRealnameHandler) claimPending(ctx context.Context, log *model.IntegrationLog) (bool, error) {
if log == nil || log.Result != constants.IntegrationResultPending {
return false, nil
}
return h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
}
func (h *CUCCRealnameHandler) complete(ctx context.Context, integrationID, result string, changed bool, reason string) error {
_, err := h.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: result, HTTPStatus: fiber.StatusOK, StateChanged: changed,
ResponseSummary: map[string]any{"reason": reason},
})
return err
}
func (h *CUCCRealnameHandler) fail(ctx context.Context, integrationID string, original error) error {
if err := h.complete(ctx, integrationID, constants.IntegrationResultFailed, false, "内部处理失败"); err != nil && h.logger != nil {
h.logger.Error("联通实名回调失败终态写入失败", zap.String("integration_id", integrationID), zap.Error(err))
}
return original
}
func cuccRealnameIdempotencyKey(body []byte, translated carriercallback.CUCCRealnameTranslation, valid bool) string {
if valid {
semantic := strings.TrimSpace(translated.ICCID) + "\x00" + strings.TrimSpace(translated.DateChanged)
return "cucc-realname-semantic:" + shortHash(semantic)
}
return "cucc-realname-body:" + shortHashBytes(body)
}

View File

@@ -0,0 +1,159 @@
package callback
import (
"context"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// CUCCRealnameRemovalHandler 处理中国联通解除实名留痕回调。
type CUCCRealnameRemovalHandler struct {
translator *carriercallback.CUCCRealnameRemovalTranslator
resolver *carriercallback.CUCCCardResolver
integration *integrationlog.Repository
config SystemConfigReader
logger *zap.Logger
}
// NewCUCCRealnameRemovalHandler 创建中国联通解除实名回调 Handler。
func NewCUCCRealnameRemovalHandler(translator *carriercallback.CUCCRealnameRemovalTranslator, resolver *carriercallback.CUCCCardResolver, integration *integrationlog.Repository, config SystemConfigReader, logger *zap.Logger) *CUCCRealnameRemovalHandler {
return &CUCCRealnameRemovalHandler{translator: translator, resolver: resolver, integration: integration, config: config, logger: logger}
}
// Remove 接收联通解除实名结果,仅精确识别资源并留痕忽略。
// POST /api/callback/carriers/cucc/realname/remove
func (h *CUCCRealnameRemovalHandler) Remove(c *fiber.Ctx) error {
startedAt := time.Now()
enabled, err := carrierCallbackEnabled(c.UserContext(), h.config, constants.SystemConfigCarrierCallbackCUCCRealnameRemovalEnabled)
if err != nil {
if h.logger != nil {
h.logger.Error("读取联通解除实名回调启停配置失败,已按关闭处理并返回成功", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
if !enabled {
if err := recordDisabledCarrierCallback(c.UserContext(), h.integration, constants.IntegrationProviderCUCC, constants.IntegrationOperationCUCCRealnameRemovalCallback, "cucc-realname-remove", c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("联通解除实名回调已关闭,但留痕失败", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
if err := h.process(c.UserContext(), c.Body(), c.Get("Content-Type")); err != nil && h.logger != nil {
h.logger.Error("处理联通解除实名回调失败,已按运营商约定返回成功", zap.Error(err))
}
return sendCUCCSuccess(c, startedAt)
}
func (h *CUCCRealnameRemovalHandler) process(ctx context.Context, body []byte, contentType string) error {
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil {
return apperrors.New(apperrors.CodeInternalError, "联通解除实名回调能力未完整配置")
}
translated, translateErr := h.translator.Translate(body)
key := cuccRemovalIdempotencyKey(body, translated, translateErr == nil)
integrationID := "cucc-realname-remove:" + shortHash(key)
requestID := middleware.GetRequestIDFromContext(ctx)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: integrationID, IdempotencyKey: key,
Provider: constants.IntegrationProviderCUCC, Operation: constants.IntegrationOperationCUCCRealnameRemovalCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
if isAppConflict(err) {
return h.recordConflict(ctx, body, contentType, key, translated, requestID)
}
return err
}
if !created {
claimed, claimErr := h.claimPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
if translateErr != nil {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultInvalidPayload, "报文、嵌套 data 或字段校验失败")
}
cards, err := h.resolver.Resolve(ctx, translated.ICCID)
if err != nil {
return h.fail(ctx, log.IntegrationID, err)
}
if len(cards) == 0 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultNotFound, "精确列未找到卡")
}
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, "精确列匹配多张卡")
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultIgnored, "解除实名仅留痕,不修改本地实名事实")
}
func (h *CUCCRealnameRemovalHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameRemovalTranslation, requestID *string) error {
key := baseKey + ":conflict:" + shortHashBytes(body)
log, created, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IntegrationID: "cucc-realname-remove-conflict:" + shortHash(key), IdempotencyKey: key,
Provider: constants.IntegrationProviderCUCC, Operation: constants.IntegrationOperationCUCCRealnameRemovalCallback,
ResourceType: constants.CardObservationResourceTypeCard, ResourceKey: optionalHashedResourceKey(translated.ICCID),
RawPayload: body, ContentType: contentType, RequestID: requestID, CorrelationID: requestID,
})
if err != nil {
return err
}
if !created {
claimed, claimErr := h.claimPending(ctx, log)
if claimErr != nil || !claimed {
return claimErr
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, "同一解除实名语义对应不同载荷")
}
func (h *CUCCRealnameRemovalHandler) claimPending(ctx context.Context, log *model.IntegrationLog) (bool, error) {
if log == nil || log.Result != constants.IntegrationResultPending {
return false, nil
}
return h.integration.ClaimExpiredInboundPending(ctx, log.IntegrationID, constants.IntegrationInboundProcessingLease)
}
func (h *CUCCRealnameRemovalHandler) complete(ctx context.Context, integrationID, result, reason string) error {
_, err := h.integration.Complete(ctx, integrationID, integrationlog.Completion{Result: result, HTTPStatus: fiber.StatusOK, ResponseSummary: map[string]any{"reason": reason}})
return err
}
func (h *CUCCRealnameRemovalHandler) fail(ctx context.Context, integrationID string, original error) error {
if err := h.complete(ctx, integrationID, constants.IntegrationResultFailed, "内部处理失败"); err != nil && h.logger != nil {
h.logger.Error("联通解除实名回调失败终态写入失败", zap.String("integration_id", integrationID), zap.Error(err))
}
return original
}
func cuccRemovalIdempotencyKey(body []byte, translated carriercallback.CUCCRealnameRemovalTranslation, valid bool) string {
if valid {
semantic := strings.TrimSpace(translated.ICCID) + "\x00" + strings.TrimSpace(translated.DateChanged)
return "cucc-semantic:" + shortHash(semantic)
}
return "cucc-body:" + shortHashBytes(body)
}
type cuccSuccessResponse struct {
Code int `json:"code"`
Message string `json:"msg"`
Timestamp string `json:"timestamp"`
}
func sendCUCCSuccess(c *fiber.Ctx, startedAt time.Time) error {
body, err := sonic.Marshal(cuccSuccessResponse{Code: 200, Message: "success", Timestamp: startedAt.Format("2006-01-02 15:04:05")})
if err != nil {
return apperrors.Wrap(apperrors.CodeInternalError, err, "生成联通回调应答失败")
}
c.Type("json", "utf-8")
return c.Status(fiber.StatusOK).Send(body)
}

View File

@@ -0,0 +1,221 @@
package cardobservation
import (
"context"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// BestEffortSeriesDispatcher 将读入口触发失败降级为中文安全日志。
type BestEffortSeriesDispatcher struct {
trigger *cardapp.SeriesTrigger
logger *zap.Logger
jobs chan cardapp.SeriesRequest
deviceJobs chan cardapp.DeviceCardsSeriesRequest
deviceControlJobs chan cardapp.DeviceControlSeriesRequest
realnameJobs chan cardapp.RealnameCapabilitySeriesRequest
deviceSimBindingStore *postgres.DeviceSimBindingStore
carrierStore *postgres.CarrierStore
}
const bestEffortSeriesQueueSize = 1024
// NewBestEffortSeriesDispatcher 创建读入口观测序列分发器。
func NewBestEffortSeriesDispatcher(trigger *cardapp.SeriesTrigger, logger *zap.Logger, deviceSimBindingStore *postgres.DeviceSimBindingStore, carrierStore *postgres.CarrierStore) *BestEffortSeriesDispatcher {
dispatcher := &BestEffortSeriesDispatcher{
trigger: trigger, logger: logger,
jobs: make(chan cardapp.SeriesRequest, bestEffortSeriesQueueSize),
deviceJobs: make(chan cardapp.DeviceCardsSeriesRequest, bestEffortSeriesQueueSize),
deviceControlJobs: make(chan cardapp.DeviceControlSeriesRequest, bestEffortSeriesQueueSize),
realnameJobs: make(chan cardapp.RealnameCapabilitySeriesRequest, bestEffortSeriesQueueSize),
deviceSimBindingStore: deviceSimBindingStore, carrierStore: carrierStore,
}
go dispatcher.run()
return dispatcher
}
// DispatchDeviceControl 在后台解析设备控制前后的相关卡,避免增加原操作响应耗时。
func (d *BestEffortSeriesDispatcher) DispatchDeviceControl(_ context.Context, request cardapp.DeviceControlSeriesRequest) {
if d == nil || d.trigger == nil || request.DeviceID == 0 {
return
}
request.Request = normalizeRequest(request.Request)
select {
case d.deviceControlJobs <- request:
default:
d.logFailure(request.Request, "", false, nil, "后台设备控制观测队列已满")
}
}
// Dispatch 尝试创建或合并序列,失败不向原查询调用方返回。
func (d *BestEffortSeriesDispatcher) Dispatch(_ context.Context, request cardapp.SeriesRequest) {
if d == nil || d.trigger == nil {
return
}
request = normalizeRequest(request)
select {
case d.jobs <- request:
default:
d.logFailure(request, "", false, nil, "后台观测队列已满")
}
}
// DispatchDeviceCards 在后台展开设备当前有效绑定卡,避免请求协程逐卡查询数据库。
func (d *BestEffortSeriesDispatcher) DispatchDeviceCards(_ context.Context, request cardapp.DeviceCardsSeriesRequest) {
if d == nil || d.trigger == nil || request.DeviceID == 0 || d.deviceSimBindingStore == nil {
return
}
request.Request = normalizeRequest(request.Request)
select {
case d.deviceJobs <- request:
default:
d.logFailure(request.Request, "", false, nil, "后台设备观测队列已满")
}
}
// DispatchRealnameWithCapability 在后台读取运营商实名能力none 能力不触发观测。
func (d *BestEffortSeriesDispatcher) DispatchRealnameWithCapability(_ context.Context, request cardapp.RealnameCapabilitySeriesRequest) {
if d == nil || d.trigger == nil || request.CarrierID == 0 || d.carrierStore == nil {
return
}
request.Request = normalizeRequest(request.Request)
select {
case d.realnameJobs <- request:
default:
d.logFailure(request.Request, "", false, nil, "后台实名观测队列已满")
}
}
func (d *BestEffortSeriesDispatcher) run() {
for {
select {
case request := <-d.jobs:
d.triggerRequest(request)
case request := <-d.deviceJobs:
d.expandDeviceRequest(request)
case request := <-d.deviceControlJobs:
d.expandDeviceControlRequest(request)
case request := <-d.realnameJobs:
d.expandRealnameRequest(request)
}
}
}
func (d *BestEffortSeriesDispatcher) expandDeviceControlRequest(request cardapp.DeviceControlSeriesRequest) {
deviceRequest := request.Request
deviceRequest.ResourceType = constants.CardObservationResourceTypeDevice
deviceRequest.ResourceID = strconv.FormatUint(uint64(request.DeviceID), 10)
deviceRequest.SyncType = constants.CardObservationSyncTypeDeviceInfo
deviceRequest.ExpectedValue = strings.TrimSpace(request.TargetICCID)
d.triggerRequest(deviceRequest)
if strings.TrimSpace(request.TargetICCID) == "" {
for _, cardID := range request.BoundCardIDs {
d.triggerCardControlRequest(request.Request, cardID, constants.CardObservationSyncTypeNetwork)
}
return
}
d.triggerCardControlRequest(request.Request, request.SourceCardID, constants.CardObservationSyncTypeNetwork)
if request.TargetCardID != request.SourceCardID {
d.triggerCardControlRequest(request.Request, request.TargetCardID, constants.CardObservationSyncTypeNetwork)
}
if request.IncludeTargetTraffic {
d.triggerCardControlRequest(request.Request, request.TargetCardID, constants.CardObservationSyncTypeTraffic)
}
}
func (d *BestEffortSeriesDispatcher) triggerCardControlRequest(base cardapp.SeriesRequest, cardID uint, syncType string) {
if cardID == 0 {
return
}
base.ResourceType = constants.CardObservationResourceTypeCard
base.ResourceID = strconv.FormatUint(uint64(cardID), 10)
base.SyncType = syncType
base.ExpectedValue = ""
d.triggerRequest(base)
}
func (d *BestEffortSeriesDispatcher) triggerRequest(request cardapp.SeriesRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
seriesID, merged, err := d.trigger.Trigger(ctx, request)
cancel()
if err != nil {
d.logFailure(request, seriesID, merged, err, "后台观测序列触发失败")
}
}
func (d *BestEffortSeriesDispatcher) expandDeviceRequest(request cardapp.DeviceCardsSeriesRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
bindings, err := d.deviceSimBindingStore.ListByDeviceID(ctx, request.DeviceID)
cancel()
if err != nil {
d.logFailure(request.Request, "", false, err, "查询设备有效绑定卡失败")
return
}
for _, binding := range bindings {
if binding == nil || binding.IotCardID == 0 {
continue
}
cardRequest := request.Request
cardRequest.ResourceType = constants.CardObservationResourceTypeCard
cardRequest.ResourceID = strconv.FormatUint(uint64(binding.IotCardID), 10)
d.triggerRequest(cardRequest)
}
}
func (d *BestEffortSeriesDispatcher) expandRealnameRequest(request cardapp.RealnameCapabilitySeriesRequest) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
carrier, err := d.carrierStore.GetByID(ctx, request.CarrierID)
cancel()
if err != nil {
d.logFailure(request.Request, "", false, err, "查询运营商实名能力失败")
return
}
if carrier.RealnameLinkType == constants.RealnameLinkTypeNone {
return
}
d.triggerRequest(request.Request)
}
func normalizeRequest(request cardapp.SeriesRequest) cardapp.SeriesRequest {
request.RequestID = strings.TrimSpace(request.RequestID)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
if request.RequestID == "" && request.CorrelationID == "" {
request.RequestID = uuid.NewString()
request.CorrelationID = request.RequestID
} else if request.RequestID == "" {
request.RequestID = request.CorrelationID
} else if request.CorrelationID == "" {
request.CorrelationID = request.RequestID
}
return request
}
func (d *BestEffortSeriesDispatcher) logFailure(request cardapp.SeriesRequest, seriesID string, merged bool, err error, message string) {
if d.logger == nil {
return
}
fields := []zap.Field{
zap.String("scene", request.Scene),
zap.String("resource_type", request.ResourceType),
zap.String("resource_id", request.ResourceID),
zap.String("sync_type", request.SyncType),
zap.String("series_id", seriesID),
zap.Bool("merged", merged),
zap.String("request_id", request.RequestID),
zap.String("correlation_id", request.CorrelationID),
}
if err != nil {
fields = append(fields, zap.Error(err))
}
d.logger.Warn(message+",已保持原查询结果", fields...)
}
var _ cardapp.BestEffortSeriesDispatcher = (*BestEffortSeriesDispatcher)(nil)

View File

@@ -146,6 +146,7 @@ func (c *NetworkChangedConsumer) Consume(ctx context.Context, envelope outbox.De
if c == nil || c.db == nil { if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡网络事件消费者未配置") return errors.New(errors.CodeInternalError, "卡网络事件消费者未配置")
} }
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
if envelope.EventType != constants.OutboxEventTypeCardNetworkChanged || envelope.PayloadVersion != constants.CardNetworkChangedPayloadVersionV1 { if envelope.EventType != constants.OutboxEventTypeCardNetworkChanged || envelope.PayloadVersion != constants.CardNetworkChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡网络事件类型或版本不受支持") return errors.New(errors.CodeInvalidParam, "卡网络事件类型或版本不受支持")
} }
@@ -179,6 +180,7 @@ func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.D
if c == nil || c.db == nil { if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置") return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置")
} }
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 { if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持") return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持")
} }
@@ -235,6 +237,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo
if c == nil || c.db == nil || c.redis == nil || c.deductor == nil { if c == nil || c.db == nil || c.redis == nil || c.deductor == nil {
return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置") return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置")
} }
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 { if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持") return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持")
} }

View File

@@ -5,49 +5,87 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/bytedance/sonic"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
) )
// SeriesCoordinator 使用 Redis 原子协调活跃序列、幂等尝试和实际请求互斥。 // SeriesCoordinator 使用 Redis 原子协调活跃序列、幂等尝试和实际请求互斥。
type SeriesCoordinator struct { type SeriesCoordinator struct {
redis *redis.Client redis *redis.Client
now func() time.Time trafficLock *cardtrafficlock.Lock
now func() time.Time
}
// ClaimSchedule 保证每个固定阶梯任务只成功入队一次。
func (c *SeriesCoordinator) ClaimSchedule(ctx context.Context, seriesID string, attempt int) (bool, error) {
claimed, err := c.redis.SetNX(ctx, constants.RedisCardObservationScheduleKey(seriesID, attempt), "scheduled", constants.CardObservationSeriesResultTTL).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "领取卡观测序列入队槽位失败")
}
return claimed, nil
}
// IsScheduled 判断稳定业务事件是否已经创建过第一阶梯任务。
func (c *SeriesCoordinator) IsScheduled(ctx context.Context, seriesID string) (bool, error) {
keys := make([]string, 0, constants.CardObservationSeriesAttemptCount)
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
keys = append(keys, constants.RedisCardObservationScheduleKey(seriesID, attempt))
}
exists, err := c.redis.Exists(ctx, keys...).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "读取卡观测序列业务幂等状态失败")
}
return exists == int64(constants.CardObservationSeriesAttemptCount), nil
}
// ReleaseSchedule 在入队失败时释放槽位,允许后续同场景触发补齐原序列。
func (c *SeriesCoordinator) ReleaseSchedule(ctx context.Context, seriesID string, attempt int) {
_ = c.redis.Del(ctx, constants.RedisCardObservationScheduleKey(seriesID, attempt)).Err()
} }
// NewSeriesCoordinator 创建观测序列 Redis 协调器。 // NewSeriesCoordinator 创建观测序列 Redis 协调器。
func NewSeriesCoordinator(redisClient *redis.Client) *SeriesCoordinator { func NewSeriesCoordinator(redisClient *redis.Client) *SeriesCoordinator {
return &SeriesCoordinator{redis: redisClient, now: time.Now} return &SeriesCoordinator{redis: redisClient, trafficLock: cardtrafficlock.New(redisClient), now: time.Now}
} }
// Reserve 原子保留同场景活跃序列;重复触发不会刷新 TTL。 // Reserve 原子保留同场景活跃序列;重复触发不会刷新 TTL。
func (c *SeriesCoordinator) Reserve(ctx context.Context, request cardapp.SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (string, time.Time, bool, error) { func (c *SeriesCoordinator) Reserve(ctx context.Context, request cardapp.SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (string, time.Time, cardapp.SeriesRequest, bool, error) {
if c == nil || c.redis == nil { if c == nil || c.redis == nil {
return "", time.Time{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置") return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置")
}
requestJSON, err := sonic.Marshal(request)
if err != nil {
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInvalidParam, err, "序列原始请求无法保存")
} }
seriesKey := constants.RedisCardObservationSeriesKey(request.Scene, request.ResourceType, request.ResourceID, request.SyncType) seriesKey := constants.RedisCardObservationSeriesKey(request.Scene, request.ResourceType, request.ResourceID, request.SyncType)
indexKey := constants.RedisCardObservationSeriesIndexKey(request.ResourceType, request.ResourceID, request.SyncType) indexKey := constants.RedisCardObservationSeriesIndexKey(request.ResourceType, request.ResourceID, request.SyncType)
result, err := c.redis.Eval(ctx, ` result, err := c.redis.Eval(ctx, `
local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms') local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms', 'request_json')
if current[1] then return {current[1], current[2], '1'} end if current[1] then return {current[1], current[2], current[3], '1'} end
redis.call('HSET', KEYS[1], 'series_id', ARGV[1], 'base_time_ms', ARGV[2]) redis.call('HSET', KEYS[1], 'series_id', ARGV[1], 'base_time_ms', ARGV[2], 'request_json', ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[3]) redis.call('EXPIRE', KEYS[1], ARGV[4])
redis.call('SADD', KEYS[2], KEYS[1]) redis.call('SADD', KEYS[2], KEYS[1])
redis.call('EXPIRE', KEYS[2], ARGV[3]) redis.call('EXPIRE', KEYS[2], ARGV[4])
return {ARGV[1], ARGV[2], '0'} return {ARGV[1], ARGV[2], ARGV[3], '0'}
`, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice() `, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), string(requestJSON), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice()
if err != nil || len(result) != 3 { if err != nil || len(result) != 4 {
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, err, "保留卡观测序列失败") return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInternalError, err, "保留卡观测序列失败")
} }
baseTimeMS, parseErr := strconv.ParseInt(result[1], 10, 64) baseTimeMS, parseErr := strconv.ParseInt(result[1], 10, 64)
if parseErr != nil { if parseErr != nil {
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, parseErr, "解析卡观测序列基准时间失败") return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInternalError, parseErr, "解析卡观测序列基准时间失败")
} }
return result[0], time.UnixMilli(baseTimeMS).UTC(), result[2] == "1", nil var originalRequest cardapp.SeriesRequest
if unmarshalErr := sonic.Unmarshal([]byte(result[2]), &originalRequest); unmarshalErr != nil {
return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInternalError, unmarshalErr, "解析序列原始请求失败")
}
return result[0], time.UnixMilli(baseTimeMS).UTC(), originalRequest, result[3] == "1", nil
} }
// IsCompleted 判断序列是否已由预期状态或回调提前完成。 // IsCompleted 判断序列是否已由预期状态或回调提前完成。
@@ -70,14 +108,14 @@ func (c *SeriesCoordinator) ClaimAttempt(ctx context.Context, seriesID string, a
// AcquireRequest 获取仅覆盖实际 Gateway 请求的互斥,并返回最小间隔剩余等待时间。 // AcquireRequest 获取仅覆盖实际 Gateway 请求的互斥,并返回最小间隔剩余等待时间。
func (c *SeriesCoordinator) AcquireRequest(ctx context.Context, payload cardapp.SeriesTaskPayload, provider string) (func(), time.Duration, bool, error) { func (c *SeriesCoordinator) AcquireRequest(ctx context.Context, payload cardapp.SeriesTaskPayload, provider string) (func(), time.Duration, bool, error) {
token := uuid.NewString()
lockKey := constants.RedisCardObservationInflightKey(provider, payload.SyncType, payload.ResourceID)
// 流量观测复用现有卡级同步锁,避免事件、轮询和手动刷新并发读取同一上游读数。 // 流量观测复用现有卡级同步锁,避免事件、轮询和手动刷新并发读取同一上游读数。
if payload.SyncType == constants.CardObservationSyncTypeTraffic { if payload.SyncType == constants.CardObservationSyncTypeTraffic {
if cardID, parseErr := strconv.ParseUint(payload.ResourceID, 10, 64); parseErr == nil && cardID > 0 { if cardID, parseErr := strconv.ParseUint(payload.ResourceID, 10, 64); parseErr == nil && cardID > 0 {
lockKey = constants.RedisCardTrafficSyncLockKey(uint(cardID)) return c.acquireTrafficRequest(ctx, payload, provider, uint(cardID))
} }
} }
token := uuid.NewString()
lockKey := constants.RedisCardObservationInflightKey(provider, payload.SyncType, payload.ResourceID)
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID) lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
nowMS := c.now().UTC().UnixMilli() nowMS := c.now().UTC().UnixMilli()
result, err := c.redis.Eval(ctx, ` result, err := c.redis.Eval(ctx, `
@@ -105,6 +143,34 @@ return 0
return release, time.Duration(result[0]) * time.Millisecond, true, nil return release, time.Duration(result[0]) * time.Millisecond, true, nil
} }
func (c *SeriesCoordinator) acquireTrafficRequest(ctx context.Context, payload cardapp.SeriesTaskPayload, provider string, cardID uint) (func(), time.Duration, bool, error) {
token, acquired, err := c.trafficLock.Acquire(ctx, cardID)
if err != nil {
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡流量 Gateway 请求互斥失败")
}
if !acquired {
return func() {}, 0, false, nil
}
release := func() {
_ = c.trafficLock.Release(context.Background(), cardID, token)
}
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
nowMS := c.now().UTC().UnixMilli()
wait, err := c.redis.Eval(ctx, `
local now = tonumber(ARGV[1])
local minimum = tonumber(ARGV[2])
local last = tonumber(redis.call('GET', KEYS[1]) or '0')
local wait = math.max(0, last + minimum - now)
redis.call('PSETEX', KEYS[1], minimum * 3, now + wait)
return wait
`, []string{lastKey}, nowMS, constants.CardObservationGatewayMinInterval.Milliseconds()).Int64()
if err != nil {
release()
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡流量 Gateway 最小请求间隔失败")
}
return release, time.Duration(wait) * time.Millisecond, true, nil
}
// FinishAttempt 保存尝试终态,并在最后一次尝试后释放同场景活跃序列。 // FinishAttempt 保存尝试终态,并在最后一次尝试后释放同场景活跃序列。
func (c *SeriesCoordinator) FinishAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload) error { func (c *SeriesCoordinator) FinishAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
if err := c.redis.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, payload.Attempt), "completed", constants.CardObservationSeriesResultTTL).Err(); err != nil { if err := c.redis.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, payload.Attempt), "completed", constants.CardObservationSeriesResultTTL).Err(); err != nil {

View File

@@ -0,0 +1,119 @@
package cardobservation
import (
"context"
"strconv"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesEventWriter 将业务成功观测请求写入公共 Outbox。
type SeriesEventWriter struct {
outbox *outbox.Repository
}
// NewSeriesEventWriter 创建业务观测事件 Writer。
func NewSeriesEventWriter(repository *outbox.Repository) *SeriesEventWriter {
return &SeriesEventWriter{outbox: repository}
}
// AppendSeriesRequested 在原业务事务中追加稳定观测请求事件。
func (w *SeriesEventWriter) AppendSeriesRequested(ctx context.Context, tx *gorm.DB, event cardapp.SeriesRequestedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "业务观测 Outbox Writer 未配置")
}
if cardapp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if strings.TrimSpace(event.EventID) == "" || event.ResourceID == 0 || len(event.SyncTypes) == 0 {
return errors.New(errors.CodeInvalidParam, "业务观测事件载荷不完整")
}
if tx == nil {
return errors.New(errors.CodeInternalError, "业务观测事件缺少原业务事务")
}
if event.ResourceType == constants.CardObservationResourceTypeDevice {
event.ResourceIDs = nil
if err := tx.WithContext(ctx).
Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", event.ResourceID, constants.BindStatusBound).
Order("slot_position ASC").
Pluck("iot_card_id", &event.ResourceIDs).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "冻结业务观测设备绑定卡快照失败")
}
}
_, err := w.outbox.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeCardSeriesRequested,
PayloadVersion: constants.CardSeriesRequestedPayloadVersionV1,
AggregateType: event.ResourceType, AggregateID: strconv.FormatUint(uint64(event.ResourceID), 10),
ResourceType: event.ResourceType, ResourceID: strconv.FormatUint(uint64(event.ResourceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入业务观测 Outbox 事件失败")
}
return nil
}
// SeriesRequestedConsumer 消费业务成功事实并触发卡观测序列。
type SeriesRequestedConsumer struct {
trigger *cardapp.SeriesTrigger
}
// NewSeriesRequestedConsumer 创建业务观测序列消费者。
func NewSeriesRequestedConsumer(trigger *cardapp.SeriesTrigger) *SeriesRequestedConsumer {
return &SeriesRequestedConsumer{trigger: trigger}
}
// Consume 将一个业务事件展开为卡观测序列;重复投递由稳定序列键幂等。
func (c *SeriesRequestedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.trigger == nil {
return errors.New(errors.CodeInternalError, "业务观测序列消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardSeriesRequested || envelope.PayloadVersion != constants.CardSeriesRequestedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "业务观测事件类型或版本不受支持")
}
var event cardapp.SeriesRequestedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "业务观测事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.ResourceID == 0 || len(event.SyncTypes) == 0 {
return errors.New(errors.CodeInvalidParam, "业务观测事件载荷不完整")
}
if event.ResourceType != constants.CardObservationResourceTypeCard && event.ResourceType != constants.CardObservationResourceTypeDevice {
return errors.New(errors.CodeInvalidParam, "业务观测事件资源类型不受支持")
}
cardIDs := []uint{event.ResourceID}
if event.ResourceType == constants.CardObservationResourceTypeDevice {
cardIDs = event.ResourceIDs
}
if len(cardIDs) == 0 {
return nil
}
for _, cardID := range cardIDs {
for _, syncType := range event.SyncTypes {
request := cardapp.SeriesRequest{
Scene: event.Scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(cardID), 10), SyncType: syncType,
ExpectedValue: event.ExpectedValue, Source: event.Source,
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
}
key := event.EventID + ":" + strconv.FormatUint(uint64(cardID), 10) + ":" + syncType
if _, _, err := c.trigger.TriggerEvent(ctx, key, request); err != nil {
return err
}
}
}
return nil
}
var _ cardapp.SeriesEventWriter = (*SeriesEventWriter)(nil)
var _ outbox.EventConsumer = (*SeriesRequestedConsumer)(nil)

View File

@@ -37,14 +37,20 @@ func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.Series
resourceID := payload.ResourceID resourceID := payload.ResourceID
source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID
requestID, correlationID := optionalText(payload.RequestID), optionalText(payload.CorrelationID) requestID, correlationID := optionalText(payload.RequestID), optionalText(payload.CorrelationID)
_, err := l.repository.Start(ctx, integrationlog.Attempt{ attempt, err := l.repository.Start(ctx, integrationlog.Attempt{
IntegrationID: unsentIntegrationID(payload), Provider: constants.IntegrationProviderGateway, IntegrationID: unsentIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType), Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, TriggerSource: &source, ResourceType: payload.ResourceType, ResourceID: &resourceID, TriggerSource: &source,
TriggerScene: &scene, TriggerSeries: &seriesID, ScheduledAt: &payload.ScheduledAt, TriggerScene: &scene, TriggerSeries: &seriesID, ScheduledAt: &payload.ScheduledAt,
Attempt: payload.Attempt, InitialResult: result, RequestID: requestID, CorrelationID: correlationID, Attempt: payload.Attempt, InitialResult: initialResult(result), RequestID: requestID, CorrelationID: correlationID,
Metadata: map[string]any{"reason": reason, "sync_type": payload.SyncType}, Metadata: map[string]any{"reason": reason, "sync_type": payload.SyncType},
}) })
if err != nil || result != constants.IntegrationResultFailed {
return err
}
_, err = l.repository.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ResponseSummary: map[string]any{"reason": reason},
})
return err return err
} }
@@ -80,6 +86,12 @@ func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *ca
// Provider 返回用于请求互斥的运营商接入标识。 // Provider 返回用于请求互斥的运营商接入标识。
func (r *SeriesRunner) Provider(ctx context.Context, payload cardapp.SeriesTaskPayload) (string, error) { func (r *SeriesRunner) Provider(ctx context.Context, payload cardapp.SeriesTaskPayload) (string, error) {
if payload.ResourceType == constants.CardObservationResourceTypeDevice {
if _, err := r.loadDevice(ctx, payload); err != nil {
return "", err
}
return constants.CardObservationResourceTypeDevice, nil
}
card, err := r.loadCard(ctx, payload) card, err := r.loadCard(ctx, payload)
if err != nil { if err != nil {
return "", err return "", err
@@ -97,6 +109,18 @@ func (r *SeriesRunner) ExpectationMet(ctx context.Context, payload cardapp.Serie
if expected == "" { if expected == "" {
return false, nil return false, nil
} }
if payload.ResourceType == constants.CardObservationResourceTypeDevice && payload.SyncType == constants.CardObservationSyncTypeDeviceInfo {
currentICCIDs, err := r.loadCurrentICCIDs(ctx, payload)
if err != nil {
return false, err
}
for _, currentICCID := range currentICCIDs {
if strings.EqualFold(expected, strings.TrimSpace(currentICCID)) {
return true, nil
}
}
return false, nil
}
card, err := r.loadCard(ctx, payload) card, err := r.loadCard(ctx, payload)
if err != nil { if err != nil {
return false, err return false, err
@@ -120,6 +144,9 @@ func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayloa
if r == nil || r.db == nil || r.gateway == nil || r.observation == nil || r.integration == nil { if r == nil || r.db == nil || r.gateway == nil || r.observation == nil || r.integration == nil {
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInternalError, "卡观测序列 Gateway 能力未完整配置") return cardapp.RunResult{}, apperrors.New(apperrors.CodeInternalError, "卡观测序列 Gateway 能力未完整配置")
} }
if payload.ResourceType == constants.CardObservationResourceTypeDevice && payload.SyncType == constants.CardObservationSyncTypeDeviceInfo {
return r.runDeviceInfo(ctx, payload)
}
card, err := r.loadCard(ctx, payload) card, err := r.loadCard(ctx, payload)
if err != nil { if err != nil {
return cardapp.RunResult{}, err return cardapp.RunResult{}, err
@@ -148,6 +175,178 @@ func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayloa
return result, runErr return result, runErr
} }
func (r *SeriesRunner) runDeviceInfo(ctx context.Context, payload cardapp.SeriesTaskPayload) (cardapp.RunResult, error) {
device, err := r.loadDevice(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
}
attempt, err := r.startDeviceAttempt(ctx, payload, device)
if err != nil {
return cardapp.RunResult{}, err
}
startedAt := time.Now()
response, runErr := r.gateway.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: deviceGatewayIdentifier(device)})
result := cardapp.RunResult{}
if runErr == nil {
result.StateChanged, runErr = r.applyDeviceInfo(ctx, device, response)
}
completionResult := constants.IntegrationResultSuccess
if runErr != nil {
completionResult = constants.IntegrationResultFailed
if isRateLimited(runErr) {
completionResult = constants.IntegrationResultRateLimited
result.RateLimited = true
}
}
_, completeErr := r.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: completionResult, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: result.StateChanged,
ResponseSummary: map[string]any{"sync_type": payload.SyncType, "applied": runErr == nil},
})
if completeErr != nil {
return result, completeErr
}
return result, runErr
}
func (r *SeriesRunner) startDeviceAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, device *model.Device) (*model.IntegrationLog, error) {
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
resourceKey := "device:" + strconv.FormatUint(uint64(device.ID), 10)
return r.integration.Start(ctx, integrationlog.Attempt{
IntegrationID: gatewayIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, ResourceKey: &resourceKey,
TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
ScheduledAt: &payload.ScheduledAt, Attempt: payload.Attempt,
RequestSummary: map[string]any{"device_id": device.ID, "sync_type": payload.SyncType},
RequestID: optionalText(payload.RequestID), CorrelationID: optionalText(payload.CorrelationID),
})
}
func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp) (bool, error) {
if response == nil {
return false, apperrors.New(apperrors.CodeGatewayError, "Gateway 设备信息响应为空")
}
now := time.Now()
updates := map[string]any{
"online_status": int(response.OnlineStatus),
"software_version": string(response.SoftwareVersion),
"switch_mode": string(response.SwitchMode),
"last_gateway_sync_at": now,
}
if lastOnlineTime := parseGatewayTime(response.LastOnlineTime); lastOnlineTime != nil {
updates["last_online_time"] = lastOnlineTime
}
var currentBinding struct {
SlotPosition int
}
currentSlotErr := r.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("slot_position").
Where("device_id = ? AND bind_status = ? AND is_current = ?", device.ID, constants.BindStatusBound, true).
Take(&currentBinding).Error
if currentSlotErr != nil && currentSlotErr != gorm.ErrRecordNotFound {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, currentSlotErr, "读取设备当前槽位失败")
}
changed := device.OnlineStatus != int(response.OnlineStatus) ||
device.SoftwareVersion != string(response.SoftwareVersion) ||
device.SwitchMode != string(response.SwitchMode) ||
currentBinding.SlotPosition != int(response.CurrentSlotNo)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(updates).Error; err != nil {
return err
}
if err := tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", device.ID, constants.BindStatusBound).
Update("is_current", false).Error; err != nil {
return err
}
if int(response.CurrentSlotNo) <= 0 {
return nil
}
return tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND slot_position = ? AND bind_status = ?", device.ID, int(response.CurrentSlotNo), constants.BindStatusBound).
Update("is_current", true).Error
})
if err != nil {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败")
}
return changed, nil
}
func (r *SeriesRunner) loadDevice(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.Device, error) {
if payload.ResourceType != constants.CardObservationResourceTypeDevice {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测资源类型无效")
}
deviceID, err := strconv.ParseUint(payload.ResourceID, 10, 64)
if err != nil || deviceID == 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备观测资源 ID 无效")
}
var device model.Device
if err := r.db.WithContext(ctx).Where("id = ?", uint(deviceID)).First(&device).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, apperrors.New(apperrors.CodeNotFound, "设备不存在")
}
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询设备观测本地快照失败")
}
if deviceGatewayIdentifier(&device) == "" {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备缺少 Gateway 标识")
}
return &device, nil
}
func (r *SeriesRunner) loadCurrentICCIDs(ctx context.Context, payload cardapp.SeriesTaskPayload) ([]string, error) {
device, err := r.loadDevice(ctx, payload)
if err != nil {
return nil, err
}
var current struct {
ICCID string
ICCID19 string
ICCID20 *string
}
err = r.db.WithContext(ctx).Table("tb_device_sim_binding AS binding").
Select("card.iccid, card.iccid_19, card.iccid_20").
Joins("JOIN tb_iot_card AS card ON card.id = binding.iot_card_id AND card.deleted_at IS NULL").
Where("binding.device_id = ? AND binding.bind_status = ? AND binding.is_current = ? AND binding.deleted_at IS NULL", device.ID, constants.BindStatusBound, true).
Take(&current).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询设备当前卡失败")
}
iccids := []string{current.ICCID, current.ICCID19}
if current.ICCID20 != nil {
iccids = append(iccids, *current.ICCID20)
}
return iccids, nil
}
func deviceGatewayIdentifier(device *model.Device) string {
if strings.TrimSpace(device.IMEI) != "" {
return strings.TrimSpace(device.IMEI)
}
return strings.TrimSpace(device.SN)
}
func parseGatewayTime(raw gateway.FlexString) *time.Time {
value := strings.TrimSpace(string(raw))
if value == "" {
return nil
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return &parsed
}
timestamp, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return nil
}
if timestamp > 1_000_000_000_000 {
timestamp /= 1000
}
parsed := time.Unix(timestamp, 0)
return &parsed
}
func (r *SeriesRunner) startAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard) (*model.IntegrationLog, error) { func (r *SeriesRunner) startAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard) (*model.IntegrationLog, error) {
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
resourceKey := "card:" + formatCardID(card.ID) resourceKey := "card:" + formatCardID(card.ID)
@@ -235,7 +434,7 @@ func operationForSyncType(syncType string) string {
case constants.CardObservationSyncTypeNetwork: case constants.CardObservationSyncTypeNetwork:
return constants.IntegrationOperationGatewayNetwork return constants.IntegrationOperationGatewayNetwork
default: default:
return "query_device_info" return constants.IntegrationOperationGatewayDeviceInfo
} }
} }
@@ -255,5 +454,12 @@ func optionalText(value string) *string {
return &value return &value
} }
func initialResult(result string) string {
if result == constants.IntegrationResultFailed {
return constants.IntegrationResultPending
}
return result
}
var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil) var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil)
var _ cardapp.SeriesRunner = (*SeriesRunner)(nil) var _ cardapp.SeriesRunner = (*SeriesRunner)(nil)

View File

@@ -0,0 +1,49 @@
// Package cardtrafficlock 提供卡流量上游请求的统一 Redis 互斥。
package cardtrafficlock
import (
"context"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
var releaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`)
// Lock 统一卡流量上游请求互斥,防止过期持有者误删新锁。
type Lock struct {
redis *redis.Client
}
// New 创建卡流量请求锁。
func New(redisClient *redis.Client) *Lock {
return &Lock{redis: redisClient}
}
// Acquire 使用随机令牌获取至少覆盖完整 Gateway 重试窗口的卡级锁。
func (l *Lock) Acquire(ctx context.Context, cardID uint) (string, bool, error) {
if l == nil || l.redis == nil {
return "", true, nil
}
token := uuid.NewString()
acquired, err := l.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), token, constants.CardObservationGatewayLockTTL).Result()
if err != nil || !acquired {
return "", acquired, err
}
return token, true, nil
}
// Release 仅由仍持有相同随机令牌的调用方释放卡级锁。
func (l *Lock) Release(ctx context.Context, cardID uint, token string) error {
if l == nil || l.redis == nil || token == "" {
return nil
}
return releaseScript.Run(ctx, l.redis, []string{constants.RedisCardTrafficSyncLockKey(cardID)}, token).Err()
}

View File

@@ -0,0 +1,67 @@
package carriercallback
import (
"bytes"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
// CMCCRealnameTranslation 是移动实名 JSON 回调的受控语义。
type CMCCRealnameTranslation struct {
ICCID string
BusiSeq string
}
// CMCCRealnameTranslator 解析移动实名成功回调的固定 JSON 结构。
type CMCCRealnameTranslator struct{}
// CMCCCardResolver 复用系统级 ICCID 精确查询约束。
type CMCCCardResolver = CTCCCardResolver
// NewCMCCCardResolver 创建移动回调卡定位器。
func NewCMCCCardResolver(db *gorm.DB) *CMCCCardResolver {
return NewCTCCCardResolver(db)
}
// NewCMCCRealnameTranslator 创建移动实名回调 Translator。
func NewCMCCRealnameTranslator() *CMCCRealnameTranslator {
return &CMCCRealnameTranslator{}
}
type cmccRealnamePayload struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
RegStatus string `json:"regStatus"`
BusiSeq string `json:"busiSeq"`
ICCID string `json:"iccid"`
} `json:"result"`
}
// Translate 只接受 status=0、message=正确、首条结果实名成功且 ICCID 合法。
func (t *CMCCRealnameTranslator) Translate(body []byte) (CMCCRealnameTranslation, error) {
var result CMCCRealnameTranslation
if len(bytes.TrimSpace(body)) == 0 {
return result, errors.New(errors.CodeInvalidParam, "移动实名回调报文为空")
}
var payload cmccRealnamePayload
if err := sonic.Unmarshal(body, &payload); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "移动实名回调 JSON 解析失败")
}
if len(payload.Result) > 0 {
result.BusiSeq = strings.TrimSpace(payload.Result[0].BusiSeq)
result.ICCID = strings.TrimSpace(payload.Result[0].ICCID)
}
if payload.Status != "0" || payload.Message != "正确" || len(payload.Result) == 0 || payload.Result[0].RegStatus != "00000" {
return result, errors.New(errors.CodeInvalidParam, "移动实名回调业务结果无效")
}
if validation := pkgvalidator.ValidateICCIDWithoutCarrier(result.ICCID); !validation.Valid {
return result, errors.New(errors.CodeInvalidParam, "移动实名回调 ICCID 无效")
}
return result, nil
}

View File

@@ -0,0 +1,107 @@
// Package carriercallback 提供运营商回调协议到内部观测模型的适配能力。
package carriercallback
import (
"bytes"
"context"
"encoding/xml"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
const (
// CTCCRealnameActionVerified 表示电信实名补录已完成。
CTCCRealnameActionVerified = "verified"
// CTCCRealnameActionIgnored 表示报文合法但无需改变实名事实。
CTCCRealnameActionIgnored = "ignored"
)
// CTCCRealnameTranslation 是电信 XML 回调的受控内部语义。
type CTCCRealnameTranslation struct {
ICCID string
AcceptMessage string
ResultMessage string
GroupTransactionID string
Action string
}
type ctccContractRoot struct {
XMLName xml.Name `xml:"ContractRoot"`
ICCID string `xml:"ICCID"`
AcceptMessage string `xml:"ACCEPTMSG"`
ResultMessage string `xml:"RESULTMSG"`
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
}
// CTCCRealnameTranslator 解析已知电信 XML 协议字段。
type CTCCRealnameTranslator struct{}
// NewCTCCRealnameTranslator 创建电信实名回调 Translator。
func NewCTCCRealnameTranslator() *CTCCRealnameTranslator {
return &CTCCRealnameTranslator{}
}
// Translate 将 XML 转为受控语义;未知业务结果保留为 ignored。
func (t *CTCCRealnameTranslator) Translate(body []byte) (CTCCRealnameTranslation, error) {
var result CTCCRealnameTranslation
if len(bytes.TrimSpace(body)) == 0 {
return result, errors.New(errors.CodeInvalidParam, "电信实名回调报文为空")
}
var payload ctccContractRoot
if err := xml.Unmarshal(body, &payload); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "电信实名回调 XML 解析失败")
}
result.ICCID = strings.TrimSpace(payload.ICCID)
result.AcceptMessage = strings.TrimSpace(payload.AcceptMessage)
result.ResultMessage = strings.TrimSpace(payload.ResultMessage)
result.GroupTransactionID = strings.TrimSpace(payload.GroupTransactionID)
if err := t.validateICCID(result.ICCID); err != nil {
return result, err
}
result.Action = CTCCRealnameActionIgnored
if result.ResultMessage == "成功" && strings.Contains(result.AcceptMessage, "已完成实名信息补录") {
result.Action = CTCCRealnameActionVerified
}
return result, nil
}
func (t *CTCCRealnameTranslator) validateICCID(iccid string) error {
if result := pkgvalidator.ValidateICCIDWithoutCarrier(iccid); !result.Valid {
return errors.New(errors.CodeInvalidParam, "电信实名回调 ICCID 无效")
}
return nil
}
// CTCCCardResolver 使用系统级精确列查询定位电信回调卡。
type CTCCCardResolver struct {
db *gorm.DB
}
// NewCTCCCardResolver 创建电信回调卡定位器。
func NewCTCCCardResolver(db *gorm.DB) *CTCCCardResolver {
return &CTCCCardResolver{db: db}
}
// Resolve 按 ICCID 长度选择唯一列,禁止跨列降级。
func (r *CTCCCardResolver) Resolve(ctx context.Context, iccid string) ([]*model.IotCard, error) {
if r == nil || r.db == nil {
return nil, errors.New(errors.CodeInternalError, "电信实名回调卡查询能力未配置")
}
column := "iccid_19"
if len(iccid) == 20 {
column = "iccid_20"
}
var cards []*model.IotCard
if err := r.db.WithContext(ctx).
Where(column+" = ? AND deleted_at IS NULL", iccid).
Limit(2).
Find(&cards).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询电信实名回调卡失败")
}
return cards, nil
}

View File

@@ -0,0 +1,22 @@
package carriercallback
// CUCCRealnameTranslation 是联通实名成功回调的受控语义。
type CUCCRealnameTranslation = CUCCRealnameRemovalTranslation
// CUCCRealnameTranslator 解析联通外层 data 字符串及其内层实名 JSON。
type CUCCRealnameTranslator struct {
parser *CUCCRealnameRemovalTranslator
}
// NewCUCCRealnameTranslator 创建联通实名成功回调 Translator。
func NewCUCCRealnameTranslator() *CUCCRealnameTranslator {
return &CUCCRealnameTranslator{parser: NewCUCCRealnameRemovalTranslator()}
}
// Translate 解析联通实名成功报文;协议结构与解除实名报文一致。
func (t *CUCCRealnameTranslator) Translate(body []byte) (CUCCRealnameTranslation, error) {
if t == nil || t.parser == nil {
return NewCUCCRealnameRemovalTranslator().Translate(body)
}
return t.parser.Translate(body)
}

View File

@@ -0,0 +1,67 @@
package carriercallback
import (
"bytes"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/errors"
pkgvalidator "github.com/break/junhong_cmp_fiber/pkg/validator"
)
// CUCCRealnameRemovalTranslation 是联通解除实名回调的受控语义。
type CUCCRealnameRemovalTranslation struct {
ICCID string
DateChanged string
}
// CUCCRealnameRemovalTranslator 解析外层 data 字符串及其内层 JSON。
type CUCCRealnameRemovalTranslator struct{}
// NewCUCCRealnameRemovalTranslator 创建联通解除实名 Translator。
func NewCUCCRealnameRemovalTranslator() *CUCCRealnameRemovalTranslator {
return &CUCCRealnameRemovalTranslator{}
}
// Translate 解析并校验联通解除实名报文,不执行任何业务写入。
func (t *CUCCRealnameRemovalTranslator) Translate(body []byte) (CUCCRealnameRemovalTranslation, error) {
var result CUCCRealnameRemovalTranslation
if len(bytes.TrimSpace(body)) == 0 {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调报文为空")
}
var outer struct {
Data string `json:"data"`
}
if err := sonic.Unmarshal(body, &outer); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "联通解除实名外层 JSON 解析失败")
}
if strings.TrimSpace(outer.Data) == "" {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调 data 为空")
}
var inner struct {
ICCID string `json:"iccid"`
DateChanged string `json:"dateChanged"`
}
if err := sonic.Unmarshal([]byte(outer.Data), &inner); err != nil {
return result, errors.Wrap(errors.CodeInvalidParam, err, "联通解除实名内层 JSON 解析失败")
}
result.ICCID = strings.TrimSpace(inner.ICCID)
result.DateChanged = strings.TrimSpace(inner.DateChanged)
if validation := pkgvalidator.ValidateICCIDWithoutCarrier(result.ICCID); !validation.Valid {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调 ICCID 无效")
}
if result.DateChanged == "" {
return result, errors.New(errors.CodeInvalidParam, "联通解除实名回调变更时间为空")
}
return result, nil
}
// CUCCCardResolver 复用系统级 ICCID 精确查询约束。
type CUCCCardResolver = CTCCCardResolver
// NewCUCCCardResolver 创建联通回调卡定位器。
func NewCUCCCardResolver(db *gorm.DB) *CUCCCardResolver {
return NewCTCCCardResolver(db)
}

View File

@@ -187,7 +187,7 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置") return nil, false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
} }
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" || len(input.RawPayload) == 0 { if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 参数无效") return nil, false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 参数无效")
} }
if input.IntegrationID == "" { if input.IntegrationID == "" {
@@ -233,6 +233,29 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
return &existing, false, nil return &existing, false, nil
} }
// ClaimExpiredInboundPending 原子认领已超过处理租约的入站 pending 记录。
func (r *Repository) ClaimExpiredInboundPending(ctx context.Context, integrationID string, lease time.Duration) (bool, error) {
if r == nil || r.db == nil {
return false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
}
if strings.TrimSpace(integrationID) == "" || lease <= 0 {
return false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 恢复参数无效")
}
now := r.now().UTC()
result := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("integration_id = ? AND direction = ? AND result = ? AND (started_at IS NULL OR started_at <= ?)",
integrationID, constants.IntegrationDirectionInbound, constants.IntegrationResultPending, now.Add(-lease)).
Updates(map[string]any{
"started_at": now,
"attempt": gorm.Expr("attempt + 1"),
"updated_at": now,
})
if result.Error != nil {
return false, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, result.Error, "认领待恢复入站 Integration Log 失败")
}
return result.RowsAffected == 1, nil
}
func validateAttempt(input Attempt) error { func validateAttempt(input Attempt) error {
if input.Provider == "" || input.Operation == "" { if input.Provider == "" || input.Operation == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 提供方和操作不能为空") return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 提供方和操作不能为空")
@@ -250,6 +273,7 @@ func isTerminalResult(result string) bool {
switch result { switch result {
case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown, case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,
constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored, constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored,
constants.IntegrationResultConflict,
constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted,
constants.IntegrationResultCancelled: constants.IntegrationResultCancelled:
return true return true

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/datatypes" "gorm.io/datatypes"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/asynctask" "github.com/break/junhong_cmp_fiber/pkg/asynctask"
@@ -42,6 +43,15 @@ func NewRepository() *Repository {
// Append 在业务事务中持久化稳定事件身份和必要快照。 // Append 在业务事务中持久化稳定事件身份和必要快照。
func (r *Repository) Append(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) { func (r *Repository) Append(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) {
return r.append(ctx, tx, envelope, false)
}
// AppendIdempotent 在稳定 EventID 重投时保持原事件,不重复创建事实。
func (r *Repository) AppendIdempotent(ctx context.Context, tx *gorm.DB, envelope Envelope) (*model.OutboxEvent, error) {
return r.append(ctx, tx, envelope, true)
}
func (r *Repository) append(ctx context.Context, tx *gorm.DB, envelope Envelope, idempotent bool) (*model.OutboxEvent, error) {
if tx == nil { if tx == nil {
return nil, stderrors.New("Outbox 追加必须传入 GORM 事务句柄") return nil, stderrors.New("Outbox 追加必须传入 GORM 事务句柄")
} }
@@ -71,7 +81,11 @@ func (r *Repository) Append(ctx context.Context, tx *gorm.DB, envelope Envelope)
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, Payload: datatypes.JSON(payload), RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, Payload: datatypes.JSON(payload),
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now, Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
} }
if err := tx.WithContext(ctx).Create(event).Error; err != nil { create := tx.WithContext(ctx)
if idempotent {
create = create.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "event_id"}}, DoNothing: true})
}
if err := create.Create(event).Error; err != nil {
return nil, err return nil, err
} }
return event, nil return event, nil

View File

@@ -2,9 +2,9 @@ package dto
// GrantPackageItem 授权套餐操作项(用于创建/管理套餐列表) // GrantPackageItem 授权套餐操作项(用于创建/管理套餐列表)
type GrantPackageItem struct { type GrantPackageItem struct {
PackageID uint `json:"package_id" validate:"required" description:"套餐ID"` PackageID uint `json:"package_id" validate:"required" required:"true" minimum:"1" description:"套餐ID"`
CostPrice int64 `json:"cost_price" validate:"required,min=0" description:"成本价(分)"` CostPrice *int64 `json:"cost_price,omitempty" validate:"omitempty,min=0" minimum:"0" description:"成本价(分),新增或调价时必填"`
Remove *bool `json:"remove,omitempty" description:"是否删除该套餐授权true=删除)"` Remove *bool `json:"remove,omitempty" description:"是否删除该套餐授权true=删除)"`
} }
// ShopSeriesGrantPackageItem 授权套餐详情(响应中的套餐信息) // ShopSeriesGrantPackageItem 授权套餐详情(响应中的套餐信息)
@@ -56,13 +56,13 @@ type ShopSeriesGrantResponse struct {
// CreateShopSeriesGrantRequest 创建系列授权请求 // CreateShopSeriesGrantRequest 创建系列授权请求
type CreateShopSeriesGrantRequest struct { type CreateShopSeriesGrantRequest struct {
ShopID uint `json:"shop_id" validate:"required" description:"被授权代理店铺ID"` ShopID uint `json:"shop_id" validate:"required" required:"true" minimum:"1" description:"被授权代理店铺ID"`
SeriesID uint `json:"series_id" validate:"required" description:"套餐系列ID"` SeriesID uint `json:"series_id" validate:"required" required:"true" minimum:"1" description:"套餐系列ID"`
OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"固定模式佣金金额(分),固定模式必填"` OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"固定模式佣金金额(分),固定模式必填"`
CommissionTiers []GrantCommissionTierItem `json:"commission_tiers,omitempty" description:"梯度模式阶梯配置,梯度模式必填"` CommissionTiers []GrantCommissionTierItem `json:"commission_tiers,omitempty" description:"梯度模式阶梯配置,梯度模式必填"`
EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用代理强充"` EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用代理强充"`
ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"` ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"代理强充金额(分)"`
Packages []GrantPackageItem `json:"packages,omitempty" description:"初始授权套餐列表"` Packages []GrantPackageItem `json:"packages" validate:"required,min=1,max=100,dive" required:"true" minItems:"1" maxItems:"100" description:"初始授权套餐列表1100项套餐ID不可重复"`
ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"`
ExpiryBaseOverrideSet bool `json:"-"` ExpiryBaseOverrideSet bool `json:"-"`
} }
@@ -77,7 +77,7 @@ type UpdateShopSeriesGrantRequest struct {
// ManageGrantPackagesRequest 管理授权套餐请求 // ManageGrantPackagesRequest 管理授权套餐请求
type ManageGrantPackagesRequest struct { type ManageGrantPackagesRequest struct {
Packages []GrantPackageItem `json:"packages" validate:"required,min=1" description:"套餐操作列表"` Packages []GrantPackageItem `json:"packages" validate:"required,min=1,max=100,dive" required:"true" minItems:"1" maxItems:"100" description:"套餐操作列表1100项套餐ID不可重复"`
ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"`
ExpiryBaseOverrideSet bool `json:"-"` ExpiryBaseOverrideSet bool `json:"-"`
} }

View File

@@ -52,6 +52,42 @@ func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler,
}) })
} }
// registerCarrierCallbackRoutes 注册运营商业务回调路由。
func registerCarrierCallbackRoutes(router fiber.Router, handler *callback.CTCCRealnameHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/carriers/ctcc/realname", handler.Realname, RouteSpec{
Summary: "电信实名结果回调",
Description: "接收电信 XML 实名补录结果,固定返回运营商成功应答。",
Tags: []string{"运营商回调"},
Input: nil,
Output: nil,
Auth: false,
})
}
// registerCMCCCallbackRoutes 注册中国移动实名回调路由。
func registerCMCCCallbackRoutes(router fiber.Router, handler *callback.CMCCRealnameHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/carriers/cmcc/realname", handler.Realname, RouteSpec{
Summary: "移动实名结果回调", Description: "接收中国移动 JSON 实名成功结果,固定返回运营商成功应答。",
Tags: []string{"运营商回调"}, Input: nil, Output: nil, Auth: false,
})
}
// registerCUCCRealnameCallbackRoutes 注册中国联通实名成功回调路由。
func registerCUCCRealnameCallbackRoutes(router fiber.Router, handler *callback.CUCCRealnameHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/carriers/cucc/realname", handler.Realname, RouteSpec{
Summary: "联通实名结果回调", Description: "接收中国联通嵌套 JSON 实名成功结果,固定返回运营商成功应答。",
Tags: []string{"运营商回调"}, Input: nil, Output: nil, Auth: false,
})
}
// registerCUCCCallbackRoutes 注册中国联通解除实名回调路由。
func registerCUCCCallbackRoutes(router fiber.Router, handler *callback.CUCCRealnameRemovalHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/carriers/cucc/realname/remove", handler.Remove, RouteSpec{
Summary: "联通解除实名回调", Description: "识别中国联通解除实名 JSON仅留痕且不修改本地实名事实。",
Tags: []string{"运营商回调"}, Input: nil, Output: nil, Auth: false,
})
}
// registerPaymentCallbackRoutes 注册支付回调路由 // registerPaymentCallbackRoutes 注册支付回调路由
func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) { func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{ Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{

View File

@@ -46,4 +46,20 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew
callbackGroup := app.Group("/api/callback") callbackGroup := app.Group("/api/callback")
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback") registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")
} }
if handlers.CTCCRealnameCallback != nil {
callbackGroup := app.Group("/api/callback")
registerCarrierCallbackRoutes(callbackGroup, handlers.CTCCRealnameCallback, doc, "/api/callback")
}
if handlers.CMCCRealnameCallback != nil {
callbackGroup := app.Group("/api/callback")
registerCMCCCallbackRoutes(callbackGroup, handlers.CMCCRealnameCallback, doc, "/api/callback")
}
if handlers.CUCCRealnameCallback != nil {
callbackGroup := app.Group("/api/callback")
registerCUCCRealnameCallbackRoutes(callbackGroup, handlers.CUCCRealnameCallback, doc, "/api/callback")
}
if handlers.CUCCRealnameRemovalCallback != nil {
callbackGroup := app.Group("/api/callback")
registerCUCCCallbackRoutes(callbackGroup, handlers.CUCCRealnameRemovalCallback, doc, "/api/callback")
}
} }

View File

@@ -4,9 +4,11 @@ import (
"context" "context"
"fmt" "fmt"
"math/rand" "math/rand"
"strconv"
"strings" "strings"
"time" "time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet" domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -38,6 +40,12 @@ type Service struct {
agentWalletStore *postgres.AgentWalletStore agentWalletStore *postgres.AgentWalletStore
deviceSimBindingStore *postgres.DeviceSimBindingStore deviceSimBindingStore *postgres.DeviceSimBindingStore
deviceStore *postgres.DeviceStore deviceStore *postgres.DeviceStore
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesDispatcher 注入 OpenAPI 读取后的后台观测序列端口。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
} }
// New 创建代理开放接口业务编排服务 // New 创建代理开放接口业务编排服务
@@ -83,7 +91,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
if cardErr == nil { if cardErr == nil {
// 独立卡:直接查卡维度流量 // 独立卡:直接查卡维度流量
if card.IsStandalone { if card.IsStandalone {
return s.buildCardTrafficResponse(ctx, req.CardNo, "iot_card", card.ID, "") resp, err := s.buildCardTrafficResponse(ctx, req.CardNo, "iot_card", card.ID, "")
if err == nil {
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardTraffic, card.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, err
} }
// 已绑定设备的卡:反查设备后查设备维度流量 // 已绑定设备的卡:反查设备后查设备维度流量
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID) binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
@@ -94,7 +106,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
if err != nil { if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定设备信息失败") return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定设备信息失败")
} }
return s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo) resp, buildErr := s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
if buildErr == nil {
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardTraffic, card.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, buildErr
} }
// 兜底:尝试解析为设备标识(支持 IMEI/虚拟号) // 兜底:尝试解析为设备标识(支持 IMEI/虚拟号)
@@ -103,7 +119,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ
// 两种解析都失败,返回原始卡解析错误(语义更贴近入参) // 两种解析都失败,返回原始卡解析错误(语义更贴近入参)
return nil, cardErr return nil, cardErr
} }
return s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo) resp, buildErr := s.buildCardTrafficResponse(ctx, req.CardNo, "device", device.ID, device.VirtualNo)
if buildErr == nil {
s.dispatchDeviceCardObservations(ctx, constants.CardObservationSceneOpenCardTraffic, device.ID, constants.CardObservationSyncTypeTraffic)
}
return resp, buildErr
} }
// buildCardTrafficResponse 统一构造卡流量响应,支持卡和设备两种载体维度 // buildCardTrafficResponse 统一构造卡流量响应,支持卡和设备两种载体维度
@@ -167,14 +187,16 @@ func (s *Service) GetCardStatus(ctx context.Context, req *dto.AgentOpenAPICardQu
stopReason = "" stopReason = ""
} }
return &dto.AgentOpenAPICardStatusResponse{ resp := &dto.AgentOpenAPICardStatusResponse{
CardNo: req.CardNo, CardNo: req.CardNo,
CardStatus: cardStatus, CardStatus: cardStatus,
CardStatusName: constants.AgentOpenAPICardStatusName(cardStatus), CardStatusName: constants.AgentOpenAPICardStatusName(cardStatus),
GatewayExtend: card.GatewayExtend, GatewayExtend: card.GatewayExtend,
StopReason: stopReason, StopReason: stopReason,
StopReasonName: constants.AgentOpenAPIStopReasonName(stopReason), StopReasonName: constants.AgentOpenAPIStopReasonName(stopReason),
}, nil }
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardNetwork, card.ID, constants.CardObservationSyncTypeNetwork)
return resp, nil
} }
// GetRealnameStatus 查询开放接口单卡实名状态 // GetRealnameStatus 查询开放接口单卡实名状态
@@ -184,10 +206,12 @@ func (s *Service) GetRealnameStatus(ctx context.Context, req *dto.AgentOpenAPICa
return nil, err return nil, err
} }
return &dto.AgentOpenAPIRealnameStatusResponse{ resp := &dto.AgentOpenAPIRealnameStatusResponse{
CardNo: req.CardNo, CardNo: req.CardNo,
IsRealnamed: card.RealNameStatus == constants.RealNameStatusVerified, IsRealnamed: card.RealNameStatus == constants.RealNameStatusVerified,
}, nil }
s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardRealname, card.ID, constants.CardObservationSyncTypeRealname)
return resp, nil
} }
// ResumeCard 调用上游复机机卡分离停机卡 // ResumeCard 调用上游复机机卡分离停机卡
@@ -396,6 +420,41 @@ func (s *Service) CreateWalletPackageOrders(ctx context.Context, req *dto.AgentO
return resp, nil return resp, nil
} }
func (s *Service) dispatchDeviceCardObservations(ctx context.Context, scene string, deviceID uint, syncType string) {
if s.observationSeries == nil || deviceID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
s.observationSeries.DispatchDeviceCards(ctx, cardObservationApp.DeviceCardsSeriesRequest{
DeviceID: deviceID,
Request: cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeDevice,
ResourceID: strconv.FormatUint(uint64(deviceID), 10), SyncType: syncType,
Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
},
})
}
func (s *Service) dispatchCardObservation(ctx context.Context, scene string, cardID uint, syncType string) {
if s.observationSeries == nil || cardID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
s.observationSeries.Dispatch(ctx, cardObservationApp.SeriesRequest{
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(cardID), 10), SyncType: syncType,
Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
})
}
// resolveOpenAPIDevice 将开放接口设备标识解析为当前代理可见的设备 // resolveOpenAPIDevice 将开放接口设备标识解析为当前代理可见的设备
// 设备不存在或不属于代理管辖店铺时统一返回 CodeForbidden避免信息泄露 // 设备不存在或不属于代理管辖店铺时统一返回 CodeForbidden避免信息泄露
func (s *Service) resolveOpenAPIDevice(ctx context.Context, deviceNo string) (*model.Device, error) { func (s *Service) resolveOpenAPIDevice(ctx context.Context, deviceNo string) (*model.Device, error) {
@@ -473,6 +532,7 @@ func (s *Service) GetDeviceTraffic(ctx context.Context, req *dto.AgentOpenAPIDev
resp.PendingPackages = append(resp.PendingPackages, s.buildTrafficItem(usage, packageMap, seriesMap, false)) resp.PendingPackages = append(resp.PendingPackages, s.buildTrafficItem(usage, packageMap, seriesMap, false))
} }
s.dispatchDeviceCardObservations(ctx, constants.CardObservationSceneOpenDeviceTraffic, device.ID, constants.CardObservationSyncTypeTraffic)
return resp, nil return resp, nil
} }

View File

@@ -129,6 +129,7 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt
) )
return err return err
} }
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{ if err = s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{
CardNo: imei, CardNo: imei,
Params: gateway.WiFiParams{ Params: gateway.WiFiParams{
@@ -175,6 +176,7 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt
0, 0,
nil, nil,
) )
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSetWiFi, observation, false)
return nil return nil
} }
@@ -200,6 +202,7 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req
) )
return err return err
} }
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, req.TargetICCID)
if err = s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{ if err = s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{
CardNo: imei, CardNo: imei,
ICCID: req.TargetICCID, ICCID: req.TargetICCID,
@@ -233,6 +236,7 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req
0, 0,
nil, nil,
) )
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchCard, observation, true)
return nil return nil
} }
@@ -255,6 +259,7 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er
) )
return err return err
} }
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{ if err = s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei, DeviceID: imei,
}); err != nil { }); err != nil {
@@ -287,6 +292,7 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er
0, 0,
nil, nil,
) )
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReboot, observation, false)
return nil return nil
} }
@@ -309,6 +315,7 @@ func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) err
) )
return err return err
} }
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{ if err = s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei, DeviceID: imei,
}); err != nil { }); err != nil {
@@ -341,6 +348,7 @@ func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) err
0, 0,
nil, nil,
) )
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReset, observation, false)
return nil return nil
} }
@@ -556,6 +564,7 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req
) )
return appErr return appErr
} }
observation := s.captureDeviceControlObservation(ctx, device.ID, targetCard.ID, targetCard.ICCID)
if err = s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{ if err = s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{
CardNo: imei, CardNo: imei,
SwitchMode: strconv.Itoa(switchMode), SwitchMode: strconv.Itoa(switchMode),
@@ -590,5 +599,6 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req
0, 0,
nil, nil,
) )
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchMode, observation, true)
return nil return nil
} }

View File

@@ -2,8 +2,11 @@ package device
import ( import (
"context" "context"
"strings"
"time" "time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/google/uuid"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
@@ -37,6 +40,93 @@ type Service struct {
enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore
enterpriseStore *postgres.EnterpriseStore enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query packageExpiryQuery *packageexpiry.Query
observationSeriesEvents cardObservationApp.SeriesEventWriter
observationSeries cardObservationApp.BestEffortSeriesDispatcher
}
// SetObservationSeriesEventWriter 注入设备停复机成功观测序列 Outbox Writer。
func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
}
// SetObservationSeriesDispatcher 注入设备控制成功后的后台观测分发器。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
}
type deviceControlObservationSnapshot struct {
SourceCardID uint
TargetCardID uint
BoundCardIDs []uint
TargetICCID string
}
func (s *Service) captureDeviceControlObservation(ctx context.Context, deviceID, knownTargetCardID uint, targetICCID string) deviceControlObservationSnapshot {
snapshot := deviceControlObservationSnapshot{TargetICCID: targetICCID}
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
logger.GetAppLogger().Warn("冻结设备控制观测绑定快照失败,继续执行原操作", zap.Uint("device_id", deviceID), zap.Error(err))
return snapshot
}
bound := make(map[uint]struct{}, len(bindings))
for _, binding := range bindings {
if binding == nil || binding.IotCardID == 0 {
continue
}
snapshot.BoundCardIDs = append(snapshot.BoundCardIDs, binding.IotCardID)
bound[binding.IotCardID] = struct{}{}
if binding.IsCurrent {
snapshot.SourceCardID = binding.IotCardID
}
}
if knownTargetCardID != 0 {
if _, ok := bound[knownTargetCardID]; ok {
snapshot.TargetCardID = knownTargetCardID
}
return snapshot
}
if strings.TrimSpace(targetICCID) == "" || len(snapshot.BoundCardIDs) == 0 {
return snapshot
}
cards, err := s.iotCardStore.GetByIDs(ctx, snapshot.BoundCardIDs)
if err != nil {
logger.GetAppLogger().Warn("冻结设备控制观测目标卡快照失败,继续执行原操作", zap.Uint("device_id", deviceID), zap.String("target_iccid", targetICCID), zap.Error(err))
return snapshot
}
for _, card := range cards {
if card != nil && cardMatchesICCID(card, targetICCID) {
snapshot.TargetCardID = card.ID
break
}
}
return snapshot
}
func cardMatchesICCID(card *model.IotCard, expected string) bool {
expected = strings.TrimSpace(expected)
return strings.EqualFold(strings.TrimSpace(card.ICCID), expected) ||
strings.EqualFold(strings.TrimSpace(card.ICCID19), expected) ||
(card.ICCID20 != nil && strings.EqualFold(strings.TrimSpace(*card.ICCID20), expected))
}
func (s *Service) dispatchDeviceControlObservation(ctx context.Context, deviceID uint, scene string, snapshot deviceControlObservationSnapshot, includeTargetTraffic bool) {
if s.observationSeries == nil || deviceID == 0 {
return
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
request := cardObservationApp.SeriesRequest{
Scene: scene, Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
}
s.observationSeries.DispatchDeviceControl(ctx, cardObservationApp.DeviceControlSeriesRequest{
DeviceID: deviceID, TargetICCID: snapshot.TargetICCID,
SourceCardID: snapshot.SourceCardID, TargetCardID: snapshot.TargetCardID,
BoundCardIDs: snapshot.BoundCardIDs,
IncludeTargetTraffic: includeTargetTraffic, Request: request,
})
} }
// SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。 // SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。
@@ -1678,11 +1768,11 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus
} }
now := time.Now() now := time.Now()
if dbErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{ if dbErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOffline, "network_status": constants.NetworkStatusOffline,
"stopped_at": now, "stopped_at": now,
"stop_reason": constants.StopReasonManual, "stop_reason": constants.StopReasonManual,
}); dbErr != nil { }, constants.CardObservationSceneBusinessStop, "offline", s.gatewayClient != nil); dbErr != nil {
log.Error("设备停机-更新卡状态失败", log.Error("设备停机-更新卡状态失败",
zap.Uint("card_id", card.ID), zap.Uint("card_id", card.ID),
zap.Error(dbErr)) zap.Error(dbErr))
@@ -1699,7 +1789,7 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus
// 成功停机至少一张卡后设置保护期 // 成功停机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil { if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), 1, constants.DeviceProtectPeriodDuration) s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start")) s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start"))
} }
@@ -1907,11 +1997,11 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
} }
now := time.Now() now := time.Now()
if dbErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{ if dbErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline, "network_status": constants.NetworkStatusOnline,
"resumed_at": now, "resumed_at": now,
"stop_reason": "", "stop_reason": "",
}); dbErr != nil { }, constants.CardObservationSceneBusinessResume, "online", s.gatewayClient != nil); dbErr != nil {
log.Error("设备复机-更新卡状态失败", log.Error("设备复机-更新卡状态失败",
zap.Uint("card_id", card.ID), zap.Uint("card_id", card.ID),
zap.Error(dbErr)) zap.Error(dbErr))
@@ -1926,7 +2016,7 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
// 成功复机至少一张卡后设置保护期 // 成功复机至少一张卡后设置保护期
if successCount > 0 && s.redis != nil { if successCount > 0 && s.redis != nil {
s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), 1, constants.DeviceProtectPeriodDuration) s.redis.Set(ctx, constants.RedisDeviceProtectKey(deviceID, "start"), uuid.NewString(), constants.DeviceProtectPeriodDuration)
s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")) s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "stop"))
} }
@@ -2089,3 +2179,29 @@ func (s *Service) invalidatePollingCardCache(cardID uint) {
} }
_ = s.redis.Del(context.Background(), constants.RedisPollingCardInfoKey(cardID)).Err() _ = s.redis.Del(context.Background(), constants.RedisPollingCardInfoKey(cardID)).Err()
} }
func (s *Service) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected string, upstreamCalled bool) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新设备绑定卡停复机状态失败")
}
if !upstreamCalled || cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "设备停复机观测 Outbox Writer 未配置")
}
operationID := uuid.NewString()
requestID := operationID
if value := middleware.GetRequestIDFromContext(ctx); value != nil && *value != "" {
requestID = *value
}
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: "card-observation:network-command:" + operationID,
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
RequestID: requestID, CorrelationID: requestID,
})
})
}

View File

@@ -2,8 +2,12 @@ package iot_card
import ( import (
"context" "context"
"strconv"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -50,11 +54,35 @@ func (s *Service) GatewayQueryRealnameStatus(ctx context.Context, iccid string)
// GatewayGetRealnameLink 获取实名认证跳转链接 // GatewayGetRealnameLink 获取实名认证跳转链接
func (s *Service) GatewayGetRealnameLink(ctx context.Context, iccid string) (*gateway.RealnameLinkResp, error) { func (s *Service) GatewayGetRealnameLink(ctx context.Context, iccid string) (*gateway.RealnameLinkResp, error) {
if err := s.validateCardAccess(ctx, iccid); err != nil { card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "卡不存在或无权限访问")
}
response, err := s.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
CardNo: iccid,
})
if err != nil {
return nil, err return nil, err
} }
return s.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{ s.dispatchAdminRealnameObservation(ctx, card)
CardNo: iccid, return response, nil
}
func (s *Service) dispatchAdminRealnameObservation(ctx context.Context, card *model.IotCard) {
if s.observationSeries == nil || card == nil {
return
}
requestID := requestIDFromContext(ctx)
s.observationSeries.DispatchRealnameWithCapability(ctx, cardapp.RealnameCapabilitySeriesRequest{
CarrierID: card.CarrierID,
Request: cardapp.SeriesRequest{
Scene: constants.CardObservationSceneAdminRealnameLink,
ResourceType: constants.CardObservationResourceTypeCard,
ResourceID: strconv.FormatUint(uint64(card.ID), 10),
SyncType: constants.CardObservationSyncTypeRealname,
ExpectedValue: "verified", Source: constants.CardObservationSourceBusinessEvent,
RequestID: requestID, CorrelationID: requestID,
},
}) })
} }

View File

@@ -9,6 +9,7 @@ import (
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/model/dto"
packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
@@ -24,8 +25,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const cardTrafficSyncLockTTL = 10 * time.Minute
// PollingCallback 轮询回调接口 // PollingCallback 轮询回调接口
// 用于在卡生命周期事件发生时通知轮询调度器 // 用于在卡生命周期事件发生时通知轮询调度器
type PollingCallback interface { type PollingCallback interface {
@@ -71,6 +70,13 @@ type Service struct {
enterpriseStore *postgres.EnterpriseStore enterpriseStore *postgres.EnterpriseStore
packageExpiryQuery *packageexpiry.Query packageExpiryQuery *packageexpiry.Query
cardObservation *cardapp.Service cardObservation *cardapp.Service
observationSeries cardapp.BestEffortSeriesDispatcher
trafficLock *cardtrafficlock.Lock
}
// SetObservationSeriesDispatcher 注入获取实名链接后的后台观测端口。
func (s *Service) SetObservationSeriesDispatcher(dispatcher cardapp.BestEffortSeriesDispatcher) {
s.observationSeries = dispatcher
} }
// SetCardObservationService 注入统一卡实名观测写入用例。 // SetCardObservationService 注入统一卡实名观测写入用例。
@@ -152,22 +158,17 @@ func (s *Service) SetDeviceSimBindingStore(deviceSimBindingStore *postgres.Devic
// 用于清理实名逆转计数器,避免历史计数干扰手动实名后的判定 // 用于清理实名逆转计数器,避免历史计数干扰手动实名后的判定
func (s *Service) SetRedisClient(redisClient *redis.Client) { func (s *Service) SetRedisClient(redisClient *redis.Client) {
s.redis = redisClient s.redis = redisClient
s.trafficLock = cardtrafficlock.New(redisClient)
} }
// acquireCardTrafficSyncLock 获取卡流量同步锁,避免主动刷新与轮询重复统计同一上游读数。 // acquireCardTrafficSyncLock 获取卡流量同步锁,避免主动刷新与轮询重复统计同一上游读数。
func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (bool, error) { func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (string, bool, error) {
if s.redis == nil { return s.trafficLock.Acquire(ctx, cardID)
return true, nil
}
return s.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), "1", cardTrafficSyncLockTTL).Result()
} }
// releaseCardTrafficSyncLock 释放卡流量同步锁。 // releaseCardTrafficSyncLock 释放卡流量同步锁。
func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint) { func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint, token string) {
if s.redis == nil { if err := s.trafficLock.Release(ctx, cardID, token); err != nil {
return
}
if err := s.redis.Del(ctx, constants.RedisCardTrafficSyncLockKey(cardID)).Err(); err != nil {
s.logger.Warn("释放卡流量同步锁失败", zap.Uint("card_id", cardID), zap.Error(err)) s.logger.Warn("释放卡流量同步锁失败", zap.Uint("card_id", cardID), zap.Error(err))
} }
} }
@@ -1531,13 +1532,13 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
return err return err
} }
locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID) lockToken, locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID)
if lockErr != nil { if lockErr != nil {
return errors.Wrap(errors.CodeInternalError, lockErr, "获取卡流量同步锁失败") return errors.Wrap(errors.CodeInternalError, lockErr, "获取卡流量同步锁失败")
} else if !locked { } else if !locked {
return errors.New(errors.CodeTooManyRequests, "卡流量正在同步,请稍后重试") return errors.New(errors.CodeTooManyRequests, "卡流量正在同步,请稍后重试")
} else { } else {
defer s.releaseCardTrafficSyncLock(ctx, card.ID) defer s.releaseCardTrafficSyncLock(ctx, card.ID, lockToken)
latestCard, loadErr := s.iotCardStore.GetByID(ctx, card.ID) latestCard, loadErr := s.iotCardStore.GetByID(ctx, card.ID)
if loadErr != nil { if loadErr != nil {
return errors.Wrap(errors.CodeInternalError, loadErr, "刷新卡数据失败") return errors.Wrap(errors.CodeInternalError, loadErr, "刷新卡数据失败")

View File

@@ -6,6 +6,8 @@ import (
"strings" "strings"
"time" "time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/google/uuid"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
@@ -31,19 +33,27 @@ var _ StopResumeServiceInterface = (*StopResumeService)(nil)
// StopResumeService 停复机服务 // StopResumeService 停复机服务
// 处理 IoT 卡的自动停机、复机和手动停复机逻辑 // 处理 IoT 卡的自动停机、复机和手动停复机逻辑
type StopResumeService struct { type StopResumeService struct {
redis *redis.Client db *gorm.DB
iotCardStore *postgres.IotCardStore redis *redis.Client
packageUsageStore *postgres.PackageUsageStore iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore packageUsageStore *postgres.PackageUsageStore
gatewayClient *gateway.Client deviceSimBindingStore *postgres.DeviceSimBindingStore
logger *zap.Logger gatewayClient *gateway.Client
assetAuditService AssetAuditService logger *zap.Logger
pollingCallback PollingCallback assetAuditService AssetAuditService
pollingCallback PollingCallback
observationSeriesEvents cardObservationApp.SeriesEventWriter
maxRetries int maxRetries int
retryInterval time.Duration retryInterval time.Duration
} }
// SetObservationSeriesEventWriter 注入停复机成功观测序列 Outbox Writer。
func (s *StopResumeService) SetObservationSeriesEventWriter(db *gorm.DB, writer cardObservationApp.SeriesEventWriter) {
s.db = db
s.observationSeriesEvents = writer
}
// NewStopResumeService 创建停复机服务 // NewStopResumeService 创建停复机服务
func NewStopResumeService( func NewStopResumeService(
redis *redis.Client, redis *redis.Client,
@@ -96,8 +106,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
zap.Uint("card_id", card.ID), zap.Error(lookupErr)) zap.Uint("card_id", card.ID), zap.Error(lookupErr))
// 降级:查不到绑定时按单卡处理 // 降级:查不到绑定时按单卡处理
} else if bound { } else if bound {
s.stopDeviceCards(ctx, deviceID, primaryReason) return s.stopDeviceCards(ctx, deviceID, primaryReason)
return nil
} }
} }
return s.stopCardWithRetry(ctx, card, primaryReason) return s.stopCardWithRetry(ctx, card, primaryReason)
@@ -121,8 +130,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
s.logger.Error("查询设备绑定关系失败,降级为单卡复机", s.logger.Error("查询设备绑定关系失败,降级为单卡复机",
zap.Uint("card_id", card.ID), zap.Error(lookupErr)) zap.Uint("card_id", card.ID), zap.Error(lookupErr))
} else if bound { } else if bound {
s.resumeDeviceCards(ctx, deviceID) return s.resumeDeviceCards(ctx, deviceID)
return nil
} }
} }
return s.resumeSingleCard(ctx, card.ID) return s.resumeSingleCard(ctx, card.ID)
@@ -291,14 +299,17 @@ func (s *StopResumeService) shouldResume(ctx context.Context, card *model.IotCar
// stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁) // stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁)
// 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 停机调用 // 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 停机调用
func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) { func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, stopReason string) error {
// 设备维度幂等锁:防止多协程同时对同一设备停机 // 设备维度幂等锁:防止多协程同时对同一设备停机
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID) lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if lockErr != nil {
return errors.Wrap(errors.CodeRedisError, lockErr, "获取设备停机锁失败")
}
if !locked { if !locked {
s.logger.Debug("设备停机操作已在进行中,跳过重复调用", s.logger.Debug("设备停机操作已在进行中,跳过重复调用",
zap.Uint("device_id", deviceID)) zap.Uint("device_id", deviceID))
return return nil
} }
defer s.redis.Del(ctx, lockKey) defer s.redis.Del(ctx, lockKey)
@@ -307,29 +318,38 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint,
if err != nil { if err != nil {
s.logger.Error("查询设备在线卡失败", s.logger.Error("查询设备在线卡失败",
zap.Uint("device_id", deviceID), zap.Error(err)) zap.Uint("device_id", deviceID), zap.Error(err))
return return errors.Wrap(errors.CodeDatabaseError, err, "查询设备在线卡失败")
} }
var cardErrors []error
for _, card := range cards { for _, card := range cards {
if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil { if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil {
cardErrors = append(cardErrors, stopErr)
s.logger.Warn("设备卡停机失败,继续处理其他卡", s.logger.Warn("设备卡停机失败,继续处理其他卡",
zap.Uint("device_id", deviceID), zap.Uint("device_id", deviceID),
zap.Uint("card_id", card.ID), zap.Uint("card_id", card.ID),
zap.Error(stopErr)) zap.Error(stopErr))
} }
} }
if len(cardErrors) > 0 {
return errors.Wrap(errors.CodeInternalError, stderrors.Join(cardErrors...), "设备停机存在未成功处理的卡")
}
return nil
} }
// resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁) // resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁)
// 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过 // 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过
func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) { func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error {
// 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发 // 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发
lockKey := constants.RedisPollingDeviceOpLockKey(deviceID) lockKey := constants.RedisPollingDeviceOpLockKey(deviceID)
locked, _ := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result()
if lockErr != nil {
return errors.Wrap(errors.CodeRedisError, lockErr, "获取设备复机锁失败")
}
if !locked { if !locked {
s.logger.Debug("设备复机操作已在进行中,跳过重复调用", s.logger.Debug("设备复机操作已在进行中,跳过重复调用",
zap.Uint("device_id", deviceID)) zap.Uint("device_id", deviceID))
return return nil
} }
defer s.redis.Del(ctx, lockKey) defer s.redis.Del(ctx, lockKey)
@@ -338,24 +358,31 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint
if err != nil { if err != nil {
s.logger.Error("查询设备停机卡失败", s.logger.Error("查询设备停机卡失败",
zap.Uint("device_id", deviceID), zap.Error(err)) zap.Uint("device_id", deviceID), zap.Error(err))
return return errors.Wrap(errors.CodeDatabaseError, err, "查询设备停机卡失败")
} }
var cardErrors []error
for _, card := range cards { for _, card := range cards {
if !s.isRealnameOK(card) { if !s.isRealnameOK(card) {
if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil { if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil {
cardErrors = append(cardErrors, updateErr)
s.logger.Warn("更新未实名卡停机原因失败", s.logger.Warn("更新未实名卡停机原因失败",
zap.Uint("card_id", card.ID), zap.Error(updateErr)) zap.Uint("card_id", card.ID), zap.Error(updateErr))
} }
continue continue
} }
if resumeErr := s.resumeSingleCard(ctx, card.ID); resumeErr != nil { if resumeErr := s.resumeSingleCard(ctx, card.ID); resumeErr != nil {
cardErrors = append(cardErrors, resumeErr)
s.logger.Warn("设备卡复机失败,继续处理其他卡", s.logger.Warn("设备卡复机失败,继续处理其他卡",
zap.Uint("device_id", deviceID), zap.Uint("device_id", deviceID),
zap.Uint("card_id", card.ID), zap.Uint("card_id", card.ID),
zap.Error(resumeErr)) zap.Error(resumeErr))
} }
} }
if len(cardErrors) > 0 {
return errors.Wrap(errors.CodeInternalError, stderrors.Join(cardErrors...), "设备复机存在未成功处理的卡")
}
return nil
} }
// CheckAndStopCard 检查流量耗尽并停机(旧入口,保留兼容性) // CheckAndStopCard 检查流量耗尽并停机(旧入口,保留兼容性)
@@ -375,8 +402,7 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType
case "iot_card": case "iot_card":
return s.resumeSingleCard(ctx, carrierID) return s.resumeSingleCard(ctx, carrierID)
case "device": case "device":
s.resumeDeviceCards(ctx, carrierID) return s.resumeDeviceCards(ctx, carrierID)
return nil
default: default:
return nil return nil
} }
@@ -460,11 +486,11 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
} }
now := time.Now() now := time.Now()
if err := s.iotCardStore.UpdateFields(ctx, cardID, map[string]any{ if err := s.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{
"network_status": constants.NetworkStatusOnline, "network_status": constants.NetworkStatusOnline,
"resumed_at": now, "resumed_at": now,
"stop_reason": "", "stop_reason": "",
}); err != nil { }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
s.logger.Error("复机 Gateway 成功但 DB 更新失败", s.logger.Error("复机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", cardID), zap.Uint("card_id", cardID),
zap.String("iccid", card.ICCID), zap.String("iccid", card.ICCID),
@@ -488,7 +514,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e
"stop_reason": "", "stop_reason": "",
}, },
}) })
return nil return err
} }
s.reschedulePolling(ctx, card.ID) s.reschedulePolling(ctx, card.ID)
@@ -568,11 +594,11 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
zap.String("iccid", card.ICCID)) zap.String("iccid", card.ICCID))
now := time.Now() now := time.Now()
if updateErr := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{ if updateErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOffline, "network_status": constants.NetworkStatusOffline,
"stopped_at": now, "stopped_at": now,
"stop_reason": stopReason, "stop_reason": stopReason,
}); updateErr != nil { }, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString()); updateErr != nil {
s.logger.Error("停机 Gateway 成功但 DB 更新失败", s.logger.Error("停机 Gateway 成功但 DB 更新失败",
zap.Uint("card_id", card.ID), zap.Uint("card_id", card.ID),
zap.String("iccid", card.ICCID), zap.String("iccid", card.ICCID),
@@ -596,6 +622,7 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I
"stop_reason": stopReason, "stop_reason": stopReason,
}, },
}) })
return updateErr
} }
s.reschedulePolling(ctx, card.ID) s.reschedulePolling(ctx, card.ID)
@@ -763,12 +790,12 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
} }
now := time.Now() now := time.Now()
if err := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{ if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline, "network_status": constants.NetworkStatusOnline,
"resumed_at": now, "resumed_at": now,
"stop_reason": "", "stop_reason": "",
"gateway_extend": "", "gateway_extend": "",
}); err != nil { }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -1008,11 +1035,11 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
} }
now := time.Now() now := time.Now()
if err := s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{ if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{
"network_status": constants.NetworkStatusOnline, "network_status": constants.NetworkStatusOnline,
"resumed_at": now, "resumed_at": now,
"stop_reason": "", "stop_reason": "",
}); err != nil { }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil {
wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
@@ -1056,6 +1083,31 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
return nil return nil
} }
func (s *StopResumeService) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected, operationID string) error {
if s.db == nil || s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "停复机观测 Outbox 能力未配置")
}
requestID := requestIDFromContext(ctx)
if requestID == "" {
requestID = operationID
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡停复机状态失败")
}
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: "card-observation:network-command:" + operationID,
Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
RequestID: requestID, CorrelationID: requestID,
})
})
}
// invalidatePollingCache 删除轮询卡缓存,下次轮询自动从 DB 重建 // invalidatePollingCache 删除轮询卡缓存,下次轮询自动从 DB 重建
func (s *StopResumeService) invalidatePollingCache(ctx context.Context, cardID uint) { func (s *StopResumeService) invalidatePollingCache(ctx context.Context, cardID uint) {
if s.redis == nil { if s.redis == nil {

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
@@ -66,6 +67,12 @@ type Service struct {
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
agentWalletDebit *walletapp.DebitService agentWalletDebit *walletapp.DebitService
agentWalletReservation *walletapp.ReservationService agentWalletReservation *walletapp.ReservationService
observationSeriesEvents cardObservationApp.SeriesEventWriter
}
// SetObservationSeriesEventWriter 注入购包成功观测序列 Outbox Writer。
func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
} }
// SetAgentWalletDebitService 注入代理主钱包统一扣款用例。 // SetAgentWalletDebitService 注入代理主钱包统一扣款用例。
@@ -1940,8 +1947,9 @@ func (s *Service) tryResumeAfterPayment(ctx context.Context, order *model.Order)
return return
} }
resumeCtx := context.WithoutCancel(ctx)
go func(ct string, cid uint) { go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil { if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, ct, cid); err != nil {
s.logger.Error("支付后自动复机失败", s.logger.Error("支付后自动复机失败",
zap.String("carrier_type", ct), zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid), zap.Uint("carrier_id", cid),
@@ -1975,6 +1983,7 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
} }
now := time.Now() now := time.Now()
createdUsage := false
for _, item := range items { for _, item := range items {
// 检查是否已存在使用记录 // 检查是否已存在使用记录
var existingUsage model.PackageUsage var existingUsage model.PackageUsage
@@ -2002,17 +2011,44 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model
if err := s.activateMainPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil { if err := s.activateMainPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
return err return err
} }
createdUsage = true
} else if pkg.PackageType == "addon" { } else if pkg.PackageType == "addon" {
// 加油包处理逻辑(任务 8.5-8.7 // 加油包处理逻辑(任务 8.5-8.7
if err := s.activateAddonPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil { if err := s.activateAddonPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
return err return err
} }
createdUsage = true
}
}
if createdUsage {
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "购包观测 Outbox Writer 未配置")
}
eventID := "card-observation:order:" + strconv.FormatUint(uint64(order.ID), 10)
requestID := requestIDFromContext(ctx)
if requestID == "" {
requestID = eventID
}
if err := s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: eventID,
Scene: constants.CardObservationScenePackageChanged, ResourceType: carrierType, ResourceID: carrierID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now,
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
} }
} }
return nil return nil
} }
func requestIDFromContext(ctx context.Context) string {
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
return *value
}
return ""
}
func (s *Service) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error { func (s *Service) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
switch carrierType { switch carrierType {
case constants.AssetWalletResourceTypeIotCard, "card": case constants.AssetWalletResourceTypeIotCard, "card":

View File

@@ -2,8 +2,10 @@ package packagepkg
import ( import (
"context" "context"
"strconv"
"time" "time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -27,6 +29,12 @@ type ActivationService struct {
packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore
logger *zap.Logger logger *zap.Logger
resumeCallback ResumeCallback // 复机回调,可选 resumeCallback ResumeCallback // 复机回调,可选
observationSeriesEvents cardObservationApp.SeriesEventWriter
}
// SetObservationSeriesEventWriter 注入套餐激活成功观测序列 Outbox Writer。
func (s *ActivationService) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) {
s.observationSeriesEvents = writer
} }
func NewActivationService( func NewActivationService(
@@ -81,8 +89,9 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
now := time.Now() now := time.Now()
activated := false
// 在事务中激活套餐 // 在事务中激活套餐
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, usage := range pendingUsages { for _, usage := range pendingUsages {
// 查询套餐信息 // 查询套餐信息
var pkg model.Package var pkg model.Package
@@ -144,6 +153,9 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
if err := tx.Model(usage).Updates(updates).Error; err != nil { if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败") return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败")
} }
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID) s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -153,20 +165,18 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
zap.Time("activated_at", activatedAt), zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt)) zap.Time("expires_at", expiresAt))
if s.resumeCallback != nil { activated = true
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("实名激活后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
}
} }
return nil return nil
}) })
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "实名激活后自动复机失败")
}
return nil
} }
// ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活 // ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活
@@ -187,7 +197,8 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
} }
defer s.redis.Del(ctx, lockKey) defer s.redis.Del(ctx, lockKey)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { activated := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 任务 9.5: 检测并标记过期的主套餐 // 任务 9.5: 检测并标记过期的主套餐
now := time.Now() now := time.Now()
var expiredMainUsages []*model.PackageUsage var expiredMainUsages []*model.PackageUsage
@@ -221,13 +232,22 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy
} }
// 任务 9.6: 激活下一个待生效主套餐 // 任务 9.6: 激活下一个待生效主套餐
if err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now); err != nil { currentActivated, err := s.activateNextMainPackage(ctx, tx, carrierType, carrierID, now)
if err != nil {
return err return err
} }
activated = activated || currentActivated
} }
return nil return nil
}) })
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "排队激活后自动复机失败")
}
return nil
} }
// ActivateSpecificPackage 任务 4: 激活指定的套餐使用记录 // ActivateSpecificPackage 任务 4: 激活指定的套餐使用记录
@@ -285,7 +305,8 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
} }
// 事务内激活 // 事务内激活
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { activated := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 再次幂等检查(防止锁释放期间被其他操作激活) // 再次幂等检查(防止锁释放期间被其他操作激活)
var currentUsage model.PackageUsage var currentUsage model.PackageUsage
if err := tx.First(&currentUsage, packageUsageID).Error; err != nil { if err := tx.First(&currentUsage, packageUsageID).Error; err != nil {
@@ -323,21 +344,16 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package
if err := s.activatePendingUsage(ctx, tx, &currentUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil { if err := s.activatePendingUsage(ctx, tx, &currentUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil {
return err return err
} }
activated = true
// 异步复机
if s.resumeCallback != nil {
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("激活后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
}
return nil return nil
}) })
if err != nil {
return err
}
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "激活后自动复机失败")
}
return nil
} }
// ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。 // ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。
@@ -409,6 +425,9 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context,
if err != nil { if err != nil {
return false, err return false, err
} }
if activated {
s.resumeAfterActivation(ctx, carrierType, carrierID, "队首激活后自动复机失败")
}
return activated, nil return activated, nil
} }
@@ -451,22 +470,22 @@ func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, m
} }
// activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐 // activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐
func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) error { func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, now time.Time) (bool, error) {
// 查询下一个待生效主套餐 // 查询下一个待生效主套餐
nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID) nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID)
if err == gorm.ErrRecordNotFound { if err == gorm.ErrRecordNotFound {
s.logger.Info("没有待生效的主套餐", s.logger.Info("没有待生效的主套餐",
zap.String("carrier_type", carrierType), zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID)) zap.Uint("carrier_id", carrierID))
return nil return false, nil
} }
if err != nil { if err != nil {
return err return false, err
} }
canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID) canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID)
if err != nil { if err != nil {
return err return false, err
} }
if !canActivate { if !canActivate {
s.logger.Info("下一个待生效主套餐暂不满足激活条件", s.logger.Info("下一个待生效主套餐暂不满足激活条件",
@@ -474,16 +493,19 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor
zap.String("carrier_type", carrierType), zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID), zap.Uint("carrier_id", carrierID),
zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation)) zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation))
return nil return false, nil
} }
// 查询套餐信息 // 查询套餐信息
var pkg model.Package var pkg model.Package
if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil { if err := tx.First(&pkg, nextMain.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
} }
return s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活") if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活"); err != nil {
return false, err
}
return true, nil
} }
func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (*model.PackageUsage, error) { func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (*model.PackageUsage, error) {
@@ -589,6 +611,9 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
if err := tx.Model(usage).Updates(updates).Error; err != nil { if err := tx.Model(usage).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败") return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败")
} }
if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil {
return err
}
s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID) s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID)
@@ -598,20 +623,45 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D
zap.Time("activated_at", activatedAt), zap.Time("activated_at", activatedAt),
zap.Time("expires_at", expiresAt)) zap.Time("expires_at", expiresAt))
if s.resumeCallback != nil {
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("排队激活后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Error(err))
}
}(carrierType, carrierID)
}
return nil return nil
} }
// resumeAfterActivation 在激活事务提交后异步尝试复机,避免外部副作用先于业务事实提交。
func (s *ActivationService) resumeAfterActivation(ctx context.Context, carrierType string, carrierID uint, errorMessage string) {
if s.resumeCallback == nil {
return
}
resumeCtx := context.WithoutCancel(ctx)
go func() {
if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, carrierType, carrierID); err != nil {
s.logger.Error(errorMessage,
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
}
}()
}
func (s *ActivationService) appendActivationObservation(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint, occurredAt time.Time) error {
if cardObservationApp.IsSeriesTriggerSuppressed(ctx) {
return nil
}
if s.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "套餐激活观测 Outbox Writer 未配置")
}
if usage == nil || usage.ID == 0 || carrierID == 0 {
return errors.New(errors.CodeInvalidParam, "套餐激活观测事件缺少载体")
}
eventID := "card-observation:package-usage:" + strconv.FormatUint(uint64(usage.ID), 10) + ":activated"
return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: eventID, Scene: constants.CardObservationScenePackageChanged,
ResourceType: carrierType, ResourceID: carrierID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: occurredAt,
RequestID: eventID, CorrelationID: eventID,
})
}
func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) { func (s *ActivationService) syncCarrierStatusActivated(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, carrierType string, carrierID uint) {
if usage == nil { if usage == nil {
return return

View File

@@ -247,6 +247,7 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages []
return return
} }
resumeCtx := context.WithoutCancel(ctx)
for _, pkg := range packages { for _, pkg := range packages {
var carrierType string var carrierType string
var carrierID uint var carrierID uint
@@ -262,7 +263,7 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages []
} }
go func(ct string, cid uint) { go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil { if err := s.resumeCallback.ResumeCardIfStopped(resumeCtx, ct, cid); err != nil {
s.logger.Warn("流量重置后自动复机失败", s.logger.Warn("流量重置后自动复机失败",
zap.String("carrier_type", ct), zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid), zap.Uint("carrier_id", cid),

View File

@@ -186,7 +186,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
} }
if shouldSuspend { if shouldSuspend {
s.triggerSuspensionAfterCommit(suspendCarrierType, suspendCarrierID) s.triggerSuspensionAfterCommit(ctx, suspendCarrierType, suspendCarrierID)
} }
return nil return nil
@@ -405,7 +405,7 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
} }
// triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。 // triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。
func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierID uint) { func (s *UsageService) triggerSuspensionAfterCommit(ctx context.Context, carrierType string, carrierID uint) {
if s.stopResumeCallback == nil { if s.stopResumeCallback == nil {
if carrierType == constants.AssetTypeDevice { if carrierType == constants.AssetTypeDevice {
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机", s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
@@ -414,9 +414,9 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
return return
} }
stopCtx := context.WithoutCancel(ctx)
if carrierType == constants.AssetTypeIotCard { if carrierType == constants.AssetTypeIotCard {
go func() { go func() {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil { if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil {
s.logger.Error("调用停机服务失败", s.logger.Error("调用停机服务失败",
zap.Uint("card_id", carrierID), zap.Uint("card_id", carrierID),
@@ -430,7 +430,7 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
return return
} }
bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID) bindings, err := s.deviceSimBindingStore.ListByDeviceID(stopCtx, carrierID)
if err != nil { if err != nil {
s.logger.Error("查询设备绑定卡失败", s.logger.Error("查询设备绑定卡失败",
zap.Uint("device_id", carrierID), zap.Uint("device_id", carrierID),
@@ -446,7 +446,6 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
for _, b := range bindings { for _, b := range bindings {
cardID := b.IotCardID cardID := b.IotCardID
go func(cID uint) { go func(cID uint) {
stopCtx := context.Background()
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil { if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil {
s.logger.Error("调用停机服务失败", s.logger.Error("调用停机服务失败",
zap.Uint("card_id", cID), zap.Uint("card_id", cID),

View File

@@ -16,6 +16,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/middleware"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// Service 代理系列授权业务服务 // Service 代理系列授权业务服务
@@ -30,6 +31,12 @@ type Service struct {
logger *zap.Logger logger *zap.Logger
} }
// initialGrantPackage 保存通过创建前校验的套餐及其请求价格。
type initialGrantPackage struct {
item dto.GrantPackageItem
packageModel *model.Package
}
// New 创建代理系列授权服务实例 // New 创建代理系列授权服务实例
func New( func New(
db *gorm.DB, db *gorm.DB,
@@ -53,6 +60,89 @@ func New(
} }
} }
// validateInitialGrantPackageItems 校验首次授权套餐的数量、价格和批内唯一性。
func validateInitialGrantPackageItems(items []dto.GrantPackageItem) ([]uint, error) {
if len(items) == 0 || len(items) > constants.ShopSeriesGrantMaxPackages {
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐数量必须为1到100项")
}
packageIDs := make([]uint, 0, len(items))
seen := make(map[uint]struct{}, len(items))
for _, item := range items {
if item.PackageID == 0 || item.CostPrice == nil || *item.CostPrice < 0 {
return nil, errors.New(errors.CodeInvalidParam, "套餐ID和成本价参数无效")
}
if _, exists := seen[item.PackageID]; exists {
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐ID不能重复")
}
seen[item.PackageID] = struct{}{}
packageIDs = append(packageIDs, item.PackageID)
}
return packageIDs, nil
}
// validateInitialGrantPackages 批量校验首次授权的套餐归属、授权链和成本价边界。
func (s *Service) validateInitialGrantPackages(ctx context.Context, tx *gorm.DB, req *dto.CreateShopSeriesGrantRequest, allocatorShopID uint) ([]initialGrantPackage, error) {
packageIDs, err := validateInitialGrantPackageItems(req.Packages)
if err != nil {
return nil, err
}
var packages []*model.Package
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id IN ?", packageIDs).
Find(&packages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询初始授权套餐失败")
}
if len(packages) != len(packageIDs) {
return nil, errors.New(errors.CodeInvalidParam, "初始授权套餐不存在或已删除")
}
packageMap := make(map[uint]*model.Package, len(packages))
for _, pkg := range packages {
packageMap[pkg.ID] = pkg
}
parentCostMap := make(map[uint]int64, len(packageIDs))
if allocatorShopID > 0 {
var parentAllocations []*model.ShopPackageAllocation
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND package_id IN ? AND status = ?", allocatorShopID, packageIDs, constants.StatusEnabled).
Find(&parentAllocations).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级套餐授权失败")
}
if len(parentAllocations) != len(packageIDs) {
return nil, errors.New(errors.CodeForbidden, "无权限分配套餐")
}
for _, allocation := range parentAllocations {
parentCostMap[allocation.PackageID] = allocation.CostPrice
}
}
validated := make([]initialGrantPackage, 0, len(req.Packages))
for _, item := range req.Packages {
pkg := packageMap[item.PackageID]
if pkg.SeriesID != req.SeriesID {
return nil, errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权")
}
if pkg.IsGift {
return nil, errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理")
}
minimumCost := pkg.CostPrice
if allocatorShopID > 0 {
minimumCost = parentCostMap[item.PackageID]
}
if *item.CostPrice < minimumCost {
return nil, errors.New(errors.CodeInvalidParam, "授权成本价不能低于当前上级成本价")
}
validated = append(validated, initialGrantPackage{item: item, packageModel: pkg})
}
return validated, nil
}
// getParentCeilingFixed 查询固定模式佣金天花板 // getParentCeilingFixed 查询固定模式佣金天花板
// allocatorShopID=0 表示平台分配,天花板为 PackageSeries.commission_amount // allocatorShopID=0 表示平台分配,天花板为 PackageSeries.commission_amount
// allocatorShopID>0 表示代理分配,天花板为分配者自身的 ShopSeriesAllocation.one_time_commission_amount // allocatorShopID>0 表示代理分配,天花板为分配者自身的 ShopSeriesAllocation.one_time_commission_amount
@@ -225,10 +315,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
} }
config, _ := series.GetOneTimeCommissionConfig() config, _ := series.GetOneTimeCommissionConfig()
// 1.5 校验目标店铺是否存在 // 1.5 校验管理范围,避免通过错误差异探测其他店铺。
_, err = s.shopStore.GetByID(ctx, req.ShopID) if err := middleware.CanManageShop(ctx, req.ShopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
targetShop, err := s.shopStore.GetByID(ctx, req.ShopID)
if err != nil { if err != nil {
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在") return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
} }
// 2. 检查重复授权 // 2. 检查重复授权
@@ -244,12 +337,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
var allocatorShopID uint var allocatorShopID uint
if operatorType == constants.UserTypeAgent { if operatorType == constants.UserTypeAgent {
allocatorShopID = operatorShopID allocatorShopID = operatorShopID
_, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, req.SeriesID) if allocatorShopID == 0 || targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID {
if err != nil { return nil, errors.New(errors.CodeForbidden, "只能授权直属下级店铺")
}
var parentSeriesAllocation model.ShopSeriesAllocation
if err := s.db.WithContext(ctx).
Where("shop_id = ? AND series_id = ? AND status = ?", allocatorShopID, req.SeriesID, constants.StatusEnabled).
First(&parentSeriesAllocation).Error; err != nil {
return nil, errors.New(errors.CodeForbidden, "当前账号无此系列授权,无法向下分配") return nil, errors.New(errors.CodeForbidden, "当前账号无此系列授权,无法向下分配")
} }
} }
// 平台/超管 allocatorShopID = 0 // 平台/超管 allocatorShopID = 0
if operatorType != constants.UserTypeAgent && operatorType != constants.UserTypePlatform && operatorType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "无权限创建系列授权")
}
// 4. 参数验证:仅启用一次性佣金的系列才需要配置佣金金额 // 4. 参数验证:仅启用一次性佣金的系列才需要配置佣金金额
allocation := &model.ShopSeriesAllocation{ allocation := &model.ShopSeriesAllocation{
@@ -322,59 +423,67 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ
// 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation // 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var lockedTargetShop model.Shop
if lockErr := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&lockedTargetShop, req.ShopID).Error; lockErr != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if allocatorShopID > 0 {
if lockedTargetShop.ParentID == nil || *lockedTargetShop.ParentID != allocatorShopID {
return errors.New(errors.CodeForbidden, "只能授权直属下级店铺")
}
var parentSeriesAllocation model.ShopSeriesAllocation
if lockErr := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND series_id = ? AND status = ?", allocatorShopID, req.SeriesID, constants.StatusEnabled).
First(&parentSeriesAllocation).Error; lockErr != nil {
return errors.New(errors.CodeForbidden, "当前账号无有效系列授权,无法向下分配")
}
}
validatedPackages, validateErr := s.validateInitialGrantPackages(ctx, tx, req, allocatorShopID)
if validateErr != nil {
return validateErr
}
txSeriesStore := postgres.NewShopSeriesAllocationStore(tx) txSeriesStore := postgres.NewShopSeriesAllocationStore(tx)
if createErr := txSeriesStore.Create(ctx, allocation); createErr != nil { if createErr := txSeriesStore.Create(ctx, allocation); createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建系列授权失败") return errors.Wrap(errors.CodeDatabaseError, createErr, "创建系列授权失败")
} }
// 创建套餐分配 // 所有套餐已在当前事务内完成批量校验,此处只写入同一业务单元。
if len(req.Packages) > 0 { txPkgStore := postgres.NewShopPackageAllocationStore(tx)
txPkgStore := postgres.NewShopPackageAllocationStore(tx) txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx)
txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx) for _, validated := range validatedPackages {
for _, item := range req.Packages { item := validated.item
if item.Remove != nil && *item.Remove { pkg := validated.packageModel
continue pkgAlloc := &model.ShopPackageAllocation{
} ShopID: req.ShopID,
// W1: 校验套餐归属于该系列,防止跨系列套餐混入 PackageID: item.PackageID,
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID) AllocatorShopID: allocatorShopID,
if pkgErr != nil || pkg.SeriesID != req.SeriesID { CostPrice: *item.CostPrice,
return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权") RetailPrice: pkg.SuggestedRetailPrice,
} RetailPriceConfigStatus: pkg.PriceConfigStatus,
if pkg.IsGift { SeriesAllocationID: &allocation.ID,
return errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理") ExpiryBaseOverride: expiryBaseOverride,
} Status: constants.StatusEnabled,
// W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配 ShelfStatus: constants.ShelfStatusOn,
if allocatorShopID > 0 { }
_, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocatorShopID, item.PackageID) pkgAlloc.Creator = operatorID
if authErr != nil { pkgAlloc.Updater = operatorID
return errors.New(errors.CodeForbidden, "无权限分配该套餐") if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
} return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
} }
pkgAlloc := &model.ShopPackageAllocation{ if err := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
ShopID: req.ShopID, AllocationID: pkgAlloc.ID,
PackageID: item.PackageID, OldCostPrice: 0,
AllocatorShopID: allocatorShopID, NewCostPrice: *item.CostPrice,
CostPrice: item.CostPrice, ChangeReason: "初始授权",
RetailPrice: pkg.SuggestedRetailPrice, ChangedBy: operatorID,
RetailPriceConfigStatus: pkg.PriceConfigStatus, EffectiveFrom: time.Now(),
SeriesAllocationID: &allocation.ID, }); err != nil {
ExpiryBaseOverride: expiryBaseOverride, return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐价格历史失败")
Status: constants.StatusEnabled,
ShelfStatus: constants.ShelfStatusOn,
}
pkgAlloc.Creator = operatorID
pkgAlloc.Updater = operatorID
if err := txPkgStore.Create(ctx, pkgAlloc); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败")
}
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID,
OldCostPrice: 0,
NewCostPrice: item.CostPrice,
ChangeReason: "初始授权",
ChangedBy: operatorID,
EffectiveFrom: time.Now(),
})
} }
} }
@@ -639,45 +748,56 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
for _, item := range req.Packages { for _, item := range req.Packages {
if item.Remove != nil && *item.Remove { if item.Remove != nil && *item.Remove {
// 软删除已有的 active 分配 // 软删除已有的有效分配
existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID) existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID)
if findErr != nil { if findErr != nil {
// 找不到则静默忽略 // 已不存在时按幂等成功处理
continue continue
} }
_ = txPkgStore.Delete(ctx, existing.ID) if deleteErr := txPkgStore.Delete(ctx, existing.ID); deleteErr != nil {
return errors.Wrap(errors.CodeDatabaseError, deleteErr, "删除套餐分配失败")
}
continue continue
} }
if item.CostPrice == nil {
return errors.New(errors.CodeInvalidParam, "新增或修改套餐授权时必须提交成本价")
}
costPrice := *item.CostPrice
// 新增或更新套餐分配 // 新增或更新套餐分配
existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID) existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID)
if findErr == nil { if findErr == nil {
// 已有记录:更新成本价并写历史 // 已有记录:更新成本价并写历史
oldPrice := existing.CostPrice oldPrice := existing.CostPrice
if oldPrice != item.CostPrice { if oldPrice != costPrice {
// cost_price 锁定检查:存在下级分配记录时禁止修改 // cost_price 锁定检查:存在下级分配记录时禁止修改
var subCount int64 var subCount int64
tx.Model(&model.ShopPackageAllocation{}). if countErr := tx.Model(&model.ShopPackageAllocation{}).
Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, item.PackageID). Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, item.PackageID).
Count(&subCount) Count(&subCount).Error; countErr != nil {
return errors.Wrap(errors.CodeDatabaseError, countErr, "查询下级套餐分配失败")
}
if subCount > 0 { if subCount > 0 {
return errors.New(errors.CodeForbidden, "存在下级分配记录,请先回收后再修改成本价") return errors.New(errors.CodeForbidden, "存在下级分配记录,请先回收后再修改成本价")
} }
} }
existing.CostPrice = item.CostPrice existing.CostPrice = costPrice
existing.Updater = operatorID existing.Updater = operatorID
if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil { if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐分配失败") return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐分配失败")
} }
if oldPrice != item.CostPrice { if oldPrice != costPrice {
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: existing.ID, AllocationID: existing.ID,
OldCostPrice: oldPrice, OldCostPrice: oldPrice,
NewCostPrice: item.CostPrice, NewCostPrice: costPrice,
ChangeReason: "手动调价", ChangeReason: "手动调价",
ChangedBy: operatorID, ChangedBy: operatorID,
EffectiveFrom: time.Now(), EffectiveFrom: time.Now(),
}) }); historyErr != nil {
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
}
} }
} else { } else {
pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID) pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID)
@@ -697,7 +817,7 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
ShopID: allocation.ShopID, ShopID: allocation.ShopID,
PackageID: item.PackageID, PackageID: item.PackageID,
AllocatorShopID: allocation.AllocatorShopID, AllocatorShopID: allocation.AllocatorShopID,
CostPrice: item.CostPrice, CostPrice: costPrice,
RetailPrice: pkg.SuggestedRetailPrice, RetailPrice: pkg.SuggestedRetailPrice,
RetailPriceConfigStatus: pkg.PriceConfigStatus, RetailPriceConfigStatus: pkg.PriceConfigStatus,
SeriesAllocationID: &allocation.ID, SeriesAllocationID: &allocation.ID,
@@ -710,14 +830,16 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr
if createErr := txPkgStore.Create(ctx, pkgAlloc); createErr != nil { if createErr := txPkgStore.Create(ctx, pkgAlloc); createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败") return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败")
} }
_ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{
AllocationID: pkgAlloc.ID, AllocationID: pkgAlloc.ID,
OldCostPrice: 0, OldCostPrice: 0,
NewCostPrice: item.CostPrice, NewCostPrice: costPrice,
ChangeReason: "新增授权", ChangeReason: "新增授权",
ChangedBy: operatorID, ChangedBy: operatorID,
EffectiveFrom: time.Now(), EffectiveFrom: time.Now(),
}) }); historyErr != nil {
return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败")
}
} }
} }
return nil return nil

View File

@@ -3,6 +3,7 @@ package task
import ( import (
"context" "context"
"errors" "errors"
"strconv"
"time" "time"
"github.com/bytedance/sonic" "github.com/bytedance/sonic"
@@ -12,6 +13,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
@@ -41,6 +43,7 @@ type AutoPurchaseHandler struct {
redis *redis.Client redis *redis.Client
asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务 asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务
logger *zap.Logger logger *zap.Logger
observationSeriesEvents cardObservationApp.SeriesEventWriter
} }
// NewAutoPurchaseHandler 创建充值后自动购包处理器 // NewAutoPurchaseHandler 创建充值后自动购包处理器
@@ -55,6 +58,7 @@ func NewAutoPurchaseHandler(
redisClient *redis.Client, redisClient *redis.Client,
asynqClient *asynq.Client, asynqClient *asynq.Client,
logger *zap.Logger, logger *zap.Logger,
observationSeriesEvents cardObservationApp.SeriesEventWriter,
) *AutoPurchaseHandler { ) *AutoPurchaseHandler {
if orderStore == nil { if orderStore == nil {
orderStore = postgres.NewOrderStore(db, redisClient) orderStore = postgres.NewOrderStore(db, redisClient)
@@ -89,6 +93,7 @@ func NewAutoPurchaseHandler(
redis: redisClient, redis: redisClient,
asynqClient: asynqClient, asynqClient: asynqClient,
logger: logger, logger: logger,
observationSeriesEvents: observationSeriesEvents,
} }
} }
@@ -248,6 +253,23 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task)
if err = h.activatePackages(ctx, tx, order, packages, now); err != nil { if err = h.activatePackages(ctx, tx, order, packages, now); err != nil {
return err return err
} }
if h.observationSeriesEvents == nil {
return pkgerrors.New(pkgerrors.CodeInternalError, "自动购包观测 Outbox Writer 未配置")
}
resourceType, resourceID := orderObservationResource(order)
if resourceID == 0 {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "自动购包观测事件缺少载体")
}
requestID := "card-observation:auto-purchase:" + strconv.FormatUint(uint64(order.ID), 10)
if err = h.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: requestID, Scene: constants.CardObservationScenePackageChanged,
ResourceType: resourceType, ResourceID: resourceID,
SyncTypes: []string{constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic, constants.CardObservationSyncTypeNetwork},
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: now,
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
if err = tx.Model(&model.RechargeOrder{}). if err = tx.Model(&model.RechargeOrder{}).
Where("id = ?", rechargeOrder.ID). Where("id = ?", rechargeOrder.ID).
@@ -289,6 +311,16 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task)
return nil return nil
} }
func orderObservationResource(order *model.Order) (string, uint) {
if order != nil && order.DeviceID != nil {
return constants.CardObservationResourceTypeDevice, *order.DeviceID
}
if order != nil && order.IotCardID != nil {
return constants.CardObservationResourceTypeCard, *order.IotCardID
}
return "", 0
}
// NewAutoPurchaseTask 创建充值后自动购包任务 // NewAutoPurchaseTask 创建充值后自动购包任务
func NewAutoPurchaseTask(rechargeOrderID uint) (*asynq.Task, error) { func NewAutoPurchaseTask(rechargeOrderID uint) (*asynq.Task, error) {
payloadBytes, err := sonic.Marshal(AutoPurchasePayload{RechargeOrderID: rechargeOrderID}) payloadBytes, err := sonic.Marshal(AutoPurchasePayload{RechargeOrderID: rechargeOrderID})

View File

@@ -7,6 +7,7 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock"
"github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -28,7 +29,6 @@ var acquireConcurrencyScript = redis.NewScript(`
return current return current
`) `)
const cardTrafficSyncLockTTL = 10 * time.Minute
const pollingFallbackOperationTimeout = 5 * time.Second const pollingFallbackOperationTimeout = 5 * time.Second
// pollingFallbackContext 创建轮询兜底操作使用的独立短超时上下文。 // pollingFallbackContext 创建轮询兜底操作使用的独立短超时上下文。
@@ -47,6 +47,7 @@ type PollingBase struct {
iotCardStore *postgres.IotCardStore iotCardStore *postgres.IotCardStore
logger *zap.Logger logger *zap.Logger
verboseLog bool verboseLog bool
trafficLock *cardtrafficlock.Lock
} }
// NewPollingBase 创建轮询共享基类 // NewPollingBase 创建轮询共享基类
@@ -65,6 +66,7 @@ func NewPollingBase(
iotCardStore: iotCardStore, iotCardStore: iotCardStore,
logger: logger, logger: logger,
verboseLog: verboseLog, verboseLog: verboseLog,
trafficLock: cardtrafficlock.New(redisClient),
} }
} }
@@ -105,26 +107,16 @@ func (b *PollingBase) releaseConcurrency(_ context.Context, taskType string) {
} }
// acquireCardTrafficSyncLock 获取卡流量同步锁,避免轮询与手动刷新重复统计同一上游读数。 // acquireCardTrafficSyncLock 获取卡流量同步锁,避免轮询与手动刷新重复统计同一上游读数。
func (b *PollingBase) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (bool, error) { func (b *PollingBase) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (string, bool, error) {
if b.redis == nil { return b.trafficLock.Acquire(ctx, cardID)
return true, nil
}
locked, err := b.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), "1", cardTrafficSyncLockTTL).Result()
if err != nil {
return false, err
}
return locked, nil
} }
// releaseCardTrafficSyncLock 释放卡流量同步锁。 // releaseCardTrafficSyncLock 释放卡流量同步锁。
func (b *PollingBase) releaseCardTrafficSyncLock(_ context.Context, cardID uint) { func (b *PollingBase) releaseCardTrafficSyncLock(_ context.Context, cardID uint, token string) {
if b.redis == nil {
return
}
ctx, cancel := pollingFallbackContext() ctx, cancel := pollingFallbackContext()
defer cancel() defer cancel()
if err := b.redis.Del(ctx, constants.RedisCardTrafficSyncLockKey(cardID)).Err(); err != nil { if err := b.trafficLock.Release(ctx, cardID, token); err != nil {
b.logger.Warn("释放卡流量同步锁失败", zap.Uint("card_id", cardID), zap.Error(err)) b.logger.Warn("释放卡流量同步锁失败", zap.Uint("card_id", cardID), zap.Error(err))
} }
} }

View File

@@ -42,12 +42,12 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
} }
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCarddata) defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCarddata)
locked, err := h.base.acquireCardTrafficSyncLock(ctx, cardID) lockToken, locked, err := h.base.acquireCardTrafficSyncLock(ctx, cardID)
if err != nil || !locked { if err != nil || !locked {
h.base.logger.Warn("卡流量同步锁不可用,延迟重入队", zap.Uint("card_id", cardID), zap.Error(err)) h.base.logger.Warn("卡流量同步锁不可用,延迟重入队", zap.Uint("card_id", cardID), zap.Error(err))
return h.base.queueMgr.Requeue(ctx, cardID, constants.TaskTypePollingCarddata, time.Now().Add(5*time.Second)) return h.base.queueMgr.Requeue(ctx, cardID, constants.TaskTypePollingCarddata, time.Now().Add(5*time.Second))
} }
defer h.base.releaseCardTrafficSyncLock(ctx, cardID) defer h.base.releaseCardTrafficSyncLock(ctx, cardID, lockToken)
h.base.invalidateCardCache(ctx, cardID) h.base.invalidateCardCache(ctx, cardID)
card, err := h.base.iotCardStore.GetByID(ctx, cardID) card, err := h.base.iotCardStore.GetByID(ctx, cardID)

View File

@@ -2,15 +2,20 @@ package task
import ( import (
"context" "context"
"strconv"
"time" "time"
"github.com/hibiken/asynq" "github.com/hibiken/asynq"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
) )
// PollingProtectHandler 保护期一致性检查任务处理器 // PollingProtectHandler 保护期一致性检查任务处理器
@@ -18,15 +23,19 @@ import (
// 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件 // 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件
// 两种路径不可混淆:保护期内=强制修正;保护期结束=重新评估 // 两种路径不可混淆:保护期内=强制修正;保护期结束=重新评估
type PollingProtectHandler struct { type PollingProtectHandler struct {
base *PollingBase db *gorm.DB
gateway *gateway.Client observationSeriesEvents cardObservationApp.SeriesEventWriter
iotCardStore *postgres.IotCardStore base *PollingBase
deviceSimBindingStore *postgres.DeviceSimBindingStore gateway *gateway.Client
stopResumeSvc iot_card_svc.StopResumeServiceInterface iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
stopResumeSvc iot_card_svc.StopResumeServiceInterface
} }
// NewPollingProtectHandler 创建保护期一致性检查任务处理器 // NewPollingProtectHandler 创建保护期一致性检查任务处理器
func NewPollingProtectHandler( func NewPollingProtectHandler(
db *gorm.DB,
observationSeriesEvents cardObservationApp.SeriesEventWriter,
base *PollingBase, base *PollingBase,
gw *gateway.Client, gw *gateway.Client,
iotCardStore *postgres.IotCardStore, iotCardStore *postgres.IotCardStore,
@@ -34,11 +43,13 @@ func NewPollingProtectHandler(
stopResumeSvc iot_card_svc.StopResumeServiceInterface, stopResumeSvc iot_card_svc.StopResumeServiceInterface,
) *PollingProtectHandler { ) *PollingProtectHandler {
return &PollingProtectHandler{ return &PollingProtectHandler{
base: base, db: db,
gateway: gw, observationSeriesEvents: observationSeriesEvents,
iotCardStore: iotCardStore, base: base,
deviceSimBindingStore: deviceSimBindingStore, gateway: gw,
stopResumeSvc: stopResumeSvc, iotCardStore: iotCardStore,
deviceSimBindingStore: deviceSimBindingStore,
stopResumeSvc: stopResumeSvc,
} }
} }
@@ -82,8 +93,10 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
} }
deviceID := binding.DeviceID deviceID := binding.DeviceID
stopProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() > 0 stopProtectGeneration := h.base.redis.Get(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val()
startProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() > 0 startProtectGeneration := h.base.redis.Get(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val()
stopProtect := stopProtectGeneration != ""
startProtect := startProtectGeneration != ""
actionTaken := "no_action" actionTaken := "no_action"
@@ -101,15 +114,18 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime)) h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
} }
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, map[string]any{ if updateErr := h.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{
"network_status": constants.NetworkStatusOffline, "network_status": constants.NetworkStatusOffline,
"stopped_at": time.Now(), "stopped_at": time.Now(),
"stop_reason": constants.StopReasonProtectPeriod, "stop_reason": constants.StopReasonProtectPeriod,
}); updateErr != nil { }, constants.CardObservationSceneBusinessStop, "offline", "stop", stopProtectGeneration); updateErr != nil {
h.base.logger.Warn("保护期停机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr)) h.base.logger.Warn("保护期停机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
h.base.invalidateCardCache(ctx, cardID) h.base.invalidateCardCache(ctx, cardID)
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime)) h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) if requeueErr := h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect); requeueErr != nil {
return errors.Wrap(errors.CodeInternalError, requeueErr, "保护期停机事务失败且重入队失败")
}
return updateErr
} }
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOffline}) h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOffline})
actionTaken = "forced_stop" actionTaken = "forced_stop"
@@ -127,15 +143,18 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime)) h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
} }
if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, map[string]any{ if updateErr := h.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{
"network_status": constants.NetworkStatusOnline, "network_status": constants.NetworkStatusOnline,
"resumed_at": time.Now(), "resumed_at": time.Now(),
"stop_reason": "", "stop_reason": "",
}); updateErr != nil { }, constants.CardObservationSceneBusinessResume, "online", "start", startProtectGeneration); updateErr != nil {
h.base.logger.Warn("保护期复机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr)) h.base.logger.Warn("保护期复机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr))
h.base.invalidateCardCache(ctx, cardID) h.base.invalidateCardCache(ctx, cardID)
h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime)) h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) if requeueErr := h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect); requeueErr != nil {
return errors.Wrap(errors.CodeInternalError, requeueErr, "保护期复机事务失败且重入队失败")
}
return updateErr
} }
h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOnline}) h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOnline})
actionTaken = "forced_resume" actionTaken = "forced_resume"
@@ -170,3 +189,35 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error
h.base.updateStats(ctx, constants.TaskTypePollingProtect, true, time.Since(startTime)) h.base.updateStats(ctx, constants.TaskTypePollingProtect, true, time.Since(startTime))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect)
} }
// updateCardAndAppendNetworkSeries 在同一事务中更新卡状态并写入网络观测请求。
func (h *PollingProtectHandler) updateCardAndAppendNetworkSeries(
ctx context.Context,
cardID uint,
fields map[string]any,
scene string,
expected string,
operation string,
protectGeneration string,
) error {
if h.db == nil || h.observationSeriesEvents == nil {
return errors.New(errors.CodeInternalError, "保护期停复机观测 Outbox 能力未配置")
}
if protectGeneration == "" {
return errors.New(errors.CodeInvalidParam, "保护期停复机事件缺少保护期标识")
}
requestID := "card-observation:polling-protect:" + operation + ":" +
strconv.FormatUint(uint64(cardID), 10) + ":" + protectGeneration
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新保护期停复机卡状态失败")
}
return h.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{
EventID: requestID, Scene: scene,
ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID,
SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected,
Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(),
RequestID: requestID, CorrelationID: requestID,
})
})
}

View File

@@ -0,0 +1,11 @@
-- 回滚前将 conflict 归并为 failed避免恢复旧约束失败。
UPDATE tb_integration_log SET result = 'failed' WHERE result = 'conflict';
ALTER TABLE tb_integration_log
DROP CONSTRAINT IF EXISTS ck_integration_log_result;
ALTER TABLE tb_integration_log
ADD CONSTRAINT ck_integration_log_result CHECK (result IN (
'pending', 'success', 'failed', 'unknown', 'not_found', 'invalid_payload',
'ignored', 'merged', 'rate_limited', 'completed', 'cancelled'
));

View File

@@ -0,0 +1,9 @@
-- 扩展 Integration Log 终态,支持入站幂等或资源唯一性冲突。
ALTER TABLE tb_integration_log
DROP CONSTRAINT IF EXISTS ck_integration_log_result;
ALTER TABLE tb_integration_log
ADD CONSTRAINT ck_integration_log_result CHECK (result IN (
'pending', 'success', 'failed', 'unknown', 'not_found', 'invalid_payload',
'conflict', 'ignored', 'merged', 'rate_limited', 'completed', 'cancelled'
));

View File

@@ -17,7 +17,7 @@
- 保留 Integration Log、Outbox、Domain Ledger、幂等、事务和外部调用恢复能力它们继续作为业务可靠性设施不因 Audit Event 移出范围而删除、回滚或绕过。 - 保留 Integration Log、Outbox、Domain Ledger、幂等、事务和外部调用恢复能力它们继续作为业务可靠性设施不因 Audit Event 移出范围而删除、回滚或绕过。
- 永久排除已于 2026-07-14 移出本期的需求16不得创建分销码、代理申请、分销关系、佣金提现相关表、API、页面、领域目录或迁移。 - 永久排除已于 2026-07-14 移出本期的需求16不得创建分销码、代理申请、分销关系、佣金提现相关表、API、页面、领域目录或迁移。
- 统一接口与工程约束Fiber、GORM、PostgreSQL、Redis、Asynq、Viper、Zap、sonic复杂写、简单写、Query 三通道触碰式迁移;统一响应 `{code,msg,data,timestamp}`;错误使用 `pkg/errors`;列表默认 20、最大 100数据权限、幂等、无外键和文档生成器更新继续遵守项目标准。 - 统一接口与工程约束Fiber、GORM、PostgreSQL、Redis、Asynq、Viper、Zap、sonic复杂写、简单写、Query 三通道触碰式迁移;统一响应 `{code,msg,data,timestamp}`;错误使用 `pkg/errors`;列表默认 20、最大 100数据权限、幂等、无外键和文档生成器更新继续遵守项目标准。
- **BREAKING**:按冻结 PRD 和标准评审稿实施时,退款与平台线下充值将停机切换到渠道无关审批契约,并以企微作为本期首个交付 Adapter下线旧本地审批/入账动作接口系列授权等明确要求同批切换的旧混合命令语义不保留长期兼容层 - **BREAKING**:按冻结 PRD 和标准评审稿实施时,退款与平台线下充值将停机切换到渠道无关审批契约,并以企微作为本期首个交付 Adapter下线旧本地审批/入账动作接口系列授权不属于本次破坏性切换范围,必须继续兼容已交付的 `packages[].remove` 请求字段及其删除语义
## Capabilities ## Capabilities

View File

@@ -12,7 +12,7 @@
- [x] 0.8 `JULY-UR38` 代理主钱包信用额度|来源 `.scratch/ur38-agent-main-wallet-credit/`10 Tickets状态后端代码与测试环境切换契约完成、自动化/真实资金验证延期|主通道复杂写,辅助简单写/Query依赖0.1。 - [x] 0.8 `JULY-UR38` 代理主钱包信用额度|来源 `.scratch/ur38-agent-main-wallet-credit/`10 Tickets状态后端代码与测试环境切换契约完成、自动化/真实资金验证延期|主通道复杂写,辅助简单写/Query依赖0.1。
- [ ] 0.9 `JULY-UR40` 下架套餐历史用户续费|来源 `.scratch/ur40-off-shelf-package-renewal/`8 Tickets状态未开始主通道 Domain Policy辅助 Query依赖0.1。 - [ ] 0.9 `JULY-UR40` 下架套餐历史用户续费|来源 `.scratch/ur40-off-shelf-package-renewal/`8 Tickets状态未开始主通道 Domain Policy辅助 Query依赖0.1。
- [ ] 0.10 `JULY-UR42` 统一导出与字段权限|来源 `.scratch/ur42-unified-export-field-permissions/PRD.md`|状态:未开始|主通道 Query/DataSource辅助简单写 Application依赖0.3、0.4、0.5、0.12、0.14。 - [ ] 0.10 `JULY-UR42` 统一导出与字段权限|来源 `.scratch/ur42-unified-export-field-permissions/PRD.md`|状态:未开始|主通道 Query/DataSource辅助简单写 Application依赖0.3、0.4、0.5、0.12、0.14。
- [ ] 0.11 `JULY-UR43` 系列套餐批量授权|来源 `.scratch/ur43-series-package-bulk-authorization/`3 Tickets状态未开始|主通道复杂写,辅助 Query依赖0.1。 - [ ] 0.11 `JULY-UR43` 系列套餐批量授权|来源 `.scratch/ur43-series-package-bulk-authorization/`3 Tickets状态首次授权后端已完成,后续管理兼容契约修正待完成|主通道复杂写,辅助 Query依赖0.1。
- [ ] 0.12 `JULY-UR44` 列表提交人与审批摘要|来源 `.scratch/ur44-list-submitter-approval-summary/PRD.md`|状态:未开始|主通道 Query辅助简单写/迁移依赖4.6、4.11、2.10、4.W9业务列表基础字段不依赖企微专属投影。 - [ ] 0.12 `JULY-UR44` 列表提交人与审批摘要|来源 `.scratch/ur44-list-submitter-approval-summary/PRD.md`|状态:未开始|主通道 Query辅助简单写/迁移依赖4.6、4.11、2.10、4.W9业务列表基础字段不依赖企微专属投影。
- [x] 0.13 `JULY-UR45` 换货快照与新旧独立搜索|来源 `.scratch/ur45-exchange-asset-search/`2 Tickets状态后端完成、前端/人工待验收|主通道复杂写 + Query。 - [x] 0.13 `JULY-UR45` 换货快照与新旧独立搜索|来源 `.scratch/ur45-exchange-asset-search/`2 Tickets状态后端完成、前端/人工待验收|主通道复杂写 + Query。
- [x] 0.14 `JULY-UR46` 预计最终到期时间|来源 `.scratch/ur46-estimated-final-expiry/`4 Tickets状态后端完成、前端/人工未完成|主通道 Query依赖 0.19 已满足。 - [x] 0.14 `JULY-UR46` 预计最终到期时间|来源 `.scratch/ur46-estimated-final-expiry/`4 Tickets状态后端完成、前端/人工未完成|主通道 Query依赖 0.19 已满足。
@@ -26,7 +26,7 @@
- [ ] 0.22 `JULY-UR62` H5 购买与实名顺序配置|来源 `.scratch/ur62-h5-realname-order/PRD.md`|状态:未开始|主通道 Domain/Application辅助简单写/Query依赖0.25。 - [ ] 0.22 `JULY-UR62` H5 购买与实名顺序配置|来源 `.scratch/ur62-h5-realname-order/PRD.md`|状态:未开始|主通道 Domain/Application辅助简单写/Query依赖0.25。
- [ ] 0.23 `JULY-UR73` 按运营商实名能力控制复机|来源 `.scratch/ur73-resume-realname-policy/PRD.md`|状态:未开始|主通道复杂写,辅助 Gateway/Outbox依赖0.25。 - [ ] 0.23 `JULY-UR73` 按运营商实名能力控制复机|来源 `.scratch/ur73-resume-realname-policy/PRD.md`|状态:未开始|主通道复杂写,辅助 Gateway/Outbox依赖0.25。
- [x] 0.24 `JULY-UR86` 资产前代/后代换货链|来源 `.scratch/ur86-asset-exchange-trace/`2 Tickets状态后端完成、前端验收与历史抽样未完成主通道 Query。 - [x] 0.24 `JULY-UR86` 资产前代/后代换货链|来源 `.scratch/ur86-asset-exchange-trace/`2 Tickets状态后端完成、前端验收与历史抽样未完成主通道 Query。
- [ ] 0.25 `JULY-UR94` 卡状态公共写入与运营商回调|来源 `.scratch/ur94-card-state-events-callbacks/`13 Tickets)|状态:未开始|主通道复杂写,辅助 Adapter/Query依赖0.1。 - [x] 0.25 `JULY-UR94` 卡状态公共写入与运营商回调|来源 `.scratch/ur94-card-state-events-callbacks/`13 Tickets + 新增联通实名真实样例)|状态:后端代码与测试环境装配完成、真实运营商/Gateway 验证延期|主通道复杂写,辅助 Adapter/Query依赖0.1。
- [x] 0.26 `JULY-UR96` 店铺业务员归属|来源 `.scratch/ur96-shop-business-owner/`6 Tickets状态后端代码完成、验证延期主通道简单写辅助 Query/Port依赖0.1。 - [x] 0.26 `JULY-UR96` 店铺业务员归属|来源 `.scratch/ur96-shop-business-owner/`6 Tickets状态后端代码完成、验证延期主通道简单写辅助 Query/Port依赖0.1。
- [ ] 0.27 `JULY-UR97` 钱包低余额预警|来源 `.scratch/ur97-wallet-low-balance-alert/PRD.md`|状态:未开始|主通道复杂写,辅助 Query/Outbox/Notification依赖0.2、0.8、0.26。 - [ ] 0.27 `JULY-UR97` 钱包低余额预警|来源 `.scratch/ur97-wallet-low-balance-alert/PRD.md`|状态:未开始|主通道复杂写,辅助 Query/Outbox/Notification依赖0.2、0.8、0.26。
- [ ] 0.28 `JULY-UR98` 换货新资产继承店铺|来源 `.scratch/ur98-exchange-inherit-shop/`4 Tickets状态未开始主通道复杂写辅助 Query/Infrastructure依赖0.1。 - [ ] 0.28 `JULY-UR98` 换货新资产继承店铺|来源 `.scratch/ur98-exchange-inherit-shop/`4 Tickets状态未开始主通道复杂写辅助 Query/Infrastructure依赖0.1。
@@ -84,20 +84,21 @@
- [x] 2.35 映射原 UR94-03将流量观测收口到同一公共写入用例并保持已有周期/资格语义。【主:复杂写 Domain不迁移套餐轮询依赖2.34】 - [x] 2.35 映射原 UR94-03将流量观测收口到同一公共写入用例并保持已有周期/资格语义。【主:复杂写 Domain不迁移套餐轮询依赖2.34】
- [x] 2.36 映射原 UR94-04将网络状态观测收口到同一用例并保持停复机业务边界。【主复杂写 Domain不迁移复机资格由 3.2 负责依赖2.34】 - [x] 2.36 映射原 UR94-04将网络状态观测收口到同一用例并保持停复机业务边界。【主复杂写 Domain不迁移复机资格由 3.2 负责依赖2.34】
- [x] 2.37 映射原 UR94-05一次性切换三个现有轮询结果写入口到公共用例保持 PostgreSQL 配置、Redis 分片、间隔、开关和失败重排不变。【主Application/Infrastructure不迁移轮询调度依赖2.34-2.36】 - [x] 2.37 映射原 UR94-05一次性切换三个现有轮询结果写入口到公共用例保持 PostgreSQL 配置、Redis 分片、间隔、开关和失败重排不变。【主Application/Infrastructure不迁移轮询调度依赖2.34-2.36】
- [ ] 2.38 映射原 UR94-06交付同场景可合并的立即/3/5 分钟观测序列、单次请求互斥、最小间隔与 Integration Log。【主ApplicationAsynq/Adapter不迁移自动退避状态依赖2.34-2.36】 - [x] 2.38 映射原 UR94-06交付同场景可合并的立即/3/5 分钟观测序列、单次请求互斥、最小间隔与 Integration Log。【主ApplicationAsynq/Adapter不迁移自动退避状态依赖2.34-2.36】
- [ ] 2.39 映射原 UR94-07在查询资产、获取实名链接等成功边界接入观测序列不新增显式同步 API。【主Application Adapter不迁移读取 Query 业务依赖2.38】 - [x] 2.39 映射原 UR94-07在查询资产、获取实名链接等成功边界接入观测序列不新增显式同步 API。【主Application Adapter不迁移读取 Query 业务依赖2.38】
- [ ] 2.40 映射原 UR94-08在停复机、购包和套餐激活成功边界接入观测序列仅负责触发不复制业务资格。【主Application Adapter不迁移UR73/套餐规则依赖2.38】 - [x] 2.40 映射原 UR94-08在停复机、购包和套餐激活成功边界接入观测序列仅负责触发不复制业务资格。【主Application Adapter不迁移UR73/套餐规则依赖2.38】
- [ ] 2.41 映射原 UR94-09在切卡、重启等设备控制成功边界接入观测序列并解析最终卡。【主Application Adapter不迁移设备控制规则依赖2.38】 - [x] 2.41 映射原 UR94-09在切卡、重启等设备控制成功边界接入观测序列并解析最终卡。【主Application Adapter不迁移设备控制规则依赖2.38】
- [ ] 2.42 映射原 UR94-10交付电信实名回调 Translator、19/20 位精确路由、幂等入站 Integration Log 和成功应答。【主Infrastructure AdapterApplication依赖2.33、2.34、2.38】 - [x] 2.42 映射原 UR94-10交付电信实名回调 Translator、19/20 位精确路由、幂等入站 Integration Log 和成功应答。【主Infrastructure AdapterApplication依赖2.33、2.34、2.38】
- [ ] 2.43 映射原 UR94-11交付移动实名成功回调的对应防腐层与公共写入接入。【主Infrastructure Adapter依赖2.33、2.34、2.38】 - [x] 2.43 映射原 UR94-11交付移动实名成功回调的对应防腐层与公共写入接入。【主Infrastructure Adapter依赖2.33、2.34、2.38】
- [ ] 2.44 映射原 UR94-12交付联通解除实名回调的留痕与约定成功应答不修改本地实名状态。【主Infrastructure Adapter边界Integration Log only依赖2.33】 - [x] 2.44 映射原 UR94-12交付联通解除实名回调的留痕与约定成功应答不修改本地实名状态。【主Infrastructure Adapter边界Integration Log only依赖2.33】
- [ ] 2.45 映射原 UR94-13 为测试环境发布检查:装配回调 Handler/路由/文档生成器、观测 Worker、Gateway Adapter 与配置,执行格式化、必要生成、构建和部署检查;真实运营商/Gateway验证转 6.4。【主Infrastructure依赖2.37-2.44 - [x] 2.44A 根据新增来源材料 `docs/7月迭代/来源材料/realname.go` 补充联通实名成功回调:解析与解除实名一致的双层 JSON按 19/20 位精确定位并接入公共实名观测;禁止 20 位截断、旧 `inner_callback`、第三方推送和 Gateway 二次确认。【主Infrastructure AdapterApplication依赖2.33、2.34、2.38
- [x] 2.45 映射原 UR94-13 为测试环境发布检查:通过受控 `system_config` 为电信实名、移动实名、联通实名及联通解除实名提供默认关闭的独立开关;装配四个回调 Handler/路由/文档生成器、观测 Worker、Gateway Adapter 与配置,执行格式化、必要生成、构建和部署检查;真实运营商/Gateway验证转 6.4。【主Infrastructure依赖2.37-2.44A】
### B1-05B1-09 独立 frontier ### B1-05B1-09 独立 frontier
- [ ] 2.46 `JULY-UR43` 映射原 Ticket 01首次系列授权在同一事务创建系列授权和至少一条套餐授权复用现有套餐 Query已授权项置灰。【主复杂写Query不迁移授权模型/候选 API依赖1.1】 - [x] 2.46 `JULY-UR43` 映射原 Ticket 01首次系列授权在同一事务创建系列授权和至少一条套餐授权复用现有套餐 Query已授权项置灰。【主复杂写Query不迁移授权模型/候选 API依赖1.1】
- [ ] 2.47 `JULY-UR43` 映射原 Ticket 02`operation_type=authorize|update_cost|remove` 三类原子命令,最多 100 项、同价幂等、异价冲突。【主:复杂写|不迁移:混合命令兼容依赖2.46】 - [ ] 2.47 `JULY-UR43` 映射原 Ticket 02在保留已交付 `packages:[{package_id,cost_price,remove}]` 契约的前提下,完成最多 100 项的原子批量新增、改价与移除;`remove=true` 继续表示软删除,禁止以必填 `operation_type` 替代或移除该字段,并补齐同价幂等、异价冲突和下级授权保护。【主:复杂写|不迁移:既有请求字段与响应结构依赖2.46】
- [ ] 2.48 `JULY-UR43` 映射原 Ticket 03交付首次/后续管理前端契约、三类价格语义、OpenAPI/文档生成器与同批切换部署检查。【主API/Query 契约依赖2.46、2.47】 - [ ] 2.48 `JULY-UR43` 映射原 Ticket 03交付首次/后续管理前端契约、`packages[].remove` 删除语义、OpenAPI/文档生成器与兼容部署检查;旧前端请求必须继续可用,不得要求与后端同批切换到新命令字段。【主API/Query 契约依赖2.46、2.47】
- [ ] 2.49 `JULY-UR47` 映射原 Ticket 01修复同一设备多当前卡并建立部分唯一约束异常数据先入清单。【主Infrastructure不迁移绑定历史依赖1.1】 - [ ] 2.49 `JULY-UR47` 映射原 Ticket 01修复同一设备多当前卡并建立部分唯一约束异常数据先入清单。【主Infrastructure不迁移绑定历史依赖1.1】
- [ ] 2.50 `JULY-UR47` 映射原 Ticket 02交付语义化 `speed_level` 到账号/运营商渠道 `code` 的 Gateway Adapter、能力校验与 Integration Log。【主Port/Adapter不迁移自动限速依赖2.49】 - [ ] 2.50 `JULY-UR47` 映射原 Ticket 02交付语义化 `speed_level` 到账号/运营商渠道 `code` 的 Gateway Adapter、能力校验与 Integration Log。【主Port/Adapter不迁移自动限速依赖2.49】
- [ ] 2.51 `JULY-UR47` 映射原 Ticket 03交付统一资产手动限速 API设备解析当前卡区分不限速与 0kbps外部结果未知可恢复Audit Event 不属于本 Change 且不阻塞上线。【主ApplicationAdapter不迁移套餐限速依赖2.49、2.50】 - [ ] 2.51 `JULY-UR47` 映射原 Ticket 03交付统一资产手动限速 API设备解析当前卡区分不限速与 0kbps外部结果未知可恢复Audit Event 不属于本 Change 且不阻塞上线。【主ApplicationAdapter不迁移套餐限速依赖2.49、2.50】

View File

@@ -30,7 +30,7 @@ const (
CardObservationSeriesAttemptCount = 3 // 每个序列固定尝试次数 CardObservationSeriesAttemptCount = 3 // 每个序列固定尝试次数
CardObservationSeriesTTL = 7 * time.Minute // 活跃序列覆盖最后一次任务及缓冲 CardObservationSeriesTTL = 7 * time.Minute // 活跃序列覆盖最后一次任务及缓冲
CardObservationSeriesResultTTL = 24 * time.Hour // 尝试幂等与提前完成结果保留时间 CardObservationSeriesResultTTL = 24 * time.Hour // 尝试幂等与提前完成结果保留时间
CardObservationGatewayLockTTL = 70 * time.Second // Gateway 请求互斥超时加安全余量 CardObservationGatewayLockTTL = 16 * time.Minute // 覆盖 300 秒超时、两次网络重试及安全余量
CardObservationGatewayMinInterval = 10 * time.Second // 未配置时的运营商最小请求间隔 CardObservationGatewayMinInterval = 10 * time.Second // 未配置时的运营商最小请求间隔
) )
@@ -64,27 +64,46 @@ const (
// Gateway 轮询 Integration Log 操作编码。 // Gateway 轮询 Integration Log 操作编码。
const ( const (
IntegrationProviderGateway = "gateway" // Gateway 外部系统 IntegrationProviderGateway = "gateway" // Gateway 外部系统
IntegrationOperationGatewayRealname = "query_realname_status" // 查询实名状态 IntegrationOperationGatewayRealname = "query_realname_status" // 查询实名状态
IntegrationOperationGatewayTraffic = "query_flow" // 查询流量 IntegrationOperationGatewayTraffic = "query_flow" // 查询流量
IntegrationOperationGatewayNetwork = "query_card_status" // 查询网络状态 IntegrationOperationGatewayNetwork = "query_card_status" // 查询网络状态
IntegrationOperationGatewayDeviceInfo = "query_device_info" // 查询设备信息
) )
// 卡实名观测场景 // 卡实名观测场景
const ( const (
CardObservationSceneRealnamePolling = "realname_polling" // 实名轮询 CardObservationSceneRealnamePolling = "realname_polling" // 实名轮询
CardObservationSceneTrafficPolling = "traffic_polling" // 流量轮询 CardObservationSceneTrafficPolling = "traffic_polling" // 流量轮询
CardObservationSceneNetworkPolling = "network_polling" // 网络状态轮询 CardObservationSceneNetworkPolling = "network_polling" // 网络状态轮询
CardObservationSceneManualRefresh = "manual_refresh" // 手动刷新 CardObservationSceneManualRefresh = "manual_refresh" // 手动刷新
CardObservationSceneCarrierCallback = "carrier_realname_callback" // 运营商实名回调 CardObservationSceneCarrierCallback = "carrier_realname_callback" // 运营商实名回调
CardObservationSceneClientAssetRead = "client_asset_read" // C 端资产详情读取
CardObservationSceneAdminAssetRead = "admin_asset_realtime" // 后台资产实时状态读取
CardObservationSceneOpenCardTraffic = "openapi_card_traffic" // OpenAPI 卡流量读取
CardObservationSceneOpenCardNetwork = "openapi_card_network" // OpenAPI 卡网络读取
CardObservationSceneOpenCardRealname = "openapi_card_realname" // OpenAPI 卡实名读取
CardObservationSceneOpenDeviceTraffic = "openapi_device_traffic" // OpenAPI 设备流量读取
CardObservationSceneClientRealnameLink = "client_realname_link" // C 端获取实名链接
CardObservationSceneAdminRealnameLink = "admin_realname_link" // 后台获取实名链接
CardObservationSceneBusinessStop = "business_stop" // 业务停机成功
CardObservationSceneBusinessResume = "business_resume" // 业务复机成功
CardObservationScenePackageChanged = "package_changed" // 购包或套餐激活成功
CardObservationSceneDeviceSwitchCard = "device_switch_card" // 设备切卡成功
CardObservationSceneDeviceSwitchMode = "device_switch_mode" // 设备切卡模式成功
CardObservationSceneDeviceReboot = "device_reboot" // 设备重启成功
CardObservationSceneDeviceReset = "device_reset" // 设备恢复出厂成功
CardObservationSceneDeviceSetWiFi = "device_set_wifi" // 设备 WiFi 设置成功
) )
// 卡实名状态变更 Outbox 事件 // 卡实名状态变更 Outbox 事件
const ( const (
OutboxEventTypeCardRealnameChanged = "card.realname.changed" // 卡实名状态变化 OutboxEventTypeCardRealnameChanged = "card.realname.changed" // 卡实名状态变化
CardRealnameChangedPayloadVersionV1 = 1 // 卡实名状态变化载荷版本 CardRealnameChangedPayloadVersionV1 = 1 // 卡实名状态变化载荷版本
OutboxEventTypeCardTrafficIncremented = "card.traffic.incremented" // 卡流量正增量 OutboxEventTypeCardTrafficIncremented = "card.traffic.incremented" // 卡流量正增量
CardTrafficIncrementedPayloadVersionV1 = 1 // 卡流量正增量载荷版本 CardTrafficIncrementedPayloadVersionV1 = 1 // 卡流量正增量载荷版本
OutboxEventTypeCardNetworkChanged = "card.network.changed" // 卡网络状态变化 OutboxEventTypeCardNetworkChanged = "card.network.changed" // 卡网络状态变化
CardNetworkChangedPayloadVersionV1 = 1 // 卡网络状态变化载荷版本 CardNetworkChangedPayloadVersionV1 = 1 // 卡网络状态变化载荷版本
OutboxEventTypeCardSeriesRequested = "card.observation.series.requested" // 业务成功请求卡观测序列
CardSeriesRequestedPayloadVersionV1 = 1 // 业务成功观测序列载荷版本
) )

View File

@@ -1,5 +1,7 @@
package constants package constants
import "time"
const ( const (
// IntegrationDirectionInbound 表示外部系统调用本系统。 // IntegrationDirectionInbound 表示外部系统调用本系统。
IntegrationDirectionInbound = "inbound" IntegrationDirectionInbound = "inbound"
@@ -7,6 +9,25 @@ const (
IntegrationDirectionOutbound = "outbound" IntegrationDirectionOutbound = "outbound"
) )
const (
// IntegrationProviderCTCC 表示中国电信回调提供方。
IntegrationProviderCTCC = "ctcc"
// IntegrationOperationCTCCRealnameCallback 表示中国电信实名结果回调。
IntegrationOperationCTCCRealnameCallback = "realname_callback"
// IntegrationProviderCMCC 表示中国移动回调提供方。
IntegrationProviderCMCC = "cmcc"
// IntegrationOperationCMCCRealnameCallback 表示中国移动实名结果回调。
IntegrationOperationCMCCRealnameCallback = "realname_callback"
// IntegrationProviderCUCC 表示中国联通回调提供方。
IntegrationProviderCUCC = "cucc"
// IntegrationOperationCUCCRealnameRemovalCallback 表示中国联通解除实名回调。
IntegrationOperationCUCCRealnameRemovalCallback = "realname_removal_callback"
// IntegrationOperationCUCCRealnameCallback 表示中国联通实名成功回调。
IntegrationOperationCUCCRealnameCallback = "realname_callback"
// IntegrationInboundProcessingLease 表示入站回调 pending 记录允许恢复前的处理租约。
IntegrationInboundProcessingLease = time.Minute
)
const ( const (
// IntegrationResultPending 表示外部尝试已建立但尚未终结。 // IntegrationResultPending 表示外部尝试已建立但尚未终结。
IntegrationResultPending = "pending" IntegrationResultPending = "pending"
@@ -20,6 +41,8 @@ const (
IntegrationResultNotFound = "not_found" IntegrationResultNotFound = "not_found"
// IntegrationResultInvalidPayload 表示入站载荷无效。 // IntegrationResultInvalidPayload 表示入站载荷无效。
IntegrationResultInvalidPayload = "invalid_payload" IntegrationResultInvalidPayload = "invalid_payload"
// IntegrationResultConflict 表示入站事实存在唯一性或幂等冲突。
IntegrationResultConflict = "conflict"
// IntegrationResultIgnored 表示外部尝试被安全忽略。 // IntegrationResultIgnored 表示外部尝试被安全忽略。
IntegrationResultIgnored = "ignored" IntegrationResultIgnored = "ignored"
// IntegrationResultMerged 表示尝试被合并且未发送请求。 // IntegrationResultMerged 表示尝试被合并且未发送请求。
@@ -41,6 +64,7 @@ func IntegrationResultName(result string) string {
IntegrationResultUnknown: "结果未知", IntegrationResultUnknown: "结果未知",
IntegrationResultNotFound: "未找到", IntegrationResultNotFound: "未找到",
IntegrationResultInvalidPayload: "无效载荷", IntegrationResultInvalidPayload: "无效载荷",
IntegrationResultConflict: "冲突",
IntegrationResultIgnored: "已忽略", IntegrationResultIgnored: "已忽略",
IntegrationResultMerged: "已合并", IntegrationResultMerged: "已合并",
IntegrationResultRateLimited: "已限频", IntegrationResultRateLimited: "已限频",

View File

@@ -494,6 +494,11 @@ func RedisCardObservationAttemptKey(seriesID string, attempt int) string {
return fmt.Sprintf("cardsync:attempt:%s:%d", seriesID, attempt) return fmt.Sprintf("cardsync:attempt:%s:%d", seriesID, attempt)
} }
// RedisCardObservationScheduleKey 返回固定阶梯任务的入队幂等键。
func RedisCardObservationScheduleKey(seriesID string, attempt int) string {
return fmt.Sprintf("cardsync:schedule:%s:%d", seriesID, attempt)
}
// RedisCardObservationInflightKey 返回一次实际 Gateway 请求的互斥键。 // RedisCardObservationInflightKey 返回一次实际 Gateway 请求的互斥键。
func RedisCardObservationInflightKey(provider, syncType, resourceID string) string { func RedisCardObservationInflightKey(provider, syncType, resourceID string) string {
return fmt.Sprintf("cardsync:inflight:%s:%s:%s", provider, syncType, resourceID) return fmt.Sprintf("cardsync:inflight:%s:%s:%s", provider, syncType, resourceID)

View File

@@ -0,0 +1,6 @@
package constants
const (
// ShopSeriesGrantMaxPackages 系列授权单次最多处理的套餐数量。
ShopSeriesGrantMaxPackages = 100
)

View File

@@ -16,6 +16,16 @@ const (
SystemConfigTypeJSON = "json" SystemConfigTypeJSON = "json"
// SystemConfigCacheTTL 是系统配置默认缓存时长。 // SystemConfigCacheTTL 是系统配置默认缓存时长。
SystemConfigCacheTTL = 5 * time.Minute SystemConfigCacheTTL = 5 * time.Minute
// SystemConfigModuleCarrierCallback 是运营商回调配置模块。
SystemConfigModuleCarrierCallback = "carrier_callback"
// SystemConfigCarrierCallbackCTCCRealnameEnabled 控制电信实名回调是否执行业务处理。
SystemConfigCarrierCallbackCTCCRealnameEnabled = "carrier_callback.ctcc_realname.enabled"
// SystemConfigCarrierCallbackCMCCRealnameEnabled 控制移动实名回调是否执行业务处理。
SystemConfigCarrierCallbackCMCCRealnameEnabled = "carrier_callback.cmcc_realname.enabled"
// SystemConfigCarrierCallbackCUCCRealnameEnabled 控制联通实名成功回调是否执行业务处理。
SystemConfigCarrierCallbackCUCCRealnameEnabled = "carrier_callback.cucc_realname.enabled"
// SystemConfigCarrierCallbackCUCCRealnameRemovalEnabled 控制联通解除实名回调是否执行业务处理。
SystemConfigCarrierCallbackCUCCRealnameRemovalEnabled = "carrier_callback.cucc_realname_removal.enabled"
) )
// RedisSystemConfigKey 生成单个系统配置的 Redis 缓存键。 // RedisSystemConfigKey 生成单个系统配置的 Redis 缓存键。

View File

@@ -34,6 +34,10 @@ var (
Name: "wecom_callback", Sensitive: true, Name: "wecom_callback", Sensitive: true,
SafeFields: fieldSet("event_type", "resource_type", "resource_id", "result_code", "status"), SafeFields: fieldSet("event_type", "resource_type", "resource_id", "result_code", "status"),
} }
carrierCallbackPolicy = AccessPolicy{
Name: "carrier_callback", Sensitive: true, PresenceOnly: true,
ResultFields: fieldSet("code", "msg", "timestamp"),
}
configPolicy = AccessPolicy{ configPolicy = AccessPolicy{
Name: "sensitive_config", Sensitive: true, PresenceOnly: true, Name: "sensitive_config", Sensitive: true, PresenceOnly: true,
SafeFields: fieldSet("config_key", "value_type", "module", "readonly", "sensitive", "configured", "status", "result_code"), SafeFields: fieldSet("config_key", "value_type", "module", "readonly", "sensitive", "configured", "status", "result_code"),
@@ -64,6 +68,8 @@ func policyForPath(path string) AccessPolicy {
return configPolicy return configPolicy
case strings.Contains(normalized, "/callback") && (strings.Contains(normalized, "wecom") || strings.Contains(normalized, "wework")): case strings.Contains(normalized, "/callback") && (strings.Contains(normalized, "wecom") || strings.Contains(normalized, "wework")):
return wecomPolicy return wecomPolicy
case strings.Contains(normalized, "/callback/carriers/"):
return carrierCallbackPolicy
case strings.Contains(normalized, "payment"), strings.Contains(normalized, "wechat-pay"), case strings.Contains(normalized, "payment"), strings.Contains(normalized, "wechat-pay"),
strings.Contains(normalized, "alipay"), strings.Contains(normalized, "fuiou-pay"): strings.Contains(normalized, "alipay"), strings.Contains(normalized, "fuiou-pay"):
return paymentPolicy return paymentPolicy

View File

@@ -54,6 +54,10 @@ func BuildDocHandlers() *bootstrap.Handlers {
AdminOrder: admin.NewOrderHandler(nil, nil), AdminOrder: admin.NewOrderHandler(nil, nil),
AdminExchange: admin.NewExchangeHandler(nil, nil, nil), AdminExchange: admin.NewExchangeHandler(nil, nil, nil),
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil, nil, nil), PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil, nil, nil),
CTCCRealnameCallback: callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil),
CMCCRealnameCallback: callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil),
CUCCRealnameCallback: callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil),
CUCCRealnameRemovalCallback: callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil),
PollingConfig: admin.NewPollingConfigHandler(nil), PollingConfig: admin.NewPollingConfigHandler(nil),
PollingConcurrency: admin.NewPollingConcurrencyHandler(nil), PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),
PollingMonitoring: admin.NewPollingMonitoringHandler(nil), PollingMonitoring: admin.NewPollingMonitoringHandler(nil),

View File

@@ -9,6 +9,7 @@ import (
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
) )
// CardObservationSeriesScheduler 将固定阶梯任务提交到独立队列。 // CardObservationSeriesScheduler 将固定阶梯任务提交到独立队列。
@@ -23,6 +24,9 @@ func NewCardObservationSeriesScheduler(client *Client) *CardObservationSeriesSch
// Enqueue 以稳定 series_id + attempt 任务 ID 零重试入队。 // Enqueue 以稳定 series_id + attempt 任务 ID 零重试入队。
func (s *CardObservationSeriesScheduler) Enqueue(ctx context.Context, payload cardapp.SeriesTaskPayload) error { func (s *CardObservationSeriesScheduler) Enqueue(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
if s == nil || s.client == nil {
return pkgerrors.New(pkgerrors.CodeInternalError, "卡观测序列队列客户端未配置")
}
taskID := "card-series-" + payload.SeriesID + "-" + strconv.Itoa(payload.Attempt) taskID := "card-series-" + payload.SeriesID + "-" + strconv.Itoa(payload.Attempt)
err := s.client.EnqueueTask( err := s.client.EnqueueTask(
ctx, ctx,

View File

@@ -228,6 +228,7 @@ func (h *Handler) registerPollingHandlers() {
h.pollingBase, h.workerResult.Stores.IotCard, h.pollingBase, h.workerResult.Stores.IotCard,
h.pollingStopResumeSvc) h.pollingStopResumeSvc)
protectHandler := task.NewPollingProtectHandler( protectHandler := task.NewPollingProtectHandler(
h.db, h.workerResult.Services.ObservationSeriesEvents,
h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard, h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard,
h.workerResult.Stores.DeviceSimBinding, h.pollingStopResumeSvc) h.workerResult.Stores.DeviceSimBinding, h.pollingStopResumeSvc)
cardStatusHandler := task.NewPollingCardStatusHandler( cardStatusHandler := task.NewPollingCardStatusHandler(
@@ -295,6 +296,7 @@ func (h *Handler) registerAutoPurchaseHandler() {
h.redis, h.redis,
h.asynqClient, h.asynqClient,
h.logger, h.logger,
h.workerResult.Services.ObservationSeriesEvents,
) )
h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask) h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask)
h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge)) h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge))

View File

@@ -54,17 +54,18 @@ type WorkerStores struct {
// WorkerServices Worker 侧所有 Service 的集合 // WorkerServices Worker 侧所有 Service 的集合
type WorkerServices struct { type WorkerServices struct {
CardObservation *cardObservationApp.Service CardObservation *cardObservationApp.Service
CardObservationSeries *cardObservationApp.SeriesAttemptService CardObservationSeries *cardObservationApp.SeriesAttemptService
CommissionCalculation *commission_calculation.Service ObservationSeriesEvents cardObservationApp.SeriesEventWriter
CommissionStats *commission_stats.Service CommissionCalculation *commission_calculation.Service
UsageService *packagepkg.UsageService CommissionStats *commission_stats.Service
ActivationService *packagepkg.ActivationService UsageService *packagepkg.UsageService
ResetService *packagepkg.ResetService ActivationService *packagepkg.ActivationService
AlertService *pollingSvc.AlertService ResetService *packagepkg.ResetService
CleanupService *pollingSvc.CleanupService AlertService *pollingSvc.AlertService
StopResumeService packagepkg.StopResumeCallback // 停复机服务,用于注入 Scheduler CleanupService *pollingSvc.CleanupService
OrderExpirer OrderExpirer // 订单超时取消服务(接口类型,避免循环依赖) StopResumeService packagepkg.StopResumeCallback // 停复机服务,用于注入 Scheduler
OrderExpirer OrderExpirer // 订单超时取消服务(接口类型,避免循环依赖)
} }
// WorkerBootstrapResult Worker Bootstrap 结果 // WorkerBootstrapResult Worker Bootstrap 结果