diff --git a/.scratch/tech-global-audit/审计覆盖基线.md b/.scratch/tech-global-audit/审计覆盖基线.md index 8ac9917..049b0e7 100644 --- a/.scratch/tech-global-audit/审计覆盖基线.md +++ b/.scratch/tech-global-audit/审计覆盖基线.md @@ -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.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 | | 代理商资金概况信用投影 | 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/A(Audit Event 已移出本 Change;运营商来源真实性验证也不在本票边界) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/ignored/not_found/conflict/success/failed` 终态 | 实名事实首次变化时由公共 `ApplyCardObservation` 同事务写入实名状态变化 Outbox;重复成功不重复写事件 | +| 移动实名成功回调 | N/A(Audit Event 已移出本 Change;不接入旧平台登录、MSISDN 补查或来源真实性验证) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed` 终态,并通过 pending 租约恢复中断处理 | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox,重复成功不重复写事件 | +| 联通实名成功回调 | N/A(Audit 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 与旧表写入口清单 diff --git a/.scratch/ur43-series-package-bulk-authorization/PRD.md b/.scratch/ur43-series-package-bulk-authorization/PRD.md index 2dd637a..fde9fa6 100644 --- a/.scratch/ur43-series-package-bulk-authorization/PRD.md +++ b/.scratch/ur43-series-package-bulk-authorization/PRD.md @@ -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 复用现有套餐列表和系列授权详情,不新增候选 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 1. 作为平台或上级代理,我希望一次选择多个系列套餐并授权给目标代理。 2. 作为授权人员,我希望看到公司成本价、目标代理当前授权成本价和建议零售价,不再混淆单一 `cost_price` 的含义。 3. 作为授权人员,我希望已经授权的套餐明确置灰,避免重复选择。 -4. 作为授权人员,我希望新增授权、调价和移除是三个明确动作,避免误操作。 -5. 作为并发操作人员,我希望重复同价授权安全幂等,不同价并发请求明确冲突而不是覆盖。 +4. 作为授权人员,我希望继续使用已交付的每项 `remove` 字段完成移除,避免发布后旧前端失效。 +5. 作为并发操作人员,我希望批量请求全成全败,并在价格或授权状态冲突时得到明确反馈。 6. 作为上级代理,我只能把自己有权销售的套餐授权给直属下级,不能借候选或构造请求越权。 -7. 作为审计人员,我希望知道一次批量操作的命令、系列、目标代理、套餐及前后价格。 +7. 作为维护人员,我希望 OpenAPI 和前端契约准确记录 `remove` 字段,避免后续再次误删。 ## Implementation Decisions @@ -32,7 +32,7 @@ Status: ready-for-agent - 复用 `tb_shop_series_allocation` 和 `tb_shop_package_allocation`,不新建授权关系表。 - 系列授权记录是目标店铺获得该系列销售能力的入口;套餐授权记录保存目标店铺对具体套餐的成本价、零售价、状态和上下架状态。 -- 本需求不重建系列佣金、强充配置、套餐零售价或价格继承规则,也不新增候选 Query;只规范现有列表组合、首次批量授权和后续明确的批量套餐命令。 +- 本需求不重建系列佣金、强充配置、套餐零售价或价格继承规则,也不新增候选 Query;只规范现有列表组合、首次批量授权和兼容既有字段的后续批量管理。 - 赠送套餐 `is_gift=true` 不属于代理授权候选,也不能通过构造请求加入。 - 金额统一使用分,类型为 `int64`,禁止浮点金额。 @@ -58,33 +58,29 @@ Status: ready-for-agent - 继续使用 `POST /api/admin/shop-series-grants`,在一个事务内创建 `ShopSeriesAllocation` 与请求中的多条 `ShopPackageAllocation`;不拆成两步,不允许留下无套餐的空系列授权。 - `packages` 从当前可选字段收紧为必填,至少 1 项、最多 100 项;每项为 `package_id + cost_price`,同一请求内套餐 ID 必须唯一。 -- 首次授权中的套餐必须满足与后续 `authorize` 相同的系列、赠送、状态、上级授权、价格和权限规则;任一套餐失败则系列授权、全部套餐授权、价格历史和成功审计均不落库。 +- 首次授权中的套餐必须满足与后续新增授权相同的系列、赠送、状态、上级授权、价格和权限规则;任一套餐失败则系列授权、全部套餐授权和价格历史均不落库。 - 首次授权不需要 `operation_type`,因为 `POST` 的业务语义已经唯一明确为“创建系列授权并首次授权套餐”;该接口不承担后续调价或移除。 -### 批量写契约 +### 批量写兼容契约 - 保留 `PUT /api/admin/shop-series-grants/{id}/packages`,请求固定为: ```json { - "operation_type": "authorize", "packages": [ {"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 返回参数错误,不能以“最后一个覆盖前一个”处理。 -- `authorize` 和 `update_cost` 每项必须提供大于等于 0 的 `cost_price`;`remove` 只使用 `package_id`,如携带价格则拒绝,避免无效参数制造歧义。 -- 不再使用每项 `remove=true` 混合命令;旧模糊请求不得继续触发新增、改价或删除。 -- `operation_type` 缺失返回参数错误。该契约要求前后端同批发布,不提供会延续模糊语义的长期兼容层。 +- 每项 `remove=true` 时执行软删除;字段缺失或为 `false` 时必须提供大于等于 0 的 `cost_price`,未授权则新增,已授权则按现有规则更新价格。 +- `remove` 是已交付稳定字段,不得删除、改名或要求调用方改传必填顶层 `operation_type`。 +- 成功响应继续返回最新 `ShopSeriesGrantResponse`,不得强制旧前端适配新的结构化计数响应。 -### `authorize` 规则 +### 新增授权规则 - 每个套餐必须未删除、属于当前系列且不是赠送套餐。套餐当前禁用或下架状态原样展示但不阻止授权,保持现有“可以先配置授权、销售时由可售策略拦截”的能力;本需求不把销售状态误当成授权状态。 - 代理操作者必须拥有该套餐的有效授权;目标店铺必须仍满足现有直属下级和系列授权管理权限。 @@ -93,7 +89,7 @@ Status: ready-for-agent - 目标代理已经存在有效授权但成本价不同:返回冲突,整批不做任何写入。不得借 `authorize` 静默改价。 - 已软删除的旧授权是否允许按新授权恢复,应复用项目现有唯一索引和新建授权语义:创建新的有效记录或按明确恢复操作处理,但不能把旧价格静默带回;审计必须标明恢复来源。 -### `update_cost` 规则 +### 更新成本价规则 - 每个套餐必须已经存在目标代理的当前有效授权;未授权项导致整批失败。 - 新价格与当前价格相同时按幂等成功,不写重复价格历史。 @@ -101,7 +97,7 @@ Status: ready-for-agent - 若目标代理已将该套餐继续授权给下级,沿用现有规则禁止修改成本价,返回“存在下级分配记录,请先回收后再修改成本价”;整批失败,不允许部分跳过。 - 此命令是按套餐指定绝对目标成本价,与现有 `/shop-package-batch-pricing` 按店铺/系列整体固定或比例调价不同,二者不能互相冒充。 -### `remove` 规则 +### `remove=true` 规则 - 每个套餐必须已经存在目标代理的当前有效授权;同一授权已不存在时按幂等成功,不重复写删除审计。 - 若该套餐已经继续授权给下级、存在会被破坏的销售/授权不变量或项目现有回收前置条件,必须拒绝并要求先回收,不能留下下级拥有而上级无权的悬空链路。 @@ -110,7 +106,7 @@ Status: ready-for-agent ### 事务、并发与幂等 -- 批量命令是轻量 Application 事务脚本。Application 负责权限、命令解析、批量加载、规则校验、条件写入、价格历史和审计;不创建无业务行为的聚合根。 +- 批量管理是轻量 Application 事务脚本。Application 负责权限、逐项语义解析、批量加载、规则校验、条件写入和价格历史;不创建无业务行为的聚合根。 - 一批请求在一个 PostgreSQL 事务内全成全败。必须先批量加载和校验所有套餐、目标授权及下级引用,再执行写入。 - 数据库保留目标店铺与套餐的有效授权唯一约束;`authorize` 创建应使用条件写入/唯一冲突后的重新读取,正确区分同价幂等与不同价冲突。 - 并发 `authorize` 同一批套餐时,一方成功后另一方重新核对当前价格;相同价格成功返回,不同价格冲突,不得将唯一约束错误暴露给客户端。 @@ -126,23 +122,22 @@ Status: ready-for-agent ### 审计与响应 -- 每次成功批量命令写统一 Audit Event,至少包含 `operation_type`、系列授权 ID、目标店铺、系列 ID、套餐 ID 列表、前后成本价、操作者和幂等项摘要。 -- 拒绝和冲突按公共失败审计规则记录;价格、账号等敏感信息遵循全局审计脱敏规则。 -- 成功响应返回最新授权详情或结构化结果,至少包括请求数、实际新增/更新/移除数、幂等数及相关套餐 ID;不得以“跳过”掩盖不同价格冲突。 +- Audit Event 已按七月总 Change 决策移出本期,本需求不新增审计 Writer;价格历史继续作为调价事实保存。 +- 成功响应保持已交付的最新授权详情结构,不引入破坏性响应变更。 ### 前端交互 - 首次授权和后续管理复用同一个批量选择表格组件;数据来自现有套餐列表,后续管理再与现有授权详情按 `package_id` 合并。 - 分列展示当前上级成本价、目标代理授权成本价、建议零售价;平台作为上级时当前上级成本就是公司成本。未授权成本价显示“-”,不能显示 0 元造成误解。 - `is_authorized=true` 显示“已授权”并在新增授权模式置灰;切换到调价或移除模式时只允许选择已授权项。 -- 一个弹窗/提交只能处于授权、调价或移除一种模式,请求提交相应 `operation_type`。 +- 前端可以提供授权、调价或移除操作模式,但提交时继续组装既有套餐项:移除项设置 `remove=true`,新增或调价项提交 `cost_price`。 - 提交前展示命令名称、目标代理、系列、套餐数量和价格摘要;提交期间禁止重复提交。 - 并发不同价冲突时保留用户输入,提示刷新候选数据后重新确认,不能自动覆盖。 ### 发布与回滚 - 不迁移、不回填现有授权数据。发布前核对同一店铺/套餐有效授权重复、孤立下级授权及价格历史异常。 -- 前后端同批切换必填 `operation_type`;旧前端流量应在发布窗口清空或阻断,不能让缺失命令类型的请求继续按旧逻辑执行。 +- 后端发布必须向后兼容现有前端请求,无需停机切换新命令字段;发布检查应确认 OpenAPI 仍包含 `packages[].remove`。 - 回滚应用时保留新版本产生的授权、价格历史和审计;不得删除业务事实。 ## Testing Decisions @@ -150,20 +145,19 @@ Status: ready-for-agent - 读取组合测试覆盖平台与代理视角的现有套餐列表、授权详情、系列过滤、分页、赠送套餐排除、禁用/下架存量项只读展示,以及不同视角下成本价字段的准确文案。 - 字段权限测试验证无成本价查看权限的账号不能读取敏感价格,代理不能查询其他授权记录详情或借套餐列表扩大授权范围。 - 首次创建测试覆盖套餐数组必填、多项同事务成功、重复 ID、跨系列、赠送、无上级授权、非法价格及任一项失败时系列授权也不落库。 -- `authorize` 测试覆盖多项成功、同价幂等、不同价冲突、同请求重复 ID、跨系列、赠送、禁用/下架、代理自身未授权和并发唯一冲突。 -- `update_cost` 测试覆盖未授权、同价幂等、成功调价、价格历史、存在下级分配时整批失败,以及绝对价格不会被误解成固定/比例调整。 -- `remove` 测试覆盖成功、已不存在幂等、存在下级授权拒绝和不影响客户历史订单/套餐使用。 +- 新增测试覆盖多项成功、重复请求、同请求重复 ID、跨系列、赠送、禁用/下架、代理自身未授权和并发唯一冲突。 +- 调价测试覆盖同价提交、成功调价、价格历史、存在下级分配时整批失败,以及绝对价格不会被误解成固定/比例调整。 +- `remove=true` 测试覆盖成功、已不存在幂等、存在下级授权拒绝和不影响客户历史订单/套餐使用,并验证缺失 `remove` 时不会误删。 - 事务测试验证任一套餐失败时整批没有新增、改价、删除、价格历史或成功审计残留。 -- HTTP 集成测试穿过 Fiber 认证、Handler、Application/Query、GORM/PostgreSQL 和统一响应,验证必填枚举、错误码、分页与越权安全语义。 -- 前端验收覆盖首次与后续共用批量表格、两个现有读取接口的合并、已授权置灰、三种明确模式、批量摘要、空态、失败态和并发冲突刷新。 +- HTTP 集成测试穿过 Fiber 认证、Handler、Application/Query、GORM/PostgreSQL 和统一响应,验证 `remove` 字段、错误码、分页与越权安全语义。 +- 前端验收覆盖首次与后续共用批量表格、两个现有读取接口的合并、已授权置灰、`remove=true` 提交、空态、失败态和并发冲突刷新。 ## Out of Scope - 不重建系列授权、套餐授权、佣金或强充模型。 - 不新增 `package-options` 或其他重复的候选套餐读取接口。 - 不允许创建没有任何套餐的空系列授权。 -- 不允许一个请求混合授权、调价和移除。 -- 不在 `authorize` 中修改已授权套餐价格。 +- 不删除或替换既有 `packages[].remove` 字段。 - 不把赠送套餐授权给代理。 - 不自动级联调整或回收下级代理授权。 - 不修改存量客户订单、套餐使用记录或零售价配置。 @@ -171,7 +165,7 @@ Status: ready-for-agent ## Further Notes -- 当前写接口已经接受 `packages:[{package_id,cost_price,remove}]`,并会在重复授权时直接改价;实现必须主动消除这段模糊语义。 +- 当前写接口已经接受 `packages:[{package_id,cost_price,remove}]`;这是已交付契约,实现和文档必须继续保留。 - 仓库已有 `/shop-package-batch-pricing`,但它按整个店铺/系列进行固定或比例调整且允许逐项跳过,不能替代本需求按选中套餐设置绝对成本价、整批原子失败的 `update_cost`。 -- 用户已确认以必填 `operation_type` 将授权、调价和移除拆为明确命令,并接受前后端同批切换。 +- 用户已于 2026-07-24 明确纠正:`remove` 字段不应移除,必须恢复并保持兼容。 - 用户纠正并确认:首次授权保持现有 `POST` 同时授权系列和套餐;后续添加套餐由 `PUT /{id}/packages` 负责;读取复用现有套餐列表和授权详情,不新增候选接口。 diff --git a/.scratch/ur43-series-package-bulk-authorization/issues/02-atomic-package-commands.md b/.scratch/ur43-series-package-bulk-authorization/issues/02-atomic-package-commands.md index 9393b98..2fac80a 100644 --- a/.scratch/ur43-series-package-bulk-authorization/issues/02-atomic-package-commands.md +++ b/.scratch/ur43-series-package-bulk-authorization/issues/02-atomic-package-commands.md @@ -1,6 +1,6 @@ -# 02 — 后续套餐授权的原子命令契约 +# 02 — 后续套餐授权的原子兼容契约 -**What to build:** 后续套餐管理接口以必填的操作类型明确区分新增授权、修改成本价和移除授权。每次请求只执行一种命令且整批原子:同价新增安全幂等、不同价新增明确冲突;调价和移除遇到下级授权等前置条件时整批拒绝。成功操作记录价格历史和审计,权限、并发和幂等行为不会泄露底层唯一约束错误。 +**What to build:** 后续套餐管理接口保留已交付的 `packages:[{package_id,cost_price,remove}]` 契约,以 `remove=true` 表示软删除,其他项按当前授权状态新增或改价。批量请求保持事务原子性,调价和移除遇到下级授权等前置条件时整批拒绝;成功调价记录价格历史,权限、并发和幂等行为不会泄露底层唯一约束错误。 **适用架构通道:** Application 事务脚本。完整业务边界为“系列授权下套餐授权的后续批量命令”,包括命令解析、批量加载、权限与业务校验、条件写入、价格历史及审计;不迁移现有整店/系列批量调价,不自动回收下级授权,也不修改订单、套餐使用或零售价。 @@ -8,6 +8,6 @@ **Status:** ready-for-agent -- [ ] 后续管理请求必须携带 `authorize`、`update_cost` 或 `remove` 之一,套餐数量限制为 1 至 100 且 ID 唯一;旧的混合新增、改价、移除语义不再生效。 -- [ ] 三种命令均在一个 PostgreSQL 事务内完成批量校验与写入,正确处理权限、赠送套餐、跨系列、绝对成本价、下级依赖、同价幂等及不同价冲突。 -- [ ] 集成测试覆盖并发新增、全量回滚、价格历史、审计、错误码和越权安全语义,并验证响应包含实际操作数、幂等数与相关套餐标识。 +- [ ] 后续管理请求继续接受 `packages[].remove`,套餐数量限制为 1 至 100 且 ID 唯一;不得要求必填顶层 `operation_type`,不得改变既有响应结构。 +- [ ] 新增、改价和 `remove=true` 软删除均在一个 PostgreSQL 事务内完成校验与写入,正确处理权限、赠送套餐、跨系列、绝对成本价和下级依赖。 +- [ ] 集成测试覆盖 `remove` 兼容、并发新增、全量回滚、价格历史、错误码和越权安全语义。 diff --git a/.scratch/ur43-series-package-bulk-authorization/issues/03-management-ui-and-release.md b/.scratch/ur43-series-package-bulk-authorization/issues/03-management-ui-and-release.md index 77d2a00..5cdb695 100644 --- a/.scratch/ur43-series-package-bulk-authorization/issues/03-management-ui-and-release.md +++ b/.scratch/ur43-series-package-bulk-authorization/issues/03-management-ui-and-release.md @@ -1,6 +1,6 @@ # 03 — 后续授权管理批量界面与发布验收 -**What to build:** 授权人员在同一批量表格中管理既有系列授权:套餐列表与授权详情按套餐标识合并,已授权项在新增模式置灰,授权详情独有的存量项仍只读展示;调价和移除只允许选择已授权套餐。界面提交明确命令,提供批量摘要、空态、失败态及并发冲突后的刷新提示,并具备可安全发布和回滚的验收材料。 +**What to build:** 授权人员在同一批量表格中管理既有系列授权:套餐列表与授权详情按套餐标识合并,已授权项在新增模式置灰,授权详情独有的存量项仍只读展示;调价和移除只允许选择已授权套餐。界面继续使用 `packages[].remove` 提交移除,提供空态、失败态及并发冲突后的刷新提示,并具备可安全发布和回滚的验收材料。 **适用架构通道:** 主通道为 Query 与前端;辅助通道为 Application API 集成。完整业务边界为“后续授权管理的读取组合、命令交互与上线验收”;不新增候选读取接口,不扩大现有数据或价格字段权限,不重构未触碰的套餐列表。 @@ -9,5 +9,5 @@ **Status:** ready-for-agent - [ ] 后续管理页面并行复用现有套餐列表和授权详情,准确展示三类价格、授权状态及受权限变化影响的存量只读项。 -- [ ] 新增、调价、移除三种模式只提交对应命令;提交期间防重,冲突时保留输入并要求刷新确认,且响应与失败状态对用户清晰可见。 -- [ ] 完成 Fiber 到 PostgreSQL 的端到端验收、前后端同批切换核查、上线前数据异常核查和保留业务事实的回滚说明。 +- [ ] 新增、调价、移除模式继续组装既有套餐项,移除项设置 `remove=true`;提交期间防重,冲突时保留输入并要求刷新确认。 +- [ ] 完成 Fiber 到 PostgreSQL 的端到端验收、`remove` 契约兼容核查、上线前数据异常核查和保留业务事实的回滚说明。 diff --git a/README.md b/README.md index 3519f45..35e7a4a 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,8 @@ default: - **UR#96 店铺业务员归属**:店铺支持平台业务员设置/清空和创建时一次性继承,列表/详情批量投影业务员名称、手机号摘要及可用状态,并提供通知接收人解析接缝;详见 [功能总结](docs/ur96-shop-business-owner/功能总结.md)。 - **渠道无关审批核心**:业务侧统一使用 Approval Port、通用审批实例和五类标准决策,不依赖企微等具体渠道字段;详见 [功能总结](docs/tech-approval-core/功能总结.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 代理系列套餐批量授权**:首次系列授权原子创建 1~100 条套餐授权;后续管理继续使用已交付的 `packages[].remove` 字段完成软删除,并兼容既有批量新增、调价和响应结构。详见 [功能总结](docs/ur43-series-package-bulk-authorization/功能总结.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)和 H5(Agent、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)和 H5(Agent、Enterprise)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md) diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 569212a..471da36 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -5,6 +5,7 @@ import ( "go.uber.org/zap" "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/pkg/openapi" ) @@ -23,6 +24,10 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { // 3. 创建所有 Handler(使用 nil 依赖,因为只需要路由结构) // 新增 Handler 必须注册到 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. 注册所有路由到文档生成器 routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 69914a9..03cb69c 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -7,6 +7,7 @@ import ( "github.com/gofiber/fiber/v2" "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/pkg/openapi" ) @@ -32,6 +33,10 @@ func generateAdminDocs(outputPath string) error { // 3. 创建所有 Handler(使用 nil 依赖,因为只需要路由结构) // 新增 Handler 必须注册到 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. 注册所有路由到文档生成器 routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 631651a..da22776 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -14,10 +14,12 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" 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/gateway" 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" notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop" @@ -330,6 +332,16 @@ func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *za appLogger.Fatal("注册卡网络状态变化 Outbox 消费者失败", 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 注册代理主钱包资金事实消费者。 diff --git a/docs/7月迭代/7月迭代技术方案-标准评审稿.md b/docs/7月迭代/7月迭代技术方案-标准评审稿.md index 6822f81..3a65551 100644 --- a/docs/7月迭代/7月迭代技术方案-标准评审稿.md +++ b/docs/7月迭代/7月迭代技术方案-标准评审稿.md @@ -1099,7 +1099,7 @@ cash_available = balance - frozen_balance 后续管理并行读取现有套餐列表和 `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` 或改变既有响应结构。 页面按调用视角分别标注上级当前成本价、目标代理授权成本价和建议零售价;平台视角的上级成本才是公司成本。所有系列、套餐、价格和直属下级权限由后端重新校验,不能依赖前端置灰。 diff --git a/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md index 6070305..421f75b 100644 --- a/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md +++ b/docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md @@ -1019,7 +1019,7 @@ 接口约定: - 首次授权复用GET /api/admin/packages?series_id=...和POST /api/admin/shop-series-grants,POST必须同时提交至少一个packages项。 - 后续管理复用GET /api/admin/packages?series_id=...与GET /api/admin/shop-series-grants/{id}。 -- PUT /api/admin/shop-series-grants/{id}/packages,body={operation_type:authorize|update_cost|remove,packages:[{package_id,cost_price?}]}。 +- PUT /api/admin/shop-series-grants/{id}/packages,body={packages:[{package_id,cost_price?,remove?}]};`remove=true` 表示软删除,必须保持既有字段兼容。 交互规则:三类价格按分转元;提交成功后重新加载候选列表;空候选和全部已授权状态有明确提示。 @@ -1037,7 +1037,7 @@ **描述** ```markdown -目标:首次授权继续同时创建系列与套餐;后续复用现有批量接口,并把新增、调价、移除拆成明确命令;不新增候选Query。 +目标:首次授权继续同时创建系列与套餐;后续复用现有批量接口并保留 `packages[].remove` 已交付契约;不新增候选Query。 预计工时:后端0.5~1小时。 @@ -1045,11 +1045,11 @@ 首次授权:POST中的packages改为必填且至少1项,系列与套餐在同一事务全成全败,不允许空系列授权。 -后续规则:PUT必须提交operation_type=authorize|update_cost|remove,一次只执行一种命令、最多100项、事务内全成全败;authorize同价重复幂等、不同价重复冲突,不能静默改价;所有系列、套餐、直属下级与价格边界由后端校验。 +后续规则:PUT继续提交packages数组,`remove=true` 表示软删除,其他项按当前授权状态新增或改价;最多100项、事务内全成全败。不得要求必填operation_type或改变既有响应结构;所有系列、套餐、直属下级与价格边界由后端校验。 读取:前端组合现有套餐列表和授权详情;已授权但当前不在普通列表中的存量项仍从详情只读展示。成本字段按操作者视角准确命名。 -完成标准:首次授权不会产生空系列;新增、调价和移除无语义混用;重复提交不生成重复关系或静默覆盖并发价格;前后端同批切换。 +完成标准:首次授权不会产生空系列;`packages[].remove` 兼容保留;重复提交不生成重复关系;旧前端无需切换新命令字段即可继续工作。 ``` ## UR#42 导出功能 diff --git a/docs/7月迭代/来源材料/realname.go b/docs/7月迭代/来源材料/realname.go new file mode 100644 index 0000000..6ec8997 --- /dev/null +++ b/docs/7月迭代/来源材料/realname.go @@ -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 +} diff --git a/docs/7月迭代/来源材料/新需求.md b/docs/7月迭代/来源材料/新需求.md index 995182a..95d7acc 100644 --- a/docs/7月迭代/来源材料/新需求.md +++ b/docs/7月迭代/来源材料/新需求.md @@ -719,11 +719,3 @@ type Account struct { } ``` - - -优化轮询以及添加事件/触发式 数据同步 - -目前我们系统过于依赖轮询系统,且轮询系统的黑盒属性过于严重 -所以现在想加入事件触发以及实名回调,目前已知的可配置实名回调只有移动,联通,电信三个运营商,广电是没有实名回调的,上方是之前已经实现过的实名回调 - -需求差不多是这么个需求,主要就是想让数据同步这一块的及时率达到一种很快的地步,看是怎么埋点,而且开放接口的埋点还需要特殊处理,不然的话代理拿着我们的开放接口乱调用的话就等于外置了一个轮询系统了 diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index dcc48b4..c748647 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -3939,18 +3939,25 @@ components: nullable: true type: integer packages: - description: 初始授权套餐列表 + description: 初始授权套餐列表(1~100项,套餐ID不可重复) items: $ref: '#/components/schemas/DtoGrantPackageItem' + maxItems: 100 + minItems: 1 + nullable: true type: array series_id: description: 套餐系列ID - minimum: 0 + minimum: 1 type: integer shop_id: description: 被授权代理店铺ID - minimum: 0 + minimum: 1 type: integer + required: + - shop_id + - series_id + - packages type: object DtoCreateWechatConfigRequest: properties: @@ -5724,16 +5731,20 @@ components: DtoGrantPackageItem: properties: cost_price: - description: 成本价(分) + description: 成本价(分),新增或调价时必填 + minimum: 0 + nullable: true type: integer package_id: description: 套餐ID - minimum: 0 + minimum: 1 type: integer remove: description: 是否删除该套餐授权(true=删除) nullable: true type: boolean + required: + - package_id type: object DtoImportDeviceRequest: properties: @@ -6239,11 +6250,15 @@ components: nullable: true type: string packages: - description: 套餐操作列表 + description: 套餐操作列表(1~100项,套餐ID不可重复) items: $ref: '#/components/schemas/DtoGrantPackageItem' + maxItems: 100 + minItems: 1 nullable: true type: array + required: + - packages type: object DtoManualTriggerLogListResp: properties: @@ -29216,6 +29231,82 @@ paths: summary: 支付宝回调 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: post: responses: diff --git a/docs/tech-public-foundation/前端即时接口交接.md b/docs/tech-public-foundation/前端即时接口交接.md index e37b179..ec4d1ac 100644 --- a/docs/tech-public-foundation/前端即时接口交接.md +++ b/docs/tech-public-foundation/前端即时接口交接.md @@ -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 监控和人工恢复也不在本次前端范围内;公共基础只提供后端运维接缝,未来如需运营页面必须另行评审。 diff --git a/docs/tech-public-foundation/功能总结.md b/docs/tech-public-foundation/功能总结.md index 8e0ab1d..bc167ad 100644 --- a/docs/tech-public-foundation/功能总结.md +++ b/docs/tech-public-foundation/功能总结.md @@ -4,7 +4,7 @@ 本能力提供公共 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-全链路与停机发布验收) 汇总检查,公共基础仍不猜测业务事件定义。 - Outbox 待投递年龄、积压量、过期租约比例和成功率的生产告警值由发布负责人依据容量基线填写到 INT-08 发布清单。没有数值、负责人和验证记录时不得放量。 diff --git a/docs/ur43-series-package-bulk-authorization/功能总结.md b/docs/ur43-series-package-bulk-authorization/功能总结.md new file mode 100644 index 0000000..0bf67ce --- /dev/null +++ b/docs/ur43-series-package-bulk-authorization/功能总结.md @@ -0,0 +1,72 @@ +# UR#43 代理系列套餐批量授权功能总结 + +## 完成范围 + +本次复用既有系列授权、套餐授权模型和四个现有接口,不新增候选套餐 API、不新增迁移、不改整店批量调价逻辑: + +- `GET /api/admin/packages`:按 `series_id` 获取当前操作者可见套餐。 +- `POST /api/admin/shop-series-grants`:首次创建系列授权,并原子创建 1~100 条套餐授权。 +- `GET /api/admin/shop-series-grants/{id}`:读取目标代理已授权套餐及其当前成本价。 +- `PUT /api/admin/shop-series-grants/{id}/packages`:通过既有套餐项字段批量新增、修改成本价或软删除授权。 + +当前仓库不包含前端源码。本次交付框架无关的页面组合与交互契约,前端实现和真实人工验收仍需在对应仓库完成。 + +## 首次批量授权 + +创建系列授权时,`packages` 必填且只能包含 1~100 项;每项必须提供唯一 `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 | 按现有规则更新成本价 | + +请求要求 1~100 个套餐项,并在一个事务内完成权限校验、价格边界和写入。`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 生成、构建和静态部署检查。 diff --git a/docs/ur94-card-state-events-callbacks/功能总结.md b/docs/ur94-card-state-events-callbacks/功能总结.md index f49a1de..4e0f422 100644 --- a/docs/ur94-card-state-events-callbacks/功能总结.md +++ b/docs/ur94-card-state-events-callbacks/功能总结.md @@ -2,7 +2,87 @@ ## 当前完成范围 -任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.34、2.35、2.36、2.37 已分别交付实名、流量、网络观测公共写入闭环及三个轮询入口切换。观测序列和运营商回调仍按 2.38~2.45 继续实施,不能因当前能力已落地而提前开放回调入口。 +任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.34~2.44A 已交付实名、流量、网络观测公共写入闭环、三个轮询入口切换、通用观测序列、业务入口触发,以及电信实名、移动实名、联通实名成功和联通解除实名四个回调防腐层。UR#94 整体发布检查仍按 2.45 继续实施,不能因四条路由已接入而提前宣称整体生产验收完成。 + +## 联通实名成功回调 + +新增来源材料 `docs/7月迭代/来源材料/realname.go` 补齐了此前缺失的联通实名成功报文,因此任务 2.44A 新增 `POST /api/callback/carriers/cucc/realname`。协议与联通解除实名一致:外层 `data` 必须是字符串,再解析内层非空 `iccid/dateChanged`。 + +- 合法 ICCID 只按 19/20 位对应列精确查询;不复制旧代码的 20 位截 19 位,不跨列降级,也不任取多匹配卡。 +- 使用 `ICCID + dateChanged` 的安全摘要作为语义幂等键;解析失败使用正文摘要,重复、冲突和中断恢复沿用 Integration Log 租约规则。 +- 唯一命中后调用公共 `ApplyCardObservation(verified=true)`,实名事实变化时由公共用例写 Outbox,并尽力提前完成同卡实名观测序列。 +- 不调用旧 `inner_callback`、第三方推送、旧平台登录、`ModifyDate` 或 Gateway 二次确认;`dateChanged` 只作为上游变更时间和幂等语义留痕,不直接改写本地业务时间。 +- 无论开关状态、报文结果或内部处理结果,均返回入口时间对应的 HTTP 200 固定 JSON 应答,避免运营商不可控重推。 + +## 运营商回调独立开关 + +四条回调复用公共 `system_config`,模块名为 `carrier_callback`,默认全部关闭: + +- `carrier_callback.ctcc_realname.enabled` +- `carrier_callback.cmcc_realname.enabled` +- `carrier_callback.cucc_realname.enabled` +- `carrier_callback.cucc_realname_removal.enabled` + +路由始终保留。开关关闭时不解析报文、不定位卡、不调用公共观测,只按正文摘要幂等记录 `ignored` Integration Log 并返回固定成功应答;配置缺失、非法或读取失败时失败关闭,同样不执行业务写入。配置读取以 PostgreSQL 为事实来源并复用 Redis 五分钟缓存。 + +超级管理员可通过 `GET /api/admin/system-configs?module=carrier_callback` 查询四个已注册开关,并通过 `PUT /api/admin/system-configs/:key` 更新布尔值。全局 Audit Event 已按用户决策取消,Audit Writer 改为可选;若未来装配 Writer,审计仍与配置更新同事务执行,未装配时不会再阻塞受控配置更新,也不会用 Integration Log 冒充配置审计。 + +## 联通解除实名留痕回调 + +任务 2.44 新增 `POST /api/callback/carriers/cucc/realname/remove`。Adapter 强制要求外层 `data` 为 JSON 字符串,再解析内层 `iccid` 与非空 `dateChanged`;任何情况下都返回入口时间对应的固定 JSON 成功应答。 + +- 空正文、外层或内层 JSON 失败、`data` 非字符串、ICCID 非法及变更时间为空统一记录 `invalid_payload`。 +- 合法 ICCID 仅按 19/20 位对应列精确定位;未找到、多匹配和数据库错误分别记录 `not_found`、`conflict`、`failed`,不会跨列降级或任取卡。 +- 合法且唯一识别的解除通知统一终结为 `ignored`;不调用 `ApplyCardObservation`、观测序列、Gateway、旧 `DelRealName` 或第三方推送,不修改实名状态、首次实名时间、检查时间和逆转计数。 +- 解析成功时使用 `ICCID + dateChanged` 语义摘要防重,JSON 空白或字段顺序变化保持同一幂等语义;同语义不同正文另记 `conflict`,解析失败则以完整正文摘要防重,pending 记录支持租约恢复。 +- Access Log 继续使用运营商回调摘要策略,Integration Log 只保存正文长度、哈希、Content-Type 和哈希资源键;未新增数据库迁移或自动化测试。 + +## 移动实名成功回调防腐层 + +任务 2.43 新增 `POST /api/callback/carriers/cmcc/realname`,只解析已知 JSON 结构,并使用请求入口捕获时间返回 `code=200`、`msg=success` 和 `YYYY-MM-DD HH:mm:ss` 格式时间戳。 + +- 只有 `status="0"`、`message="正确"`、首条 `result.regStatus="00000"` 且 ICCID 合法时生成已实名观测;空 ICCID、失败状态、空结果和无法解析的 JSON 均记录 `invalid_payload`。 +- 优先使用 `busiSeq` 的安全摘要作为稳定幂等语义,缺失时使用正文摘要;同事务不同正文另记 `conflict`,pending 处理超过一分钟后允许原子认领恢复。 +- ICCID 仅按 19/20 位精确列定位,不按 MSISDN 补查,不登录旧管理平台,不保存账号密码或 Cookie,也不调用旧第三方推送、修改到期时间或 Gateway 二次确认。 +- 成功结果复用公共 `ApplyCardObservation` 并尽力提前完成同卡实名序列;未找到、多匹配和内部错误分别终结为 `not_found`、`conflict`、`failed`,不会任取卡写入。 +- Access Log 复用运营商回调正文摘要策略;Integration Log 不保存完整 JSON 或明文 ICCID,仅保存长度、摘要、Content-Type 和哈希资源键。 +- 新 Handler 已接入生产组合根、真实路由、OpenAPI Handler 构造和两个文档生成器。本任务按测试延期约定未新增或运行自动化测试。 + +## 电信实名回调防腐层 + +任务 2.42 新增 `POST /api/callback/carriers/ctcc/realname`,按已知 `ContractRoot` XML 协议解析电信实名结果,并始终返回运营商约定的 JSON 成功应答。 + +- 只有 `RESULTMSG=成功` 且 `ACCEPTMSG` 包含“已完成实名信息补录”时生成已实名观测;实名信息清除和其他合法业务结果仅记录 `ignored`,不查询或修改卡。 +- ICCID 去除首尾空白后复用公共格式校验,仅接受 19 或 20 位值,并分别精确查询 `iccid_19`、`iccid_20`;查询不应用登录账号数据范围,未找到和多匹配分别记录 `not_found`、`conflict`。 +- 入站先以 `GROUP_TRANSACTIONID` 的安全摘要建立稳定幂等语义;同一外部事务的重复载荷直接成功返回,不重复首次实名、套餐激活或领域事件,不同载荷另记 `conflict`。缺少外部事务号时以完整正文摘要防重;若首次处理在 `pending` 阶段中断,超过一分钟租约后的重复回调可原子认领并恢复处理。 +- 实名成功复用公共 `ApplyCardObservation` 事务闭环,并在成功后尽力提前完成同卡未执行实名观测序列;内部处理失败尽力把 Integration Log 终结为 `failed`。 +- Access Log 对 `/api/callback/carriers/` 强制使用敏感正文摘要策略,不记录完整 XML 或明文 ICCID;Integration Log 只保存正文长度、SHA-256 摘要和哈希资源键。 +- 迁移 `000181` 为 Integration Log 增加 `conflict` 终态;发布时必须先执行迁移再开放路由,down 迁移会先把已有 `conflict` 归并为 `failed`。 +- 新 Handler 已接入真实路由、生产组合根、OpenAPI Handler 构造以及 `cmd/api/docs.go`、`cmd/gendocs/main.go` 两个文档生成器。 + +## 读取与实名入口的 Best Effort 观测触发 + +任务 2.39 在不迁移 Query、不新增接口、不改变响应结构的前提下,接入了 C 端资产详情、后台资产实时状态、OpenAPI 卡/设备流量、OpenAPI 卡网络/实名查询,以及 C 端和后台实名链接入口。 + +- 读取型入口使用稳定且调用方无关的场景码,按实际卡资源和同步类型创建无预期序列;同一卡连续 OpenAPI 查询通过 2.38 的场景合并键复用未结束序列,不会按账号或每次请求追加三任务。 +- C 端和后台设备资产详情按绑定卡分别触发实名、流量、网络观测;OpenAPI 设备流量同样按绑定卡触发流量观测,卡标识解析到设备时仍以实际绑定卡为资源。 +- C 端实名链接和后台 Gateway 实名链接仅在运营商 `realname_link_type != none` 且原有链接响应成功后触发 `expected=verified` 的实名序列;不支持在线实名的运营商不会建序列。 +- 读取入口只把结构化请求放入容量受控的进程内分发队列,不等待 Redis、Asynq 或 Gateway,也不把后台序列结果写入当前响应;队列满载或 Redis/Asynq/Integration Log 失败只输出中文安全日志并保留 scene、resource、sync、series 和 request_id 关联信息。 +- 原有后台与 C 端 `refresh` 手动刷新路径未注入分发器,仍直接执行一次同步,不生成 0/3/5 序列。OpenAPI、资产查询和实名入口的现有错误码、响应字段与业务结果保持不变。 + +## 0/3/5 卡观测事件序列 + +任务 2.38 已交付内部 `SeriesTrigger`、固定阶梯 Asynq 调度器和 Worker 执行闭环,但未修改任何查询、停复机、购包、套餐或设备控制入口;入口接入仍由 2.39~2.41 分别完成。 + +- 每个新序列以稳定 `series_id` 创建立即、3 分钟、5 分钟三个结构化任务,任务 ID 使用 `series_id + attempt`,`MaxRetry(0)`,单次失败不会删除或取消后续两个独立任务。 +- Redis 合并键严格包含 `scene + resource_type + resource_id + sync_type`,只覆盖最后一次计划任务和短暂缓冲。首触发的预期、来源、请求/关联 ID 与基准时间被原子保存;重复触发不刷新上下文或 TTL,补齐首次局部入队失败时仍使用首触发上下文,同时避免重复生成第二组三任务。缺失请求/关联 ID 时生成同一稳定 UUID 贯穿三任务。 +- 尝试执行前读取本地权威快照。明确预期已满足时,当前及剩余尝试立即写 `completed` Integration Log 并标记幂等完成,不再访问 Gateway;`CompleteResourceSeries` 为可信实名回调提前结束同卡实名序列提供扩展点。 +- 实际 Gateway 请求使用 `provider + sync_type + resource_id` Redis 互斥,16 分钟 TTL 覆盖配置允许的 300 秒单次超时、两次网络重试和安全余量;流量同步复用既有 `traffic:sync:lock:card:{id}` 卡级锁,避免事件、轮询和手动刷新并发读取同一上游读数。互斥命中只把当前尝试记录为 `ignored`,后续阶梯任务保持不变。 +- 运营商接入与同步类型的默认最小请求间隔为 10 秒。任务在持有本次请求互斥期间只等待剩余间隔,不建立全局五分钟冷却,也不创建 30/60/120 秒退避;Gateway 超频只把当前 Integration Log 终结为 `rate_limited`。 +- 实名、流量、网络实际响应全部复用 `ApplyCardObservation`、`ApplyTrafficObservation`、`ApplyNetworkObservation`,没有新增第二套状态写入。设备信息同步类型已在 Application 契约中预留,具体执行适配器随 2.41 交付。 +- 合并、互斥、最小间隔取消、预期提前完成和每次实际 Gateway 请求统一写 `tb_integration_log`,传播请求 ID、关联 ID、序列 ID、尝试序号、场景、资源、结果、耗时和是否变化;请求摘要只保存卡 ID 与同步类型,不保存完整 ICCID。 + +本任务按 Change 测试延期约定未新增自动化测试,也未新增数据库迁移或同步运行表。序列运行态、幂等键和短时互斥属于 Redis 协调事实,三次可查询结果继续使用公共 Integration Log。 ## 流量观测公共写入闭环 @@ -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 事务依赖。 - 已静态核对 `000180` up/down 成对、副作用状态 CHECK 与常量一致,流量用例不再直接覆盖日流量落盘表。 - 已执行 `gofmt` 和 `go build ./...`;构建退出码为 0。 +- 已静态核对观测序列任务固定为 0/3/5 分钟、`MaxRetry(0)`、结构化载荷、同场景合并键、`series_id + attempt` 幂等键、流量共享锁和 10 秒默认最小间隔;任务 2.38 未修改业务入口。 +- 已静态核对任务 2.39 只接入 issue 07 指定读取与实名链接入口,稳定 OpenAPI 场景不包含账号身份,`realname_link_type=none` 不触发,后台/C 端手动刷新路径没有分发调用。 +- 已静态核对任务 2.42 的 XML 根结构、19/20 位精确查询、系统级资源定位、稳定外部事务幂等、载荷冲突留痕、固定成功应答、Access Log 摘要策略及 Handler/路由/文档生成器装配。 +- 已静态核对新增联通实名成功回调的双层 JSON、非空变更时间、19/20 位精确查询、语义幂等、公共实名观测、固定成功应答及四个受控系统配置开关。 +- 任务 2.45 已完成四个回调 Handler、免认证路由、生产组合根、两个文档生成器、OpenAPI 产物、观测 Worker、Outbox Consumer、API/Worker Gateway Client 和迁移顺序的静态装配检查。 +- `docs/admin-openapi.yaml` 已生成电信实名、移动实名、联通实名成功和联通解除实名四条路径;四个开关均通过后台受控 `system_config` 注册,默认关闭。 +- 已执行 `gofmt`、`git diff --check` 和离线 `go build ./...`,构建退出码为 0。按用户要求保留既有 Gateway 与部署 YAML 配置,本任务未修改其配置来源或部署值。 +- 当前状态只能标记为“后端代码与测试环境装配完成、真实验证延期”;真实运营商样例、真实 Gateway、19/20 位命中、逐个启停和异常恢复统一转 6.4。 - 按七月测试环境豁免,本轮未连接真实 PostgreSQL、未运行迁移测试;无冲突升级、19/20 位冲突、软删除重复和 down/up 重放验证延期到 6.1、6.3,不能标记为生产验收通过。 diff --git a/internal/application/cardobservation/business_event.go b/internal/application/cardobservation/business_event.go new file mode 100644 index 0000000..e4fcb6b --- /dev/null +++ b/internal/application/cardobservation/business_event.go @@ -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 +} diff --git a/internal/application/cardobservation/series.go b/internal/application/cardobservation/series.go index 45181d1..92679f1 100644 --- a/internal/application/cardobservation/series.go +++ b/internal/application/cardobservation/series.go @@ -23,6 +23,29 @@ type SeriesRequest struct { 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 任务的结构化载荷。 type SeriesTaskPayload struct { SeriesID string `json:"series_id"` @@ -46,7 +69,10 @@ type RunResult struct { // SeriesCoordinator 管理活跃序列、尝试幂等和实际请求互斥。 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) 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) @@ -60,6 +86,14 @@ type SeriesScheduler interface { 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 的合并、互斥、限频和提前完成结果。 type SeriesAttemptLogger interface { Record(ctx context.Context, payload SeriesTaskPayload, result, reason string) error @@ -88,27 +122,58 @@ func NewSeriesTrigger(coordinator SeriesCoordinator, scheduler SeriesScheduler, // Trigger 创建新序列;同场景未结束序列只留合并记录,不延长原序列。 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 { return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置") } + request = normalizeSeriesTrace(request) if err := validateSeriesRequest(request); err != nil { 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() - 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 { return "", false, err } // 重复触发仍以稳定任务 ID 补齐首次入队的局部失败;已存在任务由 Asynq 去重,不会延长原序列。 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)) payload := SeriesTaskPayload{ SeriesID: seriesID, Attempt: attempt, ScheduledAt: scheduledAt, - Scene: request.Scene, ResourceType: request.ResourceType, ResourceID: request.ResourceID, - SyncType: request.SyncType, ExpectedValue: request.ExpectedValue, Source: request.Source, - RequestID: request.RequestID, CorrelationID: request.CorrelationID, + Scene: originalRequest.Scene, ResourceType: originalRequest.ResourceType, ResourceID: originalRequest.ResourceID, + SyncType: originalRequest.SyncType, ExpectedValue: originalRequest.ExpectedValue, Source: originalRequest.Source, + RequestID: originalRequest.RequestID, CorrelationID: originalRequest.CorrelationID, } if err := s.scheduler.Enqueue(ctx, payload); err != nil { + s.coordinator.ReleaseSchedule(ctx, seriesID, attempt) 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) if err != nil { - return err + return s.recordPreGatewayFailure(ctx, payload, "读取序列完成状态失败", err) } if completed { return s.completeRemainingAttempts(ctx, payload, "序列已提前完成") } met, err := s.runner.ExpectationMet(ctx, payload) if err != nil { - return err + return s.recordPreGatewayFailure(ctx, payload, "读取本地预期快照失败", err) } if met { return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期") } provider, err := s.runner.Provider(ctx, payload) if err != nil { - return err + return s.recordPreGatewayFailure(ctx, payload, "解析运营商接入失败", err) } release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider) if err != nil { - return err + return s.recordPreGatewayFailure(ctx, payload, "获取实际请求互斥失败", err) } if !acquired { return s.logger.Record(ctx, payload, constants.IntegrationResultIgnored, "实际 Gateway 请求正在执行") @@ -194,6 +259,13 @@ func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPa 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 { baseTime := payload.ScheduledAt.Add(-constants.CardObservationAttemptDelay(payload.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 { 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 nil @@ -227,7 +300,36 @@ func validateSeriesPayload(payload SeriesTaskPayload) error { if payload.SeriesID == "" || payload.Attempt < 1 || payload.Attempt > constants.CardObservationSeriesAttemptCount { 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 { diff --git a/internal/application/systemconfig/update.go b/internal/application/systemconfig/update.go index b84a8fb..1872f6f 100644 --- a/internal/application/systemconfig/update.go +++ b/internal/application/systemconfig/update.go @@ -5,7 +5,6 @@ import ( "context" "crypto/sha256" "encoding/hex" - stderrors "errors" "time" "gorm.io/gorm" @@ -31,19 +30,11 @@ type ChangeAudit struct { CorrelationID string } -// AuditWriter 由 tech-global-audit 提供事务内实现。 +// AuditWriter 可选接收系统配置事务内审计事实。 type AuditWriter interface { 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 校验、事务更新、审计和提交后缓存失效。 type UpdateService struct { db *gorm.DB @@ -63,9 +54,6 @@ func NewUpdateService( alerts configinfra.AlertSink, now func() time.Time, ) *UpdateService { - if audit == nil { - audit = UnavailableAuditWriter{} - } if now == nil { 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 { requestID = *value } - if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{ - OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置", - ConfigKey: key, - BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)}, - AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)}, - RequestID: requestID, CorrelationID: requestID, - }); err != nil { - return err + if s.audit != nil { + if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{ + OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置", + ConfigKey: key, + BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)}, + AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)}, + RequestID: requestID, CorrelationID: requestID, + }); err != nil { + return err + } } saved = existing return nil diff --git a/internal/bootstrap/carrier_callback_config.go b/internal/bootstrap/carrier_callback_config.go new file mode 100644 index 0000000..3bb636c --- /dev/null +++ b/internal/bootstrap/carrier_callback_config.go @@ -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)) + } +} diff --git a/internal/bootstrap/dependencies.go b/internal/bootstrap/dependencies.go index 9052073..ccc7126 100644 --- a/internal/bootstrap/dependencies.go +++ b/internal/bootstrap/dependencies.go @@ -28,5 +28,5 @@ type Dependencies struct { GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil) WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选) SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选) - SystemConfigAudit systemConfigApp.AuditWriter // 统一配置变更审计 Port(可选,缺失时更新失败关闭) + SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port(可选,装配后与配置同事务写入) } diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index f51c3ab..bbd1bde 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -11,6 +11,8 @@ import ( authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" "github.com/break/junhong_cmp_fiber/internal/handler/callback" 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" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" @@ -89,6 +91,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { if systemConfigRegistry == nil { systemConfigRegistry = systemConfigInfra.NewRegistry() } + registerCarrierCallbackConfigDefinitions(systemConfigRegistry, deps.Logger) systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger) var systemConfigCache systemConfigInfra.Cache if deps.Redis != nil { @@ -108,15 +111,27 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission)) return handler }(), - Permission: admin.NewPermissionHandler(svc.Permission), - PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, 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), - 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: 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), + Permission: admin.NewPermissionHandler(svc.Permission), + PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger), + ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger), + ClientAsset: func() *app.ClientAssetHandler { + handler := app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger) + handler.SetObservationSeriesDispatcher(svc.ObservationSeries) + return handler + }(), + 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), ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate), Shop: func() *admin.ShopHandler { @@ -158,12 +173,28 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { AdminOrder: admin.NewOrderHandler(svc.Order, 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), - 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), + CTCCRealnameCallback: callback.NewCTCCRealnameHandler( + carriercallback.NewCTCCRealnameTranslator(), carriercallback.NewCTCCCardResolver(deps.DB), + integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger, + ), + CMCCRealnameCallback: callback.NewCMCCRealnameHandler( + 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 { pollingQueueMgr := pollingPkg.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger) 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.SetLifecycleService(svc.AssetLifecycle) + h.SetObservationSeriesDispatcher(svc.ObservationSeries) return h }(), AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 909b9fa..bba0e94 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -8,6 +8,7 @@ import ( cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" 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" walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" "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/pkg/constants" "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" assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet" @@ -118,6 +120,9 @@ type services struct { AgentOpenAPI *agentOpenAPISvc.Service CustomerBinding *customerBindingSvc.Service OrderPackageInvalidate *orderPackageInvalidateSvc.Service + ObservationSeries cardObservationApp.BestEffortSeriesDispatcher + CardObservation *cardObservationApp.Service + CardObservationSeries *cardObservationApp.SeriesAttemptService } func initServices(s *stores, deps *Dependencies) *services { @@ -143,11 +148,27 @@ func initServices(s *stores, deps *Dependencies) *services { assetAudit, ) cardObservationOutbox := outbox.NewRepository() - iotCard.SetCardObservationService(cardObservationApp.NewService( + observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox) + cardObservationService := cardObservationApp.NewService( deps.DB, cardObservationInfra.NewEventWriter(cardObservationOutbox), 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) pollingConfigStore := postgres.NewPollingConfigStore(deps.DB) pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger) @@ -172,6 +193,7 @@ func initServices(s *stores, deps *Dependencies) *services { s.PackageUsageDailyRecord, deps.Logger, ) + packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents) stopResumeService := iotCardSvc.NewStopResumeService( deps.Redis, @@ -183,6 +205,7 @@ func initServices(s *stores, deps *Dependencies) *services { assetAudit, ) stopResumeService.SetPollingCallback(pollingLifecycleSvc) + stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents) iotCard.SetRealnameActivator(packageActivation) iotCard.SetStopResumeService(stopResumeService) iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding) @@ -206,10 +229,13 @@ func initServices(s *stores, deps *Dependencies) *services { s.EnterpriseDeviceAuthorization, s.Enterprise, ) + device.SetObservationSeriesEventWriter(observationSeriesEvents) + device.SetObservationSeriesDispatcher(observationSeries) 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) 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.SetObservationSeriesEventWriter(observationSeriesEvents) walletOutbox := outbox.NewRepository() walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox) 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)) 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.SetObservationSeriesDispatcher(observationSeries) return &services{ Account: account, @@ -331,5 +358,8 @@ func initServices(s *stores, deps *Dependencies) *services { Refund: refundService, CustomerBinding: customerBinding, OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), + ObservationSeries: observationSeries, + CardObservation: cardObservationService, + CardObservationSeries: cardObservationSeries, } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index b14b331..8413893 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -53,6 +53,10 @@ type Handlers struct { AdminOrder *admin.OrderHandler AdminExchange *admin.ExchangeHandler PaymentCallback *callback.PaymentHandler + CTCCRealnameCallback *callback.CTCCRealnameHandler + CMCCRealnameCallback *callback.CMCCRealnameHandler + CUCCRealnameCallback *callback.CUCCRealnameHandler + CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler PollingConfig *admin.PollingConfigHandler PollingConcurrency *admin.PollingConcurrencyHandler PollingMonitoring *admin.PollingMonitoringHandler diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index ce72a7e..11f9aee 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -88,6 +88,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q deps.Logger, ) cardObservationOutbox := outbox.NewRepository() + observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox) cardObservationService := cardObservationApp.NewService( deps.DB, cardObservationInfra.NewEventWriter(cardObservationOutbox), @@ -141,22 +142,25 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q deps.Logger, assetAudit, ) + stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents) + activationService.SetObservationSeriesEventWriter(observationSeriesEvents) usageService.SetStopResumeCallback(stopResumeService) activationService.SetResumeCallback(stopResumeService) orderService.SetResumeCallback(stopResumeService) resetService.SetResumeCallback(stopResumeService) return &queue.WorkerServices{ - CardObservation: cardObservationService, - CardObservationSeries: cardObservationSeriesService, - CommissionCalculation: commissionCalculationService, - CommissionStats: commissionStatsService, - UsageService: usageService, - ActivationService: activationService, - ResetService: resetService, - AlertService: alertService, - CleanupService: cleanupService, - StopResumeService: stopResumeService, - OrderExpirer: orderService, + CardObservation: cardObservationService, + CardObservationSeries: cardObservationSeriesService, + ObservationSeriesEvents: observationSeriesEvents, + CommissionCalculation: commissionCalculationService, + CommissionStats: commissionStatsService, + UsageService: usageService, + ActivationService: activationService, + ResetService: resetService, + AlertService: alertService, + CleanupService: cleanupService, + StopResumeService: stopResumeService, + OrderExpirer: orderService, } } diff --git a/internal/handler/admin/asset.go b/internal/handler/admin/asset.go index 2f7284a..213f3b5 100644 --- a/internal/handler/admin/asset.go +++ b/internal/handler/admin/asset.go @@ -6,6 +6,7 @@ import ( "strings" "time" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" "github.com/gofiber/fiber/v2" dto "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -31,6 +32,12 @@ type AssetHandler struct { assetPolling *pollingSvc.AssetPollingService assetLifecycleService AssetLifecycleService exchangeTraceQuery AssetExchangeTraceResolver + observationSeries cardObservationApp.BestEffortSeriesDispatcher +} + +// SetObservationSeriesDispatcher 注入后台实时状态的观测序列端口。 +func (h *AssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) { + h.observationSeries = dispatcher } // AssetExchangeTraceResolver 定义资产详情换货链路读取用例。 @@ -110,10 +117,40 @@ func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error { if err != nil { return err } + h.dispatchRealtimeObservations(c.UserContext(), 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 刷新资产状态(调网关同步) // POST /api/admin/assets/:identifier/refresh func (h *AssetHandler) Refresh(c *fiber.Ctx) error { diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go index 8438308..16d54b3 100644 --- a/internal/handler/app/client_asset.go +++ b/internal/handler/app/client_asset.go @@ -7,6 +7,7 @@ import ( "strings" "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/model" "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/pkg/constants" "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/gofiber/fiber/v2" "go.uber.org/zap" @@ -36,6 +38,12 @@ type ClientAssetHandler struct { deviceStore *postgres.DeviceStore db *gorm.DB logger *zap.Logger + observationSeries cardObservationApp.BestEffortSeriesDispatcher +} + +// SetObservationSeriesDispatcher 注入读取型后台观测序列端口。 +func (h *ClientAssetHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) { + h.observationSeries = dispatcher } // NewClientAssetHandler 创建 C 端资产信息处理器 @@ -213,10 +221,47 @@ func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error { resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime) } } + h.dispatchAssetReadObservations(c.UserContext(), resolved.Asset) 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 资产可购套餐列表 // GET /api/c/v1/asset/packages func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error { diff --git a/internal/handler/app/client_device.go b/internal/handler/app/client_device.go index dbe880e..9df9b40 100644 --- a/internal/handler/app/client_device.go +++ b/internal/handler/app/client_device.go @@ -6,6 +6,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model/dto" assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" 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/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/logger" @@ -33,9 +34,15 @@ type ClientDeviceHandler struct { deviceSimBindingStore *postgres.DeviceSimBindingStore iotCardStore *postgres.IotCardStore gatewayClient *gateway.Client + deviceService *deviceSvc.Service logger *zap.Logger } +// SetDeviceService 注入统一设备控制服务,确保各入口共享成功后的观测触发。 +func (h *ClientDeviceHandler) SetDeviceService(service *deviceSvc.Service) { + h.deviceService = service +} + // NewClientDeviceHandler 创建 C 端设备能力处理器 func NewClientDeviceHandler( assetService *assetSvc.Service, @@ -195,10 +202,10 @@ func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error { return err } - // 调用 Gateway 重启设备 - if err := h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{ - DeviceID: info.IMEI, - }); err != nil { + if h.deviceService == nil { + return errors.New(errors.CodeInternalError, "设备控制服务未配置") + } + if err := h.deviceService.GatewayRebootDevice(c.UserContext(), info.IMEI); err != nil { h.logger.Error("Gateway重启设备失败", zap.String("imei", info.IMEI), zap.Error(err)) @@ -225,10 +232,10 @@ func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error { return err } - // 调用 Gateway 恢复出厂设置 - if err := h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{ - DeviceID: info.IMEI, - }); err != nil { + if h.deviceService == nil { + return errors.New(errors.CodeInternalError, "设备控制服务未配置") + } + if err := h.deviceService.GatewayResetDevice(c.UserContext(), info.IMEI); err != nil { h.logger.Error("Gateway恢复出厂设置失败", zap.String("imei", info.IMEI), zap.Error(err)) @@ -256,14 +263,11 @@ func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error { return err } - // 调用 Gateway 配置 WiFi - // CardNo 字段虽名为"卡号",但 Gateway 实际要求传入设备 IMEI - if err := h.gatewayClient.SetWiFi(c.UserContext(), &gateway.WiFiReq{ - CardNo: info.IMEI, - Params: gateway.WiFiParams{ - SSIDName: req.SSID, - SSIDPassword: req.Password, - }, + if h.deviceService == nil { + return errors.New(errors.CodeInternalError, "设备控制服务未配置") + } + if err := h.deviceService.GatewaySetWiFi(c.UserContext(), info.IMEI, &dto.SetWiFiRequest{ + SSID: req.SSID, Password: req.Password, Enabled: req.Enabled, }); err != nil { h.logger.Error("Gateway配置WiFi失败", zap.String("imei", info.IMEI), @@ -292,10 +296,11 @@ func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error { return err } - // 调用 Gateway 切卡,CardNo 传设备 IMEI - if err := h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{ - CardNo: info.IMEI, - ICCID: req.TargetICCID, + if h.deviceService == nil { + return errors.New(errors.CodeInternalError, "设备控制服务未配置") + } + if err := h.deviceService.GatewaySwitchCard(c.UserContext(), info.IMEI, &dto.SwitchCardRequest{ + TargetICCID: req.TargetICCID, }); err != nil { h.logger.Error("Gateway切卡失败", zap.String("imei", info.IMEI), diff --git a/internal/handler/app/client_realname.go b/internal/handler/app/client_realname.go index d4c6f0d..42a979a 100644 --- a/internal/handler/app/client_realname.go +++ b/internal/handler/app/client_realname.go @@ -2,9 +2,10 @@ package app import ( "context" + "strconv" "strings" - "time" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" "github.com/gofiber/fiber/v2" "go.uber.org/zap" @@ -16,7 +17,6 @@ import ( customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" 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/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/logger" @@ -36,7 +36,7 @@ type ClientRealnameHandler struct { carrierStore *postgres.CarrierStore gatewayClient *gateway.Client logger *zap.Logger - manualTriggerSvc *pollingSvc.ManualTriggerService // 手动触发服务(可为nil,nil时跳过自动触发) + observationSeries cardObservationApp.BestEffortSeriesDispatcher } // NewClientRealnameHandler 创建 C 端实名认证处理器 @@ -48,7 +48,7 @@ func NewClientRealnameHandler( carrierStore *postgres.CarrierStore, gatewayClient *gateway.Client, logger *zap.Logger, - manualTriggerSvc *pollingSvc.ManualTriggerService, // 可为nil + _ *pollingSvc.ManualTriggerService, // 兼容旧构造签名;获取链接改由 0/3/5 观测序列收敛 ) *ClientRealnameHandler { return &ClientRealnameHandler{ assetService: assetSvc, @@ -58,10 +58,14 @@ func NewClientRealnameHandler( carrierStore: carrierStore, gatewayClient: gatewayClient, logger: logger, - manualTriggerSvc: manualTriggerSvc, } } +// SetObservationSeriesDispatcher 注入获取实名链接后的观测序列端口。 +func (h *ClientRealnameHandler) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) { + h.observationSeries = dispatcher +} + // GetRealnameLink E1 获取实名认证链接 // GET /api/c/v1/realname/link func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error { @@ -138,15 +142,29 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error { return err } - // 异步触发实名检查,提升检测优先级;失败不影响主流程 - if h.manualTriggerSvc != nil && config.Get().PollingAutoTrigger.EnableAutoTrigger { - systemUserID := uint(config.Get().PollingAutoTrigger.AutoTriggerSystemUserID) - go h.triggerRealnameCheck(targetCard.ID, customerID, targetCard.ICCID, systemUserID) - } + h.dispatchRealnameObservation(ctx, targetCard.ID) 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定位目标卡 // 支持三条路径:直接卡资产、设备+指定ICCID、设备取第一张绑定卡 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 } - -// 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 而非 UserTypeSuperAdmin,SuperAdmin 不受日限制约束 - 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)) -} diff --git a/internal/handler/callback/carrier_switch.go b/internal/handler/callback/carrier_switch.go new file mode 100644 index 0000000..84a3686 --- /dev/null +++ b/internal/handler/callback/carrier_switch.go @@ -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 +} diff --git a/internal/handler/callback/cmcc_realname.go b/internal/handler/callback/cmcc_realname.go new file mode 100644 index 0000000..231723c --- /dev/null +++ b/internal/handler/callback/cmcc_realname.go @@ -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) +} diff --git a/internal/handler/callback/ctcc_realname.go b/internal/handler/callback/ctcc_realname.go new file mode 100644 index 0000000..2f86ec9 --- /dev/null +++ b/internal/handler/callback/ctcc_realname.go @@ -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) +} diff --git a/internal/handler/callback/cucc_realname.go b/internal/handler/callback/cucc_realname.go new file mode 100644 index 0000000..77817de --- /dev/null +++ b/internal/handler/callback/cucc_realname.go @@ -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) +} diff --git a/internal/handler/callback/cucc_realname_removal.go b/internal/handler/callback/cucc_realname_removal.go new file mode 100644 index 0000000..279f099 --- /dev/null +++ b/internal/handler/callback/cucc_realname_removal.go @@ -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) +} diff --git a/internal/infrastructure/cardobservation/best_effort.go b/internal/infrastructure/cardobservation/best_effort.go new file mode 100644 index 0000000..454e497 --- /dev/null +++ b/internal/infrastructure/cardobservation/best_effort.go @@ -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) diff --git a/internal/infrastructure/cardobservation/event.go b/internal/infrastructure/cardobservation/event.go index da632df..58839dd 100644 --- a/internal/infrastructure/cardobservation/event.go +++ b/internal/infrastructure/cardobservation/event.go @@ -146,6 +146,7 @@ func (c *NetworkChangedConsumer) Consume(ctx context.Context, envelope outbox.De if c == nil || c.db == nil { return errors.New(errors.CodeInternalError, "卡网络事件消费者未配置") } + ctx = cardapp.SuppressSeriesTriggerContext(ctx) if envelope.EventType != constants.OutboxEventTypeCardNetworkChanged || envelope.PayloadVersion != constants.CardNetworkChangedPayloadVersionV1 { 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 { return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置") } + ctx = cardapp.SuppressSeriesTriggerContext(ctx) if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 { 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 { return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置") } + ctx = cardapp.SuppressSeriesTriggerContext(ctx) if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 { return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持") } diff --git a/internal/infrastructure/cardobservation/series_coordinator.go b/internal/infrastructure/cardobservation/series_coordinator.go index e530a1f..8c190c1 100644 --- a/internal/infrastructure/cardobservation/series_coordinator.go +++ b/internal/infrastructure/cardobservation/series_coordinator.go @@ -5,49 +5,87 @@ import ( "strconv" "time" + "github.com/bytedance/sonic" "github.com/google/uuid" "github.com/redis/go-redis/v9" 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/errors" ) // SeriesCoordinator 使用 Redis 原子协调活跃序列、幂等尝试和实际请求互斥。 type SeriesCoordinator struct { - redis *redis.Client - now func() time.Time + redis *redis.Client + 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 协调器。 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。 -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 { - 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) indexKey := constants.RedisCardObservationSeriesIndexKey(request.ResourceType, request.ResourceID, request.SyncType) result, err := c.redis.Eval(ctx, ` -local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms') -if current[1] then return {current[1], current[2], '1'} end -redis.call('HSET', KEYS[1], 'series_id', ARGV[1], 'base_time_ms', ARGV[2]) -redis.call('EXPIRE', KEYS[1], ARGV[3]) +local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms', 'request_json') +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], 'request_json', ARGV[3]) +redis.call('EXPIRE', KEYS[1], ARGV[4]) redis.call('SADD', KEYS[2], KEYS[1]) -redis.call('EXPIRE', KEYS[2], ARGV[3]) -return {ARGV[1], ARGV[2], '0'} -`, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice() - if err != nil || len(result) != 3 { - return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, err, "保留卡观测序列失败") + redis.call('EXPIRE', KEYS[2], ARGV[4]) +return {ARGV[1], ARGV[2], ARGV[3], '0'} +`, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), string(requestJSON), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice() + if err != nil || len(result) != 4 { + return "", time.Time{}, cardapp.SeriesRequest{}, false, errors.Wrap(errors.CodeInternalError, err, "保留卡观测序列失败") } baseTimeMS, parseErr := strconv.ParseInt(result[1], 10, 64) 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 判断序列是否已由预期状态或回调提前完成。 @@ -70,14 +108,14 @@ func (c *SeriesCoordinator) ClaimAttempt(ctx context.Context, seriesID string, a // AcquireRequest 获取仅覆盖实际 Gateway 请求的互斥,并返回最小间隔剩余等待时间。 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 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) nowMS := c.now().UTC().UnixMilli() result, err := c.redis.Eval(ctx, ` @@ -105,6 +143,34 @@ return 0 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 保存尝试终态,并在最后一次尝试后释放同场景活跃序列。 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 { diff --git a/internal/infrastructure/cardobservation/series_event.go b/internal/infrastructure/cardobservation/series_event.go new file mode 100644 index 0000000..85a4300 --- /dev/null +++ b/internal/infrastructure/cardobservation/series_event.go @@ -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) diff --git a/internal/infrastructure/cardobservation/series_runner.go b/internal/infrastructure/cardobservation/series_runner.go index 1c57de3..023b984 100644 --- a/internal/infrastructure/cardobservation/series_runner.go +++ b/internal/infrastructure/cardobservation/series_runner.go @@ -37,14 +37,20 @@ func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.Series resourceID := payload.ResourceID source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID 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, Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType), ResourceType: payload.ResourceType, ResourceID: &resourceID, TriggerSource: &source, 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}, }) + 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 } @@ -80,6 +86,12 @@ func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *ca // Provider 返回用于请求互斥的运营商接入标识。 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) if err != nil { return "", err @@ -97,6 +109,18 @@ func (r *SeriesRunner) ExpectationMet(ctx context.Context, payload cardapp.Serie if expected == "" { 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) if err != nil { 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 { 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) if err != nil { return cardapp.RunResult{}, err @@ -148,6 +175,178 @@ func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayloa 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(¤tBinding).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(¤t).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) { resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID resourceKey := "card:" + formatCardID(card.ID) @@ -235,7 +434,7 @@ func operationForSyncType(syncType string) string { case constants.CardObservationSyncTypeNetwork: return constants.IntegrationOperationGatewayNetwork default: - return "query_device_info" + return constants.IntegrationOperationGatewayDeviceInfo } } @@ -255,5 +454,12 @@ func optionalText(value string) *string { return &value } +func initialResult(result string) string { + if result == constants.IntegrationResultFailed { + return constants.IntegrationResultPending + } + return result +} + var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil) var _ cardapp.SeriesRunner = (*SeriesRunner)(nil) diff --git a/internal/infrastructure/cardtrafficlock/lock.go b/internal/infrastructure/cardtrafficlock/lock.go new file mode 100644 index 0000000..02753b3 --- /dev/null +++ b/internal/infrastructure/cardtrafficlock/lock.go @@ -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() +} diff --git a/internal/infrastructure/carriercallback/cmcc_realname.go b/internal/infrastructure/carriercallback/cmcc_realname.go new file mode 100644 index 0000000..7060737 --- /dev/null +++ b/internal/infrastructure/carriercallback/cmcc_realname.go @@ -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 +} diff --git a/internal/infrastructure/carriercallback/ctcc_realname.go b/internal/infrastructure/carriercallback/ctcc_realname.go new file mode 100644 index 0000000..7ee067c --- /dev/null +++ b/internal/infrastructure/carriercallback/ctcc_realname.go @@ -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 +} diff --git a/internal/infrastructure/carriercallback/cucc_realname.go b/internal/infrastructure/carriercallback/cucc_realname.go new file mode 100644 index 0000000..b9a3c73 --- /dev/null +++ b/internal/infrastructure/carriercallback/cucc_realname.go @@ -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) +} diff --git a/internal/infrastructure/carriercallback/cucc_realname_removal.go b/internal/infrastructure/carriercallback/cucc_realname_removal.go new file mode 100644 index 0000000..93e630a --- /dev/null +++ b/internal/infrastructure/carriercallback/cucc_realname_removal.go @@ -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) +} diff --git a/internal/infrastructure/integrationlog/repository.go b/internal/infrastructure/integrationlog/repository.go index 164e575..c6ee1b1 100644 --- a/internal/infrastructure/integrationlog/repository.go +++ b/internal/infrastructure/integrationlog/repository.go @@ -187,7 +187,7 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (* if r == nil || r.db == nil { 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 参数无效") } if input.IntegrationID == "" { @@ -233,6 +233,29 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (* 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 { if input.Provider == "" || input.Operation == "" { return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 提供方和操作不能为空") @@ -250,6 +273,7 @@ func isTerminalResult(result string) bool { switch result { case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown, constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored, + constants.IntegrationResultConflict, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted, constants.IntegrationResultCancelled: return true diff --git a/internal/infrastructure/messaging/outbox/repository.go b/internal/infrastructure/messaging/outbox/repository.go index 7e902c8..d71da9c 100644 --- a/internal/infrastructure/messaging/outbox/repository.go +++ b/internal/infrastructure/messaging/outbox/repository.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" "gorm.io/datatypes" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/asynctask" @@ -42,6 +43,15 @@ func NewRepository() *Repository { // Append 在业务事务中持久化稳定事件身份和必要快照。 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 { 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), 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 event, nil diff --git a/internal/model/dto/shop_series_grant_dto.go b/internal/model/dto/shop_series_grant_dto.go index 91edc2d..3df063f 100644 --- a/internal/model/dto/shop_series_grant_dto.go +++ b/internal/model/dto/shop_series_grant_dto.go @@ -2,9 +2,9 @@ package dto // GrantPackageItem 授权套餐操作项(用于创建/管理套餐列表) type GrantPackageItem struct { - PackageID uint `json:"package_id" validate:"required" description:"套餐ID"` - CostPrice int64 `json:"cost_price" validate:"required,min=0" description:"成本价(分)"` - Remove *bool `json:"remove,omitempty" description:"是否删除该套餐授权(true=删除)"` + PackageID uint `json:"package_id" validate:"required" required:"true" minimum:"1" description:"套餐ID"` + CostPrice *int64 `json:"cost_price,omitempty" validate:"omitempty,min=0" minimum:"0" description:"成本价(分),新增或调价时必填"` + Remove *bool `json:"remove,omitempty" description:"是否删除该套餐授权(true=删除)"` } // ShopSeriesGrantPackageItem 授权套餐详情(响应中的套餐信息) @@ -56,13 +56,13 @@ type ShopSeriesGrantResponse struct { // CreateShopSeriesGrantRequest 创建系列授权请求 type CreateShopSeriesGrantRequest struct { - ShopID uint `json:"shop_id" validate:"required" description:"被授权代理店铺ID"` - SeriesID uint `json:"series_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" required:"true" minimum:"1" description:"套餐系列ID"` OneTimeCommissionAmount *int64 `json:"one_time_commission_amount,omitempty" description:"固定模式佣金金额(分),固定模式必填"` CommissionTiers []GrantCommissionTierItem `json:"commission_tiers,omitempty" description:"梯度模式阶梯配置,梯度模式必填"` EnableForceRecharge *bool `json:"enable_force_recharge,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:"初始授权套餐列表(1~100项,套餐ID不可重复)"` ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"分配生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` ExpiryBaseOverrideSet bool `json:"-"` } @@ -77,7 +77,7 @@ type UpdateShopSeriesGrantRequest struct { // ManageGrantPackagesRequest 管理授权套餐请求 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:"套餐操作列表(1~100项,套餐ID不可重复)"` ExpiryBaseOverride *string `json:"expiry_base_override" nullable:"true" description:"新增分配的生效条件覆盖 (null:跟随套餐默认值, from_activation:实名激活时生效, from_purchase:购买即生效)"` ExpiryBaseOverrideSet bool `json:"-"` } diff --git a/internal/routes/order.go b/internal/routes/order.go index bc4162d..6cc4a3c 100644 --- a/internal/routes/order.go +++ b/internal/routes/order.go @@ -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 注册支付回调路由 func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) { Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{ diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 3b67a60..588b66c 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -46,4 +46,20 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew callbackGroup := app.Group("/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") + } } diff --git a/internal/service/agent_open_api/service.go b/internal/service/agent_open_api/service.go index c29c54a..fa2be3f 100644 --- a/internal/service/agent_open_api/service.go +++ b/internal/service/agent_open_api/service.go @@ -4,9 +4,11 @@ import ( "context" "fmt" "math/rand" + "strconv" "strings" "time" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" 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/dto" @@ -38,6 +40,12 @@ type Service struct { agentWalletStore *postgres.AgentWalletStore deviceSimBindingStore *postgres.DeviceSimBindingStore deviceStore *postgres.DeviceStore + observationSeries cardObservationApp.BestEffortSeriesDispatcher +} + +// SetObservationSeriesDispatcher 注入 OpenAPI 读取后的后台观测序列端口。 +func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.BestEffortSeriesDispatcher) { + s.observationSeries = dispatcher } // New 创建代理开放接口业务编排服务 @@ -83,7 +91,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ if cardErr == nil { // 独立卡:直接查卡维度流量 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) @@ -94,7 +106,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ if err != nil { 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/虚拟号) @@ -103,7 +119,11 @@ func (s *Service) GetCardTraffic(ctx context.Context, req *dto.AgentOpenAPICardQ // 两种解析都失败,返回原始卡解析错误(语义更贴近入参) 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 统一构造卡流量响应,支持卡和设备两种载体维度 @@ -167,14 +187,16 @@ func (s *Service) GetCardStatus(ctx context.Context, req *dto.AgentOpenAPICardQu stopReason = "" } - return &dto.AgentOpenAPICardStatusResponse{ + resp := &dto.AgentOpenAPICardStatusResponse{ CardNo: req.CardNo, CardStatus: cardStatus, CardStatusName: constants.AgentOpenAPICardStatusName(cardStatus), GatewayExtend: card.GatewayExtend, StopReason: stopReason, StopReasonName: constants.AgentOpenAPIStopReasonName(stopReason), - }, nil + } + s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardNetwork, card.ID, constants.CardObservationSyncTypeNetwork) + return resp, nil } // GetRealnameStatus 查询开放接口单卡实名状态 @@ -184,10 +206,12 @@ func (s *Service) GetRealnameStatus(ctx context.Context, req *dto.AgentOpenAPICa return nil, err } - return &dto.AgentOpenAPIRealnameStatusResponse{ + resp := &dto.AgentOpenAPIRealnameStatusResponse{ CardNo: req.CardNo, IsRealnamed: card.RealNameStatus == constants.RealNameStatusVerified, - }, nil + } + s.dispatchCardObservation(ctx, constants.CardObservationSceneOpenCardRealname, card.ID, constants.CardObservationSyncTypeRealname) + return resp, nil } // ResumeCard 调用上游复机机卡分离停机卡 @@ -396,6 +420,41 @@ func (s *Service) CreateWalletPackageOrders(ctx context.Context, req *dto.AgentO 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 将开放接口设备标识解析为当前代理可见的设备 // 设备不存在或不属于代理管辖店铺时统一返回 CodeForbidden,避免信息泄露 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)) } + s.dispatchDeviceCardObservations(ctx, constants.CardObservationSceneOpenDeviceTraffic, device.ID, constants.CardObservationSyncTypeTraffic) return resp, nil } diff --git a/internal/service/device/gateway_service.go b/internal/service/device/gateway_service.go index 0a82fb1..f50bf62 100644 --- a/internal/service/device/gateway_service.go +++ b/internal/service/device/gateway_service.go @@ -129,6 +129,7 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt ) return err } + observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "") if err = s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{ CardNo: imei, Params: gateway.WiFiParams{ @@ -175,6 +176,7 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt 0, nil, ) + s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSetWiFi, observation, false) return nil } @@ -200,6 +202,7 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req ) return err } + observation := s.captureDeviceControlObservation(ctx, device.ID, 0, req.TargetICCID) if err = s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{ CardNo: imei, ICCID: req.TargetICCID, @@ -233,6 +236,7 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req 0, nil, ) + s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchCard, observation, true) return nil } @@ -255,6 +259,7 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er ) return err } + observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "") if err = s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{ DeviceID: imei, }); err != nil { @@ -287,6 +292,7 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er 0, nil, ) + s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReboot, observation, false) return nil } @@ -309,6 +315,7 @@ func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) err ) return err } + observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "") if err = s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{ DeviceID: imei, }); err != nil { @@ -341,6 +348,7 @@ func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) err 0, nil, ) + s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReset, observation, false) return nil } @@ -556,6 +564,7 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req ) return appErr } + observation := s.captureDeviceControlObservation(ctx, device.ID, targetCard.ID, targetCard.ICCID) if err = s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{ CardNo: imei, SwitchMode: strconv.Itoa(switchMode), @@ -590,5 +599,6 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req 0, nil, ) + s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchMode, observation, true) return nil } diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 2ecc2df..fa181e5 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -2,8 +2,11 @@ package device import ( "context" + "strings" "time" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" @@ -37,6 +40,93 @@ type Service struct { enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore enterpriseStore *postgres.EnterpriseStore 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 注入套餐最终到期查询,供列表使用批量投影。 @@ -1678,11 +1768,11 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus } 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, "stopped_at": now, "stop_reason": constants.StopReasonManual, - }); dbErr != nil { + }, constants.CardObservationSceneBusinessStop, "offline", s.gatewayClient != nil); dbErr != nil { log.Error("设备停机-更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(dbErr)) @@ -1699,7 +1789,7 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus // 成功停机至少一张卡后设置保护期 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")) } @@ -1907,11 +1997,11 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { } 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, "resumed_at": now, "stop_reason": "", - }); dbErr != nil { + }, constants.CardObservationSceneBusinessResume, "online", s.gatewayClient != nil); dbErr != nil { log.Error("设备复机-更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(dbErr)) @@ -1926,7 +2016,7 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { // 成功复机至少一张卡后设置保护期 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")) } @@ -2089,3 +2179,29 @@ func (s *Service) invalidatePollingCardCache(cardID uint) { } _ = 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, + }) + }) +} diff --git a/internal/service/iot_card/gateway_service.go b/internal/service/iot_card/gateway_service.go index ba8b6fd..b665c51 100644 --- a/internal/service/iot_card/gateway_service.go +++ b/internal/service/iot_card/gateway_service.go @@ -2,8 +2,12 @@ package iot_card import ( "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/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "go.uber.org/zap" ) @@ -50,11 +54,35 @@ func (s *Service) GatewayQueryRealnameStatus(ctx context.Context, iccid string) // GatewayGetRealnameLink 获取实名认证跳转链接 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 s.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{ - CardNo: iccid, + s.dispatchAdminRealnameObservation(ctx, card) + 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, + }, }) } diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index c1952b8..5552e02 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -9,6 +9,7 @@ import ( 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/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/dto" packageexpiry "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" @@ -24,8 +25,6 @@ import ( "gorm.io/gorm" ) -const cardTrafficSyncLockTTL = 10 * time.Minute - // PollingCallback 轮询回调接口 // 用于在卡生命周期事件发生时通知轮询调度器 type PollingCallback interface { @@ -71,6 +70,13 @@ type Service struct { enterpriseStore *postgres.EnterpriseStore packageExpiryQuery *packageexpiry.Query cardObservation *cardapp.Service + observationSeries cardapp.BestEffortSeriesDispatcher + trafficLock *cardtrafficlock.Lock +} + +// SetObservationSeriesDispatcher 注入获取实名链接后的后台观测端口。 +func (s *Service) SetObservationSeriesDispatcher(dispatcher cardapp.BestEffortSeriesDispatcher) { + s.observationSeries = dispatcher } // SetCardObservationService 注入统一卡实名观测写入用例。 @@ -152,22 +158,17 @@ func (s *Service) SetDeviceSimBindingStore(deviceSimBindingStore *postgres.Devic // 用于清理实名逆转计数器,避免历史计数干扰手动实名后的判定 func (s *Service) SetRedisClient(redisClient *redis.Client) { s.redis = redisClient + s.trafficLock = cardtrafficlock.New(redisClient) } // acquireCardTrafficSyncLock 获取卡流量同步锁,避免主动刷新与轮询重复统计同一上游读数。 -func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (bool, error) { - if s.redis == nil { - return true, nil - } - return s.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), "1", cardTrafficSyncLockTTL).Result() +func (s *Service) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (string, bool, error) { + return s.trafficLock.Acquire(ctx, cardID) } // releaseCardTrafficSyncLock 释放卡流量同步锁。 -func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint) { - if s.redis == nil { - return - } - if err := s.redis.Del(ctx, constants.RedisCardTrafficSyncLockKey(cardID)).Err(); err != nil { +func (s *Service) releaseCardTrafficSyncLock(ctx context.Context, cardID uint, token string) { + if err := s.trafficLock.Release(ctx, cardID, token); err != nil { 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 } - locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID) + lockToken, locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID) if lockErr != nil { return errors.Wrap(errors.CodeInternalError, lockErr, "获取卡流量同步锁失败") } else if !locked { return errors.New(errors.CodeTooManyRequests, "卡流量正在同步,请稍后重试") } else { - defer s.releaseCardTrafficSyncLock(ctx, card.ID) + defer s.releaseCardTrafficSyncLock(ctx, card.ID, lockToken) latestCard, loadErr := s.iotCardStore.GetByID(ctx, card.ID) if loadErr != nil { return errors.Wrap(errors.CodeInternalError, loadErr, "刷新卡数据失败") diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index 8bf083f..0625dd3 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -6,6 +6,8 @@ import ( "strings" "time" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" @@ -31,19 +33,27 @@ var _ StopResumeServiceInterface = (*StopResumeService)(nil) // StopResumeService 停复机服务 // 处理 IoT 卡的自动停机、复机和手动停复机逻辑 type StopResumeService struct { - redis *redis.Client - iotCardStore *postgres.IotCardStore - packageUsageStore *postgres.PackageUsageStore - deviceSimBindingStore *postgres.DeviceSimBindingStore - gatewayClient *gateway.Client - logger *zap.Logger - assetAuditService AssetAuditService - pollingCallback PollingCallback + db *gorm.DB + redis *redis.Client + iotCardStore *postgres.IotCardStore + packageUsageStore *postgres.PackageUsageStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + gatewayClient *gateway.Client + logger *zap.Logger + assetAuditService AssetAuditService + pollingCallback PollingCallback + observationSeriesEvents cardObservationApp.SeriesEventWriter maxRetries int retryInterval time.Duration } +// SetObservationSeriesEventWriter 注入停复机成功观测序列 Outbox Writer。 +func (s *StopResumeService) SetObservationSeriesEventWriter(db *gorm.DB, writer cardObservationApp.SeriesEventWriter) { + s.db = db + s.observationSeriesEvents = writer +} + // NewStopResumeService 创建停复机服务 func NewStopResumeService( 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)) // 降级:查不到绑定时按单卡处理 } else if bound { - s.stopDeviceCards(ctx, deviceID, primaryReason) - return nil + return s.stopDeviceCards(ctx, deviceID, primaryReason) } } return s.stopCardWithRetry(ctx, card, primaryReason) @@ -121,8 +130,7 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC s.logger.Error("查询设备绑定关系失败,降级为单卡复机", zap.Uint("card_id", card.ID), zap.Error(lookupErr)) } else if bound { - s.resumeDeviceCards(ctx, deviceID) - return nil + return s.resumeDeviceCards(ctx, deviceID) } } return s.resumeSingleCard(ctx, card.ID) @@ -291,14 +299,17 @@ func (s *StopResumeService) shouldResume(ctx context.Context, card *model.IotCar // stopDeviceCards 停机设备下所有在线卡(含设备维度幂等锁) // 防止设备下多张卡并发触发 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) - 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 { s.logger.Debug("设备停机操作已在进行中,跳过重复调用", zap.Uint("device_id", deviceID)) - return + return nil } defer s.redis.Del(ctx, lockKey) @@ -307,29 +318,38 @@ func (s *StopResumeService) stopDeviceCards(ctx context.Context, deviceID uint, if err != nil { s.logger.Error("查询设备在线卡失败", zap.Uint("device_id", deviceID), zap.Error(err)) - return + return errors.Wrap(errors.CodeDatabaseError, err, "查询设备在线卡失败") } + var cardErrors []error for _, card := range cards { if stopErr := s.stopCardWithRetry(ctx, card, stopReason); stopErr != nil { + cardErrors = append(cardErrors, stopErr) s.logger.Warn("设备卡停机失败,继续处理其他卡", zap.Uint("device_id", deviceID), zap.Uint("card_id", card.ID), zap.Error(stopErr)) } } + if len(cardErrors) > 0 { + return errors.Wrap(errors.CodeInternalError, stderrors.Join(cardErrors...), "设备停机存在未成功处理的卡") + } + return nil } // resumeDeviceCards 复机设备下满足条件的停机卡(含设备维度幂等锁) // 遍历时对每张卡检查实名状态:未实名普通卡更新 stop_reason='not_realname' 后跳过 -func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) { +func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint) error { // 设备维度幂等锁:与 stopDeviceCards 共用,防止停/复机并发 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 { s.logger.Debug("设备复机操作已在进行中,跳过重复调用", zap.Uint("device_id", deviceID)) - return + return nil } defer s.redis.Del(ctx, lockKey) @@ -338,24 +358,31 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint if err != nil { s.logger.Error("查询设备停机卡失败", zap.Uint("device_id", deviceID), zap.Error(err)) - return + return errors.Wrap(errors.CodeDatabaseError, err, "查询设备停机卡失败") } + var cardErrors []error for _, card := range cards { if !s.isRealnameOK(card) { if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil { + cardErrors = append(cardErrors, updateErr) s.logger.Warn("更新未实名卡停机原因失败", zap.Uint("card_id", card.ID), zap.Error(updateErr)) } continue } if resumeErr := s.resumeSingleCard(ctx, card.ID); resumeErr != nil { + cardErrors = append(cardErrors, resumeErr) s.logger.Warn("设备卡复机失败,继续处理其他卡", zap.Uint("device_id", deviceID), zap.Uint("card_id", card.ID), zap.Error(resumeErr)) } } + if len(cardErrors) > 0 { + return errors.Wrap(errors.CodeInternalError, stderrors.Join(cardErrors...), "设备复机存在未成功处理的卡") + } + return nil } // CheckAndStopCard 检查流量耗尽并停机(旧入口,保留兼容性) @@ -375,8 +402,7 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType case "iot_card": return s.resumeSingleCard(ctx, carrierID) case "device": - s.resumeDeviceCards(ctx, carrierID) - return nil + return s.resumeDeviceCards(ctx, carrierID) default: return nil } @@ -460,11 +486,11 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e } 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, "resumed_at": now, "stop_reason": "", - }); err != nil { + }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil { s.logger.Error("复机 Gateway 成功但 DB 更新失败", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), @@ -488,7 +514,7 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e "stop_reason": "", }, }) - return nil + return err } s.reschedulePolling(ctx, card.ID) @@ -568,11 +594,11 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I zap.String("iccid", card.ICCID)) 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, "stopped_at": now, "stop_reason": stopReason, - }); updateErr != nil { + }, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString()); updateErr != nil { s.logger.Error("停机 Gateway 成功但 DB 更新失败", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID), @@ -596,6 +622,7 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I "stop_reason": stopReason, }, }) + return updateErr } s.reschedulePolling(ctx, card.ID) @@ -763,12 +790,12 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card } 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, "resumed_at": now, "stop_reason": "", "gateway_extend": "", - }); err != nil { + }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil { wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ @@ -1008,11 +1035,11 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e } 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, "resumed_at": now, "stop_reason": "", - }); err != nil { + }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil { wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ @@ -1056,6 +1083,31 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e 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 重建 func (s *StopResumeService) invalidatePollingCache(ctx context.Context, cardID uint) { if s.redis == nil { diff --git a/internal/service/order/service.go b/internal/service/order/service.go index e808acc..014596f 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -10,6 +10,7 @@ import ( "strings" "time" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/model" @@ -66,6 +67,12 @@ type Service struct { personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore agentWalletDebit *walletapp.DebitService agentWalletReservation *walletapp.ReservationService + observationSeriesEvents cardObservationApp.SeriesEventWriter +} + +// SetObservationSeriesEventWriter 注入购包成功观测序列 Outbox Writer。 +func (s *Service) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) { + s.observationSeriesEvents = writer } // SetAgentWalletDebitService 注入代理主钱包统一扣款用例。 @@ -1940,8 +1947,9 @@ func (s *Service) tryResumeAfterPayment(ctx context.Context, order *model.Order) return } + resumeCtx := context.WithoutCancel(ctx) 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("支付后自动复机失败", zap.String("carrier_type", ct), zap.Uint("carrier_id", cid), @@ -1975,6 +1983,7 @@ func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model } now := time.Now() + createdUsage := false for _, item := range items { // 检查是否已存在使用记录 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 { return err } + createdUsage = true } else if pkg.PackageType == "addon" { // 加油包处理逻辑(任务 8.5-8.7) if err := s.activateAddonPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil { 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 } +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 { switch carrierType { case constants.AssetWalletResourceTypeIotCard, "card": diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index 1f41d9d..9b721d2 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -2,8 +2,10 @@ package packagepkg import ( "context" + "strconv" "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/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -27,6 +29,12 @@ type ActivationService struct { packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore logger *zap.Logger resumeCallback ResumeCallback // 复机回调,可选 + observationSeriesEvents cardObservationApp.SeriesEventWriter +} + +// SetObservationSeriesEventWriter 注入套餐激活成功观测序列 Outbox Writer。 +func (s *ActivationService) SetObservationSeriesEventWriter(writer cardObservationApp.SeriesEventWriter) { + s.observationSeriesEvents = writer } func NewActivationService( @@ -81,8 +89,9 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType 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 { // 查询套餐信息 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 { 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) @@ -153,20 +165,18 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType zap.Time("activated_at", activatedAt), 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) - } + activated = true } return nil }) + if err != nil { + return err + } + if activated { + s.resumeAfterActivation(ctx, carrierType, carrierID, "实名激活后自动复机失败") + } + return nil } // ActivateQueuedPackage 任务 9.4-9.7: 排队主套餐激活 @@ -187,7 +197,8 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy } 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: 检测并标记过期的主套餐 now := time.Now() var expiredMainUsages []*model.PackageUsage @@ -221,13 +232,22 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy } // 任务 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 } + activated = activated || currentActivated } return nil }) + if err != nil { + return err + } + if activated { + s.resumeAfterActivation(ctx, carrierType, carrierID, "排队激活后自动复机失败") + } + return nil } // 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 if err := tx.First(¤tUsage, packageUsageID).Error; err != nil { @@ -323,21 +344,16 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil { return err } - - // 异步复机 - 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) - } - + activated = true return nil }) + if err != nil { + return err + } + if activated { + s.resumeAfterActivation(ctx, carrierType, carrierID, "激活后自动复机失败") + } + return nil } // ActivateNextPendingMainPackage 按购买顺序激活载体的下一个待生效主套餐。 @@ -409,6 +425,9 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context, if err != nil { return false, err } + if activated { + s.resumeAfterActivation(ctx, carrierType, carrierID, "队首激活后自动复机失败") + } return activated, nil } @@ -451,22 +470,22 @@ func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, m } // 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) if err == gorm.ErrRecordNotFound { s.logger.Info("没有待生效的主套餐", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) - return nil + return false, nil } if err != nil { - return err + return false, err } canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID) if err != nil { - return err + return false, err } if !canActivate { s.logger.Info("下一个待生效主套餐暂不满足激活条件", @@ -474,16 +493,19 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Bool("pending_realname_activation", nextMain.PendingRealnameActivation)) - return nil + return false, nil } // 查询套餐信息 var pkg model.Package 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) { @@ -589,6 +611,9 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D if err := tx.Model(usage).Updates(updates).Error; err != nil { 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) @@ -598,20 +623,45 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D zap.Time("activated_at", activatedAt), 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 } +// 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) { if usage == nil { return diff --git a/internal/service/package/reset_service.go b/internal/service/package/reset_service.go index 93b9a88..a510b2f 100644 --- a/internal/service/package/reset_service.go +++ b/internal/service/package/reset_service.go @@ -247,6 +247,7 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages [] return } + resumeCtx := context.WithoutCancel(ctx) for _, pkg := range packages { var carrierType string var carrierID uint @@ -262,7 +263,7 @@ func (s *ResetService) triggerResumeForPackages(ctx context.Context, packages [] } 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("流量重置后自动复机失败", zap.String("carrier_type", ct), zap.Uint("carrier_id", cid), diff --git a/internal/service/package/usage_service.go b/internal/service/package/usage_service.go index a434ca3..daeb45b 100644 --- a/internal/service/package/usage_service.go +++ b/internal/service/package/usage_service.go @@ -186,7 +186,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, } if shouldSuspend { - s.triggerSuspensionAfterCommit(suspendCarrierType, suspendCarrierID) + s.triggerSuspensionAfterCommit(ctx, suspendCarrierType, suspendCarrierID) } return nil @@ -405,7 +405,7 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D } // 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 carrierType == constants.AssetTypeDevice { s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机", @@ -414,9 +414,9 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI return } + stopCtx := context.WithoutCancel(ctx) if carrierType == constants.AssetTypeIotCard { go func() { - stopCtx := context.Background() if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil { s.logger.Error("调用停机服务失败", zap.Uint("card_id", carrierID), @@ -430,7 +430,7 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI return } - bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID) + bindings, err := s.deviceSimBindingStore.ListByDeviceID(stopCtx, carrierID) if err != nil { s.logger.Error("查询设备绑定卡失败", zap.Uint("device_id", carrierID), @@ -446,7 +446,6 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI for _, b := range bindings { cardID := b.IotCardID go func(cID uint) { - stopCtx := context.Background() if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil { s.logger.Error("调用停机服务失败", zap.Uint("card_id", cID), diff --git a/internal/service/shop_series_grant/service.go b/internal/service/shop_series_grant/service.go index 96fe1ae..70c1dad 100644 --- a/internal/service/shop_series_grant/service.go +++ b/internal/service/shop_series_grant/service.go @@ -16,6 +16,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/middleware" "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // Service 代理系列授权业务服务 @@ -30,6 +31,12 @@ type Service struct { logger *zap.Logger } +// initialGrantPackage 保存通过创建前校验的套餐及其请求价格。 +type initialGrantPackage struct { + item dto.GrantPackageItem + packageModel *model.Package +} + // New 创建代理系列授权服务实例 func New( 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 查询固定模式佣金天花板 // allocatorShopID=0 表示平台分配,天花板为 PackageSeries.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() - // 1.5 校验目标店铺是否存在 - _, err = s.shopStore.GetByID(ctx, req.ShopID) + // 1.5 先校验管理范围,避免通过错误差异探测其他店铺。 + 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 { - return nil, errors.New(errors.CodeNotFound, "目标店铺不存在") + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } // 2. 检查重复授权 @@ -244,12 +337,20 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ var allocatorShopID uint if operatorType == constants.UserTypeAgent { allocatorShopID = operatorShopID - _, err := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, allocatorShopID, req.SeriesID) - if err != nil { + if allocatorShopID == 0 || targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID { + 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, "当前账号无此系列授权,无法向下分配") } } // 平台/超管 allocatorShopID = 0 + if operatorType != constants.UserTypeAgent && operatorType != constants.UserTypePlatform && operatorType != constants.UserTypeSuperAdmin { + return nil, errors.New(errors.CodeForbidden, "无权限创建系列授权") + } // 4. 参数验证:仅启用一次性佣金的系列才需要配置佣金金额 allocation := &model.ShopSeriesAllocation{ @@ -322,59 +423,67 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ // 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation 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) if createErr := txSeriesStore.Create(ctx, allocation); createErr != nil { return errors.Wrap(errors.CodeDatabaseError, createErr, "创建系列授权失败") } - // 创建套餐分配 - if len(req.Packages) > 0 { - txPkgStore := postgres.NewShopPackageAllocationStore(tx) - txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx) - for _, item := range req.Packages { - if item.Remove != nil && *item.Remove { - continue - } - // W1: 校验套餐归属于该系列,防止跨系列套餐混入 - pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID) - if pkgErr != nil || pkg.SeriesID != req.SeriesID { - return errors.New(errors.CodeInvalidParam, "套餐不属于该系列,无法添加到此授权") - } - if pkg.IsGift { - return errors.New(errors.CodeForbidden, "赠送套餐不能分配给代理") - } - // W2: 代理操作时,校验分配者已拥有此套餐授权,防止越权分配 - if allocatorShopID > 0 { - _, authErr := s.shopPackageAllocationStore.GetByShopAndPackageForSystem(ctx, allocatorShopID, item.PackageID) - if authErr != nil { - return errors.New(errors.CodeForbidden, "无权限分配该套餐") - } - } - pkgAlloc := &model.ShopPackageAllocation{ - ShopID: req.ShopID, - PackageID: item.PackageID, - AllocatorShopID: allocatorShopID, - CostPrice: item.CostPrice, - RetailPrice: pkg.SuggestedRetailPrice, - RetailPriceConfigStatus: pkg.PriceConfigStatus, - SeriesAllocationID: &allocation.ID, - ExpiryBaseOverride: expiryBaseOverride, - 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(), - }) + // 所有套餐已在当前事务内完成批量校验,此处只写入同一业务单元。 + txPkgStore := postgres.NewShopPackageAllocationStore(tx) + txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx) + for _, validated := range validatedPackages { + item := validated.item + pkg := validated.packageModel + pkgAlloc := &model.ShopPackageAllocation{ + ShopID: req.ShopID, + PackageID: item.PackageID, + AllocatorShopID: allocatorShopID, + CostPrice: *item.CostPrice, + RetailPrice: pkg.SuggestedRetailPrice, + RetailPriceConfigStatus: pkg.PriceConfigStatus, + SeriesAllocationID: &allocation.ID, + ExpiryBaseOverride: expiryBaseOverride, + 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, "创建套餐分配失败") + } + if err := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ + AllocationID: pkgAlloc.ID, + OldCostPrice: 0, + NewCostPrice: *item.CostPrice, + ChangeReason: "初始授权", + ChangedBy: operatorID, + EffectiveFrom: time.Now(), + }); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐价格历史失败") } } @@ -639,45 +748,56 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr for _, item := range req.Packages { if item.Remove != nil && *item.Remove { - // 软删除已有的 active 分配 + // 软删除已有的有效分配 existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID) if findErr != nil { - // 找不到则静默忽略 + // 已不存在时按幂等成功处理 continue } - _ = txPkgStore.Delete(ctx, existing.ID) + if deleteErr := txPkgStore.Delete(ctx, existing.ID); deleteErr != nil { + return errors.Wrap(errors.CodeDatabaseError, deleteErr, "删除套餐分配失败") + } continue } + if item.CostPrice == nil { + return errors.New(errors.CodeInvalidParam, "新增或修改套餐授权时必须提交成本价") + } + costPrice := *item.CostPrice + // 新增或更新套餐分配 existing, findErr := txPkgStore.GetByShopAndPackageForSystem(ctx, allocation.ShopID, item.PackageID) if findErr == nil { // 已有记录:更新成本价并写历史 oldPrice := existing.CostPrice - if oldPrice != item.CostPrice { + if oldPrice != costPrice { // cost_price 锁定检查:存在下级分配记录时禁止修改 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). - Count(&subCount) + Count(&subCount).Error; countErr != nil { + return errors.Wrap(errors.CodeDatabaseError, countErr, "查询下级套餐分配失败") + } if subCount > 0 { return errors.New(errors.CodeForbidden, "存在下级分配记录,请先回收后再修改成本价") } } - existing.CostPrice = item.CostPrice + existing.CostPrice = costPrice existing.Updater = operatorID if updateErr := txPkgStore.Update(ctx, existing); updateErr != nil { return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐分配失败") } - if oldPrice != item.CostPrice { - _ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ + if oldPrice != costPrice { + if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ AllocationID: existing.ID, OldCostPrice: oldPrice, - NewCostPrice: item.CostPrice, + NewCostPrice: costPrice, ChangeReason: "手动调价", ChangedBy: operatorID, EffectiveFrom: time.Now(), - }) + }); historyErr != nil { + return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败") + } } } else { 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, PackageID: item.PackageID, AllocatorShopID: allocation.AllocatorShopID, - CostPrice: item.CostPrice, + CostPrice: costPrice, RetailPrice: pkg.SuggestedRetailPrice, RetailPriceConfigStatus: pkg.PriceConfigStatus, 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 { return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败") } - _ = txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ + if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ AllocationID: pkgAlloc.ID, OldCostPrice: 0, - NewCostPrice: item.CostPrice, + NewCostPrice: costPrice, ChangeReason: "新增授权", ChangedBy: operatorID, EffectiveFrom: time.Now(), - }) + }); historyErr != nil { + return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败") + } } } return nil diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index d5a3bf5..eef212d 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -3,6 +3,7 @@ package task import ( "context" "errors" + "strconv" "time" "github.com/bytedance/sonic" @@ -12,6 +13,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/model" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" @@ -41,6 +43,7 @@ type AutoPurchaseHandler struct { redis *redis.Client asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务 logger *zap.Logger + observationSeriesEvents cardObservationApp.SeriesEventWriter } // NewAutoPurchaseHandler 创建充值后自动购包处理器 @@ -55,6 +58,7 @@ func NewAutoPurchaseHandler( redisClient *redis.Client, asynqClient *asynq.Client, logger *zap.Logger, + observationSeriesEvents cardObservationApp.SeriesEventWriter, ) *AutoPurchaseHandler { if orderStore == nil { orderStore = postgres.NewOrderStore(db, redisClient) @@ -89,6 +93,7 @@ func NewAutoPurchaseHandler( redis: redisClient, asynqClient: asynqClient, 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 { 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{}). Where("id = ?", rechargeOrder.ID). @@ -289,6 +311,16 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task) 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 创建充值后自动购包任务 func NewAutoPurchaseTask(rechargeOrderID uint) (*asynq.Task, error) { payloadBytes, err := sonic.Marshal(AutoPurchasePayload{RechargeOrderID: rechargeOrderID}) diff --git a/internal/task/polling_base.go b/internal/task/polling_base.go index 0ec5942..88b7897 100644 --- a/internal/task/polling_base.go +++ b/internal/task/polling_base.go @@ -7,6 +7,7 @@ import ( "github.com/redis/go-redis/v9" "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/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -28,7 +29,6 @@ var acquireConcurrencyScript = redis.NewScript(` return current `) -const cardTrafficSyncLockTTL = 10 * time.Minute const pollingFallbackOperationTimeout = 5 * time.Second // pollingFallbackContext 创建轮询兜底操作使用的独立短超时上下文。 @@ -47,6 +47,7 @@ type PollingBase struct { iotCardStore *postgres.IotCardStore logger *zap.Logger verboseLog bool + trafficLock *cardtrafficlock.Lock } // NewPollingBase 创建轮询共享基类 @@ -65,6 +66,7 @@ func NewPollingBase( iotCardStore: iotCardStore, logger: logger, verboseLog: verboseLog, + trafficLock: cardtrafficlock.New(redisClient), } } @@ -105,26 +107,16 @@ func (b *PollingBase) releaseConcurrency(_ context.Context, taskType string) { } // acquireCardTrafficSyncLock 获取卡流量同步锁,避免轮询与手动刷新重复统计同一上游读数。 -func (b *PollingBase) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (bool, error) { - if b.redis == nil { - return true, nil - } - locked, err := b.redis.SetNX(ctx, constants.RedisCardTrafficSyncLockKey(cardID), "1", cardTrafficSyncLockTTL).Result() - if err != nil { - return false, err - } - return locked, nil +func (b *PollingBase) acquireCardTrafficSyncLock(ctx context.Context, cardID uint) (string, bool, error) { + return b.trafficLock.Acquire(ctx, cardID) } // releaseCardTrafficSyncLock 释放卡流量同步锁。 -func (b *PollingBase) releaseCardTrafficSyncLock(_ context.Context, cardID uint) { - if b.redis == nil { - return - } +func (b *PollingBase) releaseCardTrafficSyncLock(_ context.Context, cardID uint, token string) { ctx, cancel := pollingFallbackContext() 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)) } } diff --git a/internal/task/polling_carddata_handler.go b/internal/task/polling_carddata_handler.go index 6bef3ad..254b0e7 100644 --- a/internal/task/polling_carddata_handler.go +++ b/internal/task/polling_carddata_handler.go @@ -42,12 +42,12 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) e return h.base.requeueCard(ctx, cardID, 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 { 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)) } - defer h.base.releaseCardTrafficSyncLock(ctx, cardID) + defer h.base.releaseCardTrafficSyncLock(ctx, cardID, lockToken) h.base.invalidateCardCache(ctx, cardID) card, err := h.base.iotCardStore.GetByID(ctx, cardID) diff --git a/internal/task/polling_protect_handler.go b/internal/task/polling_protect_handler.go index 7bbd987..3ec648e 100644 --- a/internal/task/polling_protect_handler.go +++ b/internal/task/polling_protect_handler.go @@ -2,15 +2,20 @@ package task import ( "context" + "strconv" "time" "github.com/hibiken/asynq" "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/model" 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/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" ) // PollingProtectHandler 保护期一致性检查任务处理器 @@ -18,15 +23,19 @@ import ( // 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件 // 两种路径不可混淆:保护期内=强制修正;保护期结束=重新评估 type PollingProtectHandler struct { - base *PollingBase - gateway *gateway.Client - iotCardStore *postgres.IotCardStore - deviceSimBindingStore *postgres.DeviceSimBindingStore - stopResumeSvc iot_card_svc.StopResumeServiceInterface + db *gorm.DB + observationSeriesEvents cardObservationApp.SeriesEventWriter + base *PollingBase + gateway *gateway.Client + iotCardStore *postgres.IotCardStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + stopResumeSvc iot_card_svc.StopResumeServiceInterface } // NewPollingProtectHandler 创建保护期一致性检查任务处理器 func NewPollingProtectHandler( + db *gorm.DB, + observationSeriesEvents cardObservationApp.SeriesEventWriter, base *PollingBase, gw *gateway.Client, iotCardStore *postgres.IotCardStore, @@ -34,11 +43,13 @@ func NewPollingProtectHandler( stopResumeSvc iot_card_svc.StopResumeServiceInterface, ) *PollingProtectHandler { return &PollingProtectHandler{ - base: base, - gateway: gw, - iotCardStore: iotCardStore, - deviceSimBindingStore: deviceSimBindingStore, - stopResumeSvc: stopResumeSvc, + db: db, + observationSeriesEvents: observationSeriesEvents, + base: base, + gateway: gw, + iotCardStore: iotCardStore, + deviceSimBindingStore: deviceSimBindingStore, + stopResumeSvc: stopResumeSvc, } } @@ -82,8 +93,10 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error } deviceID := binding.DeviceID - stopProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() > 0 - startProtect := h.base.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() > 0 + stopProtectGeneration := h.base.redis.Get(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Val() + startProtectGeneration := h.base.redis.Get(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Val() + stopProtect := stopProtectGeneration != "" + startProtect := startProtectGeneration != "" 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)) 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, "stopped_at": time.Now(), "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.invalidateCardCache(ctx, cardID) 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}) 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)) 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, "resumed_at": time.Now(), "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.invalidateCardCache(ctx, cardID) 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}) 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)) 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, + }) + }) +} diff --git a/migrations/000181_add_integration_log_conflict_result.down.sql b/migrations/000181_add_integration_log_conflict_result.down.sql new file mode 100644 index 0000000..8c1c0eb --- /dev/null +++ b/migrations/000181_add_integration_log_conflict_result.down.sql @@ -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' + )); diff --git a/migrations/000181_add_integration_log_conflict_result.up.sql b/migrations/000181_add_integration_log_conflict_result.up.sql new file mode 100644 index 0000000..062004c --- /dev/null +++ b/migrations/000181_add_integration_log_conflict_result.up.sql @@ -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' + )); diff --git a/openspec/changes/complete-july-iteration-test-release/proposal.md b/openspec/changes/complete-july-iteration-test-release/proposal.md index fa6149d..955a05a 100644 --- a/openspec/changes/complete-july-iteration-test-release/proposal.md +++ b/openspec/changes/complete-july-iteration-test-release/proposal.md @@ -17,7 +17,7 @@ - 保留 Integration Log、Outbox、Domain Ledger、幂等、事务和外部调用恢复能力,它们继续作为业务可靠性设施,不因 Audit Event 移出范围而删除、回滚或绕过。 - 永久排除已于 2026-07-14 移出本期的需求16;不得创建分销码、代理申请、分销关系、佣金提现相关表、API、页面、领域目录或迁移。 - 统一接口与工程约束: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 diff --git a/openspec/changes/complete-july-iteration-test-release/tasks.md b/openspec/changes/complete-july-iteration-test-release/tasks.md index a94e4a1..bd87678 100644 --- a/openspec/changes/complete-july-iteration-test-release/tasks.md +++ b/openspec/changes/complete-july-iteration-test-release/tasks.md @@ -12,7 +12,7 @@ - [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.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;业务列表基础字段不依赖企微专属投影。 - [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 已满足。 @@ -26,7 +26,7 @@ - [ ] 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。 - [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。 - [ ] 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。 @@ -84,20 +84,21 @@ - [x] 2.35 映射原 UR94-03:将流量观测收口到同一公共写入用例并保持已有周期/资格语义。【主:复杂写 Domain|不迁移:套餐轮询|依赖: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】 -- [ ] 2.38 映射原 UR94-06:交付同场景可合并的立即/3/5 分钟观测序列、单次请求互斥、最小间隔与 Integration Log。【主:Application|辅:Asynq/Adapter|不迁移:自动退避状态|依赖:2.34-2.36】 -- [ ] 2.39 映射原 UR94-07:在查询资产、获取实名链接等成功边界接入观测序列,不新增显式同步 API。【主:Application Adapter|不迁移:读取 Query 业务|依赖:2.38】 -- [ ] 2.40 映射原 UR94-08:在停复机、购包和套餐激活成功边界接入观测序列,仅负责触发不复制业务资格。【主:Application Adapter|不迁移:UR73/套餐规则|依赖:2.38】 -- [ ] 2.41 映射原 UR94-09:在切卡、重启等设备控制成功边界接入观测序列并解析最终卡。【主:Application Adapter|不迁移:设备控制规则|依赖:2.38】 -- [ ] 2.42 映射原 UR94-10:交付电信实名回调 Translator、19/20 位精确路由、幂等入站 Integration Log 和成功应答。【主:Infrastructure Adapter|辅:Application|依赖:2.33、2.34、2.38】 -- [ ] 2.43 映射原 UR94-11:交付移动实名成功回调的对应防腐层与公共写入接入。【主:Infrastructure Adapter|依赖:2.33、2.34、2.38】 -- [ ] 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.38 映射原 UR94-06:交付同场景可合并的立即/3/5 分钟观测序列、单次请求互斥、最小间隔与 Integration Log。【主:Application|辅:Asynq/Adapter|不迁移:自动退避状态|依赖:2.34-2.36】 +- [x] 2.39 映射原 UR94-07:在查询资产、获取实名链接等成功边界接入观测序列,不新增显式同步 API。【主:Application Adapter|不迁移:读取 Query 业务|依赖:2.38】 +- [x] 2.40 映射原 UR94-08:在停复机、购包和套餐激活成功边界接入观测序列,仅负责触发不复制业务资格。【主:Application Adapter|不迁移:UR73/套餐规则|依赖:2.38】 +- [x] 2.41 映射原 UR94-09:在切卡、重启等设备控制成功边界接入观测序列并解析最终卡。【主:Application Adapter|不迁移:设备控制规则|依赖:2.38】 +- [x] 2.42 映射原 UR94-10:交付电信实名回调 Translator、19/20 位精确路由、幂等入站 Integration Log 和成功应答。【主:Infrastructure Adapter|辅:Application|依赖:2.33、2.34、2.38】 +- [x] 2.43 映射原 UR94-11:交付移动实名成功回调的对应防腐层与公共写入接入。【主:Infrastructure Adapter|依赖:2.33、2.34、2.38】 +- [x] 2.44 映射原 UR94-12:交付联通解除实名回调的留痕与约定成功应答,不修改本地实名状态。【主:Infrastructure Adapter|边界:Integration Log only|依赖:2.33】 +- [x] 2.44A 根据新增来源材料 `docs/7月迭代/来源材料/realname.go` 补充联通实名成功回调:解析与解除实名一致的双层 JSON,按 19/20 位精确定位并接入公共实名观测;禁止 20 位截断、旧 `inner_callback`、第三方推送和 Gateway 二次确认。【主:Infrastructure Adapter|辅:Application|依赖: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-05~B1-09 独立 frontier -- [ ] 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.48 `JULY-UR43` 映射原 Ticket 03:交付首次/后续管理前端契约、三类价格语义、OpenAPI/文档生成器与同批切换部署检查。【主:API/Query 契约|依赖:2.46、2.47】 +- [x] 2.46 `JULY-UR43` 映射原 Ticket 01:首次系列授权在同一事务创建系列授权和至少一条套餐授权,复用现有套餐 Query,已授权项置灰。【主:复杂写;辅:Query|不迁移:授权模型/候选 API|依赖:1.1】 +- [ ] 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:交付首次/后续管理前端契约、`packages[].remove` 删除语义、OpenAPI/文档生成器与兼容部署检查;旧前端请求必须继续可用,不得要求与后端同批切换到新命令字段。【主:API/Query 契约|依赖:2.46、2.47】 - [ ] 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.51 `JULY-UR47` 映射原 Ticket 03:交付统一资产手动限速 API,设备解析当前卡,区分不限速与 0kbps,外部结果未知可恢复;Audit Event 不属于本 Change 且不阻塞上线。【主:Application|辅:Adapter|不迁移:套餐限速|依赖:2.49、2.50】 diff --git a/pkg/constants/card_observation.go b/pkg/constants/card_observation.go index ae16acc..9a39fa8 100644 --- a/pkg/constants/card_observation.go +++ b/pkg/constants/card_observation.go @@ -30,7 +30,7 @@ const ( CardObservationSeriesAttemptCount = 3 // 每个序列固定尝试次数 CardObservationSeriesTTL = 7 * time.Minute // 活跃序列覆盖最后一次任务及缓冲 CardObservationSeriesResultTTL = 24 * time.Hour // 尝试幂等与提前完成结果保留时间 - CardObservationGatewayLockTTL = 70 * time.Second // Gateway 请求互斥超时加安全余量 + CardObservationGatewayLockTTL = 16 * time.Minute // 覆盖 300 秒超时、两次网络重试及安全余量 CardObservationGatewayMinInterval = 10 * time.Second // 未配置时的运营商最小请求间隔 ) @@ -64,27 +64,46 @@ const ( // Gateway 轮询 Integration Log 操作编码。 const ( - IntegrationProviderGateway = "gateway" // Gateway 外部系统 - IntegrationOperationGatewayRealname = "query_realname_status" // 查询实名状态 - IntegrationOperationGatewayTraffic = "query_flow" // 查询流量 - IntegrationOperationGatewayNetwork = "query_card_status" // 查询网络状态 + IntegrationProviderGateway = "gateway" // Gateway 外部系统 + IntegrationOperationGatewayRealname = "query_realname_status" // 查询实名状态 + IntegrationOperationGatewayTraffic = "query_flow" // 查询流量 + IntegrationOperationGatewayNetwork = "query_card_status" // 查询网络状态 + IntegrationOperationGatewayDeviceInfo = "query_device_info" // 查询设备信息 ) // 卡实名观测场景 const ( - CardObservationSceneRealnamePolling = "realname_polling" // 实名轮询 - CardObservationSceneTrafficPolling = "traffic_polling" // 流量轮询 - CardObservationSceneNetworkPolling = "network_polling" // 网络状态轮询 - CardObservationSceneManualRefresh = "manual_refresh" // 手动刷新 - CardObservationSceneCarrierCallback = "carrier_realname_callback" // 运营商实名回调 + CardObservationSceneRealnamePolling = "realname_polling" // 实名轮询 + CardObservationSceneTrafficPolling = "traffic_polling" // 流量轮询 + CardObservationSceneNetworkPolling = "network_polling" // 网络状态轮询 + CardObservationSceneManualRefresh = "manual_refresh" // 手动刷新 + 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 事件 const ( - OutboxEventTypeCardRealnameChanged = "card.realname.changed" // 卡实名状态变化 - CardRealnameChangedPayloadVersionV1 = 1 // 卡实名状态变化载荷版本 - OutboxEventTypeCardTrafficIncremented = "card.traffic.incremented" // 卡流量正增量 - CardTrafficIncrementedPayloadVersionV1 = 1 // 卡流量正增量载荷版本 - OutboxEventTypeCardNetworkChanged = "card.network.changed" // 卡网络状态变化 - CardNetworkChangedPayloadVersionV1 = 1 // 卡网络状态变化载荷版本 + OutboxEventTypeCardRealnameChanged = "card.realname.changed" // 卡实名状态变化 + CardRealnameChangedPayloadVersionV1 = 1 // 卡实名状态变化载荷版本 + OutboxEventTypeCardTrafficIncremented = "card.traffic.incremented" // 卡流量正增量 + CardTrafficIncrementedPayloadVersionV1 = 1 // 卡流量正增量载荷版本 + OutboxEventTypeCardNetworkChanged = "card.network.changed" // 卡网络状态变化 + CardNetworkChangedPayloadVersionV1 = 1 // 卡网络状态变化载荷版本 + OutboxEventTypeCardSeriesRequested = "card.observation.series.requested" // 业务成功请求卡观测序列 + CardSeriesRequestedPayloadVersionV1 = 1 // 业务成功观测序列载荷版本 ) diff --git a/pkg/constants/integration_log.go b/pkg/constants/integration_log.go index dc23011..559d947 100644 --- a/pkg/constants/integration_log.go +++ b/pkg/constants/integration_log.go @@ -1,5 +1,7 @@ package constants +import "time" + const ( // IntegrationDirectionInbound 表示外部系统调用本系统。 IntegrationDirectionInbound = "inbound" @@ -7,6 +9,25 @@ const ( 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 ( // IntegrationResultPending 表示外部尝试已建立但尚未终结。 IntegrationResultPending = "pending" @@ -20,6 +41,8 @@ const ( IntegrationResultNotFound = "not_found" // IntegrationResultInvalidPayload 表示入站载荷无效。 IntegrationResultInvalidPayload = "invalid_payload" + // IntegrationResultConflict 表示入站事实存在唯一性或幂等冲突。 + IntegrationResultConflict = "conflict" // IntegrationResultIgnored 表示外部尝试被安全忽略。 IntegrationResultIgnored = "ignored" // IntegrationResultMerged 表示尝试被合并且未发送请求。 @@ -41,6 +64,7 @@ func IntegrationResultName(result string) string { IntegrationResultUnknown: "结果未知", IntegrationResultNotFound: "未找到", IntegrationResultInvalidPayload: "无效载荷", + IntegrationResultConflict: "冲突", IntegrationResultIgnored: "已忽略", IntegrationResultMerged: "已合并", IntegrationResultRateLimited: "已限频", diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index 52accf2..2d82c01 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -494,6 +494,11 @@ func RedisCardObservationAttemptKey(seriesID string, attempt int) string { 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 请求的互斥键。 func RedisCardObservationInflightKey(provider, syncType, resourceID string) string { return fmt.Sprintf("cardsync:inflight:%s:%s:%s", provider, syncType, resourceID) diff --git a/pkg/constants/shop_series_grant.go b/pkg/constants/shop_series_grant.go new file mode 100644 index 0000000..9df3942 --- /dev/null +++ b/pkg/constants/shop_series_grant.go @@ -0,0 +1,6 @@ +package constants + +const ( + // ShopSeriesGrantMaxPackages 系列授权单次最多处理的套餐数量。 + ShopSeriesGrantMaxPackages = 100 +) diff --git a/pkg/constants/system_config.go b/pkg/constants/system_config.go index eeb0a14..e13a262 100644 --- a/pkg/constants/system_config.go +++ b/pkg/constants/system_config.go @@ -16,6 +16,16 @@ const ( SystemConfigTypeJSON = "json" // SystemConfigCacheTTL 是系统配置默认缓存时长。 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 缓存键。 diff --git a/pkg/logger/access_policy.go b/pkg/logger/access_policy.go index 0797e64..fc3df42 100644 --- a/pkg/logger/access_policy.go +++ b/pkg/logger/access_policy.go @@ -34,6 +34,10 @@ var ( Name: "wecom_callback", Sensitive: true, 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{ Name: "sensitive_config", Sensitive: true, PresenceOnly: true, SafeFields: fieldSet("config_key", "value_type", "module", "readonly", "sensitive", "configured", "status", "result_code"), @@ -64,6 +68,8 @@ func policyForPath(path string) AccessPolicy { return configPolicy case strings.Contains(normalized, "/callback") && (strings.Contains(normalized, "wecom") || strings.Contains(normalized, "wework")): return wecomPolicy + case strings.Contains(normalized, "/callback/carriers/"): + return carrierCallbackPolicy case strings.Contains(normalized, "payment"), strings.Contains(normalized, "wechat-pay"), strings.Contains(normalized, "alipay"), strings.Contains(normalized, "fuiou-pay"): return paymentPolicy diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index e8536ae..6856a2f 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -54,6 +54,10 @@ func BuildDocHandlers() *bootstrap.Handlers { AdminOrder: admin.NewOrderHandler(nil, nil), AdminExchange: admin.NewExchangeHandler(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), PollingConcurrency: admin.NewPollingConcurrencyHandler(nil), PollingMonitoring: admin.NewPollingMonitoringHandler(nil), diff --git a/pkg/queue/card_observation_series.go b/pkg/queue/card_observation_series.go index 6513410..80847ed 100644 --- a/pkg/queue/card_observation_series.go +++ b/pkg/queue/card_observation_series.go @@ -9,6 +9,7 @@ import ( cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" "github.com/break/junhong_cmp_fiber/pkg/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" ) // CardObservationSeriesScheduler 将固定阶梯任务提交到独立队列。 @@ -23,6 +24,9 @@ func NewCardObservationSeriesScheduler(client *Client) *CardObservationSeriesSch // Enqueue 以稳定 series_id + attempt 任务 ID 零重试入队。 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) err := s.client.EnqueueTask( ctx, diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 6a7d122..40bd43f 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -228,6 +228,7 @@ func (h *Handler) registerPollingHandlers() { h.pollingBase, h.workerResult.Stores.IotCard, h.pollingStopResumeSvc) protectHandler := task.NewPollingProtectHandler( + h.db, h.workerResult.Services.ObservationSeriesEvents, h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard, h.workerResult.Stores.DeviceSimBinding, h.pollingStopResumeSvc) cardStatusHandler := task.NewPollingCardStatusHandler( @@ -295,6 +296,7 @@ func (h *Handler) registerAutoPurchaseHandler() { h.redis, h.asynqClient, h.logger, + h.workerResult.Services.ObservationSeriesEvents, ) h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask) h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge)) diff --git a/pkg/queue/types.go b/pkg/queue/types.go index 9b6e243..7b57ba1 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -54,17 +54,18 @@ type WorkerStores struct { // WorkerServices Worker 侧所有 Service 的集合 type WorkerServices struct { - CardObservation *cardObservationApp.Service - CardObservationSeries *cardObservationApp.SeriesAttemptService - CommissionCalculation *commission_calculation.Service - CommissionStats *commission_stats.Service - UsageService *packagepkg.UsageService - ActivationService *packagepkg.ActivationService - ResetService *packagepkg.ResetService - AlertService *pollingSvc.AlertService - CleanupService *pollingSvc.CleanupService - StopResumeService packagepkg.StopResumeCallback // 停复机服务,用于注入 Scheduler - OrderExpirer OrderExpirer // 订单超时取消服务(接口类型,避免循环依赖) + CardObservation *cardObservationApp.Service + CardObservationSeries *cardObservationApp.SeriesAttemptService + ObservationSeriesEvents cardObservationApp.SeriesEventWriter + CommissionCalculation *commission_calculation.Service + CommissionStats *commission_stats.Service + UsageService *packagepkg.UsageService + ActivationService *packagepkg.ActivationService + ResetService *packagepkg.ResetService + AlertService *pollingSvc.AlertService + CleanupService *pollingSvc.CleanupService + StopResumeService packagepkg.StopResumeCallback // 停复机服务,用于注入 Scheduler + OrderExpirer OrderExpirer // 订单超时取消服务(接口类型,避免循环依赖) } // WorkerBootstrapResult Worker Bootstrap 结果