2 Commits

Author SHA1 Message Date
5c4d17e9fc 收口七月卡状态回调与系列授权兼容契约
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
完成运营商实名回调、业务事件观测序列与受控配置装配,同时恢复 UR43 已交付的 packages[].remove 字段及旧响应兼容,统一更新 OpenSpec、OpenAPI 和交付文档。

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

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

Confidence: high

Scope-risk: broad

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

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

Not-tested: 按本 Change 约定未运行 go test,真实运营商与 Gateway 联调延期
2026-07-24 19:59:24 +08:00
a18ed8bc8d 暂存一下,防止丢失 2026-07-24 16:07:18 +08:00
234 changed files with 17869 additions and 2395 deletions

View File

@@ -24,6 +24,26 @@
| 外部回调、轮询和 Gateway 调用 | 状态变化、人工触发、连续失败或高风险异常时必须 | 业务状态仍在领域表 | 每次实际或未发送尝试都必须 | 需要可靠后续处理时必须 |
| Domain 方法 | 不直接依赖审计基础设施,由 Application 写入 | 维护业务不变量 | 由 Application/Adapter 负责 | 只记录领域事件,由 Application 持久化 |
## 七月测试环境冻结期增量登记
`complete-july-iteration-test-release` 已明确把 Audit Event Writer 与发布门禁延期到任务 6.5。以下登记仅说明测试环境阶段的临时分类不代表生产评审通过也不得删除通知事实、Access Log 或公共 Outbox
| 入口 | Audit Event | Domain Ledger | Integration Log | Outbox |
|---|---|---|---|---|
| 明确后台账号通知事件消费与幂等写入 | N/A测试环境冻结生产前由 6.5 重新评审通知失败与系统告警治理) | `tb_notification` 是通知投递与接收人已读状态的权威事实 | N/A无外部系统调用 | 消费公共 Outbox 的稳定事件,不复制 Outbox |
| 当前后台账号单条通知已读 | N/A低风险个人阅读状态普通已读操作只进入 Access Log | `tb_notification.is_read/read_at` 是权威状态 | N/A | N/A |
| 当前后台账号未读数与基础列表 | N/A普通读取不返回其他接收人数据或敏感业务正文 | 只读 `tb_notification` 投影 | N/A | N/A |
| 代理主钱包订单统一扣款 | 延期(测试环境冻结;生产前由 6.5 为资金变更补齐同事务 Audit Event | `tb_order``tb_agent_wallet``tb_agent_wallet_transaction``tb_payment` 与套餐使用记录在同一事务形成权威事实 | N/A不调用外部系统 | 同事务写入 `wallet.agent_main.debited`,为余额预警等后续消费者提供稳定事实 |
| 代理主钱包订单资金预占、释放与完成扣除 | 延期(测试环境冻结;生产前由 6.5 为冻结及终态变更补齐同事务 Audit Event | `tb_agent_wallet_reservation` 是预占金额、付款钱包与唯一终态的权威事实;钱包与完成扣除流水同事务更新 | N/A不调用外部系统 | 同事务写入 `wallet.agent_main.reservation.changed`;完成扣除同时写入 `wallet.agent_main.debited`,消费者按权威事实幂等确认 |
| 代理主钱包充值与人工调整正向入账 | 延期(测试环境冻结;生产前由 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/AAudit Event 已移出本 Change运营商来源真实性验证也不在本票边界 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/ignored/not_found/conflict/success/failed` 终态 | 实名事实首次变化时由公共 `ApplyCardObservation` 同事务写入实名状态变化 Outbox重复成功不重复写事件 |
| 移动实名成功回调 | N/AAudit Event 已移出本 Change不接入旧平台登录、MSISDN 补查或来源真实性验证) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed` 终态,并通过 pending 租约恢复中断处理 | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通实名成功回调 | N/AAudit Event 已移出本 Change不复制旧 `inner_callback`、第三方推送或 Gateway 二次确认) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实;上游 `dateChanged` 只用于幂等和留痕 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed`,关闭时记录 `ignored` | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通解除实名回调 | N/A只识别并留痕外部解除通知不把单次回调作为本地实名逆转事实 | `tb_iot_card` 保持原实名状态、首次实名时间、检查时间及逆转计数,回调不写领域事实 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/ignored/failed` 终态并支持 pending 租约恢复 | N/A不调用公共实名观测不产生状态变化、停机或套餐事件 |
## 旧 Writer 与旧表写入口清单
### 旧账号审计

View File

@@ -1,6 +1,6 @@
# PRDTECH 公共站内通知与受控跳转
Status: ready-for-agent
Status: 后端代码与契约完成、验证延期
---

View File

@@ -4,18 +4,17 @@
**Blocked by:** `.scratch/tech-public-foundation/issues/03-outbox-at-least-once-delivery.md` — 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为简单写 Application辅助通道为 Infrastructure 与 Query。
**完整业务边界:** 本票收口后台明确账号通知的存储、受控类型注册、Worker 幂等消费、未读数、基础列表和单条已读闭环。明确不实现角色或店铺动态接收人、个人客户通知、分类汇总、全部已读、目标解析、前端组件或具体业务场景触发规则,也不复制公共 Outbox 和 Relay。
- [ ] 通知事实包含稳定事件 ID、接收人类型与 ID、类别、类型、级别、纯文本标题正文、受控资源引用、已读与过期时间并通过事件 ID、接收人类型和接收人 ID 唯一约束防止重复消费。
- [ ] 通知常量、中文说明、类型到类别、默认级别、模板和允许目标的注册关系统一管理;未注册类型、模板字段永久缺失或正文包含禁止敏感内容时不生成残缺通知。
- [ ] Worker 只接受结构化载荷,重复事件和并发消费最多为同一后台账号生成一条通知;瞬时数据库错误返回任务错误,原业务事务不因通知写入失败而回滚。
- [ ] 当前后台账号可以获得准确的 `count:int64``display_count:string`,其中 0、199、100 以上分别显示 `0`、十进制文本和 `99+`,且未读数只查询 PostgreSQL。
- [ ] 后台列表只读取当前认证账号的未过期通知,固定按创建时间和 ID 倒序,默认每页 20、最大 50并返回统一响应与 ISO 8601 时间。
- [ ] 单条已读使用接收人条件和未读条件更新;别人通知、不存在通知和已读通知均幂等成功,首次写入的 `read_at` 在重复请求中保持不变。
- [ ] PostgreSQL、Worker 和真实后台认证 HTTP 集成测试覆盖唯一约束、重复消费、过期排除、分页排序、接收人篡改、越权隔离及重复已读。
- [ ] 新增后台 Handler 后完成路由、RouteSpec 和两个 OpenAPI 文档生成器注册,且静态路由顺序不会被动态通知 ID 路由吞掉。
- [x] 通知事实包含稳定事件 ID、接收人类型与 ID、类别、类型、级别、纯文本标题正文、受控资源引用、已读与过期时间并通过事件 ID、接收人类型和接收人 ID 唯一约束防止重复消费。
- [x] 通知常量、中文说明、类型到类别、默认级别、模板和允许目标的注册关系统一管理;未注册类型、模板字段永久缺失或正文包含禁止敏感内容时不生成残缺通知。
- [x] Worker 只接受结构化载荷,重复事件和并发消费最多为同一后台账号生成一条通知;瞬时数据库错误返回任务错误,原业务事务不因通知写入失败而回滚。
- [x] 当前后台账号可以获得准确的 `count:int64``display_count:string`,其中 0、199、100 以上分别显示 `0`、十进制文本和 `99+`,且未读数只查询 PostgreSQL。
- [x] 后台列表只读取当前认证账号的未过期通知,固定按创建时间和 ID 倒序,默认每页 20、最大 50并返回统一响应与 ISO 8601 时间。
- [x] 单条已读使用接收人条件和未读条件更新;别人通知、不存在通知和已读通知均幂等成功,首次写入的 `read_at` 在重复请求中保持不变。
- [ ] PostgreSQL、Worker 和真实后台认证 HTTP 集成测试覆盖唯一约束、重复消费、过期排除、分页排序、接收人篡改、越权隔离及重复已读。(按本 Change 测试环境豁免转任务 6.1
- [x] 新增后台 Handler 后完成路由、RouteSpec 和两个 OpenAPI 文档生成器注册,且静态路由顺序不会被动态通知 ID 路由吞掉。

View File

@@ -4,17 +4,16 @@
**Blocked by:** 01 — 向明确后台账号可靠投递首条站内通知
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为 Query辅助通道为简单写 Application。
**完整业务边界:** 本票收口后台通知中心所需的筛选、固定分类汇总和批量已读用例。明确不实现个人客户接口、动态接收人、目标跳转、前端页面、Redis 未读计数或管理员查看他人通知能力。
- [ ] 列表支持类别、类型、严重级别、已读状态、页码和每页数量组合过滤,所有条件使用 AND 语义并保持创建时间、ID 倒序。
- [ ] 非法类别、严重级别、已读参数或越界分页返回统一参数错误,不向客户端拼接底层校验信息。
- [ ] 未读汇总固定返回 `total``approval``expiry``sync``system`,过期通知不计入任何分类。
- [ ] 全部已读在类别为空时更新当前账号全部未过期未读通知,在类别有效时只更新该类别,并返回实际更新数量。
- [ ] 批量更新使用当前接收人、未读状态、未过期和可选类别条件;重复调用返回零更新且保持成功,不覆盖既有 `read_at`
- [ ] `/read-all` 等静态路由先于 `/{id}` 动态路由注册,生成的 OpenAPI 与真实路由、请求参数和响应结构一致。
- [ ] PostgreSQL 与真实后台认证 HTTP 集成测试覆盖组合筛选、固定汇总、最大分页、过期排除、非法类别、并发批量已读及无法操作他人通知。
- [x] 列表支持类别、类型、严重级别、已读状态、页码和每页数量组合过滤,所有条件使用 AND 语义并保持创建时间、ID 倒序。
- [x] 非法类别、严重级别、已读参数或越界分页返回统一参数错误,不向客户端拼接底层校验信息。
- [x] 未读汇总固定返回 `total``approval``expiry``sync``system`,过期通知不计入任何分类。
- [x] 全部已读在类别为空时更新当前账号全部未过期未读通知,在类别有效时只更新该类别,并返回实际更新数量。
- [x] 批量更新使用当前接收人、未读状态、未过期和可选类别条件;重复调用返回零更新且保持成功,不覆盖既有 `read_at`
- [x] `/read-all` 等静态路由先于 `/{id}` 动态路由注册,生成的 OpenAPI 与真实路由、请求参数和响应结构一致。
- [ ] PostgreSQL 与真实后台认证 HTTP 集成测试覆盖组合筛选、固定汇总、最大分页、过期排除、非法类别、并发批量已读及无法操作他人通知。(按本 Change 测试环境豁免转任务 6.1

View File

@@ -4,17 +4,16 @@
**Blocked by:** 01 — 向明确后台账号可靠投递首条站内通知
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为 Query辅助通道为简单写 Application 与 Infrastructure。
**完整业务边界:** 本票收口个人客户通知的投递、读取和已读闭环,复用既有通知表、注册表和 Worker。明确不实现 C 端分类汇总、后台动态接收人、平台运维消息展示、C 端受控目标接口或前端组件。
- [ ] Worker 能以 `personal_customer` 接收人类型和稳定客户 ID 幂等生成通知,同一事件的后台账号与个人客户通知相互独立。
- [ ] 个人客户通知类型注册明确允许的业务类别和资源引用;`sync``system` 及未对 C 端开放的类型不会出现在个人客户查询中。
- [ ] C 端未读数遵循 0、199、100 以上的显示规则,列表固定倒序、默认每页 20、最大 50并排除过期通知。
- [ ] 单条已读、全部已读只作用于当前认证客户;不存在、已删除、属于别人或已读的通知使用相同幂等安全语义。
- [ ] 请求 DTO 不接受接收人 ID额外或恶意接收人参数不能改变查询与更新范围。
- [ ] 真实个人客户认证、Handler、Query、GORM 集成测试覆盖重复投递、运维类别隔离、跨客户越权、分页、过期排除、重复已读和批量已读。
- [ ] 新增 C 端 Handler 后同步个人客户路由、RouteSpec 与两个 OpenAPI 文档生成器,接口统一挂载在约定认证上下文中。
- [x] Worker 能以 `personal_customer` 接收人类型和稳定客户 ID 幂等生成通知,同一事件的后台账号与个人客户通知相互独立。
- [x] 个人客户通知类型注册明确允许的业务类别和资源引用;`sync``system` 及未对 C 端开放的类型不会出现在个人客户查询中。
- [x] C 端未读数遵循 0、199、100 以上的显示规则,列表固定倒序、默认每页 20、最大 50并排除过期通知。
- [x] 单条已读、全部已读只作用于当前认证客户;不存在、已删除、属于别人或已读的通知使用相同幂等安全语义。
- [x] 请求 DTO 不接受接收人 ID额外或恶意接收人参数不能改变查询与更新范围。
- [ ] 真实个人客户认证、Handler、Query、GORM 集成测试覆盖重复投递、运维类别隔离、跨客户越权、分页、过期排除、重复已读和批量已读。(按本 Change 测试环境豁免转任务 6.1
- [x] 新增 C 端 Handler 后同步个人客户路由、RouteSpec 与两个 OpenAPI 文档生成器,接口统一挂载在约定认证上下文中。

View File

@@ -5,19 +5,20 @@
**Blocked by:**
- 01 — 向明确后台账号可靠投递首条站内通知
- `.scratch/ur96-shop-business-owner/issues/06-shop-business-owner-notification-recipient-release.md` — 06 — 提供业务员通知接收人解析并完成发布验证
- `.scratch/ur96-shop-business-owner/issues/06-business-owner-recipient-resolution-and-release.md` — 06 — 提供业务员通知接收人解析并完成发布验证
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为 Infrastructure Adapter辅助通道为简单写 Application。
**完整业务边界:** 本票收口公共通知 Worker 对明确申请人、平台角色和店铺接收人的解析、可用性复核、去重及无接收人语义。明确不实现 UR#33、UR#97 或企微审批的业务触发规则,不改变账号、角色、店铺层级或业务员归属,不自动转派历史通知,也不发送短信或企微消息。
- [ ] 明确申请人场景只使用业务事件携带的稳定系统账号 ID并在消费时跳过已停用或软删除账号不使用企微代提交身份替代真实业务提交人。
- [ ] 角色场景批量解析当前启用、未删除且仍持有指定平台角色的账号,结果按稳定账号 ID 去重,不按用户名或手机号投递。
- [ ] 店铺场景解析当前启用的店铺主账号,并复用 UR#96 接缝解析当前可用业务员;不沿父店铺、祖先店铺或代理数据权限向上扩散。
- [ ] 同一账号同时以主账号、业务员或角色命中时只生成一条通知,同一事件的其他接收人仍分别拥有独立已读状态。
- [ ] 暂无可用接收人记为 `no_recipient` 并成功结束,不进入无限重试;数据库等瞬时错误继续返回任务错误。
- [ ] 已生成通知不会因账号关系后续变化而转移给新接收人,历史接收人仍可在自身认证上下文中读取原通知。
- [x] 明确申请人场景只使用业务事件携带的稳定系统账号 ID并在消费时跳过已停用或软删除账号不使用企微代提交身份替代真实业务提交人。
- [x] 角色场景批量解析当前启用、未删除且仍持有指定平台角色的账号,结果按稳定账号 ID 去重,不按用户名或手机号投递。
- [x] 店铺场景解析当前启用的店铺主账号,并复用 UR#96 接缝解析当前可用业务员;不沿父店铺、祖先店铺或代理数据权限向上扩散。
- [x] 同一账号同时以主账号、业务员或角色命中时只生成一条通知,同一事件的其他接收人仍分别拥有独立已读状态。
- [x] 暂无可用接收人记为 `no_recipient` 并成功结束,不进入无限重试;数据库等瞬时错误继续返回任务错误。
- [x] 已生成通知不会因账号关系后续变化而转移给新接收人,历史接收人仍可在自身认证上下文中读取原通知。
- [ ] Application、Worker 和 PostgreSQL 集成测试覆盖申请人、角色批量解析、店铺主账号与业务员去重、停用、软删除、关系失效、无接收人及重复投递。
自动化与真实 PostgreSQL/Redis/Asynq 验证按本 Change 测试环境豁免转任务 6.1、6.3;本票已完成 `gofmt``git diff --check``go build ./...` 代码门禁。

View File

@@ -7,17 +7,18 @@
- 01 — 向明确后台账号可靠投递首条站内通知
- `.scratch/tech-global-audit/issues/13-request-correlation-integration-timeline.md` — 13 — 交付请求、业务链路和外部集成时间线
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为 Query辅助通道为 Application + Port/Adapter。
**完整业务边界:** 本票收口后台通知受控目标注册、解析、当前权限复核和安全不可用语义。明确不返回 URL、不实现前端路由构造、不把通知所有权当成目标资源权限、不创建独立卡同步执行页也不迁移各目标业务详情的既有授权规则。
- [ ] 通知只保存受控资源类型、数值 ID 或稳定 Key目标响应只包含白名单 `target_type`、结构化 `target_id/target_key``available`,任何字段均不能承载任意 URL。
- [ ] 第一版注册表至少覆盖退款详情、代理充值详情、企微审批详情、卡详情、设备详情、临期资产列表、店铺资金概况、审计外部集成和系统配置。
- [ ] 目标解析先按当前接收人固定查询通知,再调用对应业务权限 Adapter 复核资源;拥有通知不授予目标资源访问权。
- [ ] 别人通知、不存在通知和已删除通知不泄露通知事实;目标不存在、已删除或当前无权时统一返回 `available=false`,不返回资源差异信息。
- [ ] `card_sync` 等同步消息解析为统一审计中心外部集成目标并携带受控资源或 Integration Log 标识,不指向不存在的同步执行页。
- [ ] 未知通知引用或尚未支持的目标只允许展示正文,不产生开放重定向、自由路径或自动回退 URL。
- [x] 通知只保存受控资源类型、数值 ID 或稳定 Key目标响应只包含白名单 `target_type`、结构化 `target_id/target_key``available`,任何字段均不能承载任意 URL。
- [x] 第一版注册表至少覆盖退款详情、代理充值详情、企微审批详情、卡详情、设备详情、临期资产列表、店铺资金概况、审计外部集成和系统配置。
- [x] 目标解析先按当前接收人固定查询通知,再调用对应业务权限 Adapter 复核资源;拥有通知不授予目标资源访问权。
- [x] 别人通知、不存在通知和已删除通知不泄露通知事实;目标不存在、已删除或当前无权时统一返回 `available=false`,不返回资源差异信息。
- [x] `card_sync` 等同步消息解析为统一审计中心外部集成目标并携带受控资源或 Integration Log 标识,不指向不存在的同步执行页。
- [x] 未知通知引用或尚未支持的目标只允许展示正文,不产生开放重定向、自由路径或自动回退 URL。
- [ ] 契约与越权测试覆盖全部白名单、未知引用、通知越权、目标删除、权限变化、外部集成目标,并断言响应和持久化数据不存在任意 URL。
代理充值与企微审批目标类型已预注册,但其下游业务表和权限 Adapter 尚未由后续任务交付,因此当前安全返回 `available=false`。自动化与真实 PostgreSQL/HTTP 验证按本 Change 测试环境豁免转任务 6.1;本票已完成 OpenAPI 生成、`gofmt``git diff --check``go build ./...` 代码门禁。

View File

@@ -8,17 +8,18 @@
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
- `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为 Infrastructure辅助通道为简单写 Application。
**完整业务边界:** 本票收口通知展示期限、数据保留、分批清理、失败分类和统一审计/外部集成可观测接缝。明确不删除 Audit Event、Integration Log、领域流水或 Outbox不提供用户删除接口不建设管理员查看他人消息入口也不改变公共 Relay 的租约算法。
- [ ] 套餐临期、审批结果、同步异常和系统告警按约定计算展示与保留期限,审批结果不自动过期,系统告警展示期限不超过允许上限。
- [ ] 清理任务按时间和稳定主键小批量删除超过数据保留期限的通知,可中断重跑且只清理通知事实,不级联业务资源或审计记录。
- [ ] 暂无接收人记录 `no_recipient` 后成功结束;数据库、队列等瞬时错误按有限策略重试;模板永久缺失达到最大重试后进入失败监控且不写残缺正文。
- [ ] 系统告警直接入队时必须携带预先生成的稳定事件 ID重复执行仍由通知唯一键防重。
- [x] 套餐临期、审批结果、同步异常和系统告警按约定计算展示与保留期限,审批结果不自动过期,系统告警展示期限不超过允许上限。
- [x] 清理任务按时间和稳定主键小批量删除超过数据保留期限的通知,可中断重跑且只清理通知事实,不级联业务资源或审计记录。
- [x] 暂无接收人记录 `no_recipient` 后成功结束;数据库、队列等瞬时错误按有限策略重试;模板永久缺失达到最大重试后进入失败监控且不写残缺正文。
- [x] 系统告警直接入队时必须携带预先生成的稳定事件 ID重复执行仍由通知唯一键防重。
- [ ] 模板或接收人解析失败、系统告警生成和管理性排查写入统一 Audit/Integration 接缝;普通通知读取和已读只进入 Access Log。
- [ ] 日志、监控和审计只记录事件 ID、通知类型、失败类别、计数及安全资源标识不记录敏感模板数据、完整回调、Token、Secret 或任意长期 URL。
- [x] 日志、监控和审计只记录事件 ID、通知类型、失败类别、计数及安全资源标识不记录敏感模板数据、完整回调、Token、Secret 或任意长期 URL。
- [ ] 测试覆盖各类期限边界、分批清理可重入、无接收人、瞬时失败、永久模板失败、最大重试、系统告警重复入队和业务事实不回滚。
统一 Audit Event/Integration 管理性写入按本 Change 冻结转任务 6.5,不阻塞测试环境代码交付;普通 HTTP 仍由 Access Log 覆盖。自动化与真实 PostgreSQL/Redis/Asynq 验证转任务 6.1、6.3。本票已完成 `gofmt``git diff --check``go build ./...` 代码门禁。

View File

@@ -8,17 +8,18 @@
- 03 — 向个人客户投递并提供简化通知中心
- 05 — 交付通知受控目标解析与权限复核
**Status:** ready-for-agent
**Status:** 契约完成、前端与人工验收延期
**架构通道:** 主通道为 Query/API 跨仓契约,辅助通道为前端验收契约。
**完整业务边界:** 本票收口当前后端仓库能够交付的 OpenAPI、交互状态、目标白名单说明和验收数据不在本仓库实现前端组件。明确不引入 WebSocket/SSE不承诺 Redis 未读计数,不为未知目标提供自由 URL也不代替前端仓库自身的组件测试。
- [ ] 契约明确布局挂载后立即请求、每 30 秒刷新、页面不可见暂停、恢复立即刷新,以及失败保留上次成功未读数且不闪回零。
- [ ] 徽标验收覆盖 0、1、99、1000 时隐藏、199 显示数字、100 显示 `99+`,并约定固定宽度避免布局抖动。
- [ ] 后台抽屉按最近 10 条和约定分类展示,通知中心支持类别、类型、严重级别、已读状态、服务端分页及当前类别全部已读。
- [ ] 点击顺序固定为先进入已读视觉状态并调用已读接口,再解析受控目标;已读失败以下次服务端刷新为准,目标失败不恢复未读。
- [ ] 前端目标白名单只根据 `target_type` 和结构化标识构造内部路由,未知类型或 `available=false` 只展示正文且不跳转。
- [ ] C 端契约只展示当前客户相关的审批结果、套餐、订单和资产消息,不暴露后台同步或系统运维分类。
- [ ] OpenAPI、中文契约文档、示例响应与验收矩阵保持一致并明确前端代码位于外部仓库、需按对应仓库流程实施和联调。
- [x] 契约明确布局挂载后立即请求、每 30 秒刷新、页面不可见暂停、恢复立即刷新,以及失败保留上次成功未读数且不闪回零。
- [x] 徽标验收覆盖 0、1、99、1000 时隐藏、199 显示数字、100 显示 `99+`,并约定固定宽度避免布局抖动。
- [x] 后台抽屉按最近 10 条和约定分类展示,通知中心支持类别、类型、严重级别、已读状态、服务端分页及当前类别全部已读。
- [x] 点击顺序固定为先进入已读视觉状态并调用已读接口,再解析受控目标;已读失败以下次服务端刷新为准,目标失败不恢复未读。
- [x] 前端目标白名单只根据 `target_type` 和结构化标识构造内部路由,未知类型或 `available=false` 只展示正文且不跳转。
- [x] C 端契约只展示当前客户相关的审批结果、套餐、订单和资产消息,不暴露后台同步或系统运维分类。
- [x] OpenAPI、中文契约文档、示例响应与验收矩阵保持一致并明确前端代码位于外部仓库、需按对应仓库流程实施和联调。
前端源码、浏览器联调与真实人工验收不在当前仓库,保持延期到任务 6.6。

View File

@@ -13,7 +13,7 @@
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
- `.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md` — 19 — 执行一次性审计切换与停机发布门禁
**Status:** ready-for-agent
**Status:** 代码与发布契约完成、验证延期
**架构通道:** 主通道为 Infrastructure辅助通道为 Application、Query 与跨仓契约。
@@ -22,8 +22,10 @@
- [ ] 空数据库和兼容环境可执行通知正向迁移、索引校验与允许的结构回滚;已有通知事实后不得通过降级删表清理,应用回滚允许保留数据。
- [ ] PostgreSQL、Redis、公共 Relay 和 Asynq 端到端测试覆盖事务事件、至少一次投递、入队成功后重复、并发 Worker、多接收人、接收人去重和最终通知唯一性。
- [ ] 真实后台与个人客户认证测试覆盖所有公开接口、统一响应、静态路由顺序、接收人不可篡改、跨用户隔离、过期排除和受控目标权限变化。
- [ ] 运行门禁覆盖 Worker 失败、永久模板错误、无接收人、Outbox 积压、清理滞后和审计/外部集成记录异常,并给出停止放量和恢复步骤。
- [ ] 下游接入契约明确稳定事件 ID、结构化载荷、受控通知类型、接收人解析、模板字段、过期策略和目标引用调用统一队列客户端时禁止传预序列化字节。
- [ ] 发布顺序明确为迁移与校验、Worker 与监控、后端 API、前端、下游生产者下游不得在消费者和监控就绪前制造不可见积压。
- [ ] 新增管理端和 C 端 Handler 已同步路由、RouteSpec、两个 OpenAPI 文档生成器,并生成 OpenAPI 核对 `/read-all` 未被动态 ID 路由吞掉。
- [ ] 中文功能总结覆盖关键流程、前后端契约、异常闭环、监控、发布回滚和待决策项README 增加入口;测试数据使用隔离标识且不向真实用户生成通知。
- [x] 运行门禁覆盖 Worker 失败、永久模板错误、无接收人、Outbox 积压、清理滞后和审计/外部集成记录异常,并给出停止放量和恢复步骤。
- [x] 下游接入契约明确稳定事件 ID、结构化载荷、受控通知类型、接收人解析、模板字段、过期策略和目标引用调用统一队列客户端时禁止传预序列化字节。
- [x] 发布顺序明确为迁移与校验、Worker 与监控、后端 API、前端、下游生产者下游不得在消费者和监控就绪前制造不可见积压。
- [x] 新增管理端和 C 端 Handler 已同步路由、RouteSpec、两个 OpenAPI 文档生成器,并生成 OpenAPI 核对 `/read-all` 未被动态 ID 路由吞掉。
- [x] 中文功能总结覆盖关键流程、前后端契约、异常闭环、监控、发布回滚和待决策项README 增加入口;测试数据使用隔离标识且不向真实用户生成通知。
迁移演练、真实 PostgreSQL/Redis/Relay/Asynq、真实认证 HTTP 与并发验证按本 Change 豁免保持未勾选,并转任务 6.1、6.3;前端人工验收转 6.6。当前已完成 OpenAPI 生成、`gofmt``git diff --check``go build ./...` 与 OpenSpec 校验。

View File

@@ -8,23 +8,23 @@ Status: ready-for-agent
代理系列授权及套餐授权的数据结构和接口已经存在,首次授权也已经能够在同一事务中创建系列授权与多条套餐授权。但前端没有把现有系列套餐列表和授权详情正确组合成批量选择视图,后续追加套餐时不容易区分未授权与已授权,也容易混淆上级当前成本、目标代理授权成本和建议零售价。
现有 `PUT /api/admin/shop-series-grants/{id}/packages` 还将新增、改价和移除混在同一请求中:已经授权的套餐再次提交不同价格会被直接改价。这会使一个看似“新增授权”的并发请求静默覆盖其他运营人员刚设置的成本价,业务意图和审计语义均不明确
现有 `PUT /api/admin/shop-series-grants/{id}/packages` 已通过每个套餐项的 `remove` 字段支持新增、改价和移除,其中 `remove=true` 的软删除语义已经交付并被前端使用。本需求必须在增强批量原子性、权限和价格边界时保持该字段兼容,不能将它删除或改成必填的新顶层命令字段
## Solution
复用现有套餐列表和系列授权详情,不新增候选 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` 负责;读取复用现有套餐列表和授权详情,不新增候选接口。

View File

@@ -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` 兼容、并发新增、全量回滚、价格历史、错误码和越权安全语义。

View File

@@ -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` 契约兼容核查、上线前数据异常核查和保留业务事实的回滚说明。

View File

@@ -1,6 +1,6 @@
# PRDUR#45 换货资产标识与新旧资产独立搜索
Status: ready-for-agent
Status: done后端— 前端/人工待验收
---
@@ -87,3 +87,4 @@ Status: ready-for-agent
- UR#45 负责“快照规范与列表检索”;资产详情前代/后代关系由 UR#86 提供,店铺继承由 UR#98 提供。
- UR#86 应直接使用这里形成的不可变快照;历史非规范快照保持原样。
- 后端实现、OpenAPI、中文文档及自动化验证已由提交 `55bdc3a` 交付;当前仓库不含前端源码,页面实施与浏览器人工验收仍待前端仓库完成。

View File

@@ -4,19 +4,19 @@
**Blocked by:** None — can start immediately.
**Status:** ready-for-agent
**Status:** done后端— 前端/人工待验收
**架构通道:** 主通道为复杂写,辅助通道为 Infrastructure Adapter。资产解析和权威标识选择作为换货写用例复用能力收口既有 Service 可以作为迁移门面调用该能力,但规则不得继续散落在多个流程分支。
**完整业务边界:** 本票收口资产标识解析、权威快照选择以及物流创建、直接创建、物流发货三个快照写入入口并包含对应自动化测试、OpenAPI 契约和中文发布说明。明确不迁移换货状态机、确认完成、取消、资料迁移、旧资产转新、客户绑定切换等旧逻辑;不回填或改写历史换货单,不依赖 UR#86 或 UR#98 的实现。
- [ ] 使用 ICCID、接入号或虚拟号定位旧 IoT 卡时,新建物流换货单的旧资产快照均为该卡数据库中的完整 ICCID。
- [ ] 直接换货的新旧资产和物流换货发货时的新资产均复用相同规范化能力IoT 卡快照不保存请求原文、接入号或虚拟号。
- [ ] 设备无论通过虚拟号、IMEI 或 SN 定位,快照都按“虚拟号 → IMEI → SN”的优先级选择首个非空稳定标识。
- [ ] 标识解析仍执行既有资产权限、资产类型、状态和并发校验,不扩大可操作资产范围,也不改变换货生命周期规则。
- [ ] 资产不存在、类型不匹配或数据库失败时返回既有统一错误体系中的脱敏错误,不向客户端透出底层错误。
- [ ] 自动化测试覆盖卡的三种输入标识、设备的三种输入标识及设备标识优先级,并分别验证物流创建、直接创建和物流发货的持久化快照。
- [ ] 端到端回归验证三个写入入口产生的快照可由现有换货详情或列表响应读取,且构造的历史非规范快照保持原值、不被自动回填。
- [ ] OpenAPI 中创建换货和物流发货的请求标识说明、响应快照语义与实际实现一致,并完成文档重新生成验证。
- [ ] UR#45 中文功能总结记录卡与设备快照规则、历史数据不回填策略、错误边界、发布与回滚注意事项README 增加对应索引。
- [ ] 所有新增或修改的导出符号、复杂逻辑注释和日志均使用中文,并通过相关 Go 测试与格式检查。
- [x] 使用 ICCID、接入号或虚拟号定位旧 IoT 卡时,新建物流换货单的旧资产快照均为该卡数据库中的完整 ICCID。
- [x] 直接换货的新旧资产和物流换货发货时的新资产均复用相同规范化能力IoT 卡快照不保存请求原文、接入号或虚拟号。
- [x] 设备无论通过虚拟号、IMEI 或 SN 定位,快照都按“虚拟号 → IMEI → SN”的优先级选择首个非空稳定标识。
- [x] 标识解析仍执行既有资产权限、资产类型、状态和并发校验,不扩大可操作资产范围,也不改变换货生命周期规则。
- [x] 资产不存在、类型不匹配或数据库失败时返回既有统一错误体系中的脱敏错误,不向客户端透出底层错误。
- [x] 自动化测试覆盖卡的三种输入标识、设备的三种输入标识及设备标识优先级,并分别验证物流创建、直接创建和物流发货的持久化快照。
- [x] 端到端回归验证三个写入入口产生的快照可由现有换货详情或列表响应读取,且构造的历史非规范快照保持原值、不被自动回填。
- [x] OpenAPI 中创建换货和物流发货的请求标识说明、响应快照语义与实际实现一致,并完成文档重新生成验证。
- [x] UR#45 中文功能总结记录卡与设备快照规则、历史数据不回填策略、错误边界、发布与回滚注意事项README 增加对应索引。
- [x] 所有新增或修改的导出符号、复杂逻辑注释和日志均使用中文,并通过相关 Go 测试与格式检查。

View File

@@ -4,24 +4,24 @@
**Blocked by:** None — can start immediately.
**Status:** ready-for-agent
**Status:** done后端— 前端/人工待验收
**架构通道:** 主通道为 Query。仅将本次明显复杂化的换货列表读取用例收口为查询能力可直接使用 GORM、子查询或固定次数批量查询完成候选解析、权限过滤和 DTO 投影;不得让列表读取经过聚合根或执行写操作。
**完整业务边界:** 本票收口列表请求校验、候选资产解析、新旧资产主键过滤、权限、分页计数、排序、响应投影、错误转换、OpenAPI 发布、性能验证和前端联调验收说明。明确不迁移换货详情及其他读取接口,不创建换货聚合根,不修改换货写侧状态规则,不保留通用 `identifier` 的第二套长期搜索语义;不实现 UR#86 资产前代/后代关系或 UR#98 店铺继承。当前仓库未包含可实施该页面的前端工程,因此前端工作以接口契约和人工验收清单交付,不虚构前端代码改动。
- [ ] 列表请求新增最长 100 字符的 `old_asset_keyword``new_asset_keyword`,并从新契约移除通用 `identifier`;空值不增加对应过滤条件。
- [ ] 仅提供旧资产关键词时只按 `old_asset_type + old_asset_id` 过滤,绝不因新资产命中而返回;仅提供新资产关键词时规则对称。
- [ ] 两个关键词同时提供时按 AND 组合,并与状态、流程类型、创建时间范围和分页条件按 AND 组合。
- [ ] IoT 卡候选支持对 ICCID、接入号和虚拟号做包含匹配设备候选支持对虚拟号、IMEI 和 SN 做包含匹配;候选资产必须排除软删除记录。
- [ ] 换货单通过资产类型和资产主键命中,因此历史非规范快照不回填、不改写,但仍能通过所关联资产的任一受支持标识搜索到。
- [ ] 无候选资产或无换货单命中时返回成功的空分页;候选查询或换货单查询发生数据库错误时返回脱敏 500不得降级为空结果。
- [ ] 最终换货单查询继续排除软删除记录并应用现有店铺数据范围;平台、超级管理员和代理账号的既有可见范围不被候选资产解析绕过。
- [ ] 候选解析和换货单过滤使用数据库子查询或固定次数批量查询,不按换货单逐行反查资产;`total``items` 使用完全相同的过滤条件,结果按创建时间倒序。
- [ ] Handler 对完整请求 DTO 执行校验;非法关键词长度、分页、状态、流程类型或时间参数统一返回 HTTP 400、`code=1001``msg=参数验证失败`,详细原因仅记录中文日志。
- [ ] HTTP 集成测试覆盖仅旧关键词、仅新关键词、双关键词 AND、状态与时间组合、空参数、无匹配、非法参数、历史快照、数据库故障和各类账号数据权限。
- [ ] 响应保持统一外层结构并继续分别返回新旧资产类型、ID、快照标识、状态及状态名称。
- [ ] OpenAPI 中的换货列表只公开 `old_asset_keyword``new_asset_keyword`包含长度限制、AND 语义和中文说明,不再公开通用 `identifier`;重新生成文档并验证请求、响应和错误契约与实现一致。
- [ ] 使用真实 PostgreSQL 和代表性大结果集验证查询次数固定、无逐行资产反查、`total` 与分页结果一致,并记录查询计划或等价证据;性能满足项目列表接口目标。
- [ ] UR#45 中文功能总结补充搜索契约、权限与脱敏错误边界、性能结果、发布回滚方式和前端联调注意事项README 中的 UR#45 索引可以定位该说明。
- [ ] 前端人工验收清单明确要求:将单一资产输入框拆为旧资产和新资产输入框;空值不提交;双条件按 AND 提交;表格不混列新旧资产;前端不解析标识、不本地过滤当前页;清空、分页、空态和失败反馈沿用现有交互。
- [x] 列表请求新增最长 100 字符的 `old_asset_keyword``new_asset_keyword`,并从新契约移除通用 `identifier`;空值不增加对应过滤条件。
- [x] 仅提供旧资产关键词时只按 `old_asset_type + old_asset_id` 过滤,绝不因新资产命中而返回;仅提供新资产关键词时规则对称。
- [x] 两个关键词同时提供时按 AND 组合,并与状态、流程类型、创建时间范围和分页条件按 AND 组合。
- [x] IoT 卡候选支持对 ICCID、接入号和虚拟号做包含匹配设备候选支持对虚拟号、IMEI 和 SN 做包含匹配;候选资产必须排除软删除记录。
- [x] 换货单通过资产类型和资产主键命中,因此历史非规范快照不回填、不改写,但仍能通过所关联资产的任一受支持标识搜索到。
- [x] 无候选资产或无换货单命中时返回成功的空分页;候选查询或换货单查询发生数据库错误时返回脱敏 500不得降级为空结果。
- [x] 最终换货单查询继续排除软删除记录并应用现有店铺数据范围;平台、超级管理员和代理账号的既有可见范围不被候选资产解析绕过。
- [x] 候选解析和换货单过滤使用数据库子查询或固定次数批量查询,不按换货单逐行反查资产;`total``items` 使用完全相同的过滤条件,结果按创建时间倒序。
- [x] Handler 对完整请求 DTO 执行校验;非法关键词长度、分页、状态、流程类型或时间参数统一返回 HTTP 400、`code=1001``msg=参数验证失败`,详细原因仅记录中文日志。
- [x] HTTP 集成测试覆盖仅旧关键词、仅新关键词、双关键词 AND、状态与时间组合、空参数、无匹配、非法参数、历史快照、数据库故障和各类账号数据权限。
- [x] 响应保持统一外层结构并继续分别返回新旧资产类型、ID、快照标识、状态及状态名称。
- [x] OpenAPI 中的换货列表只公开 `old_asset_keyword``new_asset_keyword`包含长度限制、AND 语义和中文说明,不再公开通用 `identifier`;重新生成文档并验证请求、响应和错误契约与实现一致。
- [x] 使用真实 PostgreSQL 和代表性大结果集验证查询次数固定、无逐行资产反查、`total` 与分页结果一致,并记录查询计划或等价证据;性能满足项目列表接口目标。
- [x] UR#45 中文功能总结补充搜索契约、权限与脱敏错误边界、性能结果、发布回滚方式和前端联调注意事项README 中的 UR#45 索引可以定位该说明。
- [x] 前端人工验收清单明确要求:将单一资产输入框拆为旧资产和新资产输入框;空值不提交;双条件按 AND 提交;表格不混列新旧资产;前端不解析标识、不本地过滤当前页;清空、分页、空态和失败反馈沿用现有交互。

View File

@@ -1,6 +1,6 @@
# PRDUR#96 店铺业务员归属、继承与筛选
Status: ready-for-agent
Status: 代码完成、验证延期
---

View File

@@ -4,15 +4,15 @@
**Blocked by:** None — can start immediately
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** Infrastructure。
**完整业务边界:** 本票只建立店铺业务员归属的数据承载和索引能力。明确不实现账号候选校验、创建继承、编辑权限、查询投影、通知发送或账号停用后的级联处理,也不迁移未触碰的店铺旧代码。
- [ ] 店铺数据结构支持可空业务员账号 ID数据库类型、模型类型和空值语义一致。
- [ ] 业务员账号 ID 建立非唯一普通索引,迁移不包含数据库外键、级联动作或存量数据回填。
- [ ] 账号停用、软删除或重新启用不会自动清空或改写店铺保存的业务员 ID。
- [ ] 迁移向上、向下及再次向上均可验证,应用回滚策略允许新增字段及既有归属数据继续保留。
- [ ] PostgreSQL 集成测试验证空值保存、正 ID 保存、索引定义和软删除账号不影响历史关联。
- [ ] 迁移、模型及相关常量和注释遵守项目数据库、Model 与中文注释规范。
- [x] 店铺数据结构支持可空业务员账号 ID数据库类型、模型类型和空值语义一致。
- [x] 业务员账号 ID 建立非唯一普通索引,迁移不包含数据库外键、级联动作或存量数据回填。
- [x] 账号停用、软删除或重新启用不会自动清空或改写店铺保存的业务员 ID。
- [ ] 迁移向上、向下及再次向上均可验证,应用回滚策略允许新增字段及既有归属数据继续保留。(真实迁移验证按本 Change 豁免转任务 6.1/6.3
- [ ] PostgreSQL 集成测试验证空值保存、正 ID 保存、索引定义和软删除账号不影响历史关联。(按本 Change 测试环境豁免转任务 6.1
- [x] 迁移、模型及相关常量和注释遵守项目数据库、Model 与中文注释规范。

View File

@@ -4,16 +4,16 @@
**Blocked by:** 01 — 建立店铺业务员归属持久化基线
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为简单写 Application辅助通道为 Infrastructure。
**完整业务边界:** 本票迁移并收口平台操作者的店铺创建完整用例。明确不迁移店铺删除、启停及其他无关 CRUD不创建 Shop 聚合,不实现代理账号的请求字段拒绝规则。
- [ ] 创建命令真实区分业务员字段缺失、显式 `null`、零值和正整数Handler 不自行猜测三态语义。
- [ ] 超级管理员和平台账号显式传正 ID 时,只能绑定当前启用、未删除的普通平台账号;超级管理员、代理、企业、停用和软删除账号均不可人工新绑定。
- [ ] 显式 `null` 创建为空归属;字段缺失且有上级时复制上级当前保存的原始 ID字段缺失且无上级时创建为空归属。
- [ ] 上级保存的业务员即使当前停用或软删除,字段缺失时仍按历史 ID 原值继承,不把继承误作人工候选校验。
- [ ] 店铺、初始主账号、账号角色、店铺角色、两个钱包业务员归属和关键成功审计在同一数据库事务内成功或回滚,任一步失败不留下半成品。
- [ ] 创建事件记录归属来源 `inherited/explicit/empty`、上级店铺 ID 和最终业务员 ID并符合统一审计写入契约。
- [ ] Application、PostgreSQL 与 HTTP 集成测试覆盖平台创建矩阵、字段四态、候选失效、事务回滚和统一中文错误。
- [x] 创建命令真实区分业务员字段缺失、显式 `null`、零值和正整数Handler 不自行猜测三态语义。
- [x] 超级管理员和平台账号显式传正 ID 时,只能绑定当前启用、未删除的普通平台账号;超级管理员、代理、企业、停用和软删除账号均不可人工新绑定。
- [x] 显式 `null` 创建为空归属;字段缺失且有上级时复制上级当前保存的原始 ID字段缺失且无上级时创建为空归属。
- [x] 上级保存的业务员即使当前停用或软删除,字段缺失时仍按历史 ID 原值继承,不把继承误作人工候选校验。
- [x] 店铺、初始主账号、账号角色、店铺角色、两个钱包业务员归属在同一数据库事务内成功或回滚,任一步失败不留下半成品Audit Event 按本 Change 冻结转任务 6.5
- [ ] 创建事件记录归属来源 `inherited/explicit/empty`、上级店铺 ID 和最终业务员 ID并符合统一审计写入契约。Audit Event 按本 Change 冻结转任务 6.5
- [ ] Application、PostgreSQL 与 HTTP 集成测试覆盖平台创建矩阵、字段四态、候选失效、事务回滚和统一中文错误。(按本 Change 测试环境豁免转任务 6.1

View File

@@ -4,16 +4,16 @@
**Blocked by:** 02 — 平台创建店铺时设置或继承业务员
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为简单写 Application辅助通道为既有权限 Adapter。
**完整业务边界:** 本票收口代理操作者进入同一店铺创建用例时的权限与继承规则。明确不扩大代理原有可发展层级或数据范围,不允许代理人工选择业务员,不实现父子动态查询或批量级联。
- [ ] 代理创建前复用既有店铺层级和管理权限校验,所选上级必须处于调用者原本允许发展的范围。
- [ ] 请求 JSON 出现业务员字段即返回统一禁止访问错误,显式 `null`、零值、原值和其他正 ID 不得被静默忽略。
- [ ] 字段未出现时,上级有业务员则复制原始 ID上级为空则新店铺为空停用或软删除账号的历史 ID 仍可被复制。
- [ ] 复制是创建时快照:父店铺之后设置、清空或更换业务员,不改变既有子店铺或孙级店铺。
- [ ] 后续新建直属下级只读取其直属上级在本次创建时的当前保存值,不沿祖先动态追溯。
- [ ] 无权上级、企业操作者及资源不存在使用统一安全错误语义,不泄露资源是否存在。
- [ ] Application 与 HTTP 集成测试覆盖继承矩阵、恶意字段输入、无权上级、事务原子性及不级联回归。
- [x] 代理创建前复用既有店铺层级和管理权限校验,所选上级必须处于调用者原本允许发展的范围。
- [x] 请求 JSON 出现业务员字段即返回统一禁止访问错误,显式 `null`、零值、原值和其他正 ID 不得被静默忽略。
- [x] 字段未出现时,上级有业务员则复制原始 ID上级为空则新店铺为空停用或软删除账号的历史 ID 仍可被复制。
- [x] 复制是创建时快照:父店铺之后设置、清空或更换业务员,不改变既有子店铺或孙级店铺。
- [x] 后续新建直属下级只读取其直属上级在本次创建时的当前保存值,不沿祖先动态追溯。
- [x] 无权上级、企业操作者及资源不存在使用统一安全错误语义,不泄露资源是否存在。
- [ ] Application 与 HTTP 集成测试覆盖继承矩阵、恶意字段输入、无权上级、事务原子性及不级联回归。(按本 Change 测试环境豁免转任务 6.1

View File

@@ -7,16 +7,16 @@
- 01 — 建立店铺业务员归属持久化基线
- `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为简单写 Application辅助通道为统一审计 Port/Adapter。
**完整业务边界:** 本票迁移并收口店铺资料更新这一完整用例中的业务员归属变更。明确不迁移店铺删除、启停或其他无关 CRUD不向父级、下级、孙级或同级传播变更不承担公共审计基础设施建设。
- [ ] 更新命令真实区分字段缺失、显式 `null`、零值和正整数;字段缺失保持原归属,`null` 清空,正 ID 校验后替换,零值按非法参数处理。
- [ ] 超级管理员和平台账号只有在通过目标店铺既有资源权限检查后才能修改归属;企业、越权平台和资源不存在使用统一安全错误语义。
- [ ] 代理更新其他资料且字段缺失时保留原归属,请求一旦出现该字段即返回统一禁止访问错误。
- [ ] 设置正 ID 时在写事务内重新校验其仍为启用、未删除的普通平台账号,候选在提交前失效时不得更新店铺。
- [ ] 实际值未变化时不生成业务员变更成功审计;设置、清空和更换记录稳定动作、前后 ID、账号名摘要和可用状态。
- [ ] 店铺变化与关键成功审计同事务提交,审计失败时业务修改回滚;拒绝和失败按公共审计策略记录且不泄露底层错误。
- [ ] 测试覆盖四类操作者、字段四态、并发候选失效、无变化更新、父子不级联、审计失败回滚和中文错误契约。
- [x] 更新命令真实区分字段缺失、显式 `null`、零值和正整数;字段缺失保持原归属,`null` 清空,正 ID 校验后替换,零值按非法参数处理。
- [x] 超级管理员和平台账号只有在通过目标店铺既有资源权限检查后才能修改归属;企业、越权平台和资源不存在使用统一安全错误语义。
- [x] 代理更新其他资料且字段缺失时保留原归属,请求一旦出现该字段即返回统一禁止访问错误。
- [x] 设置正 ID 时在写事务内重新校验其仍为启用、未删除的普通平台账号,候选在提交前失效时不得更新店铺。
- [ ] 实际值未变化时不生成业务员变更成功审计;设置、清空和更换记录稳定动作、前后 ID、账号名摘要和可用状态。Audit Event 按本 Change 冻结转任务 6.5
- [ ] 店铺变化与关键成功审计同事务提交,审计失败时业务修改回滚;拒绝和失败按公共审计策略记录且不泄露底层错误。Audit Event 按本 Change 冻结转任务 6.5
- [ ] 测试覆盖四类操作者、字段四态、并发候选失效、无变化更新、父子不级联、审计失败回滚和中文错误契约。(按本 Change 测试环境豁免转任务 6.1

View File

@@ -4,17 +4,19 @@
**Blocked by:** 01 — 建立店铺业务员归属持久化基线
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** Query。
**完整业务边界:** 本票收口店铺列表、店铺详情和业务员候选三个读取用例及其 API 契约。明确不通过聚合根读取,不执行写操作,不把候选查询结果当作写侧最终校验,不扩大任何账号的数据权限。
- [ ] 店铺列表支持按保存的业务员 ID 精确筛选,并与店铺名、编号、联系电话、上级、层级、状态等既有条件按 AND 组合,分页和排序契约保持不变。
- [ ] 列表和详情统一返回可空业务员 ID、账号名、固定前三后四手机号摘要及可用状态空归属返回空摘要停用或软删除账号尽可能返回历史摘要并标记不可用。
- [ ] 当前页业务员账号通过一次批量查询完成投影,包含必要的软删除只读数据,不发生逐店铺 N+1 查询。
- [ ] 若当前没有独立店铺详情接口,则提供受既有数据权限保护的详情接口,并确保候选、级联、资金概况等静态路由不会被动态 ID 路由吞掉。
- [ ] 候选接口只允许超级管理员和平台账号调用,仅返回启用、未删除的普通平台账号,并提供用户名或手机号受控搜索、默认 20 和最大 100 的分页。
- [ ] 代理和企业不能调用候选接口;候选仅返回 ID、账号名和手机号摘要不复用披露范围更大的账号列表响应。
- [x] 店铺列表支持按保存的业务员 ID 精确筛选,并与店铺名、编号、联系电话、上级、层级、状态等既有条件按 AND 组合,分页和排序契约保持不变。
- [x] 列表和详情统一返回可空业务员 ID、账号名、固定前三后四手机号摘要及可用状态空归属返回空摘要停用或软删除账号尽可能返回历史摘要并标记不可用。
- [x] 当前页业务员账号通过一次批量查询完成投影,包含必要的软删除只读数据,不发生逐店铺 N+1 查询。
- [x] 若当前没有独立店铺详情接口,则提供受既有数据权限保护的详情接口,并确保候选、级联、资金概况等静态路由不会被动态 ID 路由吞掉。
- [x] 候选接口只允许超级管理员和平台账号调用,仅返回启用、未删除的普通平台账号,并提供用户名或手机号受控搜索、默认 20 和最大 100 的分页。
- [x] 代理和企业不能调用候选接口;候选仅返回 ID、账号名和手机号摘要不复用披露范围更大的账号列表响应。
- [ ] PostgreSQL 与 HTTP 集成测试覆盖索引筛选、AND 条件、分页总数、历史投影、手机号脱敏、无 N+1、权限和静态路由顺序。
- [ ] RouteSpec 和 OpenAPI 契约同步更新;若实现过程中新增 Handler 类型,则同步两个文档生成器,否则不得为此无谓新增 Handler。
- [x] RouteSpec 和 OpenAPI 契约同步更新;若实现过程中新增 Handler 类型,则同步两个文档生成器,否则不得为此无谓新增 Handler。
自动化与真实 PostgreSQL/HTTP 验证按本 Change 测试环境豁免转任务 6.1;本票已完成 `gofmt``git diff --check``go build ./...` 代码门禁。

View File

@@ -9,17 +9,19 @@
- 04 — 独立修改并审计店铺业务员归属
- 05 — 查询店铺业务员归属与候选账号
**Status:** ready-for-agent
**Status:** 代码完成、验证延期
**架构通道:** 主通道为 Application + Port/Adapter辅助通道为 Query/Infrastructure。
**完整业务边界:** 本票提供公共通知能力可调用的店铺业务员接收人解析接缝,并验证 UR#96 后端交付。明确不建设通知表、通知中心、铃铛或任何前端页面,不实现 UR#33 套餐临期和 UR#97 钱包低余额的触发规则,不发送外部渠道通知,也不建设自动转派。
- [ ] 接收人解析按目标店铺当前保存的业务员 ID 查询账号,仅在账号仍为启用、未删除的普通平台账号时返回稳定账号 ID。
- [ ] 店铺无归属、账号停用、软删除或类型已不符合时返回无可用业务员接收人,不清空店铺字段且不把永久无接收人当作无限重试错误。
- [ ] 解析不读取父店铺、祖先店铺或创建人,不因上级后续变更而改变目标店铺的接收人结果。
- [ ] 接缝可被公共站内通知 Worker 复用,并与其“店铺主账号加当前可用业务员、按账号 ID 去重”的接收人规则兼容;本票不复制公共通知基础设施。
- [x] 接收人解析按目标店铺当前保存的业务员 ID 查询账号,仅在账号仍为启用、未删除的普通平台账号时返回稳定账号 ID。
- [x] 店铺无归属、账号停用、软删除或类型已不符合时返回无可用业务员接收人,不清空店铺字段且不把永久无接收人当作无限重试错误。
- [x] 解析不读取父店铺、祖先店铺或创建人,不因上级后续变更而改变目标店铺的接收人结果。
- [x] 接缝可被公共站内通知 Worker 复用,并与其“店铺主账号加当前可用业务员、按账号 ID 去重”的接收人规则兼容;本票不复制公共通知基础设施。
- [ ] 集成测试覆盖可用、停用、软删除、重新启用、空归属、关系更换和父级变更不影响子店铺等场景。
- [ ] 后端验收覆盖平台显式设置和清空、代理继承、父级修改不级联、列表筛选、详情投影、候选权限、审计及创建事务原子性。
- [ ] 更新 UR#96 中文总结文档和 README 入口,记录 API 契约、架构通道、通知复用边界、上线核验及应用回滚保留字段和历史归属的策略。
- [x] 更新 UR#96 中文总结文档和 README 入口,记录 API 契约、架构通道、通知复用边界、上线核验及应用回滚保留字段和历史归属的策略。
- [ ] 发布前只读核验店铺层级异常和平台账号状态;发布后抽查显式设置、代理继承、不级联、历史不可用账号展示及接收人解析,不运行全量回填或级联脚本。
自动化、真实依赖与发布后人工核验按本 Change 测试环境豁免转任务 6.1、6.3、6.6;本票已完成 `gofmt``git diff --check``go build ./...` 代码门禁。

View File

@@ -238,6 +238,12 @@ default:
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)
- **UR#60 店铺联系电话精确查询**:店铺列表支持 11 位 ASCII 联系电话精确筛选,统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](docs/ur60-shop-phone-search/功能总结.md)。
- **UR#45 换货资产快照与新旧资产独立搜索**:新换货单将卡快照统一为完整 ICCID、设备按虚拟号/IMEI/SN 固化稳定标识;列表使用 `old_asset_keyword``new_asset_keyword` 独立搜索并按 AND 组合;详见 [功能总结](docs/ur45-exchange-asset-search/功能总结.md)。
- **公共站内通知**:已交付后台与个人客户通知闭环,以及按稳定账号、平台角色、店铺主账号和当前业务员解析的后台动态接收人;受控跳转和保留清理按七月总台账继续实施。详见 [功能总结](docs/tech-inapp-notifications/功能总结.md)。
- **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 唯一性门禁、实名/流量/网络公共观测、轮询与业务入口事件序列,以及电信实名、移动实名、联通实名成功和联通解除实名四条防腐层;四个运行时开关复用受控 `system_config` 且默认关闭,真实运营商与 Gateway 验收按七月总台账延期。详见 [功能总结](docs/ur94-card-state-events-callbacks/功能总结.md)。
- **UR#43 代理系列套餐批量授权**:首次系列授权原子创建 1100 条套餐授权;后续管理继续使用已交付的 `packages[].remove` 字段完成软删除,并兼容既有批量新增、调价和响应结构。详见 [功能总结](docs/ur43-series-package-bulk-authorization/功能总结.md)。
- **UR#86 资产前代与后代换货标识**:统一资产详情返回稳定 `exchange_trace`,展示单节点前代与后代快照,并按关联资产当前权限控制可跳转 ID详见 [功能总结](docs/ur86-asset-exchange-trace/功能总结.md)。
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)

View File

@@ -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)

View File

@@ -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)

View File

@@ -14,9 +14,16 @@ 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"
walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
@@ -274,7 +281,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
stopResumeSvc.SetPollingCallback(lifecycleSvc)
}
return &workerRuntime{
runtime := &workerRuntime{
redisAddr: redisAddr,
redisClient: redisClient,
db: db,
@@ -291,6 +298,95 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingBase: pollingBase,
lifecycleSvc: lifecycleSvc,
}
registerNotificationOutboxConsumer(runtime, appLogger)
registerWalletOutboxConsumer(runtime, appLogger)
registerCardObservationOutboxConsumer(runtime, appLogger)
return runtime
}
// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。
func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
consumer := cardObservationInfra.NewRealnameChangedConsumer(
runtime.db,
runtime.workerResult.Services.ActivationService,
runtime.workerResult.Stores.DeviceSimBinding,
stopResumeService,
)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardRealnameChanged, consumer); err != nil {
appLogger.Fatal("注册卡实名状态变化 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeCardRealnameChanged), zap.Error(err))
}
trafficConsumer := cardObservationInfra.NewTrafficIncrementedConsumer(
runtime.db,
runtime.redisClient,
runtime.workerResult.Services.UsageService,
stopResumeService,
)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardTrafficIncremented, trafficConsumer); err != nil {
appLogger.Fatal("注册卡流量正增量 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeCardTrafficIncremented), zap.Error(err))
}
networkConsumer := cardObservationInfra.NewNetworkChangedConsumer(runtime.db, stopResumeService)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardNetworkChanged, networkConsumer); err != nil {
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 注册代理主钱包资金事实消费者。
func registerWalletOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
debitConsumer := walletInfra.NewDebitEventConsumer(runtime.db)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentMainWalletDebited, debitConsumer); err != nil {
appLogger.Fatal("注册代理主钱包扣款 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAgentMainWalletDebited), zap.Error(err))
}
reservationConsumer := walletInfra.NewReservationEventConsumer(runtime.db)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentMainWalletReservationChanged, reservationConsumer); err != nil {
appLogger.Fatal("注册代理主钱包预占 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAgentMainWalletReservationChanged), zap.Error(err))
}
creditConsumer := walletInfra.NewCreditEventConsumer(runtime.db)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentMainWalletCredited, creditConsumer); err != nil {
appLogger.Fatal("注册代理主钱包入账 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAgentMainWalletCredited), zap.Error(err))
}
refundConsumer := walletInfra.NewRefundEventConsumer(runtime.db)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentMainWalletRefunded, refundConsumer); err != nil {
appLogger.Fatal("注册代理主钱包退款 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAgentMainWalletRefunded), zap.Error(err))
}
}
// registerNotificationOutboxConsumer 注册明确、动态后台账号和个人客户通知的稳定 Outbox 消费者。
func registerNotificationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
repository := notificationInfra.NewRepository(runtime.db)
registry := notificationInfra.NewRegistry()
shopRecipientResolver := shopInfra.NewRecipientResolver(runtime.db)
dynamicRecipientResolver := notificationInfra.NewDynamicRecipientResolver(runtime.db, shopRecipientResolver)
consumer := notificationApp.NewDeliveryService(repository, registry, dynamicRecipientResolver, appLogger)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAdminDirectNotification, consumer); err != nil {
appLogger.Fatal("注册站内通知 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAdminDirectNotification), zap.Error(err))
}
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypePersonalCustomerDirectNotification, consumer); err != nil {
appLogger.Fatal("注册个人客户站内通知 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypePersonalCustomerDirectNotification), zap.Error(err))
}
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAdminDynamicNotification, consumer); err != nil {
appLogger.Fatal("注册后台动态接收人站内通知 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAdminDynamicNotification), zap.Error(err))
}
}
// close 在 Worker 退出时关闭共享客户端与数据库连接。
@@ -394,7 +490,7 @@ func startPollingScheduler(
appLogger,
)
// 注入套餐失效前流量同步器:最小化 iot_card.Service只需 gateway + store + redis + 流量扣减)
// 注入套餐失效前流量同步器:复用公共卡观测写入,不在旧 Service 内重复扣减套餐。
trafficSyncer := iot_card_svc.New(
runtime.db,
runtime.pollingIotCardStore,
@@ -404,7 +500,7 @@ func startPollingScheduler(
nil,
)
trafficSyncer.SetRedisClient(runtime.redisClient)
trafficSyncer.SetDataDeductor(runtime.workerResult.Services.UsageService)
trafficSyncer.SetCardObservationService(runtime.workerResult.Services.CardObservation)
activationHandler.SetTrafficSyncer(trafficSyncer)
pollingScheduler := polling.NewScheduler(
@@ -447,7 +543,7 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
}
}()
appLogger.Info("Asynq Scheduler 已启动(订单超时: @every 1m, 告警检查: @every 1m, 数据清理: 0 2 * * *, 每日流量落盘: 0 2 * * *")
appLogger.Info("Asynq Scheduler 已启动(订单超时: @every 1m, 告警检查: @every 1m, 数据清理: 0 2 * * *, 通知清理: 15 2 * * *, 每日流量落盘: 0 2 * * *")
return asynqScheduler
}
@@ -474,6 +570,14 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil {
return fmt.Errorf("注册数据清理定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("15 2 * * *", asynq.NewTask(
constants.TaskTypeNotificationCleanup,
nil,
asynq.MaxRetry(3),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeNotificationCleanup)),
)); err != nil {
return fmt.Errorf("注册站内通知保留清理定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register(
"0 2 * * *",
asynq.NewTask(

View File

@@ -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` 或改变既有响应结构
页面按调用视角分别标注上级当前成本价、目标代理授权成本价和建议零售价;平台视角的上级成本才是公司成本。所有系列、套餐、价格和直属下级权限由后端重新校验,不能依赖前端置灰。

View File

@@ -1019,7 +1019,7 @@
接口约定:
- 首次授权复用GET /api/admin/packages?series_id=...和POST /api/admin/shop-series-grantsPOST必须同时提交至少一个packages项。
- 后续管理复用GET /api/admin/packages?series_id=...与GET /api/admin/shop-series-grants/{id}。
- PUT /api/admin/shop-series-grants/{id}/packagesbody={operation_type:authorize|update_cost|remove,packages:[{package_id,cost_price?}]}
- PUT /api/admin/shop-series-grants/{id}/packagesbody={packages:[{package_id,cost_price?,remove?}]}`remove=true` 表示软删除,必须保持既有字段兼容
交互规则:三类价格按分转元;提交成功后重新加载候选列表;空候选和全部已授权状态有明确提示。
@@ -1037,7 +1037,7 @@
**描述**
```markdown
目标:首次授权继续同时创建系列与套餐;后续复用现有批量接口,并把新增、调价、移除拆成明确命令不新增候选Query。
目标:首次授权继续同时创建系列与套餐;后续复用现有批量接口并保留 `packages[].remove` 已交付契约不新增候选Query。
预计工时后端0.51小时。
@@ -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 导出功能

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,58 @@
# 渠道无关审批核心功能总结
## 本次范围
任务 2.9 建立由业务侧拥有的 Approval Port 和通用审批实例。退款、线下充值及后续审批业务只依赖这套稳定契约,不依赖企业微信或其他渠道的 SDK、DTO、状态码和外部编号字段。
通用实例仅保存业务类型与业务 ID、真实提交人及其快照、`provider`、通用 `external_ref`、标准状态、申请/决策快照、关联 ID 和并发版本。数据库使用 `(business_type, business_id)` 保证一张业务单只有一个审批实例,并使用 `(provider, external_ref)` 的非空部分唯一索引防止同一渠道实例重复绑定。
## 标准决策
渠道 Adapter 只能向业务核心输出以下标准决策:
- `approved`:审批通过。
- `rejected`:审批拒绝。
- `cancelled`:审批撤销。
- `deleted`:审批删除。
- `revoked_after_approved`:审批通过后撤销。
业务消费者不得识别企微、钉钉或其他渠道状态。标准决策到通用终态的映射由审批领域统一维护。
## 事务与扩展边界
业务侧 Port 分为事务前 `Prepare` 和调用方事务内 `CreateInTx`。后续任务 2.12 将实现有效 Adapter、场景和发起身份的失败关闭检查以及业务单、通用审批实例和提交 Outbox 的原子创建。
本任务不实现审批同步、终态 Outbox、处理租约、通用 Query 或任何渠道 Adapter这些分别由 2.102.12 和 4.W14.W14 收口。当前没有外部调用,因此 Integration Log 为 N/A没有提交后副作用因此本任务不写 Outbox通用审批表本身是审批 Domain Ledger。Audit Event 按总台账冻结到 6.5。
## 明确排除
- 不保存企微 Token、`sp_no`、模板 ID、控件 ID 或成员身份。
- 不保存审批节点、审批人、意见、附件副本、会签或或签规则。
- 不建设本地审批流引擎,也不定义退款、充值、钱包或佣金规则。
- 不新增 API、Handler、Worker 或前端页面。
## 通用读取模型
任务 2.10 在通用实例之上增加单条和最多 100 条的批量 Query。Query 只读取当前页实例,并一次性调用业务权限 Adapter 复核当前账号对退款、充值等原业务资源的访问权;无权、资源不存在和引用失效对单条读取统一返回禁止访问。批量读取只返回有权项目,并保持调用方当前页顺序,不逐条查询业务表。
稳定投影包含业务引用与摘要、真实提交人、`provider`、标准状态及中文名称、状态时间和业务处理摘要。处理状态由对应业务 Adapter 提供,不以审批状态冒充退款或入账结果。
渠道扩展 Resolver 是可选接缝,只允许平台或超级管理员在通过原业务权限复核后读取已保存的本地快照,并明确禁止在 Query 请求中实时访问外部审批平台。代理和企业账号不会调用扩展 Resolver因此不能获得审批节点、审批人、内部意见或渠道附件。
## 回调、轮询与标准决策分发
任务 2.11 提供回调、兜底轮询和受控人工同步共用的 `SyncDecision` 用例。具体渠道 Adapter 必须先读取渠道权威详情并翻译为五类标准决策,再以 `callback``polling``manual` 来源调用同一用例;回调载荷本身不能绕过权威详情直接修改业务状态。
用例在行锁事务内执行状态机校验和乐观锁条件更新,并以 `approval:{instance_id}:{decision}` 作为稳定事件 ID同事务写标准决策投递事实和公共 Outbox。回调与轮询并发、重复回调或重复轮询只会由先到者写入一次后到的相同决策正常幂等结束。`revoked_after_approved` 允许在 `approved` 之后形成独立事件,其他互相冲突的终态拒绝覆盖。
Outbox 消费后,分发器按业务类型调用退款、充值等业务消费者。每个“审批实例 + 标准决策”拥有独立处理记录和可过期租约,成功后永久幂等;失败释放租约并保留安全摘要等待重试。业务消费者仍必须以审批实例 ID 和决策作为自身幂等键,且不得导入任何第三方审批 SDK、DTO、状态码或模板字段。
本轮新增和此前公共基础迁移 `000165``000170` 已为每个新增表字段或新增列补齐中文数据库备注;不建立外键,关联继续由 Application/Domain 显式维护。
## 原子创建与失败关闭
任务 2.12 实现业务侧 Approval Port。业务用例先在事务外调用 `Prepare`,由具体 Provider Adapter 同时确认 Adapter 已装配、场景可用和真实发起身份可解析;任一条件不满足都会在业务单、审批实例和 Outbox 写入前返回服务不可用。准备结果只有 30 秒有效期,绑定业务类型、真实提交人和关联 ID且内部字段不能由退款或充值业务包自行构造。
通过前置检查后,业务用例把自己的 GORM 事务传给 `CreateInTx`。该方法在同一事务内创建唯一通用审批实例、让 Provider Adapter 固化渠道专属安全上下文,并写 `approval.submission.requested` Outbox任何一步失败都向调用方返回错误调用方必须让包含业务单的整个事务回滚。事务内不调用 Redis、Asynq、对象存储或第三方审批网络。
当前测试环境尚未装配具体审批 Adapter 时使用失败关闭实现,因此退款和线下代充值核心可以围绕 Port 编译,但新提交入口不能被误开放。企微场景、模板和身份上下文由 4.W14.W5 实现,不进入通用实例。

View File

@@ -0,0 +1,107 @@
# 公共站内通知前端联调与验收契约
## 交付边界
本文是后台管理端、代理端和 C 端的框架无关契约。当前仓库不包含前端源码,因此页面组件、状态管理和浏览器联调必须在对应前端仓库实施;本文不把契约完成表述为前端实现或人工验收完成。
第一版固定使用 HTTP 轮询,不使用 WebSocket/SSE也不维护 Redis 未读计数。
## 未读轮询与徽标
后台布局和 C 端消息入口挂载后立即请求各自的 `unread-count`,之后每 30 秒刷新:
1. 页面变为不可见时暂停计时器。
2. 页面恢复可见时立即刷新一次,再恢复 30 秒周期。
3. 请求失败时保留上一次成功值,不改写为 0后续周期静默重试。
4. 组件卸载时必须清理计时器,避免重复轮询。
徽标固定宽度,验收矩阵如下:
| `count` | `display_count` | 展示 |
|---:|---|---|
| 0 | `0` | 隐藏徽标 |
| 1 | `1` | 显示 1 |
| 99 | `99` | 显示 99 |
| 100 | `99+` | 显示 99+ |
前端直接使用后端 `display_count`,不自行重复计算上限。
## 后台铃铛、抽屉和通知中心
铃铛点击后使用 `GET /api/admin/notifications?page=1&page_size=10` 加载最近 10 条。抽屉提供“全部、审批、临期、同步、系统”入口,对应 `category` 为空或 `approval/expiry/sync/system`
完整通知中心使用服务端参数:
- `category``approval/expiry/sync/system`
- `type`:稳定通知类型
- `severity``info/warning/error/critical`
- `is_read`:已读状态
- `page/page_size`:服务端分页,默认 20、最大 50
“全部已读”调用 `PUT /api/admin/notifications/read-all`。当前分类为空时提交空对象;在分类视图中提交对应 `category`,成功后使用 `updated_count` 更新提示并重新拉取列表、汇总和未读数。
加载时保留已有内容并展示局部加载状态;首次空结果展示空态;请求失败展示重试入口,不把上一页数据伪装成新筛选结果。
## 点击、已读和受控目标
点击顺序固定为:
1. 立即进入已读视觉状态。
2. 调用 `PUT /api/admin/notifications/:id/read`
3. 调用 `GET /api/admin/notifications/:id/target`
4. 只有 `available=true``target_type` 在前端白名单内时,使用结构化 `target_id/target_key` 构造站内路由。
已读请求失败时以下一次服务端刷新为准;目标解析失败或不可用不恢复未读。响应不包含 URL前端禁止把 `target_id``target_key` 当作路径或完整地址直接跳转。
目标白名单:
| `target_type` | 结构化标识 | 页面语义 |
|---|---|---|
| `refund_detail` | `target_id` | 退款详情 |
| `agent_recharge_detail` | `target_id` | 代理充值详情 |
| `wecom_approval_detail` | `target_id` | 企微审批详情 |
| `iot_card_detail` | `target_id` | 物联网卡详情 |
| `device_detail` | `target_id` | 设备详情 |
| `expiring_asset_list` | `target_id` | 指定店铺的临期资产列表 |
| `shop_fund_summary` | `target_id` | 店铺资金概况 |
| `integration_log` | `target_key` | 外部集成记录 |
| `system_config` | `target_key` | 受控系统配置 |
未知类型、空 `target_type``available=false` 只展示正文,不跳转、不回退到自由 URL。
示例:
```json
{
"code": 0,
"msg": "success",
"data": {
"target_type": "shop_fund_summary",
"target_id": 42,
"available": true
},
"timestamp": "2026-07-24T11:00:00+08:00"
}
```
## C 端简化通知中心
C 端只调用:
- `GET /api/c/v1/notifications/unread-count`
- `GET /api/c/v1/notifications?page=&page_size=`
- `PUT /api/c/v1/notifications/read-all`
- `PUT /api/c/v1/notifications/:id/read`
C 端不展示后台筛选、分类汇总、同步或系统运维消息。列表只呈现当前个人客户可见的已开放审批、套餐、订单和资产业务通知。无通知时展示空态;网络失败保留上一成功结果并允许重试。
## 联调验收矩阵
- 后台账号只能读取和修改自己的通知,构造其他通知 ID 不泄露事实。
- 个人客户之间完全隔离C 端不能通过 ID 已读其他客户通知。
- 过期通知不进入列表、未读数、分类汇总或受控目标解析。
- 静态 `/read-all``/unread-count``/unread-summary` 不被 `/:id` 路由吞掉。
- 筛选、翻页、全部已读后,列表、汇总和徽标最终一致。
- 权限变化、目标删除和未知引用均显示正文但不跳转。
- 0、1、99、100 四个徽标边界无布局抖动。
- 页面隐藏时无轮询,恢复后立即刷新;失败期间未读数不闪回零。

View File

@@ -0,0 +1,81 @@
# 公共站内通知功能总结
> 当前状态:任务 2.12.4 后台、个人客户通知闭环和后台动态接收人已完成代码交付;受控跳转、保留清理和完整前端契约仍按后续任务实施。
## 本次交付范围
本阶段交付明确后台账号的通知中心纵向闭环:公共 Outbox 事件经现有 `outbox:deliver` Worker 消费后,为指定且仍启用的后台账号幂等写入一条纯文本通知。当前登录账号只能查询自己的未读数、固定分类汇总和筛选分页列表,并可把自己的一条、指定类别或全部通知幂等标记为已读。
本阶段不实现目标跳转解析、前端组件、WebSocket 或具体业务触发规则,也不复制公共 Outbox 与 Relay。
## 事件与幂等契约
- 稳定事件类型:`notification.admin.direct.requested`
- 个人客户稳定事件类型:`notification.personal_customer.direct.requested`
- 后台动态接收人事件类型:`notification.admin.dynamic.requested`
- 载荷版本:`1`
- 结构化载荷包含后台接收账号 ID、注册通知类型、模板数据、可选受控资源引用和过期时间调用公共队列时保持 struct/map 载荷,不传预序列化 `[]byte`
- 当前内置通知类型为 `system.notice`标题和正文均由代码内固定模板生成不接受业务载荷传入任意正文未知字段、HTML、任意 HTTP URL、超长文本和明显敏感内容会被拒绝。
- 个人客户首个受控类型为 `package.expiring`,使用固定纯文本模板,只允许 `package``asset` 资源引用;`system.notice` 只向后台账号开放。
- 接收账号必须启用且未软删除;不可用账号记录安全日志并跳过,不把通知写入失败反向传播到原业务事务。
- `event_id + recipient_kind + recipient_id` 唯一约束与 `ON CONFLICT DO NOTHING` 共同保证重复或并发消费最多生成一条通知。
后台动态事件使用 `target_kind + target_id` 指定受控目标,当前支持:
- `account`:业务事件携带的稳定真实申请人账号 ID消费时复核账号仍启用且未删除。
- `platform_role`:批量解析当前启用的平台角色、有效账号角色关系,以及仍启用未删除的超管/平台账号。
- `shop`:复用 UR#96 接缝,只解析目标店铺当前启用主账号和当前仍可用业务员,不读取父级、祖先、创建人或代理数据权限。
解析结果按稳定账号 ID 去重和排序。同一事件对每个接收人独立幂等写入;暂无可用接收人记录 `resolution=no_recipient` 并正常结束,数据库故障继续返回 Worker 错误。已经生成的通知保持原接收人事实,关系后续变化不会转移历史通知。
## 数据与查询
迁移 `000168_create_notification` 新建 `tb_notification`,不使用外键。表内固化通知类别、类型、级别、纯文本标题正文、受控引用、首次已读时间、过期时间和创建时间,并通过 CHECK 约束保护接收人、引用与已读状态一致性。
后台查询始终绑定当前认证账号和 `recipient_kind=account`
- `GET /api/admin/notifications/unread-count`:返回准确 `count` 与徽标 `display_count`,超过 99 显示 `99+`
- `GET /api/admin/notifications/unread-summary`:使用单条 PostgreSQL 条件聚合返回 `total/approval/expiry/sync/system` 五个固定未读计数。
- `GET /api/admin/notifications`:只返回未过期通知,支持类别、类型、级别和已读状态 AND 组合筛选,按 `created_at DESC, id DESC` 排序,默认每页 20、最大 50页码最大 10000。
- `PUT /api/admin/notifications/read-all`:类别为空时更新当前账号全部未过期未读通知,指定有效类别时只更新该类别并返回实际更新数;重复调用返回零更新且保持成功。
- `PUT /api/admin/notifications/:id/read`:仅首次更新当前账号自己的未过期未读通知;不存在、属于别人或已经已读均幂等成功,不泄露通知是否存在,也不覆盖首次 `read_at`
- `GET /api/admin/notifications/:id/target`:先固定当前账号查询通知,再返回 `target_type/target_id/target_key/available` 结构化白名单目标;不返回 URL别人通知、不存在通知和过期通知统一返回不可用。
目标注册表覆盖退款、代理充值、企微审批、物联网卡、设备、临期资产列表、店铺资金概况、外部集成和系统配置。退款、卡、设备、店铺、外部集成和系统配置会复核当前数据权限与资源存在性;尚未交付下游业务表的代理充值和企微审批目标先失败关闭为 `available=false``card_sync` 统一映射到外部集成目标,不指向不存在的同步执行页面。未知引用仅展示正文。
个人客户接口固定绑定认证上下文中的 `customer_id`,请求 DTO 不包含接收人字段:
- `GET /api/c/v1/notifications/unread-count`:返回当前客户可见业务通知的准确未读数和 `display_count`
- `GET /api/c/v1/notifications`:提供默认 20、最大 50 的简化分页并固定倒序,不暴露后台筛选或分类汇总参数。
- `PUT /api/c/v1/notifications/read-all`:幂等更新当前客户可见的全部未过期未读业务通知并返回实际更新数。
- `PUT /api/c/v1/notifications/:id/read`:幂等更新当前客户的一条可见通知,跨客户 ID 与不存在 ID 使用相同成功语义。
C 端查询和更新同时限制 `recipient_kind=personal_customer`、当前客户、未过期、业务类别及开放类型白名单;`sync/system` 和未对 C 端开放的类型不会进入结果,也不能被 C 端已读接口修改。
## 审计与安全边界
- `tb_notification` 是通知投递和已读状态的权威事实;通知不能替代资金、审批、套餐等业务 Domain Ledger。
- 本阶段无外部系统调用,因此不写 Integration Log可靠输入继续使用公共 Outbox。
- 按测试环境 Change 的临时决定,本阶段不接入 Audit Event Writer。普通列表、未读数和已读进入 Access Log生产发布前由任务 6.5 重新评审通知失败与系统告警治理。
- 用户接口不接受 `recipient_id`,后台账号权限也不能查看或修改其他接收人的通知。
## 验证与发布
- 已执行 `gofmt``git diff --check`,并通过 `go build ./cmd/api ./cmd/worker ./cmd/gendocs`
- 按本次测试环境里程碑豁免,未新增或运行 `_test.go`,也未执行真实 PostgreSQL、Redis/Asynq 或 HTTP 集成验证;这些证据统一延期到任务 6.1 和 6.3,当前状态不得表述为生产验收通过。
- `000168` 的 down 迁移只允许空表回滚;一旦产生通知事实,必须停止生产者并前向修复,不允许降级删表清除事实。
通知展示与保留策略由代码统一执行:审批结果不自动过期、保留 365 天;套餐临期必须携带业务到期时间并保留 180 天;同步异常默认最多展示 30 天、保留 180 天;系统告警默认展示 30 天且最长 365 天、保留 365 天。
Worker 每天 02:15 调度 `notification:cleanup`,按类别、创建时间和通知主键,每批最多 500 条、每类每次最多 20 批执行 PostgreSQL CTE 删除。任务可中断重跑,只删除 `tb_notification`不级联业务资源、Outbox、Integration Log 或审计事实。
无接收人以 `resolution=no_recipient` 正常结束。接收人解析、模板和展示策略失败只记录事件 ID、通知类型、失败类别及安全目标标识不记录模板数据、回调、Token、Secret 或 URL瞬时错误由 Asynq 有限重试,残缺正文不会入库。统一 Audit Event 管理性写入按本 Change 冻结到任务 6.5。
## 后续任务
- 2.72.8前端契约、OpenAPI/中文文档最终收口和测试环境发布检查。
后台与 C 端的完整交互、目标白名单、示例和验收矩阵见 [前端联调与验收契约](前端联调与验收契约.md)。
测试环境发布顺序、运行门禁、恢复策略和下游事件接入方式见 [发布与下游接入清单](发布与下游接入清单.md)。
- 2.72.8前端契约、OpenAPI/文档最终生成、Worker/模板与测试环境发布检查。

View File

@@ -0,0 +1,47 @@
# 公共站内通知发布与下游接入清单
## 下游生产者契约
业务事务必须先生成稳定 `event_id`,并与业务事实在同一事务写入公共 Outbox。载荷版本固定为 `1`,调用 `EnqueueTask` 时传 struct 或 map禁止传预序列化 `[]byte`
明确后台账号事件使用 `notification.admin.direct.requested`,载荷为 `AdminDirectPayload`;个人客户事件使用 `notification.personal_customer.direct.requested`;后台动态事件使用 `notification.admin.dynamic.requested`,目标只允许:
- `account + 稳定真实申请人账号 ID`
- `platform_role + 平台角色 ID`
- `shop + 目标店铺 ID`
动态店铺目标只产生当前启用主账号和当前可用业务员,不沿层级扩散。无接收人是正常终态。同一事件必须复用原 `event_id`,不得在重试时生成新 ID。
新增业务通知类型必须在代码注册表中明确:稳定类型、类别、级别、固定纯文本模板、允许模板字段、接收人类型和允许 `ref_type`。不得透传任意标题、正文、HTML、URL、Token、Secret、回调原文或长期附件地址。
## 目标与期限
通知只保存受控 `ref_type/ref_id/ref_key`。后台目标接口只返回前端白名单 `target_type` 和结构化 ID/Key并再次复核当前权限拥有通知不授予资源权限。
- 审批结果不自动过期,数据保留 365 天。
- 套餐临期必须携带业务到期时间,数据保留 180 天。
- 同步异常默认最多展示 30 天,数据保留 180 天。
- 系统告警默认展示 30 天、最长 365 天,数据保留 365 天。
## 测试环境发布顺序
1. 进入维护窗口并确认下游生产者尚未启用。
2. 执行迁移 `000168_create_notification``000169_add_shop_business_owner`,核对无外键表、唯一索引、查询索引及店铺业务员普通索引。
3. 发布 Worker确认三个通知 Outbox 事件消费者、公共 `outbox:deliver` Handler 和每天 02:15 的 `notification:cleanup` 已注册。
4. 发布 API核对后台、C 端路由和 OpenAPI静态 `/read-all``/unread-count``/unread-summary` 必须可达。
5. 发布匹配的前端版本并按前端联调契约验收。
6. 最后启用 UR#33、UR#97、审批结果等下游生产者,避免消费者未就绪时制造不可见积压。
## 运行门禁与恢复
- 监控公共 Outbox pending/delivering/final failed、Asynq 重试与失败、通知 `no_recipient`、模板/解析失败和每日清理删除数。
- 出现永久模板错误、持续数据库错误、Outbox 积压或清理长期失败时,先停止对应下游生产者,不删除业务事实和已写通知。
- 已入队但未完成的事件继续使用原 `event_id` 恢复;不得要求用户重复提交或给同一业务生成新事件。
- 通知表已有事实后禁止执行 down 删除;应用回滚保留 `tb_notification` 和店铺业务员字段,修复后前向恢复。
- 不清理 Audit Event、Integration Log、Domain Ledger 或 Outbox不使用通知列表替代业务审计。
## 当前验证状态
已完成路由、RouteSpec、集中式文档 Handler、Worker 消费者、模板、清理 Handler/调度和组合根的静态核对;已生成 `docs/admin-openapi.yaml`,并通过 `gofmt``git diff --check``go build ./...` 和 OpenSpec 校验。
按测试环境 Change 豁免,尚未执行真实 PostgreSQL 迁移、Redis/Relay/Asynq 端到端、真实认证 HTTP、并发重复消费或浏览器人工验收这些门禁保持在任务 6.1、6.3、6.6,不能据此声明生产验收通过。

View File

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

View File

@@ -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 发布清单。没有数值、负责人和验证记录时不得放量。

View File

@@ -0,0 +1,149 @@
# UR#38 代理主钱包信用额度功能总结
## 当前完成范围
任务 2.232.32 已完成代理主钱包信用额度、订单扣款、资金预占、正向入账、退款回充、资金概况读取与测试环境切换收口:
- `tb_agent_wallet` 新增信用开关和分单位信用额度,历史主钱包与分佣钱包默认保持关闭、额度为零。
- Wallet Domain 统一定义现金可用金额、有效信用额度、总可用金额、欠款状态和欠款金额,并拒绝非法配置与 `int64` 算术溢出。
- 数据库 CHECK 保证只有主钱包可以启用信用、分佣钱包保持原现金边界、冻结金额与版本非负、总可用金额不为负。
- 迁移运行时读取目标库真实 CHECK 定义;发现历史异常即中止,不静默修正任何钱包金额。
## 统一资金口径
```text
effective_credit = credit_enabled ? credit_limit : 0
cash_available = balance - frozen_balance
available_balance = cash_available + effective_credit
is_in_debt = balance < 0
debt_amount = max(-balance, 0)
```
冻结金额只降低现金及总可用金额,不直接形成欠款。信用额度仅属于代理主钱包,不扩展到分佣钱包、资产钱包或平台员工。
## 发布与回滚
执行迁移前必须保留目标库输出的真实约束定义与异常钱包清单。若已经启用信用、产生负余额或冻结金额超过账面余额,降级迁移会主动拒绝执行;此时必须继续使用理解信用边界的新钱包逻辑,不能删除字段或恢复旧 Writer。
本任务已经切换代理订单创建即支付和待支付订单的代理主钱包扣款入口,并交付统一冻结、释放、冻结资金完成扣除、正向入账和订单退款回充接缝;批量订购仍由 3.24、3.28 使用该接缝逐项实现。旧 AgentWallet Store 写接缝已删除零调用的主钱包扣款/冻结能力,保留能力强制限定为分佣钱包;旧非事务店铺创建实现和主钱包直写清理脚本也已停用。本测试环境里程碑未运行真实 PostgreSQL 或自动化测试,验证状态为“代码完成、验证延期”。
## 角色默认信用模板
客户角色可通过 `PUT /api/admin/roles/{id}/default-credit` 配置只作用于未来新建店铺的默认信用模板。接口明确返回 `scope=new_shops_only``affects_existing_wallets=false`;修改模板不会扫描既有店铺、修改既有钱包,也不会随店铺角色增删而级联。
超级管理员按既有规则放行;普通平台账号必须拥有独立权限 `role:default-credit:manage`代理及企业账号始终拒绝。平台角色由数据库约束固定为关闭信用、额度为零。Audit Event 按本 Change 的测试环境冻结决策延期至 6.5,当前更新仍保留操作者字段,但不得据此宣称完成正式审计验收。
## 新建店铺信用快照
店铺创建事务会在事务内重新读取请求中的唯一启用客户角色,并把当时的 `default_credit_enabled/default_credit_limit` 复制到新主钱包;分佣钱包始终写入关闭/0。店铺、初始主账号、账号角色、店铺角色和两个钱包仍在同一 PostgreSQL 事务内全成全败。创建完成后修改角色模板或店铺角色关系,都不会追溯改变该钱包快照。
## 既有店铺实际额度调整
`PUT /api/admin/shops/{id}/credit-limit` 用于调整既有店铺主钱包的实际信用额度。按当前产品决定,后端不校验 `shop:credit-limit:manage` 或账号类型;该权限编码只供前端决定是否展示按钮,能够看到按钮的账号即可调用。请求携带钱包 `version`,更新同时约束主钱包类型、版本和调整后的总可用金额,成功后版本加一;降额或关闭信用无法覆盖当前欠款/冻结占用时保持原值。该动作不修改余额、冻结金额,也不创建金额为零的钱包流水。后端授权收紧留待未来单独实施。
## 统一订单扣款
代理自购、代理为下级代购以及后台代理钱包订购统一调用 Wallet Application 的 `DebitInTx`。用例按主钱包行锁读取最新状态,以 `balance - frozen_balance + effective_credit` 校验资金边界,同时保留版本条件更新;现金不足时可在额度内形成负余额,超过总可用金额时整笔事务回滚。
每个订单最多写入一条成功代理主钱包扣款流水,软删除也不能绕过该资金幂等键。钱包订单另保存 SHA-256 幂等指纹,并在事务内通过 PostgreSQL advisory lock 串行同一指纹;即使事务提交后的 Redis 标记写入失败三分钟窗口内的重试也会返回原订单不会创建新订单再次扣款或激活。Redis 锁使用随机 owner token 和比较后删除,缓存不可用时失败关闭。
订单、订单明细、钱包版本和余额、真实扣款流水、Payment、套餐处理以及 `wallet.agent_main.debited` Outbox 事件在同一事务提交任何一步失败都不会留下已支付订单或部分资金事实。Worker 已注册该事件消费者,并在确认投递前复核权威扣款流水;后续余额预警在这个稳定消费接缝上扩展。流水继续保留自购/代购子类型、关联下级店铺及资产快照。冻结或关闭钱包会拒绝扣款;资产钱包、佣金钱包和订单无关创建流程保持原实现,不在本任务迁移。
迁移 `000174` 为订单幂等指纹字段和两个索引添加中文数据库备注,并在创建扣款唯一索引前主动扫描包含软删除记录在内的历史重复成功流水;发现异常会中止而不是自动删除资金事实。若已经产生统一扣款 Outbox 事实,降级迁移会拒绝移除防重字段和约束。
## 统一资金预占
Wallet Application 提供代理主钱包订单资金的冻结、释放和完成扣除能力。冻结只增加 `frozen_balance`,以总可用金额校验信用边界,不会直接形成欠款;释放只减少冻结金额;完成扣除同时减少账面余额和冻结金额,并创建真实扣款流水及 `wallet.agent_main.debited` 事件。
`tb_agent_wallet_reservation` 以订单业务引用唯一记录预占金额、付款钱包和唯一终态。释放与完成只接收订单引用,并从预占事实读取权威钱包与金额,因此代理代购取消不会把买方店铺误当付款钱包。重复冻结、重复释放或重复完成不会二次改变钱包;释放与完成互为排斥终态,钱包行锁、版本条件、预占状态条件、流水和 Outbox 均在调用方事务内维护。
迁移 `000175` 为预占表、全部字段和索引添加中文数据库备注,不建立外键。升级前若发现无法关联稳定业务引用的历史主钱包冻结金额或历史待支付代理钱包订单会中止;降级时在事务内取得预占表排他锁,存在任何预占事实就拒绝删表,避免检查与删除之间产生新事实。该能力只覆盖代理主钱包订单预占,佣金钱包提现和资产钱包冻结保持原边界。
## 统一充值与人工调整入账
Wallet Domain 的 `Credit` 只允许正常代理主钱包执行正金额入账,并使用安全加法拒绝 `int64` 溢出。入账只增加账面余额和版本,不修改冻结金额或信用额度;钱包原有负余额时会自然表现为欠款减少或清偿。
Wallet Application 的 `PostInTx` 仅接受 `topup/recharge``manual_adjustment/adjustment` 两组受控业务类型。调用方必须先持久化具有唯一业务键的充值或人工调整业务事实,并以该事实 ID 作为稳定 `reference_id`;充值单号等可读业务号作为 `correlation_id` 贯穿事件。用例锁定主钱包并校验可选钱包 ID 归属,以 `reference_type + reference_id` 查询成功流水幂等,随后在调用方事务内更新余额和版本、写真实金额流水及 `wallet.agent_main.credited` Outbox。
现有代理充值的线下确认和在线支付回调已改为调用统一入账能力,充值单状态、钱包、流水和 Outbox 全成全败。在线回调在资金写入前校验订单不是线下充值、创建时支付渠道、回调金额和非空第三方交易号;同一渠道的第三方交易号只能绑定一张代理充值单。重复回调或 Worker 重试不会二次入账;旧 Service 不再直接拼接代理主钱包 `balance + amount`。仓库当前没有独立的人工余额调整 Handler 或业务表,本任务不虚构管理入口,只交付供后续业务事实调用的稳定幂等接缝。支付查单、企微审批和到账通知由 UR#34 后续任务负责,退款回充仍由 2.30 迁移,佣金钱包和资产钱包保持原实现。
迁移 `000176` 更新代理钱包流水类型的中文数据库备注,扫描历史重复成功入账和重复渠道交易号后创建包含软删除事实的部分唯一索引;若已经产生统一入账 Outbox降级会拒绝移除防重约束并在同一事务排他锁定充值单、资金流水和 Outbox 后再删除索引。Worker 已注册入账事件消费者,并在确认投递前复核权威成功流水。
## 统一订单退款回充
代理钱包订单退款统一调用 Wallet Application 的 `RefundInTx`。正常订单必须从包含软删除记录的成功 `order/deduct` 流水读取实际付款主钱包、原扣款金额、代购关联店铺、交易子类型和资产快照;退款金额不得超过原扣款绝对值。信用扣款无需单独分支,退款只增加账面余额并自然减少或清偿欠款,不修改冻结金额和信用额度。
历史订单缺少扣款流水时,退款编排层才按旧订单字段推导付款店铺和代购关联店铺,并优先使用订单实付金额作为退款上限,缺失时兼容总金额。新路径不会查询当前店铺关系来猜测付款方,避免代购关系变化后退错钱包。
钱包行锁、Domain 安全加法、版本条件更新、以退款单 ID 为业务键的唯一成功流水及 `wallet.agent_main.refunded` Outbox 在退款审批事务内全成全败。重复审批、Worker 重试或并发请求不能重复回充;消费者确认事件前会同时复核退款流水、原扣款流水、金额上限和资产快照。迁移 `000177` 在创建包含软删除事实的部分唯一索引前扫描重复退款流水,并为涉及字段和索引保留中文数据库备注;降级在同一事务排他锁定资金流水和 Outbox存在统一退款事实时拒绝移除防重约束。
代理退款调用点已不再直接执行余额加法。个人资产钱包退款、佣金回扣、套餐失效、退款后资产处理、渠道退款和审批终态编排均保持原边界后续分别由对应任务处理。Audit Event 按测试环境冻结决定延期至 6.5Domain Ledger 与 Outbox 不延期。
## 资金概况信用投影
`GET /api/admin/shops/fund-summary` 延续现有分页、店铺名称、主账号用户名和店铺层级数据范围,并保留 `main_balance/main_frozen_balance` 兼容字段。响应新增由服务端统一计算的 `cash_available_balance``credit_enabled``credit_limit``available_balance``is_in_debt``debt_amount``version`;前端不得自行重算金额或把读取能力解释为调额权限。
该用例已完整迁到 `internal/query/shop`,不经过 Wallet 聚合根、不执行写操作。Query 在 Count 和分页前应用店铺与主账号筛选,按 `created_at DESC, id DESC` 稳定排序,再以固定次数批量投影本页主钱包、佣金钱包、提现汇总和主账号,避免逐店铺查询。现金可用金额固定为账面余额减冻结金额,总可用金额只加启用后的额度,欠款只由负账面余额决定;冻结占用信用但余额非负时不会误报欠款。缺少主钱包的历史异常店铺暂按零值兼容,完整性告警由 UR#97 负责。
企业账号访问代理资金概况会使用资金功能专用提示返回 403平台和代理仍只读取当前既有店铺数据范围。信用额度不会加入 UR#97 的现金低余额口径。资金概况属于普通受权读取Audit Event 登记为 N/AAccess Log 和当前数据权限继续生效。
开放接口 `GET /api/open/v1/wallet/balance` 同步返回 `cash_available_balance``credit_enabled``credit_limit``available_balance``is_in_debt``debt_amount``version`。其中 `available_balance` 已统一为包含生效信用额度的总可用金额,避免开放接口仍按旧现金口径判断可支付金额。
## 测试环境停机切换清单
### 停机前
1. 停止代理钱包订单、充值、退款、店铺创建及相关 Worker 新写入。
2. 记录 `tb_agent_wallet` 当前 CHECK 定义,核对迁移 `000171``000177` 的执行顺序。
3. 查询并阻断以下异常:未知钱包类型、负冻结金额、负版本、历史信用非关闭/非零、主钱包现金可用为负、分佣钱包余额为负或冻结超过余额。
4. 确认主钱包写入口仅为 Wallet Application订单扣款、预占、充值/人工调整、退款回充与调额;旧 Store 方法只能写分佣钱包。
5. 确认 API、Worker 与 OpenAPI 为同一构建版本,四类钱包 Outbox 消费者均已注册。
迁移前异常查询口径:
```sql
SELECT id, shop_id, wallet_type, balance, frozen_balance,
credit_enabled, credit_limit, version
FROM tb_agent_wallet
WHERE wallet_type NOT IN ('main', 'commission')
OR frozen_balance < 0
OR version < 0
OR credit_enabled
OR credit_limit <> 0
OR (wallet_type = 'main' AND balance::numeric - frozen_balance::numeric < 0)
OR (wallet_type = 'commission' AND (balance < 0 OR frozen_balance > balance));
```
### 迁移后、开放访问前
1. 确认信用字段、六个资金 CHECK、信用启用部分索引、订单幂等索引、预占表、入账和退款唯一索引均存在。
2. 确认全部历史钱包仍为 `credit_enabled=false, credit_limit=0`;只有授权平台人员在开放访问后按业务决定启用信用。
3. 核对三个接口及真实路由:角色默认信用、店铺实际额度、后台资金概况;同时核对开放接口钱包余额的信用投影。
4. 执行 `gofmt`、OpenAPI 生成和 `go build ./...`;自动化、并发与真实 PostgreSQL/Redis/Asynq 验收保持延期到任务 6.1、6.3,不能标记通过。
### 监控与异常处理
- 监控钱包条件更新 `RowsAffected=0`、Outbox 积压/失败、消费者权威流水不一致、钱包版本冲突和数据库 CHECK 拒绝。
- 出现支付/退款/充值事实已提交但消费失败时保留 Domain Ledger 与 Outbox暂停异常生产者并前向恢复不回滚资金事实。
- 发现未知旧写入口时保持维护状态;禁止临时恢复 Store 主钱包写方法或直接 SQL 改余额。
### 前端联调
- 角色页明确提示默认信用只影响未来新建店铺。
- 调额按钮按 `shop:credit-limit:manage` 控制展示,后端行为仍以当前冻结产品决定为准。
- 所有金额按分传输、按元展示;现金可用与总可用分别展示,前端不自行计算。
- 降额失败保持原值;版本冲突后重新拉取资金概况和最新 `version`
- 代理无调额入口,资金概况和开放接口均正确显示信用、欠款与总可用金额。
## 回滚边界
- 尚未启用信用、未产生负余额且不存在冻结超过账面余额时,才可评估执行可逆降级。
- 一旦启用信用、产生负余额或形成旧逻辑无法解释的冻结占用,禁止删除信用字段、关闭信用或恢复旧 Writer必须先清偿欠款或继续运行理解信用边界的新资金逻辑。
- 已产生的钱包流水、订单、充值、退款、预占、Outbox 和消费事实不得清理或伪造回滚。
## 本批验证结果
- 已执行 OpenAPI 生成,`docs/admin-openapi.yaml` 与当前 DTO/路由同步。
- 已执行静态写入口盘点:旧主钱包 Store 扣款/冻结方法已删除,保留方法均带 `wallet_type=commission` 条件;旧店铺创建和主钱包直写运维脚本已收缩。
- 已执行 `go build ./...`,退出码为 0Go 模块统计缓存出现只读警告,不影响构建结果。
- 按本 Change 的测试环境豁免,未新增或运行 `_test.go`,未连接真实 PostgreSQL、Redis 或 Asynq相关验证保留在 6.1、6.3。

View File

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

View File

@@ -1,5 +1,7 @@
# UR#45 换货资产快照与新旧资产独立搜索功能总结
> 交付状态后端实现、OpenAPI 与前端联调契约已交付;前端页面实施和浏览器人工验收待完成。
## 本次交付范围
本次完成两张可独立发布的 Ticket

View File

@@ -0,0 +1,209 @@
# UR#94 卡状态公共写入与运营商回调功能总结
## 当前完成范围
任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.342.44A 已交付实名、流量、网络观测公共写入闭环、三个轮询入口切换、通用观测序列、业务入口触发以及电信实名、移动实名、联通实名成功和联通解除实名四个回调防腐层。UR#94 整体发布检查仍按 2.45 继续实施,不能因四条路由已接入而提前宣称整体生产验收完成。
## 联通实名成功回调
新增来源材料 `docs/7月迭代/来源材料/realname.go` 补齐了此前缺失的联通实名成功报文,因此任务 2.44A 新增 `POST /api/callback/carriers/cucc/realname`。协议与联通解除实名一致:外层 `data` 必须是字符串,再解析内层非空 `iccid/dateChanged`
- 合法 ICCID 只按 19/20 位对应列精确查询;不复制旧代码的 20 位截 19 位,不跨列降级,也不任取多匹配卡。
- 使用 `ICCID + dateChanged` 的安全摘要作为语义幂等键;解析失败使用正文摘要,重复、冲突和中断恢复沿用 Integration Log 租约规则。
- 唯一命中后调用公共 `ApplyCardObservation(verified=true)`,实名事实变化时由公共用例写 Outbox并尽力提前完成同卡实名观测序列。
- 不调用旧 `inner_callback`、第三方推送、旧平台登录、`ModifyDate` 或 Gateway 二次确认;`dateChanged` 只作为上游变更时间和幂等语义留痕,不直接改写本地业务时间。
- 无论开关状态、报文结果或内部处理结果,均返回入口时间对应的 HTTP 200 固定 JSON 应答,避免运营商不可控重推。
## 运营商回调独立开关
四条回调复用公共 `system_config`,模块名为 `carrier_callback`,默认全部关闭:
- `carrier_callback.ctcc_realname.enabled`
- `carrier_callback.cmcc_realname.enabled`
- `carrier_callback.cucc_realname.enabled`
- `carrier_callback.cucc_realname_removal.enabled`
路由始终保留。开关关闭时不解析报文、不定位卡、不调用公共观测,只按正文摘要幂等记录 `ignored` Integration Log 并返回固定成功应答;配置缺失、非法或读取失败时失败关闭,同样不执行业务写入。配置读取以 PostgreSQL 为事实来源并复用 Redis 五分钟缓存。
超级管理员可通过 `GET /api/admin/system-configs?module=carrier_callback` 查询四个已注册开关,并通过 `PUT /api/admin/system-configs/:key` 更新布尔值。全局 Audit Event 已按用户决策取消Audit Writer 改为可选;若未来装配 Writer审计仍与配置更新同事务执行未装配时不会再阻塞受控配置更新也不会用 Integration Log 冒充配置审计。
## 联通解除实名留痕回调
任务 2.44 新增 `POST /api/callback/carriers/cucc/realname/remove`。Adapter 强制要求外层 `data` 为 JSON 字符串,再解析内层 `iccid` 与非空 `dateChanged`;任何情况下都返回入口时间对应的固定 JSON 成功应答。
- 空正文、外层或内层 JSON 失败、`data` 非字符串、ICCID 非法及变更时间为空统一记录 `invalid_payload`
- 合法 ICCID 仅按 19/20 位对应列精确定位;未找到、多匹配和数据库错误分别记录 `not_found``conflict``failed`,不会跨列降级或任取卡。
- 合法且唯一识别的解除通知统一终结为 `ignored`;不调用 `ApplyCardObservation`、观测序列、Gateway、旧 `DelRealName` 或第三方推送,不修改实名状态、首次实名时间、检查时间和逆转计数。
- 解析成功时使用 `ICCID + dateChanged` 语义摘要防重JSON 空白或字段顺序变化保持同一幂等语义;同语义不同正文另记 `conflict`解析失败则以完整正文摘要防重pending 记录支持租约恢复。
- Access Log 继续使用运营商回调摘要策略Integration Log 只保存正文长度、哈希、Content-Type 和哈希资源键;未新增数据库迁移或自动化测试。
## 移动实名成功回调防腐层
任务 2.43 新增 `POST /api/callback/carriers/cmcc/realname`,只解析已知 JSON 结构,并使用请求入口捕获时间返回 `code=200``msg=success``YYYY-MM-DD HH:mm:ss` 格式时间戳。
- 只有 `status="0"``message="正确"`、首条 `result.regStatus="00000"` 且 ICCID 合法时生成已实名观测;空 ICCID、失败状态、空结果和无法解析的 JSON 均记录 `invalid_payload`
- 优先使用 `busiSeq` 的安全摘要作为稳定幂等语义,缺失时使用正文摘要;同事务不同正文另记 `conflict`pending 处理超过一分钟后允许原子认领恢复。
- ICCID 仅按 19/20 位精确列定位,不按 MSISDN 补查,不登录旧管理平台,不保存账号密码或 Cookie也不调用旧第三方推送、修改到期时间或 Gateway 二次确认。
- 成功结果复用公共 `ApplyCardObservation` 并尽力提前完成同卡实名序列;未找到、多匹配和内部错误分别终结为 `not_found``conflict``failed`,不会任取卡写入。
- Access Log 复用运营商回调正文摘要策略Integration Log 不保存完整 JSON 或明文 ICCID仅保存长度、摘要、Content-Type 和哈希资源键。
- 新 Handler 已接入生产组合根、真实路由、OpenAPI Handler 构造和两个文档生成器。本任务按测试延期约定未新增或运行自动化测试。
## 电信实名回调防腐层
任务 2.42 新增 `POST /api/callback/carriers/ctcc/realname`,按已知 `ContractRoot` XML 协议解析电信实名结果,并始终返回运营商约定的 JSON 成功应答。
- 只有 `RESULTMSG=成功``ACCEPTMSG` 包含“已完成实名信息补录”时生成已实名观测;实名信息清除和其他合法业务结果仅记录 `ignored`,不查询或修改卡。
- ICCID 去除首尾空白后复用公共格式校验,仅接受 19 或 20 位值,并分别精确查询 `iccid_19``iccid_20`;查询不应用登录账号数据范围,未找到和多匹配分别记录 `not_found``conflict`
- 入站先以 `GROUP_TRANSACTIONID` 的安全摘要建立稳定幂等语义;同一外部事务的重复载荷直接成功返回,不重复首次实名、套餐激活或领域事件,不同载荷另记 `conflict`。缺少外部事务号时以完整正文摘要防重;若首次处理在 `pending` 阶段中断,超过一分钟租约后的重复回调可原子认领并恢复处理。
- 实名成功复用公共 `ApplyCardObservation` 事务闭环,并在成功后尽力提前完成同卡未执行实名观测序列;内部处理失败尽力把 Integration Log 终结为 `failed`
- Access Log 对 `/api/callback/carriers/` 强制使用敏感正文摘要策略,不记录完整 XML 或明文 ICCIDIntegration Log 只保存正文长度、SHA-256 摘要和哈希资源键。
- 迁移 `000181` 为 Integration Log 增加 `conflict` 终态发布时必须先执行迁移再开放路由down 迁移会先把已有 `conflict` 归并为 `failed`
- 新 Handler 已接入真实路由、生产组合根、OpenAPI Handler 构造以及 `cmd/api/docs.go``cmd/gendocs/main.go` 两个文档生成器。
## 读取与实名入口的 Best Effort 观测触发
任务 2.39 在不迁移 Query、不新增接口、不改变响应结构的前提下接入了 C 端资产详情、后台资产实时状态、OpenAPI 卡/设备流量、OpenAPI 卡网络/实名查询,以及 C 端和后台实名链接入口。
- 读取型入口使用稳定且调用方无关的场景码,按实际卡资源和同步类型创建无预期序列;同一卡连续 OpenAPI 查询通过 2.38 的场景合并键复用未结束序列,不会按账号或每次请求追加三任务。
- C 端和后台设备资产详情按绑定卡分别触发实名、流量、网络观测OpenAPI 设备流量同样按绑定卡触发流量观测,卡标识解析到设备时仍以实际绑定卡为资源。
- C 端实名链接和后台 Gateway 实名链接仅在运营商 `realname_link_type != none` 且原有链接响应成功后触发 `expected=verified` 的实名序列;不支持在线实名的运营商不会建序列。
- 读取入口只把结构化请求放入容量受控的进程内分发队列,不等待 Redis、Asynq 或 Gateway也不把后台序列结果写入当前响应队列满载或 Redis/Asynq/Integration Log 失败只输出中文安全日志并保留 scene、resource、sync、series 和 request_id 关联信息。
- 原有后台与 C 端 `refresh` 手动刷新路径未注入分发器,仍直接执行一次同步,不生成 0/3/5 序列。OpenAPI、资产查询和实名入口的现有错误码、响应字段与业务结果保持不变。
## 0/3/5 卡观测事件序列
任务 2.38 已交付内部 `SeriesTrigger`、固定阶梯 Asynq 调度器和 Worker 执行闭环,但未修改任何查询、停复机、购包、套餐或设备控制入口;入口接入仍由 2.392.41 分别完成。
- 每个新序列以稳定 `series_id` 创建立即、3 分钟、5 分钟三个结构化任务,任务 ID 使用 `series_id + attempt``MaxRetry(0)`,单次失败不会删除或取消后续两个独立任务。
- Redis 合并键严格包含 `scene + resource_type + resource_id + sync_type`,只覆盖最后一次计划任务和短暂缓冲。首触发的预期、来源、请求/关联 ID 与基准时间被原子保存;重复触发不刷新上下文或 TTL补齐首次局部入队失败时仍使用首触发上下文同时避免重复生成第二组三任务。缺失请求/关联 ID 时生成同一稳定 UUID 贯穿三任务。
- 尝试执行前读取本地权威快照。明确预期已满足时,当前及剩余尝试立即写 `completed` Integration Log 并标记幂等完成,不再访问 Gateway`CompleteResourceSeries` 为可信实名回调提前结束同卡实名序列提供扩展点。
- 实际 Gateway 请求使用 `provider + sync_type + resource_id` Redis 互斥16 分钟 TTL 覆盖配置允许的 300 秒单次超时、两次网络重试和安全余量;流量同步复用既有 `traffic:sync:lock:card:{id}` 卡级锁,避免事件、轮询和手动刷新并发读取同一上游读数。互斥命中只把当前尝试记录为 `ignored`,后续阶梯任务保持不变。
- 运营商接入与同步类型的默认最小请求间隔为 10 秒。任务在持有本次请求互斥期间只等待剩余间隔,不建立全局五分钟冷却,也不创建 30/60/120 秒退避Gateway 超频只把当前 Integration Log 终结为 `rate_limited`
- 实名、流量、网络实际响应全部复用 `ApplyCardObservation``ApplyTrafficObservation``ApplyNetworkObservation`,没有新增第二套状态写入。设备信息同步类型已在 Application 契约中预留,具体执行适配器随 2.41 交付。
- 合并、互斥、最小间隔取消、预期提前完成和每次实际 Gateway 请求统一写 `tb_integration_log`,传播请求 ID、关联 ID、序列 ID、尝试序号、场景、资源、结果、耗时和是否变化请求摘要只保存卡 ID 与同步类型,不保存完整 ICCID。
本任务按 Change 测试延期约定未新增自动化测试,也未新增数据库迁移或同步运行表。序列运行态、幂等键和短时互斥属于 Redis 协调事实,三次可查询结果继续使用公共 Integration Log。
## 流量观测公共写入闭环
统一 `ApplyTrafficObservation` 已收口手动 Gateway 刷新、套餐失效前同步和周期流量轮询使用的流量写入规则。
- 应用用例使用 PostgreSQL `FOR UPDATE` 串行化同一卡观测,并以旧 Gateway 读数作条件更新;检查时间、可信基线、自然月累计、生命周期累计和正增量 Outbox 在同一事务提交。
- 领域规则保留运营商重置日当天及前一天窗口。非重置窗口的下降读数不覆盖可信基线、不累计流量,也不发布扣减事件;零增量只更新时间。
- 自然月切换时保存上月系统累计并初始化本月累计;运营商周期读数与系统自然月累计保持两个独立口径。
- 正增量只发布一次 `card.traffic.incremented` v1 Outbox不在请求事务内直接调用套餐服务避免卡事实成功而扣减失败形成半事务。
- Worker 先将增量写入既有 `traffic:daily:{cardID}:{date}` Redis 缓冲并保留 48 小时,再扣减套餐流量、执行停复机评估;每日落盘任务继续按原覆盖语义写 `tb_card_daily_usage`,不会与请求事务内的增量写互相覆盖。
- `tb_card_observation_effect``event_id` 唯一记录日流量、套餐扣减和停复机评估阶段。重复投递在已完成阶段直接返回;副作用已发出但结果未知时停在处理中,不盲重试造成重复扣减。
- 卡事实提交后才失效轮询缓存。统一 Audit Event 不在本次 Change 范围内Outbox、套餐使用记录、流量事实和 Access Log 边界保持不变。
迁移 `000180` 新增无外键的卡观测副作用进度表,状态为 0-待处理、1-处理中或结果未知、2-日流量已记录、3-套餐流量已扣减、4-全部完成。真实 PostgreSQL、Redis/Asynq、并发与结果未知恢复验证按本轮豁免延期。
## 网络状态观测公共写入闭环
统一 `ApplyNetworkObservation` 已收口手动 Gateway 刷新和周期网络轮询使用的网络状态写入。
- 领域层集中维护 Gateway `正常/停机/准备/待激活` 到本地开停机状态的稳定映射未知状态不以零值覆盖当前网络状态但仍可安全保存本次扩展原因、IMEI、检查时间和同步时间。
- 已知状态变化、Gateway 风险停机/销户扩展、运营商停机原因和网关卡 IMEI 由同一 PostgreSQL `FOR UPDATE` 事务写入;仅真实网络状态变化发布 `card.network.changed` v1 Outbox。
- 独立卡命中“风险停机/已销户”时在同一事务关闭 `enable_polling`;绑定设备的卡和“机卡分离停机”不触发该独立卡终止规则。
- Worker 消费网络变化事件后读取当前权威卡事实执行停复机评估,避免把 Gateway 成功响应直接当成本地停复机事实;已有 Integration Log、停复机资格和 Gateway 失败重排保持在原边界。
- 真实 Gateway 状态映射、风险卡矩阵、未知状态、IMEI、重复事件和事务回滚验证按 6.4/6.1 延期,不能据此标记生产验收完成。
## 三个轮询入口切换
任务 2.37 已将实名、流量、网络三个 Gateway 轮询 Handler 的成功结果应用统一切换到 `CardObservation` 应用服务。Handler 仍保留原有卡资格判断、Redis 分片并发、卡流量互斥、配置间隔、失败重排和监控统计;旧的直接写库、直接扣套餐、直接停复机、直接缓存和风险卡分支已删除,避免新旧路径双写。每次 Gateway 查询前创建 Integration Log查询失败或响应缺关键 ICCID 时记录失败并按原策略重排;请求关联使用 Integration Log ID 贯穿观测事件。手动刷新不生成额外 0/3/5 序列,后续序列由 2.38 负责。
## 实名观测公共写入闭环
统一 `ApplyCardObservation` 现在负责实名观测的唯一事务写入规则,手动 Gateway 刷新和后台人工纠偏已接入;现有实名轮询入口将在任务 2.37 与流量、网络轮询一起完成同批切换,期间不改变轮询配置、分片、间隔或失败重排。
- 标准观测使用类型明确的 `RealnameObservation``ObservationMetadata`,包含来源、场景、观测时间、观测 ID、请求/关联 ID 和脱敏摘要,不把 Gateway、Fiber、GORM、Redis 或 Asynq 类型带入领域层。
- 应用用例使用 PostgreSQL `FOR UPDATE` 锁定卡,并以原实名状态作为条件更新;检查时间、实名状态、首次实名时间、激活派生字段、逆转确认状态和 Outbox 在一个事务内提交。
- `first_realname_at` 仅在历史值为空且本次真实发生未实名到已实名时写入,重复成功或逆转后的再次实名不会覆盖首次时间。
- 已实名卡出现未实名周期观测时,连续三次且处于同一 10 分钟窗口才落为未实名;前两次只更新检查时间和持久化确认窗口。逆转计数保存在 `tb_iot_card`,不再依赖可能与数据库回滚脱节的 Redis 计数。
- 运营商解除实名回调不会增加或清空周期逆转计数,也不会修改本地实名状态;人工纠偏使用独立来源,可立即更正状态。
- 状态真实变化时同事务写 `card.realname.changed` v1 Outbox。Worker 消费者以当前权威卡事实幂等执行首次实名卡/设备套餐激活和停复机评估;有效无变化观测不重复发布副作用。
- 事务提交后才删除轮询卡缓存及遗留 Redis 逆转键;缓存删除失败只记录中文告警,不把已提交事实伪装成回滚。
迁移 `000179` 新增 `realname_reversal_count``realname_reversal_started_at`,并用 CHECK 约束计数只能保存 02达到第三次时状态变化与计数清零在同一事务完成。down 迁移只删除该约束和两个字段。
统一 Audit Event 已按七月总 Change 的最新范围决策移出本次上线,不是 2.34 或生产上线阻塞项Integration Log、Outbox、Domain Ledger 和 Access Log 仍分别承担外部交互恢复、可靠投递、状态事实和 HTTP 调试职责。
## ICCID 精确唯一性
现有迁移 `000131` 已建立 `tb_iot_card.iccid_19/iccid_20` 和普通部分索引。迁移 `000178` 在不改变 ICCID 展示、导入、模糊查询和卡识别规则的前提下,将两个索引升级为未删除数据范围内的部分唯一索引:
- `iccid_19`:全部未删除卡精确唯一;迁移前同时阻断空值、非 19 位和双列不一致。
- `iccid_20`:未删除且非空时精确唯一;非空值必须是与原 ICCID 一致的 20 位值。
- 软删除记录不阻塞相同 ICCID 的合法新记录。
- 迁移不截断、不补位、不跨列匹配,也不自动删除、合并或修正冲突卡。
迁移在同一事务内锁定 `tb_iot_card`,先检查原 ICCID 长度、双列空值/长度及回填一致性,再按目标唯一索引相同的谓词扫描 19 位和 20 位冲突组,最后删除普通索引并以原名创建唯一索引。发现任一异常时只输出异常卡数和冲突组数的中文安全摘要并整体回滚,不留下半完成索引。
## 发布前异常清单
发布负责人在维护窗口执行迁移前,必须分别导出以下清单并指定数据修复责任人。查询结果包含完整 ICCID只能存放在受控运维位置不得写入应用日志或普通工单正文。
### 19 位冲突
```sql
SELECT iccid_19, array_agg(id ORDER BY id) AS card_ids, COUNT(*) AS card_count
FROM tb_iot_card
WHERE deleted_at IS NULL
AND iccid_19 IS NOT NULL
GROUP BY iccid_19
HAVING COUNT(*) > 1
ORDER BY card_count DESC, iccid_19;
```
### 20 位冲突
```sql
SELECT iccid_20, array_agg(id ORDER BY id) AS card_ids, COUNT(*) AS card_count
FROM tb_iot_card
WHERE deleted_at IS NULL
AND iccid_20 IS NOT NULL
AND iccid_20 <> ''
GROUP BY iccid_20
HAVING COUNT(*) > 1
ORDER BY card_count DESC, iccid_20;
```
### 双列异常与不一致
```sql
SELECT id, iccid, iccid_19, iccid_20, carrier_type
FROM tb_iot_card
WHERE deleted_at IS NULL
AND (
LENGTH(iccid) NOT IN (19, 20)
OR iccid_19 IS NULL
OR LENGTH(iccid_19) <> 19
OR (LENGTH(iccid) = 19 AND (iccid_19 IS DISTINCT FROM iccid OR iccid_20 IS NOT NULL))
OR (LENGTH(iccid) = 20 AND (iccid_19 IS DISTINCT FROM LEFT(iccid, 19) OR iccid_20 IS DISTINCT FROM iccid))
)
ORDER BY id;
```
停止条件:任一查询返回记录时不得执行回调路由发布,也不得任取一张卡继续迁移。数据责任人必须核对运营商原始资料、资产归属和历史业务事实,按单独受控方案修复后重新扫描。仅软删除记录与有效卡重复允许存在,但必须单独登记为已确认的非阻塞项。
## 回滚
优先通过四个 `system_config` 开关分别停止业务处理路由继续固定成功应答。down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分索引,不删除双列、不修改卡数据,也不触碰其他表索引。若回调已经开放并依赖精确唯一语义,应先关闭相关开关并确认没有并发写入,再评估回滚。
迁移顺序固定为 `000178`ICCID 唯一性)→ `000179`(实名逆转窗口)→ `000180`(副作用进度)→ `000181`Integration Log conflict 终态。一旦已经产生卡状态、Outbox、Integration Log 或副作用进度事实,不清表、不删除业务事实,也不恢复旧 Writer暂停对应开关和生产者后采用前向修复。
## 验证状态
- 已静态核对 up/down 文件成对、索引名与 `000131` 一致、唯一索引谓词与冲突扫描谓词一致。
- 已静态核对 `000179` up/down 成对、字段注释和 CHECK 约束一致,领域规则与持久化字段没有 Redis 事务依赖。
- 已静态核对 `000180` up/down 成对、副作用状态 CHECK 与常量一致,流量用例不再直接覆盖日流量落盘表。
- 已执行 `gofmt``go build ./...`;构建退出码为 0。
- 已静态核对观测序列任务固定为 0/3/5 分钟、`MaxRetry(0)`、结构化载荷、同场景合并键、`series_id + attempt` 幂等键、流量共享锁和 10 秒默认最小间隔;任务 2.38 未修改业务入口。
- 已静态核对任务 2.39 只接入 issue 07 指定读取与实名链接入口,稳定 OpenAPI 场景不包含账号身份,`realname_link_type=none` 不触发,后台/C 端手动刷新路径没有分发调用。
- 已静态核对任务 2.42 的 XML 根结构、19/20 位精确查询、系统级资源定位、稳定外部事务幂等、载荷冲突留痕、固定成功应答、Access Log 摘要策略及 Handler/路由/文档生成器装配。
- 已静态核对新增联通实名成功回调的双层 JSON、非空变更时间、19/20 位精确查询、语义幂等、公共实名观测、固定成功应答及四个受控系统配置开关。
- 任务 2.45 已完成四个回调 Handler、免认证路由、生产组合根、两个文档生成器、OpenAPI 产物、观测 Worker、Outbox Consumer、API/Worker Gateway Client 和迁移顺序的静态装配检查。
- `docs/admin-openapi.yaml` 已生成电信实名、移动实名、联通实名成功和联通解除实名四条路径;四个开关均通过后台受控 `system_config` 注册,默认关闭。
- 已执行 `gofmt``git diff --check` 和离线 `go build ./...`,构建退出码为 0。按用户要求保留既有 Gateway 与部署 YAML 配置,本任务未修改其配置来源或部署值。
- 当前状态只能标记为“后端代码与测试环境装配完成、真实验证延期”;真实运营商样例、真实 Gateway、19/20 位命中、逐个启停和异常恢复统一转 6.4。
- 按七月测试环境豁免,本轮未连接真实 PostgreSQL、未运行迁移测试无冲突升级、19/20 位冲突、软删除重复和 down/up 重放验证延期到 6.1、6.3,不能标记为生产验收通过。

View File

@@ -0,0 +1,58 @@
# UR#96 店铺业务员归属功能总结
## 本次交付范围
本次完成店铺业务员归属的六个后端纵向切片:持久化、平台创建设置或继承、代理创建安全继承、独立编辑、列表/详情/候选 Query以及通知接收人解析 Port/Adapter。
业务员归属是平台内部业务责任关系,只保存当前店铺自己的 `business_owner_account_id`。它不参与店铺层级、数据权限、佣金、分销或提现计算,也不会因父店铺后续修改而级联变化。
## 创建与编辑契约
- `POST /api/admin/shops` 支持存在性感知的 `business_owner_account_id`:平台/超管可显式设置、显式 `null` 清空;字段缺失时复制直属上级店铺当时保存的原始 ID。
- 代理创建直属下级店铺时不得提交该字段;字段缺失时由服务端复制直属上级店铺当时保存的原始 ID包括已停用或软删除账号的历史 ID。
- `PUT /api/admin/shops/:id` 中字段缺失表示保持不变,显式 `null` 表示清空,正 ID 表示重新绑定。
- 只有超级管理员和平台账号可以人工设置、清空或更换,并在事务内重新校验候选仍为启用、未删除的普通平台账号。
- 店铺、初始主账号、账号角色、店铺角色和主/分佣钱包在同一 GORM 事务内创建,避免多表半成品。
Audit Event 写入按七月测试环境 Change 冻结到任务 6.5,本次没有把审计延期扩散到业务事务、权限或可靠性边界。
## Query 与前端契约
`GET /api/admin/shops` 新增 `business_owner_account_id` 精确筛选,并与其他筛选条件按 AND 组合。创建、编辑、列表和详情统一返回:
- `business_owner_account_id`
- `business_owner_username`
- `business_owner_phone_summary`
- `business_owner_available`
列表只针对当前页收集业务员 ID并通过一次批量查询投影账号名、前三后四手机号摘要和可用状态不产生逐店铺 N+1。软删除账号使用只读历史投影保留摘要并将 `business_owner_available` 标记为 `false`
新增接口:
- `GET /api/admin/shops/:id`:返回与列表一致的店铺及业务员摘要,并继续应用现有店铺数据范围。
- `GET /api/admin/shops/business-owner-candidates`:仅超级管理员和平台账号可调用,只返回启用、未删除的普通平台账号 ID、账号名和手机号摘要支持用户名/手机号关键词、默认 20、最大 100 的分页。
代理端只读展示业务员摘要,不展示候选选择或清空控件。停用或删除账号应显示历史摘要和“不可用”,空归属显示为“-”。
## 通知接收人解析边界
`NotificationRecipientResolver` Port 由 PostgreSQL `RecipientResolver` Adapter 实现。它按目标店铺当前保存的业务员 ID 解析接收人,并同时返回当前启用、未删除的店铺主账号:
- 业务员只有仍为 `user_type=2`、启用且未删除时才返回。
- 店铺主账号只有仍为代理类型、主账号、启用且未删除时才返回。
- 同一账号按稳定账号 ID 去重并排序。
- 店铺不存在、无归属或账号永久不可用时返回空集合,不作为无限重试错误。
- 数据库故障仍返回可重试错误。
- 解析不读取父店铺、祖先店铺或创建人,不会把代理数据权限误当作通知关系。
该接缝供公共站内通知的动态接收人解析复用UR#96 本身不实现套餐临期、钱包低余额等业务触发规则,也不发送短信或企业微信通知。
## 迁移、发布与回滚
迁移 `000169_add_shop_business_owner``tb_shop` 增加 nullable bigint 字段和普通索引,不建立外键、不回填存量数据、不运行父子级联脚本。
发布前应只读核验店铺层级异常和平台账号状态;发布后抽查平台显式设置/清空、代理继承、父级修改不级联、历史不可用账号展示和接收人解析。应用回滚应保留字段和已产生的历史归属down 迁移检测到任何非空归属时会拒绝删列,要求前向修复。
## 当前验证状态
已执行 `gofmt``git diff --check``go build ./...`。按本 Change 的测试环境豁免,本次未新增或运行 `_test.go`,也未连接真实 PostgreSQL/Redis集成、HTTP、迁移演练和前端人工验收分别转任务 6.1、6.3 和 6.6。

View File

@@ -1,66 +1,12 @@
BEGIN;
-- 本脚本已停用。
--
-- 原实现会直接修改代理主钱包余额,绕过 Wallet Application、Domain Ledger、
-- 幂等资金流水和 Outbox因此在信用钱包切换后不再允许执行。
-- 如需清理代理主钱包余额,必须先建立具有稳定业务单号的受控 Application 用例,
-- 并在同一事务内写入权威资金流水与可靠事件;禁止恢复本文件中的历史直写逻辑。
-- 锁定代理主钱包,确认余额和冻结余额
SELECT *
FROM tb_agent_wallet
WHERE shop_id = :shop_id
AND wallet_type = 'main'
AND deleted_at IS NULL
FOR UPDATE;
-- 确认 frozen_balance = 0 且 balance > 0 后执行
INSERT INTO tb_agent_wallet_transaction (
agent_wallet_id,
shop_id,
user_id,
transaction_type,
amount,
balance_before,
balance_after,
status,
reference_type,
reference_id,
remark,
metadata,
creator,
shop_id_tag,
enterprise_id_tag,
created_at,
updated_at
)
SELECT
id,
shop_id,
:operator_user_id,
'deduct',
-balance,
balance,
0,
1,
NULL,
NULL,
'平台清理代理预充值剩余余额,历史消费流水保留',
jsonb_build_object('reason', 'clear_agent_main_wallet_balance'),
:operator_user_id,
shop_id_tag,
enterprise_id_tag,
NOW(),
NOW()
FROM tb_agent_wallet
WHERE shop_id = :shop_id
AND wallet_type = 'main'
AND deleted_at IS NULL
AND balance > 0
AND frozen_balance = 0;
UPDATE tb_agent_wallet
SET balance = 0,
version = version + 1,
updated_at = NOW()
WHERE shop_id = :shop_id
AND wallet_type = 'main'
AND deleted_at IS NULL
AND balance > 0
AND frozen_balance = 0;
COMMIT;
DO $$
BEGIN
RAISE EXCEPTION '代理主钱包直写清理脚本已停用,请使用受控 Wallet Application 用例';
END
$$;

View File

@@ -0,0 +1,127 @@
package approval
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CreationService 实现业务侧 Approval Port并保持渠道前置检查与业务事务分离。
type CreationService struct {
providers ProviderPort
repositories RepositoryProvider
eventWriter SubmissionEventWriter
now func() time.Time
}
// NewCreationService 创建通用审批申请创建用例。
func NewCreationService(
providers ProviderPort,
repositories RepositoryProvider,
eventWriter SubmissionEventWriter,
now func() time.Time,
) *CreationService {
if now == nil {
now = time.Now
}
return &CreationService{providers: providers, repositories: repositories, eventWriter: eventWriter, now: now}
}
// Prepare 在任何业务事实写入前确认 Adapter、场景和真实发起身份可用。
func (s *CreationService) Prepare(ctx context.Context, request PrepareRequest) (Preparation, error) {
if s == nil || s.providers == nil || s.repositories == nil || s.eventWriter == nil {
return Preparation{}, errors.New(errors.CodeServiceUnavailable, "审批能力尚未配置")
}
request.BusinessType = strings.TrimSpace(request.BusinessType)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
if request.BusinessType == "" || request.SubmitterAccountID == 0 || request.CorrelationID == "" {
return Preparation{}, errors.New(errors.CodeInvalidParam)
}
providerContext, err := s.providers.Prepare(ctx, request)
if err != nil {
return Preparation{}, err
}
providerContext.Provider = strings.TrimSpace(providerContext.Provider)
if providerContext.Provider == "" {
return Preparation{}, errors.New(errors.CodeServiceUnavailable, "审批渠道未返回有效 provider")
}
return Preparation{
provider: providerContext.Provider, businessType: request.BusinessType,
submitterAccountID: request.SubmitterAccountID, correlationID: request.CorrelationID,
expiresAt: s.now().UTC().Add(constants.ApprovalPreparationTTL), issuer: s,
providerContext: providerContext,
}, nil
}
// CreateInTx 使用调用方业务事务原子创建通用实例、渠道上下文和提交 Outbox。
func (s *CreationService) CreateInTx(ctx context.Context, tx *gorm.DB, request CreateRequest) (Reference, error) {
if s == nil || tx == nil || s.repositories == nil || s.providers == nil || s.eventWriter == nil {
return Reference{}, errors.New(errors.CodeInternalError, "通用审批创建用例未完整配置")
}
now := s.now().UTC()
if err := s.validatePreparation(request, now); err != nil {
return Reference{}, err
}
instance, err := approvaldomain.NewInstance(approvaldomain.NewInstanceParams{
BusinessType: request.BusinessType, BusinessID: request.BusinessID,
SubmitterAccountID: request.SubmitterAccountID, SubmitterSnapshot: request.SubmitterSnapshot,
Provider: request.Preparation.provider, RequestSnapshot: request.RequestSnapshot,
CorrelationID: request.CorrelationID,
}, now)
if err != nil {
return Reference{}, err
}
repository := s.repositories.ForDB(tx)
if repository == nil {
return Reference{}, errors.New(errors.CodeInternalError, "通用审批 Repository 未配置")
}
if err := repository.Create(ctx, instance); err != nil {
return Reference{}, err
}
if err := s.providers.CreateContextInTx(ctx, tx, request.Preparation.providerContext, instance.ID); err != nil {
return Reference{}, err
}
event := SubmissionRequestedEvent{
EventID: "approval:" + strconv.FormatUint(uint64(instance.ID), 10) + ":submission",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, Provider: instance.Provider,
CorrelationID: instance.CorrelationID, OccurredAt: instance.CreatedAt,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return Reference{}, err
}
return Reference{InstanceID: instance.ID, Status: instance.Status}, nil
}
func (s *CreationService) validatePreparation(request CreateRequest, now time.Time) error {
preparation := request.Preparation
if preparation.issuer != s || preparation.expiresAt.IsZero() || !preparation.expiresAt.After(now) {
return errors.New(errors.CodeServiceUnavailable, "审批可用性检查已失效,请重新提交")
}
if request.BusinessType != preparation.businessType ||
request.SubmitterAccountID != preparation.submitterAccountID ||
request.CorrelationID != preparation.correlationID {
return errors.New(errors.CodeInvalidParam, "审批准备结果与业务申请不匹配")
}
return nil
}
// UnavailableProviderPort 在当前环境未装配有效审批 Adapter 时失败关闭。
type UnavailableProviderPort struct{}
// Prepare 拒绝在缺少有效 Adapter、场景或发起身份时创建业务审批。
func (UnavailableProviderPort) Prepare(_ context.Context, _ PrepareRequest) (ProviderPreparation, error) {
return ProviderPreparation{}, errors.New(errors.CodeServiceUnavailable, "当前环境没有可用审批渠道")
}
// CreateContextInTx 防止未配置渠道上下文时误写审批事实。
func (UnavailableProviderPort) CreateContextInTx(_ context.Context, _ *gorm.DB, _ ProviderPreparation, _ uint) error {
return errors.New(errors.CodeServiceUnavailable, "当前环境没有可用审批渠道")
}

View File

@@ -0,0 +1,85 @@
package approval
import (
"context"
"time"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DecisionProcessingStore 管理标准决策交给业务消费者时的处理租约。
type DecisionProcessingStore interface {
Claim(ctx context.Context, eventID string, owner string, now time.Time, duration time.Duration) (bool, error)
MarkSucceeded(ctx context.Context, eventID string, owner string, now time.Time) (bool, error)
MarkFailed(ctx context.Context, eventID string, owner string, now time.Time, errorSummary string) (bool, error)
}
// BusinessDecisionHandler 消费渠道无关标准决策。
// 实现必须以审批实例 ID 和决策作为业务幂等键,并且不得依赖任何渠道 SDK、DTO 或状态码。
type BusinessDecisionHandler interface {
Handle(ctx context.Context, event TerminalDecisionEvent) error
}
// DecisionDispatcher 使用处理租约把标准决策交给对应业务消费者。
type DecisionDispatcher struct {
store DecisionProcessingStore
handlers map[string]BusinessDecisionHandler
owner string
logger *zap.Logger
now func() time.Time
}
// NewDecisionDispatcher 创建通用审批标准决策分发器。
func NewDecisionDispatcher(
store DecisionProcessingStore,
handlers map[string]BusinessDecisionHandler,
owner string,
logger *zap.Logger,
now func() time.Time,
) *DecisionDispatcher {
if logger == nil {
logger = zap.NewNop()
}
if now == nil {
now = time.Now
}
return &DecisionDispatcher{store: store, handlers: handlers, owner: owner, logger: logger, now: now}
}
// Consume 幂等消费一条标准决策;重复投递或其他有效租约正在处理时正常结束。
func (d *DecisionDispatcher) Consume(ctx context.Context, event TerminalDecisionEvent) error {
if d == nil || d.store == nil || d.owner == "" || event.EventID == "" || event.InstanceID == 0 || event.BusinessType == "" {
return errors.New(errors.CodeInternalError, "通用审批决策分发器未完整配置")
}
handler := d.handlers[event.BusinessType]
if handler == nil {
return errors.New(errors.CodeServiceUnavailable, "审批业务消费者尚未注册")
}
now := d.now().UTC()
claimed, err := d.store.Claim(ctx, event.EventID, d.owner, now, constants.ApprovalDecisionDeliveryLeaseDuration)
if err != nil {
return err
}
if !claimed {
return nil
}
if err := handler.Handle(ctx, event); err != nil {
if _, markErr := d.store.MarkFailed(ctx, event.EventID, d.owner, d.now().UTC(), "业务消费者处理失败"); markErr != nil {
d.logger.Error("审批标准决策失败状态保存失败",
zap.String("event_id", event.EventID), zap.Uint("approval_instance_id", event.InstanceID),
zap.String("business_type", event.BusinessType), zap.Error(markErr))
}
return err
}
marked, err := d.store.MarkSucceeded(ctx, event.EventID, d.owner, d.now().UTC())
if err != nil {
return err
}
if !marked {
return errors.New(errors.CodeConflict, "审批标准决策处理租约已失效")
}
return nil
}

View File

@@ -0,0 +1,81 @@
// Package approval 定义业务用例依赖的渠道无关审批接缝。
package approval
import (
"context"
"time"
"gorm.io/gorm"
)
// PrepareRequest 是业务写入前执行审批渠道可用性检查的请求。
type PrepareRequest struct {
BusinessType string
SubmitterAccountID uint
CorrelationID string
}
// Preparation 是渠道可用性检查返回的短期、不透明准备凭据。
type Preparation struct {
provider string
businessType string
submitterAccountID uint
correlationID string
expiresAt time.Time
issuer *CreationService
providerContext ProviderPreparation
}
// CreateRequest 是业务事务内创建通用审批实例的请求。
type CreateRequest struct {
Preparation Preparation
BusinessType string
BusinessID uint
SubmitterAccountID uint
SubmitterSnapshot []byte
RequestSnapshot []byte
CorrelationID string
}
// Reference 是业务表保存的通用审批实例引用。
type Reference struct {
InstanceID uint
Status int
}
// Port 是退款、线下充值等业务用例唯一依赖的审批创建接缝。
// Prepare 必须在业务事务前确认 Adapter、场景和发起身份可用CreateInTx 必须复核准备凭据并使用调用方事务写入审批事实。
type Port interface {
Prepare(ctx context.Context, request PrepareRequest) (Preparation, error)
CreateInTx(ctx context.Context, tx *gorm.DB, request CreateRequest) (Reference, error)
}
// ProviderPreparation 是渠道 Adapter 在事务前完成场景和发起身份检查后返回的内部准备结果。
// ChannelContext 只能包含后续写入渠道专属表所需的安全快照,不得包含密钥或访问令牌。
type ProviderPreparation struct {
Provider string
ChannelContext []byte
}
// ProviderPort 定义具体审批渠道对通用创建用例提供的防腐接缝。
type ProviderPort interface {
Prepare(ctx context.Context, request PrepareRequest) (ProviderPreparation, error)
CreateContextInTx(ctx context.Context, tx *gorm.DB, preparation ProviderPreparation, instanceID uint) error
}
// SubmissionRequestedEvent 是渠道提交 Worker 接收的通用申请事件。
type SubmissionRequestedEvent struct {
EventID string `json:"event_id"`
InstanceID uint `json:"instance_id"`
BusinessType string `json:"business_type"`
BusinessID uint `json:"business_id"`
SubmitterAccountID uint `json:"submitter_account_id"`
Provider string `json:"provider"`
CorrelationID string `json:"correlation_id"`
OccurredAt time.Time `json:"occurred_at"`
}
// SubmissionEventWriter 在调用方业务事务中追加渠道提交 Outbox。
type SubmissionEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event SubmissionRequestedEvent) error
}

View File

@@ -0,0 +1,145 @@
package approval
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RepositoryProvider 为当前 GORM 事务提供纯领域 Repository。
type RepositoryProvider interface {
ForDB(db *gorm.DB) approvaldomain.Repository
}
// TerminalDecisionEvent 是业务消费者接收的渠道无关标准决策事件。
type TerminalDecisionEvent struct {
EventID string `json:"event_id"`
InstanceID uint `json:"instance_id"`
BusinessType string `json:"business_type"`
BusinessID uint `json:"business_id"`
SubmitterAccountID uint `json:"submitter_account_id"`
Decision string `json:"decision"`
Source string `json:"source"`
CorrelationID string `json:"correlation_id"`
OccurredAt time.Time `json:"occurred_at"`
}
// TerminalEventWriter 在审批状态事务中追加标准决策 Outbox。
type TerminalEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event TerminalDecisionEvent) error
}
// DecisionDeliveryWriter 在审批状态事务中创建业务消费租约事实。
type DecisionDeliveryWriter interface {
Create(ctx context.Context, tx *gorm.DB, event TerminalDecisionEvent) error
}
// SyncDecisionCommand 是回调、兜底轮询和受控人工同步共用的标准决策命令。
type SyncDecisionCommand struct {
InstanceID uint
Decision string
DecisionSnapshot []byte
Source string
}
// SyncDecisionResult 返回本次是否首次记录该标准终态。
type SyncDecisionResult struct {
Status int
FirstTerminal bool
}
// SyncDecisionService 统一处理各审批渠道回传的标准决策。
type SyncDecisionService struct {
db *gorm.DB
repositories RepositoryProvider
eventWriter TerminalEventWriter
deliveryWriter DecisionDeliveryWriter
now func() time.Time
}
// NewSyncDecisionService 创建标准决策同步用例。
func NewSyncDecisionService(
db *gorm.DB,
repositories RepositoryProvider,
eventWriter TerminalEventWriter,
deliveryWriter DecisionDeliveryWriter,
now func() time.Time,
) *SyncDecisionService {
if now == nil {
now = time.Now
}
return &SyncDecisionService{
db: db, repositories: repositories, eventWriter: eventWriter, deliveryWriter: deliveryWriter, now: now,
}
}
// Execute 将回调或轮询取得的权威渠道状态原子转换为通用审批终态和可靠业务事件。
func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionCommand) (*SyncDecisionResult, error) {
if s == nil || s.db == nil || s.repositories == nil || s.eventWriter == nil || s.deliveryWriter == nil {
return nil, errors.New(errors.CodeInternalError, "通用审批决策同步用例未完整配置")
}
if command.InstanceID == 0 || !isSupportedSyncSource(command.Source) {
return nil, errors.New(errors.CodeInvalidParam)
}
result := &SyncDecisionResult{}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
repository := s.repositories.ForDB(tx)
if repository == nil {
return errors.New(errors.CodeInternalError, "通用审批 Repository 未配置")
}
instance, err := repository.GetForUpdate(ctx, command.InstanceID)
if err != nil {
return err
}
expectedStatus, expectedVersion := instance.Status, instance.Version
changed, err := instance.ApplyDecision(command.Decision, command.DecisionSnapshot, s.now().UTC())
if err != nil {
return err
}
result.Status = instance.Status
if !changed {
return nil
}
saved, err := repository.SaveDecision(ctx, instance, expectedStatus, expectedVersion)
if err != nil {
return err
}
if !saved {
return errors.New(errors.CodeConflict, "审批状态已被其他同步任务更新")
}
event := TerminalDecisionEvent{
EventID: terminalDecisionEventID(instance.ID, command.Decision),
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, Decision: command.Decision, Source: command.Source,
CorrelationID: instance.CorrelationID, OccurredAt: instance.StatusChangedAt,
}
if err := s.deliveryWriter.Create(ctx, tx, event); err != nil {
return err
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return err
}
result.FirstTerminal = true
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func terminalDecisionEventID(instanceID uint, decision string) string {
return "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":" + decision
}
func isSupportedSyncSource(source string) bool {
return source == constants.ApprovalSyncSourceCallback ||
source == constants.ApprovalSyncSourcePolling ||
source == constants.ApprovalSyncSourceManual
}

View File

@@ -0,0 +1,120 @@
// Package cardobservation 提供卡实名观测的复杂写用例。
package cardobservation
import (
"context"
"strconv"
"time"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RealnameChangedEvent 是卡实名状态变化的可靠领域事实。
type RealnameChangedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
BeforeStatus int `json:"before_status"`
AfterStatus int `json:"after_status"`
FirstVerified bool `json:"first_verified"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// EventWriter 在卡状态事务中追加领域 Outbox 事件。
type EventWriter interface {
AppendRealname(ctx context.Context, tx *gorm.DB, event RealnameChangedEvent) error
AppendTraffic(ctx context.Context, tx *gorm.DB, event TrafficIncrementedEvent) error
AppendNetwork(ctx context.Context, tx *gorm.DB, event NetworkChangedEvent) error
}
// CacheInvalidator 在业务事务提交后失效卡缓存。
type CacheInvalidator interface {
Invalidate(ctx context.Context, cardID uint)
}
// Service 负责卡实名观测的锁定、规则应用和可靠事件写入。
type Service struct {
db *gorm.DB
eventWriter EventWriter
cache CacheInvalidator
}
// NewService 创建卡实名观测应用服务。
func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *Service {
return &Service{db: db, eventWriter: eventWriter, cache: cache}
}
// ApplyCardObservation 在同一事务中应用实名状态、逆转窗口和状态变更事件。
func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.RealnameObservation) (domain.RealnameDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.RealnameDecision{}, errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置")
}
var decision domain.RealnameDecision
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
}
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
}, observation)
if decisionErr != nil {
return decisionErr
}
decision = nextDecision
updates := map[string]any{
"last_real_name_check_at": observation.Metadata.ObservedAt,
"realname_reversal_count": decision.ReversalCount,
"realname_reversal_started_at": decision.ReversalStartedAt,
}
if observation.Metadata.Source != constants.CardObservationSourceManualOverride {
updates["last_sync_time"] = observation.Metadata.ObservedAt
}
if decision.StatusChanged {
updates["real_name_status"] = decision.AfterStatus
updates["activation_status"] = gorm.Expr(`CASE WHEN network_status = ? AND (card_category = ? OR ? = ?) THEN 1 ELSE 0 END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, decision.AfterStatus, constants.RealNameStatusVerified)
updates["activated_at"] = gorm.Expr(`CASE WHEN activated_at IS NULL AND (network_status = ? AND (card_category = ? OR ? = ?)) THEN ? ELSE activated_at END`, constants.NetworkStatusOnline, constants.CardCategoryIndustry, decision.AfterStatus, constants.RealNameStatusVerified, observation.Metadata.ObservedAt)
}
if decision.FirstVerified {
updates["first_realname_at"] = observation.Metadata.ObservedAt
}
result := tx.Model(&model.IotCard{}).Where("id = ? AND real_name_status = ?", card.ID, card.RealNameStatus).Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡实名事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡实名状态已被其他请求更新")
}
if decision.StatusChanged {
eventID := "card-realname:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
if err := s.eventWriter.AppendRealname(ctx, tx, RealnameChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.RealNameStatus, AfterStatus: decision.AfterStatus,
FirstVerified: decision.FirstVerified, ObservedAt: observation.Metadata.ObservedAt,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名 Outbox 事件失败")
}
}
return nil
})
if err != nil {
return domain.RealnameDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}

View File

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

View File

@@ -0,0 +1,98 @@
package cardobservation
import (
"context"
"strconv"
"time"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// NetworkChangedEvent 是卡网络状态变化的可靠领域事实。
type NetworkChangedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
BeforeStatus int `json:"before_status"`
AfterStatus int `json:"after_status"`
GatewayExtend string `json:"gateway_extend"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// ApplyNetworkObservation 串行应用 Gateway 网络状态、扩展原因、IMEI 和风险轮询规则。
func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domain.NetworkObservation) (domain.NetworkDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.NetworkDecision{}, errors.New(errors.CodeInternalError, "卡网络观测能力未完整配置")
}
var decision domain.NetworkDecision
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败")
}
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling,
}, observation)
if decisionErr != nil {
return decisionErr
}
decision = nextDecision
updates := map[string]any{
"last_card_status_check_at": observation.Metadata.ObservedAt,
"last_sync_time": observation.Metadata.ObservedAt,
"gateway_extend": decision.GatewayExtend,
}
if decision.UpdateIMEI {
updates["gateway_card_imei"] = decision.GatewayIMEI
}
if decision.StatusChanged {
updates["network_status"] = decision.AfterStatus
}
if decision.StopReasonChanged {
updates["stop_reason"] = decision.StopReason
}
if decision.StopPolling {
updates["enable_polling"] = false
}
result := tx.Model(&model.IotCard{}).
Where("id = ? AND network_status = ? AND enable_polling = ?", card.ID, card.NetworkStatus, card.EnablePolling).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡网络事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
}
if !decision.StatusChanged {
return nil
}
eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus,
GatewayExtend: decision.GatewayExtend, ObservedAt: observation.Metadata.ObservedAt,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡网络 Outbox 事件失败")
}
return nil
})
if err != nil {
return domain.NetworkDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}

View File

@@ -0,0 +1,343 @@
package cardobservation
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesRequest 描述一次业务成功边界产生的观测序列请求。
type SeriesRequest struct {
Scene string `json:"scene"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
SyncType string `json:"sync_type"`
ExpectedValue string `json:"expected_value,omitempty"`
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// DeviceCardsSeriesRequest 描述需要在后台展开设备有效绑定卡的观测请求。
type DeviceCardsSeriesRequest struct {
DeviceID uint
Request SeriesRequest
}
// DeviceControlSeriesRequest 描述设备控制成功后需要在后台解析的差异化观测资源。
type DeviceControlSeriesRequest struct {
DeviceID uint
TargetICCID string
SourceCardID uint
TargetCardID uint
BoundCardIDs []uint
IncludeTargetTraffic bool
Request SeriesRequest
}
// RealnameCapabilitySeriesRequest 描述需要在后台判断运营商实名能力的观测请求。
type RealnameCapabilitySeriesRequest struct {
CarrierID uint
Request SeriesRequest
}
// SeriesTaskPayload 是固定三次 Asynq 任务的结构化载荷。
type SeriesTaskPayload struct {
SeriesID string `json:"series_id"`
Attempt int `json:"attempt"`
ScheduledAt time.Time `json:"scheduled_at"`
Scene string `json:"scene"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
SyncType string `json:"sync_type"`
ExpectedValue string `json:"expected_value,omitempty"`
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// RunResult 描述一次实际 Gateway 请求及公共观测应用结果。
type RunResult struct {
StateChanged bool
RateLimited bool
}
// SeriesCoordinator 管理活跃序列、尝试幂等和实际请求互斥。
type SeriesCoordinator interface {
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)
FinishAttempt(ctx context.Context, payload SeriesTaskPayload) error
CompleteSeries(ctx context.Context, payload SeriesTaskPayload) error
CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error
}
// SeriesScheduler 提交固定的零重试观测任务。
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
RecordMerged(ctx context.Context, request SeriesRequest, seriesID string) error
}
// SeriesRunner 查询本地快照并执行一次 Gateway 公共观测。
type SeriesRunner interface {
Provider(ctx context.Context, payload SeriesTaskPayload) (string, error)
ExpectationMet(ctx context.Context, payload SeriesTaskPayload) (bool, error)
Run(ctx context.Context, payload SeriesTaskPayload) (RunResult, error)
}
// SeriesTrigger 创建或合并固定的立即、3 分钟、5 分钟任务序列。
type SeriesTrigger struct {
coordinator SeriesCoordinator
scheduler SeriesScheduler
logger SeriesAttemptLogger
now func() time.Time
}
// NewSeriesTrigger 创建观测序列触发器。
func NewSeriesTrigger(coordinator SeriesCoordinator, scheduler SeriesScheduler, logger SeriesAttemptLogger) *SeriesTrigger {
return &SeriesTrigger{coordinator: coordinator, scheduler: scheduler, logger: logger, now: time.Now}
}
// 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, 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: 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
}
}
if merged {
if err := s.logger.RecordMerged(ctx, request, seriesID); err != nil {
return "", true, err
}
}
return seriesID, merged, nil
}
// SeriesAttemptService 执行单次事件观测,不改变后续阶梯任务。
type SeriesAttemptService struct {
coordinator SeriesCoordinator
runner SeriesRunner
logger SeriesAttemptLogger
}
// NewSeriesAttemptService 创建序列尝试服务。
func NewSeriesAttemptService(coordinator SeriesCoordinator, runner SeriesRunner, logger SeriesAttemptLogger) *SeriesAttemptService {
return &SeriesAttemptService{coordinator: coordinator, runner: runner, logger: logger}
}
// Execute 执行一次幂等尝试;失败只结束当前任务。
func (s *SeriesAttemptService) Execute(ctx context.Context, payload SeriesTaskPayload) error {
if s == nil || s.coordinator == nil || s.runner == nil || s.logger == nil {
return errors.New(errors.CodeInternalError, "卡观测序列执行能力未完整配置")
}
if err := validateSeriesPayload(payload); err != nil {
return err
}
claimed, err := s.coordinator.ClaimAttempt(ctx, payload.SeriesID, payload.Attempt)
if err != nil || !claimed {
return err
}
defer func() { _ = s.coordinator.FinishAttempt(context.Background(), payload) }()
completed, err := s.coordinator.IsCompleted(ctx, payload.SeriesID)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "读取序列完成状态失败", err)
}
if completed {
return s.completeRemainingAttempts(ctx, payload, "序列已提前完成")
}
met, err := s.runner.ExpectationMet(ctx, payload)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "读取本地预期快照失败", err)
}
if met {
return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期")
}
provider, err := s.runner.Provider(ctx, payload)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "解析运营商接入失败", err)
}
release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider)
if err != nil {
return s.recordPreGatewayFailure(ctx, payload, "获取实际请求互斥失败", err)
}
if !acquired {
return s.logger.Record(ctx, payload, constants.IntegrationResultIgnored, "实际 Gateway 请求正在执行")
}
defer release()
if wait > 0 {
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-ctx.Done():
return s.logger.Record(ctx, payload, constants.IntegrationResultRateLimited, "最小请求间隔等待被取消")
case <-timer.C:
}
}
result, err := s.runner.Run(ctx, payload)
if result.RateLimited {
return nil
}
if err != nil {
return err
}
if payload.Attempt == constants.CardObservationSeriesAttemptCount {
return s.coordinator.CompleteSeries(ctx, payload)
}
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++ {
remaining := payload
remaining.Attempt = attempt
remaining.ScheduledAt = baseTime.Add(constants.CardObservationAttemptDelay(attempt))
if err := s.logger.Record(ctx, remaining, constants.IntegrationResultCompleted, reason); err != nil {
return err
}
}
return s.coordinator.CompleteSeries(ctx, payload)
}
// CompleteResourceSeries 供可信回调在公共观测成功后提前完成同资源序列。
func (s *SeriesAttemptService) CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error {
if s == nil || s.coordinator == nil {
return errors.New(errors.CodeInternalError, "卡观测序列协调器未配置")
}
return s.coordinator.CompleteResourceSeries(ctx, resourceType, resourceID, syncType)
}
func validateSeriesRequest(request SeriesRequest) error {
if strings.TrimSpace(request.Scene) == "" || strings.TrimSpace(request.ResourceType) == "" ||
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
}
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, 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 {
switch syncType {
case constants.CardObservationSyncTypeRealname, constants.CardObservationSyncTypeTraffic,
constants.CardObservationSyncTypeNetwork, constants.CardObservationSyncTypeDeviceInfo:
return true
default:
return false
}
}

View File

@@ -0,0 +1,86 @@
package cardobservation
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TrafficIncrementedEvent 是卡流量正增量的可靠领域事实。
type TrafficIncrementedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
IncrementMB float64 `json:"increment_mb"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// ApplyTrafficObservation 串行应用流量读数,并在正增量时同事务写 Outbox。
func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domain.TrafficObservation) (domain.TrafficDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
}
var decision domain.TrafficDecision
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "IoT卡不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
}
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
LastGatewayReadingMB: card.LastGatewayReadingMB,
}, observation)
if decisionErr != nil {
return decisionErr
}
decision = nextDecision
updates := map[string]any{
"last_data_check_at": observation.Metadata.ObservedAt, "last_sync_time": observation.Metadata.ObservedAt,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"current_month_usage_mb": decision.CurrentMonthUsageMB, "data_usage_mb": decision.DataUsageMB,
}
if decision.ReadingAccepted {
updates["last_gateway_reading_mb"] = decision.LastGatewayReadingMB
}
result := tx.Model(&model.IotCard{}).
Where("id = ? AND last_gateway_reading_mb = ?", card.ID, card.LastGatewayReadingMB).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
}
if decision.IncrementMB <= 0 {
return nil
}
eventID := "card-traffic:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":incremented"
return s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
CorrelationID: observation.Metadata.CorrelationID,
})
})
if err != nil {
return domain.TrafficDecision{}, err
}
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
return decision, nil
}

View File

@@ -0,0 +1,252 @@
// Package notification 提供站内通知简单写用例与 Outbox 消费边界。
package notification
import (
"context"
"strings"
"time"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AdminDirectPayload 是明确后台账号通知的结构化 Outbox 载荷。
type AdminDirectPayload struct {
RecipientID uint `json:"recipient_id"`
NotificationType string `json:"notification_type"`
TemplateData map[string]string `json:"template_data"`
RefType string `json:"ref_type,omitempty"`
RefID string `json:"ref_id,omitempty"`
RefKey string `json:"ref_key,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// PersonalCustomerDirectPayload 是明确个人客户通知的结构化 Outbox 载荷。
type PersonalCustomerDirectPayload = AdminDirectPayload
// AdminDynamicPayload 是按账号、平台角色或店铺动态解析后台接收人的结构化 Outbox 载荷。
type AdminDynamicPayload struct {
TargetKind string `json:"target_kind"`
TargetID uint `json:"target_id"`
NotificationType string `json:"notification_type"`
TemplateData map[string]string `json:"template_data"`
RefType string `json:"ref_type,omitempty"`
RefID string `json:"ref_id,omitempty"`
RefKey string `json:"ref_key,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
type deliveryRequest struct {
notificationType string
templateData map[string]string
refType string
refID string
refKey string
expiresAt *time.Time
}
// DeliveryService 校验接收人并幂等生成站内通知。
type DeliveryService struct {
repository *notificationinfra.Repository
registry *notificationinfra.Registry
resolver DynamicRecipientResolver
logger *zap.Logger
now func() time.Time
}
// NewDeliveryService 创建站内通知投递用例。
func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger) *DeliveryService {
if logger == nil {
logger = zap.NewNop()
}
return &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now}
}
// Consume 消费明确或动态接收人通知事件;所有可恢复错误交给 Asynq 重试策略处理。
func (s *DeliveryService) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if envelope.PayloadVersion != constants.NotificationPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "通知事件类型或载荷版本不受支持")
}
if envelope.EventType == constants.OutboxEventTypeAdminDynamicNotification {
return s.consumeDynamic(ctx, envelope)
}
return s.consumeDirect(ctx, envelope)
}
func (s *DeliveryService) consumeDirect(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
recipientKind, err := recipientKindForDirectEvent(envelope.EventType)
if err != nil {
return err
}
var payload AdminDirectPayload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "通知事件载荷格式错误")
}
if payload.RecipientID == 0 || payload.NotificationType == "" {
return errors.New(errors.CodeInvalidParam, "通知事件载荷不完整")
}
request := deliveryRequest{
notificationType: payload.NotificationType, templateData: payload.TemplateData,
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
}
if err := validateDeliveryRequest(request); err != nil {
return err
}
return s.deliver(ctx, envelope.EventID, recipientKind, []uint{payload.RecipientID}, request)
}
func (s *DeliveryService) consumeDynamic(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
var payload AdminDynamicPayload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "通知事件载荷格式错误")
}
if payload.TargetKind == "" || payload.TargetID == 0 || payload.NotificationType == "" {
return errors.New(errors.CodeInvalidParam, "通知事件载荷不完整")
}
if s.resolver == nil {
return errors.New(errors.CodeInternalError, "通知动态接收人解析器未配置")
}
request := deliveryRequest{
notificationType: payload.NotificationType, templateData: payload.TemplateData,
refType: payload.RefType, refID: payload.RefID, refKey: payload.RefKey, expiresAt: payload.ExpiresAt,
}
if err := validateDeliveryRequest(request); err != nil {
return err
}
recipientIDs, err := s.resolver.Resolve(ctx, payload.TargetKind, payload.TargetID)
if err != nil {
s.logger.Error("站内通知接收人解析失败",
zap.String("event_id", envelope.EventID), zap.String("notification_type", payload.NotificationType),
zap.String("target_kind", payload.TargetKind), zap.Uint("target_id", payload.TargetID),
zap.String("failure_category", "recipient_resolution"))
return err
}
if len(recipientIDs) == 0 {
s.logger.Info("站内通知暂无可用接收人,已正常结束",
zap.String("event_id", envelope.EventID), zap.String("target_kind", payload.TargetKind), zap.Uint("target_id", payload.TargetID),
zap.String("resolution", "no_recipient"))
return nil
}
return s.deliver(ctx, envelope.EventID, constants.NotificationRecipientKindAccount, recipientIDs, request)
}
func validateDeliveryRequest(request deliveryRequest) error {
if strings.Contains(request.refID, "://") || strings.Contains(request.refKey, "://") {
return errors.New(errors.CodeInvalidParam, "通知资源引用禁止包含任意 URL")
}
if request.refType == "" && (request.refID != "" || request.refKey != "") {
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少受控类型")
}
if request.refType != "" && request.refID == "" && request.refKey == "" {
return errors.New(errors.CodeInvalidParam, "通知资源引用缺少定位值")
}
return nil
}
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
if err != nil {
s.logger.Error("站内通知模板校验失败",
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
zap.String("failure_category", "template"))
return errors.Wrap(errors.CodeInvalidParam, err, "站内通知模板校验失败")
}
now := s.now().UTC()
expiresAt, err := notificationDisplayExpiry(rendered.Category, request.expiresAt, now)
if err != nil {
s.logger.Error("站内通知展示期限校验失败",
zap.String("event_id", eventID), zap.String("notification_type", request.notificationType),
zap.String("failure_category", "display_policy"))
return err
}
for _, recipientID := range recipientIDs {
active, err := s.isActiveRecipient(ctx, recipientKind, recipientID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "校验通知接收人失败")
}
if !active {
s.logger.Info("站内通知接收人不可用,已跳过",
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
continue
}
notification := &model.Notification{
EventID: eventID, RecipientKind: recipientKind,
RecipientID: recipientID, Category: rendered.Category, Type: rendered.Type,
Severity: rendered.Severity, Title: rendered.Title, Body: rendered.Body,
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
ExpiresAt: expiresAt, CreatedAt: now,
}
created, err := s.repository.CreateIdempotent(ctx, notification)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
}
if !created {
s.logger.Info("站内通知重复事件已幂等忽略",
zap.String("event_id", eventID), zap.String("recipient_kind", recipientKind), zap.Uint("recipient_id", recipientID))
}
}
return nil
}
func notificationDisplayExpiry(category string, requested *time.Time, now time.Time) (*time.Time, error) {
switch category {
case constants.NotificationCategoryApproval:
return nil, nil
case constants.NotificationCategoryExpiry:
if requested == nil {
return nil, errors.New(errors.CodeInvalidParam, "临期通知缺少业务到期时间")
}
expiresAt := requested.UTC()
return &expiresAt, nil
case constants.NotificationCategorySync:
return cappedNotificationExpiry(requested, now, constants.NotificationSyncDisplayDays), nil
case constants.NotificationCategorySystem:
return cappedNotificationExpiry(requested, now, constants.NotificationSystemMaxDisplayDays), nil
default:
return nil, errors.New(errors.CodeInvalidParam, "通知类别不支持展示期限策略")
}
}
func cappedNotificationExpiry(requested *time.Time, now time.Time, maxDays int) *time.Time {
maximum := now.AddDate(0, 0, maxDays)
if requested == nil {
if maxDays == constants.NotificationSystemMaxDisplayDays {
defaultExpiry := now.AddDate(0, 0, constants.NotificationSystemDefaultDisplayDays)
return &defaultExpiry
}
return &maximum
}
expiresAt := requested.UTC()
if expiresAt.After(maximum) {
expiresAt = maximum
}
return &expiresAt
}
func recipientKindForDirectEvent(eventType string) (string, error) {
switch eventType {
case constants.OutboxEventTypeAdminDirectNotification:
return constants.NotificationRecipientKindAccount, nil
case constants.OutboxEventTypePersonalCustomerDirectNotification:
return constants.NotificationRecipientKindPersonalCustomer, nil
default:
return "", errors.New(errors.CodeInvalidParam, "通知事件类型或载荷版本不受支持")
}
}
func (s *DeliveryService) isActiveRecipient(ctx context.Context, recipientKind string, recipientID uint) (bool, error) {
switch recipientKind {
case constants.NotificationRecipientKindAccount:
return s.repository.IsActiveAccount(ctx, recipientID)
case constants.NotificationRecipientKindPersonalCustomer:
return s.repository.IsActivePersonalCustomer(ctx, recipientID)
default:
return false, nil
}
}

View File

@@ -0,0 +1,110 @@
package notification
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ReadService 执行后台账号与个人客户的幂等已读事务脚本。
type ReadService struct {
db *gorm.DB
now func() time.Time
}
// NewReadService 创建单条已读用例。
func NewReadService(db *gorm.DB) *ReadService {
return &ReadService{db: db, now: time.Now}
}
// MarkRead 仅首次更新当前接收人的未过期未读通知。
func (s *ReadService) MarkRead(ctx context.Context, recipientID, notificationID uint) error {
if recipientID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := s.db.WithContext(ctx).Model(&model.Notification{}).
Where("id = ? AND recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
notificationID, constants.NotificationRecipientKindAccount, recipientID, false, now).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新通知已读状态失败")
}
return nil
}
// MarkAllRead 将当前后台账号全部或指定类别的未过期通知幂等标记为已读。
func (s *ReadService) MarkAllRead(ctx context.Context, recipientID uint, request dto.NotificationReadAllRequest) (*dto.NotificationReadAllResponse, error) {
if recipientID == 0 || !isReadAllCategory(request.Category) {
return nil, errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
db := s.db.WithContext(ctx).Model(&model.Notification{}).
Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
constants.NotificationRecipientKindAccount, recipientID, false, now)
if request.Category != "" {
db = db.Where("category = ?", request.Category)
}
result := db.Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
}
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
}
func isReadAllCategory(category string) bool {
switch category {
case "", constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry,
constants.NotificationCategorySync, constants.NotificationCategorySystem:
return true
default:
return false
}
}
// MarkPersonalRead 仅首次更新当前个人客户可见的未过期未读通知。
func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notificationID uint) error {
if customerID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
Where("id = ? AND is_read = ?", notificationID, false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新个人客户通知已读状态失败")
}
return nil
}
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。
func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint) (*dto.NotificationReadAllResponse, error) {
if customerID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
Where("is_read = ?", false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新个人客户通知已读状态失败")
}
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
}
func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {
return db.Where(`recipient_kind = ? AND recipient_id = ?
AND category IN ? AND type IN ? AND (expires_at IS NULL OR expires_at > ?)`,
constants.NotificationRecipientKindPersonalCustomer,
customerID,
[]string{constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry},
[]string{constants.NotificationTypePackageExpiring},
now,
)
}

View File

@@ -0,0 +1,8 @@
package notification
import "context"
// DynamicRecipientResolver 定义后台通知动态接收人解析 Port。
type DynamicRecipientResolver interface {
Resolve(ctx context.Context, targetKind string, targetID uint) ([]uint, error)
}

View File

@@ -0,0 +1,108 @@
// Package role 提供角色默认信用模板的应用用例。
package role
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"gorm.io/gorm"
)
// PermissionChecker 检查平台账号是否拥有独立信用模板权限。
type PermissionChecker interface {
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
}
// DefaultCreditService 更新客户角色的新建代理默认信用模板。
type DefaultCreditService struct {
db *gorm.DB
permissionChecker PermissionChecker
}
// NewDefaultCreditService 创建角色默认信用模板服务。
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker) *DefaultCreditService {
return &DefaultCreditService{db: db, permissionChecker: permissionChecker}
}
// Update 更新模板;该操作不扫描或修改任何既有钱包。
func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled bool, limit int64) (*model.Role, error) {
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
if err := s.authorize(ctx, operatorID); err != nil {
return nil, err
}
if err := validateDefaultCredit(enabled, limit); err != nil {
return nil, err
}
var role model.Role
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses().First(&role, roleID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeRoleNotFound)
}
return errors.Wrap(errors.CodeInternalError, err, "读取角色失败")
}
if role.RoleType != constants.RoleTypeCustomer {
return errors.New(errors.CodeInvalidParam, "只有客户角色可以配置新建代理默认信用")
}
result := tx.Model(&model.Role{}).
Where("id = ? AND role_type = ?", roleID, constants.RoleTypeCustomer).
Updates(map[string]any{
"default_credit_enabled": enabled,
"default_credit_limit": limit,
"updater": operatorID,
})
if result.Error != nil {
return errors.Wrap(errors.CodeInternalError, result.Error, "更新角色默认信用失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "角色默认信用已发生变化,请刷新后重试")
}
role.DefaultCreditEnabled = enabled
role.DefaultCreditLimit = limit
role.Updater = operatorID
return nil
})
if err != nil {
return nil, err
}
return &role, nil
}
func (s *DefaultCreditService) authorize(ctx context.Context, operatorID uint) error {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeSuperAdmin {
return nil
}
if userType != constants.UserTypePlatform || s.permissionChecker == nil {
return errors.New(errors.CodeForbidden, "无权限配置角色默认信用")
}
hasPermission, err := s.permissionChecker.CheckPermission(ctx, operatorID, constants.PermissionRoleDefaultCreditManage, constants.PlatformWeb)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "检查角色默认信用权限失败")
}
if !hasPermission {
return errors.New(errors.CodeForbidden, "无权限配置角色默认信用")
}
return nil
}
func validateDefaultCredit(enabled bool, limit int64) error {
if limit < 0 {
return errors.New(errors.CodeInvalidParam, "默认信用额度不能为负数")
}
if enabled && limit == 0 {
return errors.New(errors.CodeInvalidParam, "启用默认信用时额度必须大于零")
}
if !enabled && limit != 0 {
return errors.New(errors.CodeInvalidParam, "关闭默认信用时额度必须为零")
}
return nil
}

View File

@@ -0,0 +1,251 @@
// Package shop 提供店铺创建与业务员归属的简单写事务脚本。
package shop
import (
"context"
"strings"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// CreateService 收口平台与代理创建店铺的完整事务。
type CreateService struct {
db *gorm.DB
}
// NewCreateService 创建店铺创建事务脚本。
func NewCreateService(db *gorm.DB) *CreateService {
return &CreateService{db: db}
}
// Create 按操作者类型执行平台显式归属或代理安全继承。
func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopRequest) (*dto.ShopResponse, error) {
userType := middleware.GetUserTypeFromContext(ctx)
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
resolver := resolvePlatformBusinessOwner
switch userType {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
case constants.UserTypeAgent:
if request.BusinessOwnerAccountIDSet {
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
}
if request.ParentID == nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if err := middleware.CanManageShop(ctx, *request.ParentID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
resolver = resolveInheritedBusinessOwner
default:
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.InitPassword), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
}
var response *dto.ShopResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
created, createErr := createShop(tx, request, operatorID, string(hashedPassword), resolver)
if createErr != nil {
return createErr
}
response = created
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
type businessOwnerResolver func(*gorm.DB, *dto.CreateShopRequest, *model.Shop) (*uint, error)
func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver) (*dto.ShopResponse, error) {
if exists, err := recordExists(tx, &model.Shop{}, "shop_code = ?", request.ShopCode); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺编号失败")
} else if exists {
return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在")
}
if exists, err := recordExists(tx, &model.Account{}, "username = ?", request.InitUsername); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验初始账号用户名失败")
} else if exists {
return nil, errors.New(errors.CodeUsernameExists, "初始账号用户名已存在")
}
if exists, err := recordExists(tx, &model.Account{}, "phone = ?", request.InitPhone); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验初始账号手机号失败")
} else if exists {
return nil, errors.New(errors.CodePhoneExists, "初始账号手机号已存在")
}
parent, level, err := resolveParent(tx, request.ParentID)
if err != nil {
return nil, err
}
ownerID, err := resolveOwner(tx, request, parent)
if err != nil {
return nil, err
}
var role model.Role
if err := tx.Where("id = ? AND role_type = ? AND status = ?", request.DefaultRoleID, constants.RoleTypeCustomer, constants.StatusEnabled).First(&role).Error; err != nil {
return nil, errors.New(errors.CodeInvalidParam, "请选择启用的客户角色")
}
shop := &model.Shop{
ShopName: request.ShopName, ShopCode: request.ShopCode, ParentID: request.ParentID,
BusinessOwnerAccountID: ownerID, Level: level, ContactName: request.ContactName,
ContactPhone: request.ContactPhone, Province: request.Province, City: request.City,
District: request.District, Address: request.Address, Status: constants.ShopStatusEnabled,
}
shop.Creator = operatorID
shop.Updater = operatorID
if err := tx.Create(shop).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺失败")
}
account := &model.Account{
Username: request.InitUsername, Phone: request.InitPhone, Password: hashedPassword,
UserType: constants.UserTypeAgent, ShopID: &shop.ID, Status: constants.StatusEnabled, IsPrimary: true,
}
account.Creator = operatorID
account.Updater = operatorID
if err := tx.Create(account).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建初始账号失败")
}
if err := tx.Create(&model.AccountRole{
AccountID: account.ID, RoleID: request.DefaultRoleID, Status: constants.StatusEnabled,
Creator: operatorID, Updater: operatorID,
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "为初始账号分配角色失败")
}
if err := tx.Create(&model.ShopRole{
ShopID: shop.ID, RoleID: request.DefaultRoleID, Status: constants.StatusEnabled,
Creator: operatorID, Updater: operatorID,
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "设置店铺默认角色失败")
}
if err := tx.Create([]*model.AgentWallet{
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeMain,
CreditEnabled: role.DefaultCreditEnabled, CreditLimit: role.DefaultCreditLimit,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
{
ShopID: shop.ID, WalletType: constants.AgentWalletTypeCommission,
CreditEnabled: false, CreditLimit: 0,
Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID,
},
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "初始化店铺钱包失败")
}
parentName := ""
if parent != nil {
parentName = parent.ShopName
}
response := newShopResponse(shop, parentName)
if err := fillBusinessOwnerResponse(tx, shop, response); err != nil {
return nil, err
}
return response, nil
}
func resolveParent(tx *gorm.DB, parentID *uint) (*model.Shop, int, error) {
if parentID == nil {
return nil, 1, nil
}
var parent model.Shop
if err := tx.First(&parent, *parentID).Error; err != nil {
return nil, 0, errors.New(errors.CodeInvalidParentID, "上级店铺不存在或无效")
}
level := parent.Level + 1
if level > constants.ShopMaxLevel {
return nil, 0, errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级")
}
return &parent, level, nil
}
func resolvePlatformBusinessOwner(tx *gorm.DB, request *dto.CreateShopRequest, parent *model.Shop) (*uint, error) {
if !request.BusinessOwnerAccountIDSet {
if parent == nil || parent.BusinessOwnerAccountID == nil {
return nil, nil
}
ownerID := *parent.BusinessOwnerAccountID
return &ownerID, nil
}
if request.BusinessOwnerAccountID == nil {
return nil, nil
}
if *request.BusinessOwnerAccountID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效")
}
var account model.Account
if err := tx.Where("id = ? AND user_type = ? AND status = ?", *request.BusinessOwnerAccountID, constants.UserTypePlatform, constants.StatusEnabled).
First(&account).Error; err != nil {
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效或不可用")
}
ownerID := account.ID
return &ownerID, nil
}
func resolveInheritedBusinessOwner(_ *gorm.DB, _ *dto.CreateShopRequest, parent *model.Shop) (*uint, error) {
if parent == nil || parent.BusinessOwnerAccountID == nil {
return nil, nil
}
ownerID := *parent.BusinessOwnerAccountID
return &ownerID, nil
}
func recordExists(tx *gorm.DB, target any, query string, value any) (bool, error) {
var count int64
err := tx.Model(target).Where(query, value).Count(&count).Error
return count > 0, err
}
func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse {
return &dto.ShopResponse{
ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID,
BusinessOwnerAccountID: shop.BusinessOwnerAccountID,
ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName,
ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City,
District: shop.District, Address: shop.Address, Status: shop.Status,
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}
}
func fillBusinessOwnerResponse(tx *gorm.DB, shop *model.Shop, response *dto.ShopResponse) error {
if shop.BusinessOwnerAccountID == nil {
return nil
}
var account model.Account
err := tx.Unscoped().Where("id = ?", *shop.BusinessOwnerAccountID).First(&account).Error
if err == gorm.ErrRecordNotFound {
return nil
}
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询业务员摘要失败")
}
response.BusinessOwnerUsername = account.Username
response.BusinessOwnerPhoneSummary = maskBusinessOwnerPhone(account.Phone)
response.BusinessOwnerAvailable = account.UserType == constants.UserTypePlatform && account.Status == constants.StatusEnabled && !account.DeletedAt.Valid
return nil
}
func maskBusinessOwnerPhone(phone string) string {
phone = strings.TrimSpace(phone)
if len(phone) < 7 {
return ""
}
return phone[:3] + "****" + phone[len(phone)-4:]
}

View File

@@ -0,0 +1,10 @@
package shop
import "context"
// NotificationRecipientResolver 定义按店铺解析当前可用后台通知接收人的 Port。
//
// 实现只返回稳定账号 ID无可用接收人是正常结果不应触发无限重试。
type NotificationRecipientResolver interface {
ResolveNotificationRecipients(ctx context.Context, shopID uint) ([]uint, error)
}

View File

@@ -0,0 +1,108 @@
package shop
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
type UpdateService struct {
db *gorm.DB
}
// NewUpdateService 创建店铺更新事务脚本。
func NewUpdateService(db *gorm.DB) *UpdateService {
return &UpdateService{db: db}
}
// Update 更新单个店铺;业务员归属变化不会传播到其他店铺。
func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.UpdateShopRequest) (*dto.ShopResponse, error) {
if shopID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if err := middleware.CanManageShop(ctx, shopID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if userType == constants.UserTypeAgent && request.BusinessOwnerAccountIDSet {
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
}
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
var response *dto.ShopResponse
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var shop model.Shop
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shop, shopID).Error; err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if request.BusinessOwnerAccountIDSet {
ownerID, err := validateUpdatedBusinessOwner(tx, request.BusinessOwnerAccountID)
if err != nil {
return err
}
shop.BusinessOwnerAccountID = ownerID
}
shop.ShopName = request.ShopName
shop.ContactName = request.ContactName
shop.ContactPhone = request.ContactPhone
shop.Province = request.Province
shop.City = request.City
shop.District = request.District
shop.Address = request.Address
shop.Status = request.Status
shop.Updater = operatorID
if err := tx.Save(&shop).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新店铺失败")
}
parentName := ""
if shop.ParentID != nil {
var parent model.Shop
if err := tx.Select("shop_name").First(&parent, *shop.ParentID).Error; err == nil {
parentName = parent.ShopName
}
}
response = newShopResponse(&shop, parentName)
if err := fillBusinessOwnerResponse(tx, &shop, response); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
return response, nil
}
func validateUpdatedBusinessOwner(tx *gorm.DB, requestedID *uint) (*uint, error) {
if requestedID == nil {
return nil, nil
}
if *requestedID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效")
}
var account model.Account
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).
Where("id = ? AND user_type = ? AND status = ?", *requestedID, constants.UserTypePlatform, constants.StatusEnabled).
First(&account).Error; err != nil {
return nil, errors.New(errors.CodeInvalidParam, "业务员账号无效或不可用")
}
ownerID := account.ID
return &ownerID, nil
}

View File

@@ -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

View File

@@ -0,0 +1,58 @@
// Package wallet 提供代理主钱包复杂写用例。
package wallet
import (
"context"
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"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// ChangeCreditService 调整既有店铺主钱包实际信用额度。
type ChangeCreditService struct {
db *gorm.DB
}
// NewChangeCreditService 创建实际信用额度调整服务。
func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
return &ChangeCreditService{db: db}
}
// Execute 按主钱包类型和版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64, version int) (*dto.ShopCreditLimitResponse, error) {
var result *dto.ShopCreditLimitResponse
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stored model.AgentWallet
if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
aggregate := domainwallet.AgentWallet{ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit, Status: stored.Status, Version: stored.Version}
if err := aggregate.ChangeCredit(enabled, limit); err != nil {
return err
}
update := tx.Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, version, aggregate.EffectiveCredit()).
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
if update.Error != nil {
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
}
if update.RowsAffected != 1 {
var current model.AgentWallet
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(&current).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
if current.Version != version {
return errors.New(errors.CodeConflict, "钱包版本已变化,请刷新后重试")
}
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
}
available, _ := aggregate.AvailableBalance()
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: version + 1}
return nil
})
return result, err
}

View File

@@ -0,0 +1,202 @@
// Package wallet 提供代理主钱包复杂写用例。
package wallet
import (
"context"
"strconv"
"strings"
"time"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// DebitCommand 描述一次具有稳定业务引用的代理主钱包扣款。
type DebitCommand struct {
ShopID uint
Amount int64
ReferenceType string
ReferenceID uint
UserID uint
Creator uint
TransactionSubtype string
RelatedShopID *uint
AssetType string
AssetID uint
AssetIdentifier string
Remark string
RequestID string
CorrelationID string
}
// DebitResult 返回统一扣款后的资金快照。
type DebitResult struct {
WalletID uint
BalanceBefore int64
BalanceAfter int64
Version int
AlreadyApplied bool
}
// DebitedEvent 是代理主钱包扣款成功后的可靠领域事实。
type DebitedEvent struct {
EventID string `json:"event_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
Amount int64 `json:"amount"`
BalanceBefore int64 `json:"balance_before"`
BalanceAfter int64 `json:"balance_after"`
Version int `json:"version"`
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
TransactionType string `json:"transaction_type"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// DebitEventWriter 在调用方事务内追加扣款成功事件。
type DebitEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event DebitedEvent) error
}
// DebitService 统一代理主钱包扣款、流水与可靠事件。
type DebitService struct {
eventWriter DebitEventWriter
now func() time.Time
}
// NewDebitService 创建统一代理主钱包扣款服务。
func NewDebitService(eventWriter DebitEventWriter, now func() time.Time) *DebitService {
if now == nil {
now = time.Now
}
return &DebitService{eventWriter: eventWriter, now: now}
}
// DebitInTx 在调用方事务内完成锁定、扣款、唯一流水和 Outbox 事件。
func (s *DebitService) DebitInTx(ctx context.Context, tx *gorm.DB, command DebitCommand) (DebitResult, error) {
if s == nil || s.eventWriter == nil || tx == nil {
return DebitResult{}, errors.New(errors.CodeInternalError, "代理主钱包扣款能力未完整配置")
}
if err := validateDebitCommand(command); err != nil {
return DebitResult{}, err
}
var stored model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain).
First(&stored).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return DebitResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
existing, err := findExistingDebit(ctx, tx, command.ReferenceType, command.ReferenceID)
if err != nil {
return DebitResult{}, err
}
if existing != nil {
if existing.AgentWalletID != stored.ID || existing.Amount != -command.Amount {
return DebitResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包扣款流水")
}
return DebitResult{
WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter,
Version: stored.Version, AlreadyApplied: true,
}, nil
}
aggregate := domainwallet.AgentWallet{
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
Status: stored.Status, Version: stored.Version,
}
if err := aggregate.Debit(command.Amount); err != nil {
return DebitResult{}, err
}
updatedAt := s.now().UTC()
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where(`id = ? AND wallet_type = ? AND status = ? AND version = ?
AND balance::numeric - frozen_balance::numeric
+ CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0 END >= ?::numeric`,
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version, command.Amount).
Updates(map[string]any{
"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": updatedAt,
})
if update.Error != nil {
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "扣减代理主钱包失败")
}
if update.RowsAffected != 1 {
return DebitResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
transaction := buildDebitTransaction(stored, aggregate.Balance, command)
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包扣款流水失败")
}
event := DebitedEvent{
EventID: "agent-wallet:order:" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":debited",
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID,
TransactionType: constants.AgentTransactionTypeDeduct, OccurredAt: updatedAt,
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return DebitResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包扣款事件失败")
}
return DebitResult{
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
Version: stored.Version + 1,
}, nil
}
func validateDebitCommand(command DebitCommand) error {
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款参数无效")
}
if strings.TrimSpace(command.ReferenceType) != constants.ReferenceTypeOrder {
return errors.New(errors.CodeInvalidParam, "当前统一扣款仅支持订单业务")
}
return nil
}
func findExistingDebit(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
referenceType, referenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
First(&transaction).Error
if err == nil {
return &transaction, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包扣款流水失败")
}
func buildDebitTransaction(stored model.AgentWallet, balanceAfter int64, command DebitCommand) *model.AgentWalletTransaction {
referenceType := strings.TrimSpace(command.ReferenceType)
remark := strings.TrimSpace(command.Remark)
var subtype *string
if value := strings.TrimSpace(command.TransactionSubtype); value != "" {
subtype = &value
}
return &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: constants.AgentTransactionTypeDeduct, TransactionSubtype: subtype,
Amount: -command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
AssetIdentifier: command.AssetIdentifier, Remark: &remark, Creator: command.Creator,
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
}

View File

@@ -0,0 +1,182 @@
package wallet
import (
"context"
"strconv"
"strings"
"time"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// PostingCommand 描述一次具有稳定业务引用的代理主钱包正向入账。
type PostingCommand struct {
ShopID uint
WalletID uint
Amount int64
ReferenceType string
ReferenceID uint
TransactionType string
UserID uint
Creator uint
Remark string
Metadata *string
RequestID string
CorrelationID string
}
// PostingResult 返回统一入账后的资金快照。
type PostingResult struct {
WalletID uint
BalanceBefore int64
BalanceAfter int64
Version int
AlreadyApplied bool
}
// CreditedEvent 是代理主钱包正向入账成功后的可靠领域事实。
type CreditedEvent struct {
EventID string `json:"event_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
Amount int64 `json:"amount"`
BalanceBefore int64 `json:"balance_before"`
BalanceAfter int64 `json:"balance_after"`
Version int `json:"version"`
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
TransactionType string `json:"transaction_type"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// CreditEventWriter 在调用方事务内追加正向入账事件。
type CreditEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event CreditedEvent) error
}
// PostingService 统一代理主钱包充值与人工调整入账。
type PostingService struct {
eventWriter CreditEventWriter
now func() time.Time
}
// NewPostingService 创建统一代理主钱包入账服务。
func NewPostingService(eventWriter CreditEventWriter, now func() time.Time) *PostingService {
if now == nil {
now = time.Now
}
return &PostingService{eventWriter: eventWriter, now: now}
}
// PostInTx 在调用方事务内完成锁定、入账、唯一流水和 Outbox 事件。
func (s *PostingService) PostInTx(ctx context.Context, tx *gorm.DB, command PostingCommand) (PostingResult, error) {
if s == nil || s.eventWriter == nil || tx == nil {
return PostingResult{}, errors.New(errors.CodeInternalError, "代理主钱包入账能力未完整配置")
}
if err := validatePostingCommand(command); err != nil {
return PostingResult{}, err
}
var stored model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", command.ShopID, constants.AgentWalletTypeMain).
First(&stored).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return PostingResult{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
if command.WalletID > 0 && command.WalletID != stored.ID {
return PostingResult{}, errors.New(errors.CodeConflict, "入账业务单与代理主钱包归属不一致")
}
existing, err := findExistingPosting(ctx, tx, command.ReferenceType, command.ReferenceID)
if err != nil {
return PostingResult{}, err
}
if existing != nil {
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount || existing.TransactionType != command.TransactionType {
return PostingResult{}, errors.New(errors.CodeConflict, "业务单已存在不一致的钱包入账流水")
}
return PostingResult{WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter, Version: stored.Version, AlreadyApplied: true}, nil
}
aggregate := domainwallet.AgentWallet{
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
Status: stored.Status, Version: stored.Version,
}
if err := aggregate.Credit(command.Amount); err != nil {
return PostingResult{}, err
}
now := s.now().UTC()
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?", stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now})
if update.Error != nil {
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "增加代理主钱包余额失败")
}
if update.RowsAffected != 1 {
return PostingResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
referenceType := strings.TrimSpace(command.ReferenceType)
remark := strings.TrimSpace(command.Remark)
transaction := &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: command.TransactionType, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Status: constants.TransactionStatusSuccess,
ReferenceType: &referenceType, ReferenceID: &command.ReferenceID, Remark: &remark, Metadata: command.Metadata,
Creator: command.Creator, ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包入账流水失败")
}
event := CreditedEvent{
EventID: "agent-wallet:" + referenceType + ":" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":credited",
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
ReferenceType: referenceType, ReferenceID: command.ReferenceID, TransactionType: command.TransactionType,
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return PostingResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包入账事件失败")
}
return PostingResult{WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1}, nil
}
func validatePostingCommand(command PostingCommand) error {
if command.ShopID == 0 || command.Amount <= 0 || command.ReferenceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账参数无效")
}
referenceType := strings.TrimSpace(command.ReferenceType)
validRecharge := referenceType == constants.ReferenceTypeTopup && command.TransactionType == constants.AgentTransactionTypeRecharge
validAdjustment := referenceType == constants.ReferenceTypeManualAdjustment && command.TransactionType == constants.AgentTransactionTypeAdjustment
if !validRecharge && !validAdjustment {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账业务类型无效")
}
return nil
}
func findExistingPosting(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type IN ? AND status = ?",
strings.TrimSpace(referenceType), referenceID, []string{constants.AgentTransactionTypeRecharge, constants.AgentTransactionTypeAdjustment}, constants.TransactionStatusSuccess).
First(&transaction).Error
if err == nil {
return &transaction, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包入账流水失败")
}

View File

@@ -0,0 +1,289 @@
package wallet
import (
"context"
"math"
"strconv"
"strings"
"time"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RefundCommand 描述一次沿订单原扣款回溯的代理主钱包退款。
type RefundCommand struct {
OrderID uint
RefundID uint
Amount int64
LegacyPayerShopID uint
LegacyDeductAmount int64
LegacyRelatedShopID *uint
AssetType string
AssetID uint
AssetIdentifier string
UserID uint
Creator uint
Remark string
RequestID string
CorrelationID string
}
// RefundResult 返回代理主钱包退款后的资金快照。
type RefundResult struct {
WalletID uint
BalanceBefore int64
BalanceAfter int64
Version int
AlreadyApplied bool
}
// RefundedEvent 是代理主钱包订单退款成功后的可靠资金事实。
type RefundedEvent struct {
EventID string `json:"event_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
OrderID uint `json:"order_id"`
RefundID uint `json:"refund_id"`
Amount int64 `json:"amount"`
BalanceBefore int64 `json:"balance_before"`
BalanceAfter int64 `json:"balance_after"`
Version int `json:"version"`
OriginalDebitTransactionID uint `json:"original_debit_transaction_id,omitempty"`
OriginalDeductAmount int64 `json:"original_deduct_amount"`
RelatedShopID *uint `json:"related_shop_id,omitempty"`
TransactionSubtype *string `json:"transaction_subtype,omitempty"`
AssetType string `json:"asset_type,omitempty"`
AssetID uint `json:"asset_id,omitempty"`
AssetIdentifier string `json:"asset_identifier,omitempty"`
Legacy bool `json:"legacy"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// RefundEventWriter 在调用方事务内追加代理主钱包退款事件。
type RefundEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event RefundedEvent) error
}
// RefundService 统一代理订单退款回充、真实流水和可靠事件。
type RefundService struct {
eventWriter RefundEventWriter
now func() time.Time
}
// NewRefundService 创建统一代理主钱包退款服务。
func NewRefundService(eventWriter RefundEventWriter, now func() time.Time) *RefundService {
if now == nil {
now = time.Now
}
return &RefundService{eventWriter: eventWriter, now: now}
}
// RefundInTx 在调用方事务内按原扣款事实完成退款回充、版本递增、唯一流水和 Outbox。
func (s *RefundService) RefundInTx(ctx context.Context, tx *gorm.DB, command RefundCommand) (RefundResult, error) {
if s == nil || s.eventWriter == nil || tx == nil {
return RefundResult{}, errors.New(errors.CodeInternalError, "代理主钱包退款能力未完整配置")
}
if err := validateRefundCommand(command); err != nil {
return RefundResult{}, err
}
origin, err := findOriginalOrderDebit(ctx, tx, command.OrderID)
if err != nil {
return RefundResult{}, err
}
resolution, err := resolveRefundTarget(command, origin)
if err != nil {
return RefundResult{}, err
}
stored, err := lockRefundWallet(ctx, tx, resolution)
if err != nil {
return RefundResult{}, err
}
existing, err := findExistingRefund(ctx, tx, command.RefundID)
if err != nil {
return RefundResult{}, err
}
if existing != nil {
if existing.AgentWalletID != stored.ID || existing.Amount != command.Amount ||
!sameOptionalUint(existing.RelatedShopID, resolution.relatedShopID) ||
!sameOptionalString(existing.TransactionSubtype, resolution.subtype) ||
existing.AssetType != resolution.assetType || existing.AssetID != resolution.assetID ||
existing.AssetIdentifier != resolution.assetIdentifier {
return RefundResult{}, errors.New(errors.CodeConflict, "退款单已存在不一致的钱包回充流水")
}
return RefundResult{
WalletID: stored.ID, BalanceBefore: existing.BalanceBefore, BalanceAfter: existing.BalanceAfter,
Version: stored.Version, AlreadyApplied: true,
}, nil
}
aggregate := domainwallet.AgentWallet{
ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType,
Balance: stored.Balance, FrozenBalance: stored.FrozenBalance,
CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit,
Status: stored.Status, Version: stored.Version,
}
if err := aggregate.Credit(command.Amount); err != nil {
return RefundResult{}, err
}
now := s.now().UTC()
update := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?",
stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
Updates(map[string]any{"balance": aggregate.Balance, "version": gorm.Expr("version + 1"), "updated_at": now})
if update.Error != nil {
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, update.Error, "退回代理主钱包余额失败")
}
if update.RowsAffected != 1 {
return RefundResult{}, errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
transaction := buildRefundTransaction(stored, aggregate.Balance, command, resolution)
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包退款流水失败")
}
event := RefundedEvent{
EventID: "agent-wallet:refund:" + strconv.FormatUint(uint64(command.RefundID), 10) + ":refunded",
WalletID: stored.ID, ShopID: stored.ShopID, OrderID: command.OrderID, RefundID: command.RefundID,
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
Version: stored.Version + 1, OriginalDebitTransactionID: resolution.originalDebitID,
OriginalDeductAmount: resolution.deductAmount,
RelatedShopID: resolution.relatedShopID, TransactionSubtype: resolution.subtype, Legacy: resolution.legacy,
AssetType: resolution.assetType, AssetID: resolution.assetID, AssetIdentifier: resolution.assetIdentifier,
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return RefundResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包退款事件失败")
}
return RefundResult{
WalletID: stored.ID, BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
Version: stored.Version + 1,
}, nil
}
type refundResolution struct {
walletID uint
shopID uint
originalDebitID uint
deductAmount int64
relatedShopID *uint
subtype *string
assetType string
assetID uint
assetIdentifier string
legacy bool
}
func validateRefundCommand(command RefundCommand) error {
if command.OrderID == 0 || command.RefundID == 0 || command.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包退款参数无效")
}
return nil
}
func findOriginalOrderDebit(ctx context.Context, tx *gorm.DB, orderID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeOrder, orderID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
Order("id ASC").First(&transaction).Error
if err == nil {
return &transaction, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理主钱包扣款流水失败")
}
func resolveRefundTarget(command RefundCommand, origin *model.AgentWalletTransaction) (refundResolution, error) {
if origin != nil {
if origin.Amount >= 0 || origin.Amount == math.MinInt64 {
return refundResolution{}, errors.New(errors.CodeInternalError, "原代理主钱包扣款流水金额非法")
}
maxAmount := -origin.Amount
if command.Amount > maxAmount {
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于原钱包扣款金额")
}
return refundResolution{
walletID: origin.AgentWalletID, originalDebitID: origin.ID, deductAmount: maxAmount,
relatedShopID: origin.RelatedShopID, subtype: origin.TransactionSubtype,
assetType: origin.AssetType, assetID: origin.AssetID, assetIdentifier: origin.AssetIdentifier,
}, nil
}
if command.LegacyPayerShopID == 0 || command.LegacyDeductAmount <= 0 {
return refundResolution{}, errors.New(errors.CodeInternalError, "历史订单缺少原扣款流水和兼容付款快照")
}
if command.Amount > command.LegacyDeductAmount {
return refundResolution{}, errors.New(errors.CodeInvalidParam, "退款金额不能大于历史订单实付金额")
}
return refundResolution{
shopID: command.LegacyPayerShopID, relatedShopID: command.LegacyRelatedShopID,
deductAmount: command.LegacyDeductAmount,
assetType: command.AssetType, assetID: command.AssetID, assetIdentifier: command.AssetIdentifier,
legacy: true,
}, nil
}
func lockRefundWallet(ctx context.Context, tx *gorm.DB, resolution refundResolution) (model.AgentWallet, error) {
var stored model.AgentWallet
query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("wallet_type = ?", constants.AgentWalletTypeMain)
if resolution.walletID > 0 {
query = query.Where("id = ?", resolution.walletID)
} else {
query = query.Where("shop_id = ?", resolution.shopID)
}
if err := query.First(&stored).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return model.AgentWallet{}, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return model.AgentWallet{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
return stored, nil
}
func findExistingRefund(ctx context.Context, tx *gorm.DB, refundID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().
Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeRefund, refundID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess).
First(&transaction).Error
if err == nil {
return &transaction, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包退款流水失败")
}
func buildRefundTransaction(stored model.AgentWallet, balanceAfter int64, command RefundCommand, resolution refundResolution) *model.AgentWalletTransaction {
referenceType := constants.ReferenceTypeRefund
remark := strings.TrimSpace(command.Remark)
return &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: constants.AgentTransactionTypeRefund, TransactionSubtype: resolution.subtype,
Amount: command.Amount, BalanceBefore: stored.Balance, BalanceAfter: balanceAfter,
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.RefundID,
RelatedShopID: resolution.relatedShopID, AssetType: resolution.assetType, AssetID: resolution.assetID,
AssetIdentifier: resolution.assetIdentifier, Remark: &remark, Creator: command.Creator,
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
}
func sameOptionalUint(left, right *uint) bool {
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}
func sameOptionalString(left, right *string) bool {
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}

View File

@@ -0,0 +1,297 @@
package wallet
import (
"context"
"strconv"
"time"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ReservationCommand 描述一次具有稳定业务引用的代理主钱包预占操作。
type ReservationCommand struct {
ShopID uint
Amount int64
ReferenceType string
ReferenceID uint
UserID uint
Creator uint
Subtype string
RelatedShopID *uint
AssetType string
AssetID uint
AssetIdentifier string
Remark string
RequestID string
CorrelationID string
}
// ReservationEvent 是代理主钱包预占状态变化事件。
type ReservationEvent struct {
EventID string `json:"event_id"`
ReservationID uint `json:"reservation_id"`
WalletID uint `json:"wallet_id"`
ShopID uint `json:"shop_id"`
Amount int64 `json:"amount"`
Status int `json:"status"`
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// ReservationEventWriter 在业务事务内追加预占状态事件。
type ReservationEventWriter interface {
Append(ctx context.Context, tx *gorm.DB, event ReservationEvent) error
}
// ReservationService 统一代理主钱包冻结、释放与完成扣除。
type ReservationService struct {
reservationEvents ReservationEventWriter
debitEvents DebitEventWriter
now func() time.Time
}
// NewReservationService 创建代理主钱包预占服务。
func NewReservationService(reservationEvents ReservationEventWriter, debitEvents DebitEventWriter, now func() time.Time) *ReservationService {
if now == nil {
now = time.Now
}
return &ReservationService{reservationEvents: reservationEvents, debitEvents: debitEvents, now: now}
}
// FreezeInTx 在调用方事务内冻结资金并创建唯一预占事实。
func (s *ReservationService) FreezeInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
if err := s.validateFreeze(tx, command); err != nil {
return err
}
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
if err != nil {
return err
}
existing, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, false)
if err != nil {
return err
}
if existing != nil {
if existing.AgentWalletID == wallet.ID && existing.Amount == command.Amount && existing.Status == constants.AgentWalletReservationStatusFrozen {
return nil
}
return errors.New(errors.CodeConflict, "业务引用已存在不一致的钱包预占")
}
if err := aggregate.Freeze(command.Amount); err != nil {
return err
}
now := s.now().UTC()
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
return err
}
reservation := &model.AgentWalletReservation{
AgentWalletID: wallet.ID, ShopID: wallet.ShopID, Amount: command.Amount,
Status: constants.AgentWalletReservationStatusFrozen,
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID, Creator: command.Creator,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := tx.WithContext(ctx).Create(reservation).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建代理主钱包预占事实失败")
}
return s.appendReservationEvent(ctx, tx, reservation, now, command)
}
// ReleaseInTx 幂等释放处于冻结状态的资金预占。
func (s *ReservationService) ReleaseInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusReleased)
}
// CompleteInTx 完成冻结资金扣除并创建真实扣款流水与扣款事件。
func (s *ReservationService) CompleteInTx(ctx context.Context, tx *gorm.DB, command ReservationCommand) error {
return s.finish(ctx, tx, command, constants.AgentWalletReservationStatusCompleted)
}
func (s *ReservationService) finish(ctx context.Context, tx *gorm.DB, command ReservationCommand, targetStatus int) error {
if err := s.validateFinish(tx, command); err != nil {
return err
}
reservation, err := findReservation(ctx, tx, command.ReferenceType, command.ReferenceID, true)
if err != nil {
return err
}
if reservation == nil {
return errors.New(errors.CodeNotFound, "代理主钱包预占事实不存在")
}
if (command.ShopID > 0 && reservation.ShopID != command.ShopID) || (command.Amount > 0 && reservation.Amount != command.Amount) {
return errors.New(errors.CodeConflict, "钱包预占事实与请求不一致")
}
command.ShopID = reservation.ShopID
command.Amount = reservation.Amount
if reservation.Status == targetStatus {
return nil
}
if reservation.Status != constants.AgentWalletReservationStatusFrozen {
return errors.New(errors.CodeInvalidStatus, "钱包预占已经进入其他终态")
}
wallet, aggregate, err := lockMainWallet(ctx, tx, command.ShopID)
if err != nil {
return err
}
if wallet.ID != reservation.AgentWalletID {
return errors.New(errors.CodeConflict, "钱包预占归属不一致")
}
if targetStatus == constants.AgentWalletReservationStatusReleased {
err = aggregate.Release(command.Amount)
} else {
err = aggregate.CompleteReserved(command.Amount)
}
if err != nil {
return err
}
now := s.now().UTC()
if err := updateWalletFunds(ctx, tx, wallet, aggregate, now); err != nil {
return err
}
result := tx.WithContext(ctx).Model(&model.AgentWalletReservation{}).
Where("id = ? AND status = ?", reservation.ID, constants.AgentWalletReservationStatusFrozen).
Updates(map[string]any{"status": targetStatus, "completed_at": now, "updated_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占状态失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "钱包预占状态已变化")
}
reservation.Status = targetStatus
reservation.CompletedAt = &now
if targetStatus == constants.AgentWalletReservationStatusCompleted {
if err := s.createCompletedDebit(ctx, tx, wallet, aggregate, command, now); err != nil {
return err
}
}
return s.appendReservationEvent(ctx, tx, reservation, now, command)
}
func (s *ReservationService) validateConfigured(tx *gorm.DB) error {
if s == nil || s.reservationEvents == nil || s.debitEvents == nil || tx == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占能力未完整配置")
}
return nil
}
func (s *ReservationService) validateFreeze(tx *gorm.DB, command ReservationCommand) error {
if err := s.validateConfigured(tx); err != nil {
return err
}
if command.ShopID == 0 || command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
}
return nil
}
func (s *ReservationService) validateFinish(tx *gorm.DB, command ReservationCommand) error {
if err := s.validateConfigured(tx); err != nil {
return err
}
if command.ReferenceType != constants.ReferenceTypeOrder || command.ReferenceID == 0 || command.Amount < 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包预占参数无效")
}
return nil
}
func lockMainWallet(ctx context.Context, tx *gorm.DB, shopID uint) (model.AgentWallet, *domainwallet.AgentWallet, error) {
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return wallet, nil, errors.New(errors.CodeWalletNotFound, "代理主钱包不存在")
}
return wallet, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理主钱包失败")
}
aggregate := &domainwallet.AgentWallet{
ID: wallet.ID, ShopID: wallet.ShopID, WalletType: wallet.WalletType,
Balance: wallet.Balance, FrozenBalance: wallet.FrozenBalance,
CreditEnabled: wallet.CreditEnabled, CreditLimit: wallet.CreditLimit,
Status: wallet.Status, Version: wallet.Version,
}
return wallet, aggregate, nil
}
func findReservation(ctx context.Context, tx *gorm.DB, referenceType string, referenceID uint, lock bool) (*model.AgentWalletReservation, error) {
var reservation model.AgentWalletReservation
query := tx.WithContext(ctx)
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
err := query.Where("reference_type = ? AND reference_id = ?", referenceType, referenceID).First(&reservation).Error
if err == nil {
return &reservation, nil
}
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占事实失败")
}
func updateWalletFunds(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, now time.Time) error {
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND status = ? AND version = ?", stored.ID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal, stored.Version).
Updates(map[string]any{
"balance": aggregate.Balance, "frozen_balance": aggregate.FrozenBalance,
"version": gorm.Expr("version + 1"), "updated_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新代理主钱包预占资金失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
return nil
}
func (s *ReservationService) createCompletedDebit(ctx context.Context, tx *gorm.DB, stored model.AgentWallet, aggregate *domainwallet.AgentWallet, command ReservationCommand, now time.Time) error {
referenceType := command.ReferenceType
transaction := &model.AgentWalletTransaction{
AgentWalletID: stored.ID, ShopID: stored.ShopID, UserID: command.UserID,
TransactionType: constants.AgentTransactionTypeDeduct, Amount: -command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance,
Status: constants.TransactionStatusSuccess, ReferenceType: &referenceType, ReferenceID: &command.ReferenceID,
RelatedShopID: command.RelatedShopID, AssetType: command.AssetType, AssetID: command.AssetID,
AssetIdentifier: command.AssetIdentifier, Remark: &command.Remark, Creator: command.Creator,
ShopIDTag: stored.ShopIDTag, EnterpriseIDTag: stored.EnterpriseIDTag,
}
if command.Subtype != "" {
transaction.TransactionSubtype = &command.Subtype
}
if err := tx.WithContext(ctx).Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建冻结资金扣款流水失败")
}
event := DebitedEvent{
EventID: "agent-wallet:order:" + strconv.FormatUint(uint64(command.ReferenceID), 10) + ":debited",
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
ReferenceType: command.ReferenceType, ReferenceID: command.ReferenceID,
TransactionType: constants.AgentTransactionTypeDeduct, OccurredAt: now,
RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.debitEvents.Append(ctx, tx, event); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入冻结资金扣款事件失败")
}
return nil
}
func (s *ReservationService) appendReservationEvent(ctx context.Context, tx *gorm.DB, reservation *model.AgentWalletReservation, now time.Time, command ReservationCommand) error {
event := ReservationEvent{
EventID: "agent-wallet-reservation:" + strconv.FormatUint(uint64(reservation.ID), 10) + ":" + strconv.Itoa(reservation.Status),
ReservationID: reservation.ID, WalletID: reservation.AgentWalletID, ShopID: reservation.ShopID,
Amount: reservation.Amount, Status: reservation.Status,
ReferenceType: reservation.ReferenceType, ReferenceID: reservation.ReferenceID,
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.reservationEvents.Append(ctx, tx, event); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理主钱包预占事件失败")
}
return nil
}

View File

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

View File

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

View File

@@ -1,17 +1,25 @@
package bootstrap
import (
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/app"
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"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
@@ -83,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 {
@@ -95,23 +104,51 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
)
return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate),
Account: admin.NewAccountHandler(svc.Account),
Role: admin.NewRoleHandler(svc.Role, validate),
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),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
Shop: admin.NewShopHandler(svc.Shop, validate),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
Auth: authHandler.NewHandler(svc.Auth, validate),
Account: admin.NewAccountHandler(svc.Account),
Role: func() *admin.RoleHandler {
handler := admin.NewRoleHandler(svc.Role, validate)
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: 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 {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB))
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB))
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB))
return handler
}(),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: func() *admin.ShopCommissionHandler {
handler := admin.NewShopCommissionHandler(svc.ShopCommission)
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
return handler
}(),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
@@ -121,6 +158,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
IotCard: admin.NewIotCardHandler(svc.IotCard),
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
@@ -135,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(
@@ -153,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),

View File

@@ -5,6 +5,12 @@ import (
"go.uber.org/zap"
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"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit"
@@ -22,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"
@@ -113,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 {
@@ -137,6 +147,28 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger,
assetAudit,
)
cardObservationOutbox := outbox.NewRepository()
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)
@@ -147,10 +179,6 @@ func initServices(s *stores, deps *Dependencies) *services {
pollingQueueMgr := polling.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
pollingLifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, s.IotCard, s.DeviceSimBinding, s.Device, deps.Logger)
iotCard.SetPollingCallback(pollingLifecycleSvc)
// 注入流量扣减回调,使手动刷新资产时能触发套餐流量扣减
usageService := packageSvc.NewUsageService(deps.DB, deps.Redis, s.PackageUsage, s.PackageUsageDailyRecord, s.DeviceSimBinding, deps.Logger)
iotCard.SetDataDeductor(usageService)
// 创建支付配置服务Order 和 Recharge 依赖)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, accountAudit, deps.Redis, deps.Logger)
@@ -165,6 +193,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageUsageDailyRecord,
deps.Logger,
)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
stopResumeService := iotCardSvc.NewStopResumeService(
deps.Redis,
@@ -176,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)
@@ -199,12 +229,48 @@ 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))
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
agentRechargeService := agentRechargeSvc.New(
deps.DB,
s.AgentRecharge,
s.AgentWallet,
s.Shop,
wechatConfig,
accountAudit,
operationPassword,
deps.Redis,
deps.Logger,
)
agentRechargeService.SetAgentWalletPostingService(walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil))
refundService := refundSvc.New(
deps.DB,
s.RefundRequest,
s.Order,
s.CommissionRecord,
s.AgentWallet,
s.AgentWalletTransaction,
stopResumeService,
device,
packageActivation,
s.IotCard,
s.Device,
s.AssetWallet,
deps.Logger,
)
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,
@@ -227,7 +293,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger,
customerBinding,
),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
ShopCommission: shopCommission,
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
@@ -284,38 +350,16 @@ func initServices(s *stores, deps *Dependencies) *services {
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
StopResumeService: stopResumeService,
WechatConfig: wechatConfig,
AgentRecharge: agentRechargeSvc.New(
deps.DB,
s.AgentRecharge,
s.AgentWallet,
s.AgentWalletTransaction,
s.Shop,
wechatConfig,
accountAudit,
operationPassword,
deps.Redis,
deps.Logger,
),
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI,
Refund: refundSvc.New(
deps.DB,
s.RefundRequest,
s.Order,
s.CommissionRecord,
s.AgentWallet,
s.AgentWalletTransaction,
stopResumeService,
device,
packageActivation,
s.IotCard,
s.Device,
s.AssetWallet,
deps.Logger,
),
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
AgentRecharge: agentRechargeService,
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI,
Refund: refundService,
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,
}
}

View File

@@ -24,6 +24,7 @@ type Handlers struct {
ClientRealname *app.ClientRealnameHandler
ClientDevice *app.ClientDeviceHandler
ClientRechargeOrder *app.ClientRechargeOrderHandler
ClientNotification *app.ClientNotificationHandler
Shop *admin.ShopHandler
ShopRole *admin.ShopRoleHandler
AdminAuth *admin.AuthHandler
@@ -37,6 +38,7 @@ type Handlers struct {
IotCard *admin.IotCardHandler
IotCardImport *admin.IotCardImportHandler
ExportTask *admin.ExportTaskHandler
Notification *admin.NotificationHandler
Device *admin.DeviceHandler
DeviceImport *admin.DeviceImportHandler
AssetAllocationRecord *admin.AssetAllocationRecordHandler
@@ -51,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

View File

@@ -1,6 +1,12 @@
package bootstrap
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"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
@@ -81,6 +87,20 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.DataCleanupLog,
deps.Logger,
)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
cardObservationService := cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
cardObservationSeriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, cardObservationIntegration),
cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration),
)
// 初始化订单服务(仅用于超时自动取消,不需要微信支付和队列客户端)
orderService := orderSvc.New(
@@ -108,6 +128,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PersonalCustomer,
stores.PersonalCustomerPhone,
)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
stopResumeService := iotCardSvc.NewStopResumeService(
@@ -119,20 +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{
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,
}
}

View File

@@ -0,0 +1,132 @@
// Package approval 提供渠道无关的通用审批领域事实。
package approval
import (
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Instance 是只保存渠道无关事实的通用审批实例。
type Instance struct {
ID uint
BusinessType string
BusinessID uint
SubmitterAccountID uint
SubmitterSnapshot []byte
Provider string
ExternalRef string
Status int
RequestSnapshot []byte
DecisionSnapshot []byte
CorrelationID string
Version int
StatusChangedAt time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// NewInstanceParams 是创建通用审批实例所需的稳定业务事实。
type NewInstanceParams struct {
BusinessType string
BusinessID uint
SubmitterAccountID uint
SubmitterSnapshot []byte
Provider string
RequestSnapshot []byte
CorrelationID string
}
// NewInstance 创建处于提交中的渠道无关审批实例。
func NewInstance(params NewInstanceParams, now time.Time) (*Instance, error) {
businessType := strings.TrimSpace(params.BusinessType)
provider := strings.TrimSpace(params.Provider)
correlationID := strings.TrimSpace(params.CorrelationID)
if businessType == "" || params.BusinessID == 0 || params.SubmitterAccountID == 0 || provider == "" || correlationID == "" {
return nil, errors.New(errors.CodeInvalidParam, "通用审批实例的业务引用、提交人、渠道和关联 ID 不能为空")
}
if !isJSONObject(params.SubmitterSnapshot) || !isJSONObject(params.RequestSnapshot) {
return nil, errors.New(errors.CodeInvalidParam, "通用审批实例快照必须是有效 JSON 对象")
}
if now.IsZero() {
return nil, errors.New(errors.CodeInvalidParam, "通用审批实例创建时间不能为空")
}
now = now.UTC()
return &Instance{
BusinessType: businessType, BusinessID: params.BusinessID,
SubmitterAccountID: params.SubmitterAccountID, SubmitterSnapshot: cloneBytes(params.SubmitterSnapshot),
Provider: provider, Status: constants.ApprovalStatusSubmitting,
RequestSnapshot: cloneBytes(params.RequestSnapshot), CorrelationID: correlationID,
Version: constants.ApprovalInitialVersion, StatusChangedAt: now, CreatedAt: now, UpdatedAt: now,
}, nil
}
// StatusForDecision 将渠道 Adapter 输出的标准决策映射为通用审批终态。
func StatusForDecision(decision string) (int, error) {
switch decision {
case constants.ApprovalDecisionApproved:
return constants.ApprovalStatusApproved, nil
case constants.ApprovalDecisionRejected:
return constants.ApprovalStatusRejected, nil
case constants.ApprovalDecisionCancelled:
return constants.ApprovalStatusCancelled, nil
case constants.ApprovalDecisionDeleted:
return constants.ApprovalStatusDeleted, nil
case constants.ApprovalDecisionRevokedAfterApproved:
return constants.ApprovalStatusRevokedAfterApproved, nil
default:
return 0, errors.New(errors.CodeInvalidParam, "审批渠道返回了不受支持的标准决策")
}
}
// IsTerminalStatus 判断状态是否为可分发给业务消费者的标准终态。
func IsTerminalStatus(status int) bool {
return status == constants.ApprovalStatusApproved ||
status == constants.ApprovalStatusRejected ||
status == constants.ApprovalStatusCancelled ||
status == constants.ApprovalStatusDeleted ||
status == constants.ApprovalStatusRevokedAfterApproved
}
// ApplyDecision 校验标准决策状态迁移并冻结首次到达该终态的决策快照。
func (i *Instance) ApplyDecision(decision string, snapshot []byte, now time.Time) (bool, error) {
if i == nil || now.IsZero() || !isJSONObject(snapshot) {
return false, errors.New(errors.CodeInvalidParam, "审批决策实例、快照和决策时间不能为空")
}
targetStatus, err := StatusForDecision(decision)
if err != nil {
return false, err
}
if i.Status == targetStatus {
return false, nil
}
if targetStatus == constants.ApprovalStatusRevokedAfterApproved {
if i.Status != constants.ApprovalStatusApproved {
return false, errors.New(errors.CodeInvalidStatus, "只有已通过审批可以进入通过后撤销状态")
}
} else if IsTerminalStatus(i.Status) {
return false, errors.New(errors.CodeInvalidStatus, "审批已进入其他标准终态")
}
now = now.UTC()
i.Status = targetStatus
i.DecisionSnapshot = cloneBytes(snapshot)
i.StatusChangedAt = now
i.UpdatedAt = now
i.Version++
return true, nil
}
func isJSONObject(value []byte) bool {
var object map[string]any
return len(value) > 0 && sonic.Unmarshal(value, &object) == nil && object != nil
}
func cloneBytes(value []byte) []byte {
cloned := make([]byte, len(value))
copy(cloned, value)
return cloned
}

View File

@@ -0,0 +1,12 @@
package approval
import (
"context"
)
// Repository 定义通用审批实例的写侧持久化接缝。
type Repository interface {
Create(ctx context.Context, instance *Instance) error
GetForUpdate(ctx context.Context, instanceID uint) (*Instance, error)
SaveDecision(ctx context.Context, instance *Instance, expectedStatus int, expectedVersion int) (bool, error)
}

View File

@@ -0,0 +1,2 @@
// Package cardobservation 提供卡状态观测的纯领域规则。
package cardobservation

View File

@@ -0,0 +1,90 @@
package cardobservation
import (
"strings"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// NetworkObservation 是领域层接收的标准 Gateway 网络观测。
type NetworkObservation struct {
CardID uint
GatewayStatus string
GatewayExtend string
GatewayIMEI string
Metadata ObservationMetadata
}
// CardNetworkSnapshot 是网络规则需要的最小卡快照。
type CardNetworkSnapshot struct {
CardID uint
NetworkStatus int
StopReason string
IsStandalone bool
EnablePolling bool
}
// NetworkDecision 描述一次网络观测可安全持久化的最终值。
type NetworkDecision struct {
StatusKnown bool
StatusChanged bool
BeforeStatus int
AfterStatus int
GatewayExtend string
GatewayIMEI string
UpdateIMEI bool
StopReason string
StopReasonChanged bool
StopPolling bool
}
// ApplyNetwork 应用 Gateway 状态映射、运营商停机原因和独立风险卡规则。
func ApplyNetwork(snapshot CardNetworkSnapshot, observation NetworkObservation) (NetworkDecision, error) {
if err := validateObservationMetadata(observation.CardID, observation.Metadata); err != nil {
return NetworkDecision{}, err
}
if snapshot.NetworkStatus != constants.NetworkStatusOffline && snapshot.NetworkStatus != constants.NetworkStatusOnline {
return NetworkDecision{}, errors.New(errors.CodeInvalidStatus, "卡网络状态无效")
}
status, known := MapGatewayNetworkStatus(observation.GatewayStatus, observation.GatewayExtend)
extend := strings.TrimSpace(observation.GatewayExtend)
imei := strings.TrimSpace(observation.GatewayIMEI)
decision := NetworkDecision{
StatusKnown: known, BeforeStatus: snapshot.NetworkStatus, AfterStatus: snapshot.NetworkStatus,
GatewayExtend: extend, GatewayIMEI: imei, UpdateIMEI: imei != "", StopReason: snapshot.StopReason,
StopPolling: snapshot.EnablePolling && ShouldStopPollingForRisk(snapshot.IsStandalone, extend),
}
if !known {
return decision, nil
}
decision.AfterStatus = status
decision.StatusChanged = status != snapshot.NetworkStatus
if decision.StatusChanged && status == constants.NetworkStatusOffline && snapshot.StopReason == "" &&
strings.TrimSpace(observation.GatewayStatus) == constants.GatewayCardStatusStopped {
decision.StopReason = constants.StopReasonCarrierStopped
decision.StopReasonChanged = true
}
return decision, nil
}
// MapGatewayNetworkStatus 将 Gateway 状态稳定映射为本地网络状态。
func MapGatewayNetworkStatus(cardStatus, extend string) (int, bool) {
status := strings.TrimSpace(cardStatus)
ext := strings.TrimSpace(extend)
switch status {
case constants.GatewayCardStatusNormal:
return constants.NetworkStatusOnline, true
case constants.GatewayCardStatusStopped, constants.GatewayCardStatusReady:
return constants.NetworkStatusOffline, true
}
if ext == constants.GatewayCardExtendPendingActivation {
return constants.NetworkStatusOffline, true
}
return constants.NetworkStatusOffline, false
}
// ShouldStopPollingForRisk 判断独立卡是否命中运营商风险终止状态。
func ShouldStopPollingForRisk(isStandalone bool, extend string) bool {
return isStandalone && (extend == constants.GatewayCardExtendRiskStop || extend == constants.GatewayCardExtendCancelled)
}

View File

@@ -0,0 +1,133 @@
package cardobservation
import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
const (
realnameReversalThreshold = 3
realnameReversalWindow = 10 * time.Minute
)
// ObservationMetadata 描述一次上游实名观测的可追踪元数据。
type ObservationMetadata struct {
ObservationID string
Source string
Scene string
ObservedAt time.Time
RequestID string
CorrelationID string
UpstreamSummary string
}
// RealnameObservation 是领域层接收的标准实名观测。
type RealnameObservation struct {
CardID uint
Verified bool
Metadata ObservationMetadata
}
// CardRealnameSnapshot 是应用层从持久化模型映射出的最小卡实名快照。
type CardRealnameSnapshot struct {
CardID uint
Status int
FirstRealnameAt *time.Time
ReversalCount int
ReversalStartedAt *time.Time
}
// RealnameDecision 描述应用本次应持久化的实名事实。
type RealnameDecision struct {
StatusChanged bool
FirstVerified bool
ReversalPending bool
ReversalCount int
ReversalStartedAt *time.Time
ReversalReset bool
BeforeStatus int
AfterStatus int
}
// ApplyRealname 根据观测来源应用首次实名和周期逆转规则。
func ApplyRealname(snapshot CardRealnameSnapshot, observation RealnameObservation) (RealnameDecision, error) {
if err := validateObservation(observation); err != nil {
return RealnameDecision{}, err
}
decision := RealnameDecision{
BeforeStatus: snapshot.Status, AfterStatus: snapshot.Status,
ReversalCount: snapshot.ReversalCount, ReversalStartedAt: snapshot.ReversalStartedAt,
}
if snapshot.Status != constants.RealNameStatusNotVerified && snapshot.Status != constants.RealNameStatusVerified {
return RealnameDecision{}, errors.New(errors.CodeInvalidStatus, "卡实名状态无效")
}
if observation.Verified {
decision.AfterStatus = constants.RealNameStatusVerified
decision.StatusChanged = snapshot.Status != decision.AfterStatus
decision.FirstVerified = decision.StatusChanged && snapshot.FirstRealnameAt == nil
decision.ReversalReset = snapshot.ReversalCount != 0 || snapshot.ReversalStartedAt != nil
decision.ReversalCount = 0
decision.ReversalStartedAt = nil
return decision, nil
}
if snapshot.Status == constants.RealNameStatusNotVerified {
decision.ReversalReset = snapshot.ReversalCount != 0 || snapshot.ReversalStartedAt != nil
decision.ReversalCount = 0
decision.ReversalStartedAt = nil
return decision, nil
}
if observation.Metadata.Source == constants.CardObservationSourceCarrierCallback {
// 解除实名回调只留痕,不把外部单次结果变成本地逆转事实。
return decision, nil
}
if observation.Metadata.Source == constants.CardObservationSourceManualOverride {
decision.AfterStatus = constants.RealNameStatusNotVerified
decision.StatusChanged = true
decision.ReversalReset = true
decision.ReversalCount = 0
decision.ReversalStartedAt = nil
return decision, nil
}
count := snapshot.ReversalCount
startedAt := snapshot.ReversalStartedAt
now := observation.Metadata.ObservedAt
if startedAt == nil || now.Before(*startedAt) || now.Sub(*startedAt) > realnameReversalWindow {
count = 0
startedAt = &now
}
count++
decision.ReversalCount = count
decision.ReversalStartedAt = startedAt
if count < realnameReversalThreshold {
decision.ReversalPending = true
return decision, nil
}
decision.AfterStatus = constants.RealNameStatusNotVerified
decision.StatusChanged = true
decision.ReversalReset = true
decision.ReversalCount = 0
decision.ReversalStartedAt = nil
return decision, nil
}
func validateObservation(observation RealnameObservation) error {
if observation.CardID == 0 || strings.TrimSpace(observation.Metadata.ObservationID) == "" ||
strings.TrimSpace(observation.Metadata.Source) == "" || strings.TrimSpace(observation.Metadata.Scene) == "" ||
observation.Metadata.ObservedAt.IsZero() {
return errors.New(errors.CodeInvalidParam, "实名观测关键字段缺失")
}
switch observation.Metadata.Source {
case constants.CardObservationSourcePolling, constants.CardObservationSourceManualSync,
constants.CardObservationSourceManualOverride, constants.CardObservationSourceCarrierCallback,
constants.CardObservationSourceBusinessEvent:
return nil
default:
return errors.New(errors.CodeInvalidParam, "实名观测来源无效")
}
}

View File

@@ -0,0 +1,92 @@
package cardobservation
import (
"math"
"time"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TrafficObservation 是领域层接收的标准运营商流量读数。
type TrafficObservation struct {
CardID uint
GatewayReadingMB float64
ResetDay int
Metadata ObservationMetadata
}
// CardTrafficSnapshot 是流量规则需要的最小卡快照。
type CardTrafficSnapshot struct {
CardID uint
DataUsageMB int64
CurrentMonthUsageMB float64
CurrentMonthStartDate *time.Time
LastMonthTotalMB float64
LastGatewayReadingMB float64
}
// TrafficDecision 描述一次流量观测应持久化的最终值。
type TrafficDecision struct {
IncrementMB float64
ReadingAccepted bool
CrossMonth bool
DataUsageMB int64
CurrentMonthUsageMB float64
CurrentMonthStartDate time.Time
LastMonthTotalMB float64
LastGatewayReadingMB float64
}
// ApplyTraffic 应用运营商重置、跨月和异常下降保护规则。
func ApplyTraffic(snapshot CardTrafficSnapshot, observation TrafficObservation) (TrafficDecision, error) {
if err := validateObservationMetadata(observation.CardID, observation.Metadata); err != nil {
return TrafficDecision{}, err
}
if math.IsNaN(observation.GatewayReadingMB) || math.IsInf(observation.GatewayReadingMB, 0) || observation.GatewayReadingMB < 0 {
return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "流量观测读数无效")
}
if observation.ResetDay < 1 || observation.ResetDay > 31 {
return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "运营商流量重置日无效")
}
now := observation.Metadata.ObservedAt
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
decision := TrafficDecision{
ReadingAccepted: true, DataUsageMB: snapshot.DataUsageMB,
CurrentMonthUsageMB: snapshot.CurrentMonthUsageMB, CurrentMonthStartDate: monthStart,
LastMonthTotalMB: snapshot.LastMonthTotalMB, LastGatewayReadingMB: observation.GatewayReadingMB,
}
increment := observation.GatewayReadingMB - snapshot.LastGatewayReadingMB
if increment < 0 {
if isTrafficResetWindow(now, observation.ResetDay) {
increment = observation.GatewayReadingMB
} else {
increment = 0
decision.ReadingAccepted = false
decision.LastGatewayReadingMB = snapshot.LastGatewayReadingMB
}
}
decision.IncrementMB = increment
decision.CrossMonth = snapshot.CurrentMonthStartDate == nil || snapshot.CurrentMonthStartDate.Before(monthStart)
if decision.CrossMonth {
decision.LastMonthTotalMB = snapshot.CurrentMonthUsageMB
decision.CurrentMonthUsageMB = increment
} else if increment > 0 {
decision.CurrentMonthUsageMB += increment
}
if increment > 0 {
decision.DataUsageMB += int64(increment)
}
return decision, nil
}
func validateObservationMetadata(cardID uint, metadata ObservationMetadata) error {
return validateObservation(RealnameObservation{CardID: cardID, Verified: true, Metadata: metadata})
}
func isTrafficResetWindow(now time.Time, resetDay int) bool {
if now.Day() == resetDay {
return true
}
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
return now.Day() == resetDate.AddDate(0, 0, -1).Day()
}

View File

@@ -0,0 +1,2 @@
// Package wallet 定义代理主钱包的资金边界与信用额度不变量。
package wallet

View File

@@ -0,0 +1,250 @@
package wallet
import (
"math"
"github.com/break/junhong_cmp_fiber/pkg/constants"
appErrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// AgentWallet 表示代理钱包聚合的资金状态。
//
// 当前聚合统一维护扣款、冻结、完成扣除、正向入账、退款回充与调额不变量。
type AgentWallet struct {
ID uint
ShopID uint
WalletType string
Balance int64
FrozenBalance int64
CreditEnabled bool
CreditLimit int64
Status int
Version int
}
// Debit 从代理主钱包扣减指定金额,并保证扣款后总可用金额不为负数。
func (w *AgentWallet) Debit(amount int64) error {
if err := w.validateMainWalletMutation(amount); err != nil {
return err
}
candidate := *w
balance, ok := safeSub(candidate.Balance, amount)
if !ok {
return appErrors.New(appErrors.CodeInvalidParam, "扣减钱包余额时发生整数溢出")
}
candidate.Balance = balance
if err := candidate.Validate(); err != nil {
return err
}
w.Balance = balance
return nil
}
// Credit 向代理主钱包增加账面余额,负余额会自然表现为欠款减少。
func (w *AgentWallet) Credit(amount int64) error {
if err := w.validateMainWalletMutation(amount); err != nil {
return err
}
balance, ok := safeAdd(w.Balance, amount)
if !ok {
return appErrors.New(appErrors.CodeInvalidParam, "增加钱包余额时发生整数溢出")
}
candidate := *w
candidate.Balance = balance
if err := candidate.Validate(); err != nil {
return err
}
w.Balance = balance
return nil
}
// Freeze 预占代理主钱包资金,冻结金额会占用现金和信用但不直接形成欠款。
func (w *AgentWallet) Freeze(amount int64) error {
if err := w.validateMainWalletMutation(amount); err != nil {
return err
}
candidate := *w
frozen, ok := safeAdd(candidate.FrozenBalance, amount)
if !ok {
return appErrors.New(appErrors.CodeInvalidParam, "增加钱包冻结金额时发生整数溢出")
}
candidate.FrozenBalance = frozen
if err := candidate.Validate(); err != nil {
return err
}
w.FrozenBalance = frozen
return nil
}
// Release 释放已经预占的代理主钱包资金。
func (w *AgentWallet) Release(amount int64) error {
if err := w.validateMainWalletMutation(amount); err != nil {
return err
}
if w.FrozenBalance < amount {
return appErrors.New(appErrors.CodeInsufficientBalance, "钱包冻结金额不足")
}
w.FrozenBalance -= amount
return w.Validate()
}
// CompleteReserved 完成冻结资金扣除,同时减少账面余额和冻结金额。
func (w *AgentWallet) CompleteReserved(amount int64) error {
if err := w.validateMainWalletMutation(amount); err != nil {
return err
}
if w.FrozenBalance < amount {
return appErrors.New(appErrors.CodeInsufficientBalance, "钱包冻结金额不足")
}
balance, ok := safeSub(w.Balance, amount)
if !ok {
return appErrors.New(appErrors.CodeInvalidParam, "完成冻结资金扣除时发生整数溢出")
}
candidate := *w
candidate.Balance = balance
candidate.FrozenBalance -= amount
if err := candidate.Validate(); err != nil {
return err
}
w.Balance = candidate.Balance
w.FrozenBalance = candidate.FrozenBalance
return nil
}
func (w AgentWallet) validateMainWalletMutation(amount int64) error {
if amount <= 0 {
return appErrors.New(appErrors.CodeInvalidParam, "钱包变更金额必须大于零")
}
if w.WalletType != constants.AgentWalletTypeMain {
return appErrors.New(appErrors.CodeInvalidParam, "仅代理主钱包支持该资金操作")
}
if w.Status != constants.AgentWalletStatusNormal {
return appErrors.New(appErrors.CodeInvalidStatus, "当前钱包状态不允许资金操作")
}
return nil
}
// ChangeCredit 调整主钱包实际信用额度并重新校验完整资金边界。
func (w *AgentWallet) ChangeCredit(enabled bool, limit int64) error {
candidate := *w
candidate.CreditEnabled = enabled
candidate.CreditLimit = limit
if err := candidate.Validate(); err != nil {
return err
}
w.CreditEnabled = enabled
w.CreditLimit = limit
return nil
}
// Validate 校验钱包类型、信用配置、版本与总可用金额不变量。
func (w AgentWallet) Validate() error {
if w.WalletType != constants.AgentWalletTypeMain && w.WalletType != constants.AgentWalletTypeCommission {
return appErrors.New(appErrors.CodeInvalidParam, "代理钱包类型无效")
}
if w.Version < 0 {
return appErrors.New(appErrors.CodeInvalidParam, "钱包版本不能为负数")
}
if w.FrozenBalance < 0 {
return appErrors.New(appErrors.CodeInvalidParam, "冻结金额不能为负数")
}
if err := validateCredit(w.WalletType, w.CreditEnabled, w.CreditLimit); err != nil {
return err
}
if _, err := w.CashAvailableBalance(); err != nil {
return err
}
available, err := w.AvailableBalance()
if err != nil {
return err
}
if available < 0 {
return appErrors.New(appErrors.CodeInsufficientBalance, "钱包总可用金额不能为负数")
}
if _, err := w.DebtAmount(); err != nil {
return err
}
return nil
}
// EffectiveCredit 返回当前实际生效的信用额度。
func (w AgentWallet) EffectiveCredit() int64 {
if !w.CreditEnabled {
return 0
}
return w.CreditLimit
}
// CashAvailableBalance 返回现金可用金额,即账面余额减冻结金额。
func (w AgentWallet) CashAvailableBalance() (int64, error) {
available, ok := safeSub(w.Balance, w.FrozenBalance)
if !ok {
return 0, appErrors.New(appErrors.CodeInvalidParam, "计算现金可用金额时发生整数溢出")
}
return available, nil
}
// AvailableBalance 返回总可用金额,即现金可用金额加有效信用额度。
func (w AgentWallet) AvailableBalance() (int64, error) {
cashAvailable, err := w.CashAvailableBalance()
if err != nil {
return 0, err
}
available, ok := safeAdd(cashAvailable, w.EffectiveCredit())
if !ok {
return 0, appErrors.New(appErrors.CodeInvalidParam, "计算钱包总可用金额时发生整数溢出")
}
return available, nil
}
// IsInDebt 返回账面余额是否已经形成欠款。
func (w AgentWallet) IsInDebt() bool {
return w.Balance < 0
}
// DebtAmount 返回欠款金额;冻结金额不直接计入欠款。
func (w AgentWallet) DebtAmount() (int64, error) {
if !w.IsInDebt() {
return 0, nil
}
if w.Balance == math.MinInt64 {
return 0, appErrors.New(appErrors.CodeInvalidParam, "计算钱包欠款金额时发生整数溢出")
}
return -w.Balance, nil
}
func validateCredit(walletType string, enabled bool, limit int64) error {
if limit < 0 {
return appErrors.New(appErrors.CodeInvalidParam, "信用额度不能为负数")
}
if !enabled && limit != 0 {
return appErrors.New(appErrors.CodeInvalidParam, "关闭信用时信用额度必须为零")
}
if enabled && limit == 0 {
return appErrors.New(appErrors.CodeInvalidParam, "启用信用时信用额度必须大于零")
}
if walletType != constants.AgentWalletTypeMain && (enabled || limit != 0) {
return appErrors.New(appErrors.CodeInvalidParam, "只有代理主钱包可以启用信用额度")
}
return nil
}
func safeAdd(left, right int64) (int64, bool) {
if right > 0 && left > math.MaxInt64-right {
return 0, false
}
if right < 0 && left < math.MinInt64-right {
return 0, false
}
return left + right, true
}
func safeSub(left, right int64) (int64, bool) {
if right > 0 && left < math.MinInt64+right {
return 0, false
}
if right < 0 && left > math.MaxInt64+right {
return 0, false
}
return left - right, true
}

View File

@@ -3,27 +3,14 @@ package gateway
import (
"strings"
cardobservation "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// ParseCardNetworkStatus 将 Gateway 卡状态转换为系统网络状态。
// 只有网关明确返回“正常”才视为开机;“准备”或“待激活”不能按正常卡展示。
func ParseCardNetworkStatus(cardStatus, extend string) (int, bool) {
status := strings.TrimSpace(cardStatus)
ext := strings.TrimSpace(extend)
switch status {
case constants.GatewayCardStatusNormal:
return constants.NetworkStatusOnline, true
case constants.GatewayCardStatusStopped, constants.GatewayCardStatusReady:
return constants.NetworkStatusOffline, true
}
if ext == constants.GatewayCardExtendPendingActivation {
return constants.NetworkStatusOffline, true
}
return constants.NetworkStatusOffline, false
return cardobservation.MapGatewayNetworkStatus(cardStatus, extend)
}
// IsGatewayCardStopped 判断 Gateway 是否明确返回停机状态。

View File

@@ -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 {

View File

@@ -0,0 +1,146 @@
package admin
import (
"math"
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
notificationquery "github.com/break/junhong_cmp_fiber/internal/query/notification"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"go.uber.org/zap"
)
// NotificationHandler 提供当前后台账号的站内通知接口。
type NotificationHandler struct {
query *notificationquery.Query
readService *notificationapp.ReadService
validate *validator.Validate
}
// NewNotificationHandler 创建后台站内通知 Handler。
func NewNotificationHandler(query *notificationquery.Query, readService *notificationapp.ReadService, validate *validator.Validate) *NotificationHandler {
return &NotificationHandler{query: query, readService: readService, validate: validate}
}
// UnreadCount 查询当前后台账号的通知未读数。
// GET /api/admin/notifications/unread-count
func (h *NotificationHandler) UnreadCount(c *fiber.Ctx) error {
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.query.UnreadCount(c.UserContext(), recipientID)
if err != nil {
return err
}
return response.Success(c, result)
}
// UnreadSummary 查询当前后台账号的固定分类未读汇总。
// GET /api/admin/notifications/unread-summary
func (h *NotificationHandler) UnreadSummary(c *fiber.Ctx) error {
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.query.UnreadSummary(c.UserContext(), recipientID)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询当前后台账号的未过期通知列表。
// GET /api/admin/notifications
func (h *NotificationHandler) List(c *fiber.Ctx) error {
var request dto.NotificationListRequest
if err := c.QueryParser(&request); err != nil {
logNotificationListValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
if h.validate != nil {
if err := h.validate.Struct(request); err != nil {
logNotificationListValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
}
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.query.List(c.UserContext(), recipientID, request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
func logNotificationListValidationFailure(c *fiber.Ctx, request dto.NotificationListRequest, err error) {
logger.GetAppLogger().Warn("站内通知列表参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Int("page", request.Page),
zap.Int("page_size", request.PageSize),
zap.Error(err),
)
}
// MarkRead 将当前后台账号的一条通知幂等标记为已读。
// PUT /api/admin/notifications/:id/read
func (h *NotificationHandler) MarkRead(c *fiber.Ctx) error {
notificationID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || notificationID == 0 || notificationID > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
recipientID := middleware.GetUserIDFromContext(c.UserContext())
if err := h.readService.MarkRead(c.UserContext(), recipientID, uint(notificationID)); err != nil {
return err
}
return response.Success(c, dto.NotificationReadResponse{Success: true})
}
// Target 解析当前后台账号通知的受控结构化目标。
// GET /api/admin/notifications/:id/target
func (h *NotificationHandler) Target(c *fiber.Ctx) error {
notificationID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || notificationID == 0 || notificationID > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.query.Target(c.UserContext(), recipientID, uint(notificationID))
if err != nil {
return err
}
return response.Success(c, result)
}
// MarkAllRead 将当前后台账号全部或指定类别通知幂等标记为已读。
// PUT /api/admin/notifications/read-all
func (h *NotificationHandler) MarkAllRead(c *fiber.Ctx) error {
var request dto.NotificationReadAllRequest
if len(c.Body()) > 0 {
if err := c.BodyParser(&request); err != nil {
logNotificationReadAllValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
}
if h.validate != nil {
if err := h.validate.Struct(request); err != nil {
logNotificationReadAllValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
}
recipientID := middleware.GetUserIDFromContext(c.UserContext())
result, err := h.readService.MarkAllRead(c.UserContext(), recipientID, request)
if err != nil {
return err
}
return response.Success(c, result)
}
func logNotificationReadAllValidationFailure(c *fiber.Ctx, request dto.NotificationReadAllRequest, err error) {
logger.GetAppLogger().Warn("站内通知批量已读参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Bool("category_present", request.Category != ""),
zap.Error(err),
)
}

View File

@@ -11,14 +11,21 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
roleService "github.com/break/junhong_cmp_fiber/internal/service/role"
)
// RoleHandler 角色 Handler
type RoleHandler struct {
service *roleService.Service
validator *validator.Validate
service *roleService.Service
defaultCreditService *roleApp.DefaultCreditService
validator *validator.Validate
}
// SetDefaultCreditService 设置角色默认信用模板应用服务。
func (h *RoleHandler) SetDefaultCreditService(service *roleApp.DefaultCreditService) {
h.defaultCreditService = service
}
// NewRoleHandler 创建角色 Handler
@@ -254,3 +261,36 @@ func (h *RoleHandler) UpdateStatus(c *fiber.Ctx) error {
return response.Success(c, nil)
}
// UpdateDefaultCredit 更新客户角色的新建代理默认信用模板。
// PUT /api/admin/roles/:id/default-credit
func (h *RoleHandler) UpdateDefaultCredit(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的角色 ID")
}
var req dto.UpdateRoleDefaultCreditRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("角色默认信用参数验证失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
if h.defaultCreditService == nil {
return errors.New(errors.CodeInternalError, "角色默认信用服务未配置")
}
role, err := h.defaultCreditService.Update(c.UserContext(), uint(id), *req.CreditEnabled, *req.CreditLimit)
if err != nil {
return err
}
return response.Success(c, dto.RoleDefaultCreditResponse{
RoleID: role.ID,
CreditEnabled: role.DefaultCreditEnabled,
CreditLimit: role.DefaultCreditLimit,
Scope: "new_shops_only",
AffectsExistingWallets: false,
})
}

View File

@@ -1,13 +1,17 @@
package admin
import (
"math"
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
shopquery "github.com/break/junhong_cmp_fiber/internal/query/shop"
shopService "github.com/break/junhong_cmp_fiber/internal/service/shop"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -17,8 +21,32 @@ import (
// ShopHandler 店铺管理处理器。
type ShopHandler struct {
service *shopService.Service
validator *validator.Validate
service *shopService.Service
createService *shopapp.CreateService
updateService *shopapp.UpdateService
ownerQuery *shopquery.BusinessOwnerQuery
changeCreditService *walletapp.ChangeCreditService
validator *validator.Validate
}
// SetChangeCreditService 注入既有店铺实际信用额度调整用例。
func (h *ShopHandler) SetChangeCreditService(service *walletapp.ChangeCreditService) {
h.changeCreditService = service
}
// SetCreateService 注入店铺创建 Application 事务脚本。
func (h *ShopHandler) SetCreateService(service *shopapp.CreateService) {
h.createService = service
}
// SetUpdateService 注入店铺更新 Application 事务脚本。
func (h *ShopHandler) SetUpdateService(service *shopapp.UpdateService) {
h.updateService = service
}
// SetBusinessOwnerQuery 注入店铺业务员归属 Query。
func (h *ShopHandler) SetBusinessOwnerQuery(query *shopquery.BusinessOwnerQuery) {
h.ownerQuery = query
}
// NewShopHandler 创建店铺管理处理器。
@@ -49,7 +77,10 @@ func (h *ShopHandler) List(c *fiber.Ctx) error {
normalizeShopListPagination(&req)
shops, total, err := h.service.ListShopResponses(c.UserContext(), &req)
if h.ownerQuery == nil {
return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置")
}
shops, total, err := h.ownerQuery.List(c.UserContext(), req)
if err != nil {
return err
}
@@ -66,7 +97,83 @@ func (h *ShopHandler) logListValidationFailure(c *fiber.Ctx, err error) {
logger.GetAppLogger().Warn("店铺列表参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.String("query", c.Context().QueryArgs().String()),
zap.Bool("page_present", c.Context().QueryArgs().Has("page")),
zap.Bool("page_size_present", c.Context().QueryArgs().Has("page_size")),
zap.Error(err),
)
}
// Detail 查询店铺详情。
// GET /api/admin/shops/:id
func (h *ShopHandler) Detail(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 || id > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
if h.ownerQuery == nil {
return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置")
}
result, err := h.ownerQuery.Detail(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// UpdateCreditLimit 调整既有店铺代理主钱包实际信用额度。
// PUT /api/admin/shops/:id/credit-limit
func (h *ShopHandler) UpdateCreditLimit(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 || id > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
var request dto.UpdateShopCreditLimitRequest
if err := c.BodyParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.validator == nil || h.validator.Struct(&request) != nil {
return errors.New(errors.CodeInvalidParam)
}
if h.changeCreditService == nil {
return errors.New(errors.CodeInternalError, "店铺信用额度服务未配置")
}
result, err := h.changeCreditService.Execute(c.UserContext(), uint(id), *request.CreditEnabled, *request.CreditLimit, *request.Version)
if err != nil {
return err
}
return response.Success(c, result)
}
// BusinessOwnerCandidates 查询当前可人工绑定的平台业务员候选。
// GET /api/admin/shops/business-owner-candidates
func (h *ShopHandler) BusinessOwnerCandidates(c *fiber.Ctx) error {
var request dto.ShopBusinessOwnerCandidateRequest
if err := c.QueryParser(&request); err != nil {
h.logBusinessOwnerCandidateValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "业务员候选校验器未配置")
}
if err := h.validator.Struct(request); err != nil {
h.logBusinessOwnerCandidateValidationFailure(c, err)
return errors.New(errors.CodeInvalidParam)
}
if h.ownerQuery == nil {
return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置")
}
items, total, page, pageSize, err := h.ownerQuery.Candidates(c.UserContext(), request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, items, total, page, pageSize)
}
func (h *ShopHandler) logBusinessOwnerCandidateValidationFailure(c *fiber.Ctx, err error) {
logger.GetAppLogger().Warn("店铺业务员候选参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Bool("keyword_present", c.Context().QueryArgs().Has("keyword")),
zap.Error(err),
)
}
@@ -87,8 +194,20 @@ func (h *ShopHandler) Create(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "店铺创建校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("店铺创建参数验证失败",
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
shop, err := h.service.Create(c.UserContext(), &req)
createService := h.createService
if createService == nil {
return errors.New(errors.CodeInternalError, "店铺创建服务未配置")
}
shop, err := createService.Create(c.UserContext(), &req)
if err != nil {
return err
}
@@ -108,8 +227,19 @@ func (h *ShopHandler) Update(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if h.validator == nil {
return errors.New(errors.CodeInternalError, "店铺更新校验器未配置")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("店铺更新参数验证失败",
zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
shop, err := h.service.Update(c.UserContext(), uint(id), &req)
if h.updateService == nil {
return errors.New(errors.CodeInternalError, "店铺更新服务未配置")
}
shop, err := h.updateService.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
@@ -13,7 +14,8 @@ import (
// ShopCommissionHandler 代理商资金管理 Handler
type ShopCommissionHandler struct {
service *shopCommissionService.Service
service *shopCommissionService.Service
fundSummaryQuery *shopQuery.FundSummaryQuery
}
// NewShopCommissionHandler 创建代理商资金管理 Handler
@@ -21,6 +23,11 @@ func NewShopCommissionHandler(service *shopCommissionService.Service) *ShopCommi
return &ShopCommissionHandler{service: service}
}
// SetFundSummaryQuery 注入代理商资金概况 Query。
func (h *ShopCommissionHandler) SetFundSummaryQuery(query *shopQuery.FundSummaryQuery) {
h.fundSummaryQuery = query
}
// ListFundSummary 代理商资金概况列表
// GET /api/admin/shops/fund-summary
func (h *ShopCommissionHandler) ListFundSummary(c *fiber.Ctx) error {
@@ -29,7 +36,10 @@ func (h *ShopCommissionHandler) ListFundSummary(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.service.ListShopFundSummary(c.UserContext(), &req)
if h.fundSummaryQuery == nil {
return errors.New(errors.CodeInternalError, "代理商资金概况查询能力未配置")
}
result, err := h.fundSummaryQuery.List(c.UserContext(), req)
if err != nil {
return err
}

View File

@@ -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 {

View File

@@ -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),

View File

@@ -0,0 +1,110 @@
package app
import (
"math"
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
notificationquery "github.com/break/junhong_cmp_fiber/internal/query/notification"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// ClientNotificationHandler 提供当前个人客户的简化站内通知接口。
type ClientNotificationHandler struct {
query *notificationquery.Query
readService *notificationapp.ReadService
validate *validator.Validate
}
// NewClientNotificationHandler 创建个人客户站内通知 Handler。
func NewClientNotificationHandler(query *notificationquery.Query, readService *notificationapp.ReadService, validate *validator.Validate) *ClientNotificationHandler {
return &ClientNotificationHandler{query: query, readService: readService, validate: validate}
}
// UnreadCount 查询当前个人客户的业务通知未读数。
// GET /api/c/v1/notifications/unread-count
func (h *ClientNotificationHandler) UnreadCount(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
result, err := h.query.PersonalUnreadCount(c.UserContext(), customerID)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询当前个人客户的未过期业务通知列表。
// GET /api/c/v1/notifications
func (h *ClientNotificationHandler) List(c *fiber.Ctx) error {
var request dto.PersonalNotificationListRequest
if err := c.QueryParser(&request); err != nil {
logPersonalNotificationListValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
if h.validate != nil {
if err := h.validate.Struct(request); err != nil {
logPersonalNotificationListValidationFailure(c, request, err)
return errors.New(errors.CodeInvalidParam)
}
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
result, err := h.query.PersonalList(c.UserContext(), customerID, request)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
}
// MarkAllRead 将当前个人客户可见的全部通知幂等标记为已读。
// PUT /api/c/v1/notifications/read-all
func (h *ClientNotificationHandler) MarkAllRead(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
result, err := h.readService.MarkAllPersonalRead(c.UserContext(), customerID)
if err != nil {
return err
}
return response.Success(c, result)
}
// MarkRead 将当前个人客户的一条通知幂等标记为已读。
// PUT /api/c/v1/notifications/:id/read
func (h *ClientNotificationHandler) MarkRead(c *fiber.Ctx) error {
notificationID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || notificationID == 0 || notificationID > math.MaxInt64 {
return errors.New(errors.CodeInvalidParam)
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
if err := h.readService.MarkPersonalRead(c.UserContext(), customerID, uint(notificationID)); err != nil {
return err
}
return response.Success(c, dto.NotificationReadResponse{Success: true})
}
func logPersonalNotificationListValidationFailure(c *fiber.Ctx, request dto.PersonalNotificationListRequest, err error) {
logger.GetAppLogger().Warn("个人客户站内通知列表参数验证失败",
zap.String("method", c.Method()),
zap.String("path", c.Path()),
zap.Int("page", request.Page),
zap.Int("page_size", request.PageSize),
zap.Error(err),
)
}

View File

@@ -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 // 手动触发服务可为nilnil时跳过自动触发
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 而非 UserTypeSuperAdminSuperAdmin 不受日限制约束
sysCtx := pkgMiddleware.SetUserContext(ctx, &pkgMiddleware.UserContextInfo{
UserID: systemUserID,
UserType: constants.UserTypePlatform,
})
err := h.manualTriggerSvc.TriggerSingle(sysCtx, cardID, constants.TaskTypePollingRealname, systemUserID)
if err != nil {
h.logger.Warn("自动触发实名检查失败",
zap.Uint("customer_id", customerID),
zap.String("iccid", iccid),
zap.Uint("card_id", cardID),
zap.Error(err))
return
}
h.logger.Info("自动触发实名检查成功",
zap.Uint("customer_id", customerID),
zap.String("iccid", iccid),
zap.Uint("card_id", cardID))
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ import (
// AgentRechargeServiceInterface 代理充值服务接口
type AgentRechargeServiceInterface interface {
HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error
HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string, paidAmount int64) error
}
// WechatConfigServiceInterface 支付配置服务接口
@@ -170,7 +170,7 @@ func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, outTradeNo,
return fmt.Errorf("充值订单服务未配置,无法处理订单: %s", outTradeNo)
case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID)
return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID, paidAmount)
}
return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo)
default:
@@ -473,7 +473,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
return c.Send(fuiou.BuildNotifyFailResponse("充值订单服务未配置"))
case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, model.ProviderTypeFuiou, notify.TransactionId, orderAmt); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
}

View File

@@ -0,0 +1,104 @@
package approval
import (
"context"
"time"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DecisionDeliveryStore 持久化标准决策消费事实并管理处理租约。
type DecisionDeliveryStore struct {
db *gorm.DB
}
// NewDecisionDeliveryStore 创建标准决策投递 Store。
func NewDecisionDeliveryStore(db *gorm.DB) *DecisionDeliveryStore {
return &DecisionDeliveryStore{db: db}
}
// Create 在审批状态事务中创建唯一标准决策投递事实。
func (s *DecisionDeliveryStore) Create(ctx context.Context, tx *gorm.DB, event approvalapp.TerminalDecisionEvent) error {
if tx == nil {
return errors.New(errors.CodeInternalError, "审批决策投递必须使用审批状态事务")
}
record := model.ApprovalDecisionDelivery{
InstanceID: event.InstanceID, Decision: event.Decision, EventID: event.EventID,
Status: constants.ApprovalDecisionDeliveryPending, CreatedAt: event.OccurredAt, UpdatedAt: event.OccurredAt,
}
if err := tx.WithContext(ctx).Create(&record).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建审批决策投递事实失败")
}
return nil
}
// Claim 领取待处理、失败或租约已过期的标准决策。
func (s *DecisionDeliveryStore) Claim(
ctx context.Context,
eventID string,
owner string,
now time.Time,
duration time.Duration,
) (bool, error) {
if s == nil || s.db == nil || eventID == "" || owner == "" || duration <= 0 {
return false, errors.New(errors.CodeInternalError, "审批决策处理租约 Store 未完整配置")
}
result := s.db.WithContext(ctx).Model(&model.ApprovalDecisionDelivery{}).
Where("event_id = ? AND (status IN ? OR (status = ? AND lease_expires_at <= ?))",
eventID,
[]int{constants.ApprovalDecisionDeliveryPending, constants.ApprovalDecisionDeliveryFailed},
constants.ApprovalDecisionDeliveryProcessing, now).
Updates(map[string]any{
"status": constants.ApprovalDecisionDeliveryProcessing,
"lease_owner": owner, "lease_expires_at": now.Add(duration), "updated_at": now,
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "领取审批决策处理租约失败")
}
return result.RowsAffected == 1, nil
}
// MarkSucceeded 标记当前租约的业务消费者已幂等处理成功。
func (s *DecisionDeliveryStore) MarkSucceeded(ctx context.Context, eventID string, owner string, now time.Time) (bool, error) {
if s == nil || s.db == nil {
return false, errors.New(errors.CodeInternalError, "审批决策处理租约 Store 未配置")
}
result := s.db.WithContext(ctx).Model(&model.ApprovalDecisionDelivery{}).
Where("event_id = ? AND status = ? AND lease_owner = ?", eventID, constants.ApprovalDecisionDeliveryProcessing, owner).
Updates(map[string]any{
"status": constants.ApprovalDecisionDeliverySucceeded, "processed_at": now,
"lease_owner": nil, "lease_expires_at": nil, "last_error": "", "updated_at": now,
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "完成审批决策业务处理失败")
}
return result.RowsAffected == 1, nil
}
// MarkFailed 记录当前租约的安全失败摘要并释放租约等待重试。
func (s *DecisionDeliveryStore) MarkFailed(
ctx context.Context,
eventID string,
owner string,
now time.Time,
errorSummary string,
) (bool, error) {
if s == nil || s.db == nil {
return false, errors.New(errors.CodeInternalError, "审批决策处理租约 Store 未配置")
}
result := s.db.WithContext(ctx).Model(&model.ApprovalDecisionDelivery{}).
Where("event_id = ? AND status = ? AND lease_owner = ?", eventID, constants.ApprovalDecisionDeliveryProcessing, owner).
Updates(map[string]any{
"status": constants.ApprovalDecisionDeliveryFailed, "retry_count": gorm.Expr("retry_count + 1"),
"lease_owner": nil, "lease_expires_at": nil, "last_error": errorSummary, "updated_at": now,
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "记录审批决策业务处理失败")
}
return result.RowsAffected == 1, nil
}

View File

@@ -0,0 +1,112 @@
// Package approval 实现通用审批实例的 PostgreSQL 持久化 Adapter。
package approval
import (
"context"
stderrors "errors"
"gorm.io/datatypes"
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Repository 实现通用审批实例写侧仓储。
type Repository struct {
db *gorm.DB
}
// RepositoryProvider 为 Application 用例创建事务作用域 Repository。
type RepositoryProvider struct{}
// NewRepositoryProvider 创建通用审批 Repository Provider。
func NewRepositoryProvider() *RepositoryProvider {
return &RepositoryProvider{}
}
// ForDB 使用指定数据库会话创建领域 Repository。
func (p *RepositoryProvider) ForDB(db *gorm.DB) approvaldomain.Repository {
return NewRepository(db)
}
// GetForUpdate 在当前事务中锁定并读取通用审批实例。
func (r *Repository) GetForUpdate(ctx context.Context, instanceID uint) (*approvaldomain.Instance, error) {
if r == nil || r.db == nil {
return nil, errors.New(errors.CodeInternalError, "通用审批实例 Repository 未配置数据库会话")
}
var record model.ApprovalInstance
err := r.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", instanceID).First(&record).Error
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取通用审批实例失败")
}
return instanceFromModel(record), nil
}
// SaveDecision 以旧状态和旧版本为条件保存标准决策,防止并发重复终态。
func (r *Repository) SaveDecision(
ctx context.Context,
instance *approvaldomain.Instance,
expectedStatus int,
expectedVersion int,
) (bool, error) {
if r == nil || r.db == nil || instance == nil {
return false, errors.New(errors.CodeInternalError, "通用审批实例 Repository 未完整配置")
}
result := r.db.WithContext(ctx).Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ? AND version = ?", instance.ID, expectedStatus, expectedVersion).
Updates(map[string]any{
"status": instance.Status, "decision_snapshot": datatypes.JSON(instance.DecisionSnapshot),
"status_changed_at": instance.StatusChangedAt, "version": instance.Version, "updated_at": instance.UpdatedAt,
})
if result.Error != nil {
return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "保存通用审批决策失败")
}
return result.RowsAffected == 1, nil
}
func instanceFromModel(record model.ApprovalInstance) *approvaldomain.Instance {
return &approvaldomain.Instance{
ID: record.ID, BusinessType: record.BusinessType, BusinessID: record.BusinessID,
SubmitterAccountID: record.SubmitterAccountID, SubmitterSnapshot: append([]byte(nil), record.SubmitterSnapshot...),
Provider: record.Provider, ExternalRef: record.ExternalRef, Status: record.Status,
RequestSnapshot: append([]byte(nil), record.RequestSnapshot...),
DecisionSnapshot: append([]byte(nil), record.DecisionSnapshot...), CorrelationID: record.CorrelationID,
Version: record.Version, StatusChangedAt: record.StatusChangedAt,
CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt,
}
}
// NewRepository 创建通用审批实例 PostgreSQL Repository。
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
// Create 使用 Repository 持有的数据库会话创建一条唯一通用审批实例。
// 需要原子写入业务事实时,调用方必须以当前 GORM 事务创建 Repository。
func (r *Repository) Create(ctx context.Context, instance *approvaldomain.Instance) error {
if r == nil || r.db == nil {
return stderrors.New("通用审批实例 Repository 未配置数据库会话")
}
if instance == nil {
return stderrors.New("通用审批实例不能为空")
}
record := model.ApprovalInstance{
BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: datatypes.JSON(instance.SubmitterSnapshot),
Provider: instance.Provider, ExternalRef: instance.ExternalRef, Status: instance.Status,
RequestSnapshot: datatypes.JSON(instance.RequestSnapshot), DecisionSnapshot: datatypes.JSON(instance.DecisionSnapshot),
CorrelationID: instance.CorrelationID, Version: instance.Version, StatusChangedAt: instance.StatusChangedAt,
CreatedAt: instance.CreatedAt, UpdatedAt: instance.UpdatedAt,
}
if err := r.db.WithContext(ctx).Create(&record).Error; err != nil {
return err
}
instance.ID = record.ID
return nil
}

View File

@@ -0,0 +1,38 @@
package approval
import (
"context"
"strconv"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SubmissionEventWriter 将通用审批申请写入公共 Outbox。
type SubmissionEventWriter struct {
outbox *outbox.Repository
}
// NewSubmissionEventWriter 创建通用审批申请 Outbox Writer。
func NewSubmissionEventWriter(repository *outbox.Repository) *SubmissionEventWriter {
return &SubmissionEventWriter{outbox: repository}
}
// Append 在调用方业务事务中追加渠道提交事件。
func (w *SubmissionEventWriter) Append(ctx context.Context, tx *gorm.DB, event approvalapp.SubmissionRequestedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "审批申请 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeApprovalSubmissionRequested,
PayloadVersion: constants.ApprovalSubmissionPayloadVersionV1,
AggregateType: "approval", AggregateID: strconv.FormatUint(uint64(event.InstanceID), 10),
ResourceType: event.BusinessType, ResourceID: strconv.FormatUint(uint64(event.BusinessID), 10),
BusinessKey: event.EventID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -0,0 +1,73 @@
package approval
import (
"context"
"strconv"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
approvaldomain "github.com/break/junhong_cmp_fiber/internal/domain/approval"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TerminalEventWriter 将通用审批标准决策写入公共 Outbox。
type TerminalEventWriter struct {
outbox *outbox.Repository
}
// NewTerminalEventWriter 创建通用审批标准决策 Outbox Writer。
func NewTerminalEventWriter(repository *outbox.Repository) *TerminalEventWriter {
return &TerminalEventWriter{outbox: repository}
}
// Append 在审批状态事务中追加结构化标准决策事件。
func (w *TerminalEventWriter) Append(ctx context.Context, tx *gorm.DB, event approvalapp.TerminalDecisionEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "审批标准决策 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeApprovalTerminalDecision,
PayloadVersion: constants.ApprovalTerminalDecisionPayloadVersionV1,
AggregateType: "approval", AggregateID: strconv.FormatUint(uint64(event.InstanceID), 10),
ResourceType: event.BusinessType, ResourceID: strconv.FormatUint(uint64(event.BusinessID), 10),
BusinessKey: event.EventID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}
// TerminalDecisionConsumer 将公共 Outbox 信封转换为渠道无关业务决策。
type TerminalDecisionConsumer struct {
dispatcher *approvalapp.DecisionDispatcher
}
// NewTerminalDecisionConsumer 创建审批标准决策消费者 Adapter。
func NewTerminalDecisionConsumer(dispatcher *approvalapp.DecisionDispatcher) *TerminalDecisionConsumer {
return &TerminalDecisionConsumer{dispatcher: dispatcher}
}
// Consume 校验事件类型、版本和稳定身份后交给业务分发器。
func (c *TerminalDecisionConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.dispatcher == nil {
return errors.New(errors.CodeInternalError, "审批标准决策消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeApprovalTerminalDecision ||
envelope.PayloadVersion != constants.ApprovalTerminalDecisionPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "审批标准决策事件类型或版本不受支持")
}
var event approvalapp.TerminalDecisionEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "审批标准决策事件载荷格式错误")
}
if event.EventID == "" || event.EventID != envelope.EventID || event.InstanceID == 0 ||
event.BusinessType == "" || event.BusinessID == 0 || event.CorrelationID == "" {
return errors.New(errors.CodeInvalidParam, "审批标准决策事件载荷不完整")
}
if _, err := approvaldomain.StatusForDecision(event.Decision); err != nil {
return err
}
return c.dispatcher.Consume(ctx, event)
}

Some files were not shown because too many files have changed in this diff Show More