暂存一下,防止丢失

This commit is contained in:
2026-07-24 16:07:18 +08:00
parent 5d6e23f1a5
commit a18ed8bc8d
180 changed files with 13597 additions and 1986 deletions

View File

@@ -24,6 +24,21 @@
| 外部回调、轮询和 Gateway 调用 | 状态变化、人工触发、连续失败或高风险异常时必须 | 业务状态仍在领域表 | 每次实际或未发送尝试都必须 | 需要可靠后续处理时必须 | | 外部回调、轮询和 Gateway 调用 | 状态变化、人工触发、连续失败或高风险异常时必须 | 业务状态仍在领域表 | 每次实际或未发送尝试都必须 | 需要可靠后续处理时必须 |
| Domain 方法 | 不直接依赖审计基础设施,由 Application 写入 | 维护业务不变量 | 由 Application/Adapter 负责 | 只记录领域事件,由 Application 持久化 | | 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 不产生可靠副作用) |
## 旧 Writer 与旧表写入口清单 ## 旧 Writer 与旧表写入口清单
### 旧账号审计 ### 旧账号审计

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,17 +7,18 @@
- 01 — 向明确后台账号可靠投递首条站内通知 - 01 — 向明确后台账号可靠投递首条站内通知
- `.scratch/tech-global-audit/issues/13-request-correlation-integration-timeline.md` — 13 — 交付请求、业务链路和外部集成时间线 - `.scratch/tech-global-audit/issues/13-request-correlation-integration-timeline.md` — 13 — 交付请求、业务链路和外部集成时间线
**Status:** ready-for-agent **Status:** 代码完成、验证延期
**架构通道:** 主通道为 Query辅助通道为 Application + Port/Adapter。 **架构通道:** 主通道为 Query辅助通道为 Application + Port/Adapter。
**完整业务边界:** 本票收口后台通知受控目标注册、解析、当前权限复核和安全不可用语义。明确不返回 URL、不实现前端路由构造、不把通知所有权当成目标资源权限、不创建独立卡同步执行页也不迁移各目标业务详情的既有授权规则。 **完整业务边界:** 本票收口后台通知受控目标注册、解析、当前权限复核和安全不可用语义。明确不返回 URL、不实现前端路由构造、不把通知所有权当成目标资源权限、不创建独立卡同步执行页也不迁移各目标业务详情的既有授权规则。
- [ ] 通知只保存受控资源类型、数值 ID 或稳定 Key目标响应只包含白名单 `target_type`、结构化 `target_id/target_key``available`,任何字段均不能承载任意 URL。 - [x] 通知只保存受控资源类型、数值 ID 或稳定 Key目标响应只包含白名单 `target_type`、结构化 `target_id/target_key``available`,任何字段均不能承载任意 URL。
- [ ] 第一版注册表至少覆盖退款详情、代理充值详情、企微审批详情、卡详情、设备详情、临期资产列表、店铺资金概况、审计外部集成和系统配置。 - [x] 第一版注册表至少覆盖退款详情、代理充值详情、企微审批详情、卡详情、设备详情、临期资产列表、店铺资金概况、审计外部集成和系统配置。
- [ ] 目标解析先按当前接收人固定查询通知,再调用对应业务权限 Adapter 复核资源;拥有通知不授予目标资源访问权。 - [x] 目标解析先按当前接收人固定查询通知,再调用对应业务权限 Adapter 复核资源;拥有通知不授予目标资源访问权。
- [ ] 别人通知、不存在通知和已删除通知不泄露通知事实;目标不存在、已删除或当前无权时统一返回 `available=false`,不返回资源差异信息。 - [x] 别人通知、不存在通知和已删除通知不泄露通知事实;目标不存在、已删除或当前无权时统一返回 `available=false`,不返回资源差异信息。
- [ ] `card_sync` 等同步消息解析为统一审计中心外部集成目标并携带受控资源或 Integration Log 标识,不指向不存在的同步执行页。 - [x] `card_sync` 等同步消息解析为统一审计中心外部集成目标并携带受控资源或 Integration Log 标识,不指向不存在的同步执行页。
- [ ] 未知通知引用或尚未支持的目标只允许展示正文,不产生开放重定向、自由路径或自动回退 URL。 - [x] 未知通知引用或尚未支持的目标只允许展示正文,不产生开放重定向、自由路径或自动回退 URL。
- [ ] 契约与越权测试覆盖全部白名单、未知引用、通知越权、目标删除、权限变化、外部集成目标,并断言响应和持久化数据不存在任意 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/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环
- `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环 - `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环
**Status:** ready-for-agent **Status:** 代码完成、验证延期
**架构通道:** 主通道为 Infrastructure辅助通道为简单写 Application。 **架构通道:** 主通道为 Infrastructure辅助通道为简单写 Application。
**完整业务边界:** 本票收口通知展示期限、数据保留、分批清理、失败分类和统一审计/外部集成可观测接缝。明确不删除 Audit Event、Integration Log、领域流水或 Outbox不提供用户删除接口不建设管理员查看他人消息入口也不改变公共 Relay 的租约算法。 **完整业务边界:** 本票收口通知展示期限、数据保留、分批清理、失败分类和统一审计/外部集成可观测接缝。明确不删除 Audit Event、Integration Log、领域流水或 Outbox不提供用户删除接口不建设管理员查看他人消息入口也不改变公共 Relay 的租约算法。
- [ ] 套餐临期、审批结果、同步异常和系统告警按约定计算展示与保留期限,审批结果不自动过期,系统告警展示期限不超过允许上限。 - [x] 套餐临期、审批结果、同步异常和系统告警按约定计算展示与保留期限,审批结果不自动过期,系统告警展示期限不超过允许上限。
- [ ] 清理任务按时间和稳定主键小批量删除超过数据保留期限的通知,可中断重跑且只清理通知事实,不级联业务资源或审计记录。 - [x] 清理任务按时间和稳定主键小批量删除超过数据保留期限的通知,可中断重跑且只清理通知事实,不级联业务资源或审计记录。
- [ ] 暂无接收人记录 `no_recipient` 后成功结束;数据库、队列等瞬时错误按有限策略重试;模板永久缺失达到最大重试后进入失败监控且不写残缺正文。 - [x] 暂无接收人记录 `no_recipient` 后成功结束;数据库、队列等瞬时错误按有限策略重试;模板永久缺失达到最大重试后进入失败监控且不写残缺正文。
- [ ] 系统告警直接入队时必须携带预先生成的稳定事件 ID重复执行仍由通知唯一键防重。 - [x] 系统告警直接入队时必须携带预先生成的稳定事件 ID重复执行仍由通知唯一键防重。
- [ ] 模板或接收人解析失败、系统告警生成和管理性排查写入统一 Audit/Integration 接缝;普通通知读取和已读只进入 Access Log。 - [ ] 模板或接收人解析失败、系统告警生成和管理性排查写入统一 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 — 向个人客户投递并提供简化通知中心 - 03 — 向个人客户投递并提供简化通知中心
- 05 — 交付通知受控目标解析与权限复核 - 05 — 交付通知受控目标解析与权限复核
**Status:** ready-for-agent **Status:** 契约完成、前端与人工验收延期
**架构通道:** 主通道为 Query/API 跨仓契约,辅助通道为前端验收契约。 **架构通道:** 主通道为 Query/API 跨仓契约,辅助通道为前端验收契约。
**完整业务边界:** 本票收口当前后端仓库能够交付的 OpenAPI、交互状态、目标白名单说明和验收数据不在本仓库实现前端组件。明确不引入 WebSocket/SSE不承诺 Redis 未读计数,不为未知目标提供自由 URL也不代替前端仓库自身的组件测试。 **完整业务边界:** 本票收口当前后端仓库能够交付的 OpenAPI、交互状态、目标白名单说明和验收数据不在本仓库实现前端组件。明确不引入 WebSocket/SSE不承诺 Redis 未读计数,不为未知目标提供自由 URL也不代替前端仓库自身的组件测试。
- [ ] 契约明确布局挂载后立即请求、每 30 秒刷新、页面不可见暂停、恢复立即刷新,以及失败保留上次成功未读数且不闪回零。 - [x] 契约明确布局挂载后立即请求、每 30 秒刷新、页面不可见暂停、恢复立即刷新,以及失败保留上次成功未读数且不闪回零。
- [ ] 徽标验收覆盖 0、1、99、1000 时隐藏、199 显示数字、100 显示 `99+`,并约定固定宽度避免布局抖动。 - [x] 徽标验收覆盖 0、1、99、1000 时隐藏、199 显示数字、100 显示 `99+`,并约定固定宽度避免布局抖动。
- [ ] 后台抽屉按最近 10 条和约定分类展示,通知中心支持类别、类型、严重级别、已读状态、服务端分页及当前类别全部已读。 - [x] 后台抽屉按最近 10 条和约定分类展示,通知中心支持类别、类型、严重级别、已读状态、服务端分页及当前类别全部已读。
- [ ] 点击顺序固定为先进入已读视觉状态并调用已读接口,再解析受控目标;已读失败以下次服务端刷新为准,目标失败不恢复未读。 - [x] 点击顺序固定为先进入已读视觉状态并调用已读接口,再解析受控目标;已读失败以下次服务端刷新为准,目标失败不恢复未读。
- [ ] 前端目标白名单只根据 `target_type` 和结构化标识构造内部路由,未知类型或 `available=false` 只展示正文且不跳转。 - [x] 前端目标白名单只根据 `target_type` 和结构化标识构造内部路由,未知类型或 `available=false` 只展示正文且不跳转。
- [ ] C 端契约只展示当前客户相关的审批结果、套餐、订单和资产消息,不暴露后台同步或系统运维分类。 - [x] C 端契约只展示当前客户相关的审批结果、套餐、订单和资产消息,不暴露后台同步或系统运维分类。
- [ ] OpenAPI、中文契约文档、示例响应与验收矩阵保持一致并明确前端代码位于外部仓库、需按对应仓库流程实施和联调。 - [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-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 — 执行一次性审计切换与停机发布门禁 - `.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md` — 19 — 执行一次性审计切换与停机发布门禁
**Status:** ready-for-agent **Status:** 代码与发布契约完成、验证延期
**架构通道:** 主通道为 Infrastructure辅助通道为 Application、Query 与跨仓契约。 **架构通道:** 主通道为 Infrastructure辅助通道为 Application、Query 与跨仓契约。
@@ -22,8 +22,10 @@
- [ ] 空数据库和兼容环境可执行通知正向迁移、索引校验与允许的结构回滚;已有通知事实后不得通过降级删表清理,应用回滚允许保留数据。 - [ ] 空数据库和兼容环境可执行通知正向迁移、索引校验与允许的结构回滚;已有通知事实后不得通过降级删表清理,应用回滚允许保留数据。
- [ ] PostgreSQL、Redis、公共 Relay 和 Asynq 端到端测试覆盖事务事件、至少一次投递、入队成功后重复、并发 Worker、多接收人、接收人去重和最终通知唯一性。 - [ ] PostgreSQL、Redis、公共 Relay 和 Asynq 端到端测试覆盖事务事件、至少一次投递、入队成功后重复、并发 Worker、多接收人、接收人去重和最终通知唯一性。
- [ ] 真实后台与个人客户认证测试覆盖所有公开接口、统一响应、静态路由顺序、接收人不可篡改、跨用户隔离、过期排除和受控目标权限变化。 - [ ] 真实后台与个人客户认证测试覆盖所有公开接口、统一响应、静态路由顺序、接收人不可篡改、跨用户隔离、过期排除和受控目标权限变化。
- [ ] 运行门禁覆盖 Worker 失败、永久模板错误、无接收人、Outbox 积压、清理滞后和审计/外部集成记录异常,并给出停止放量和恢复步骤。 - [x] 运行门禁覆盖 Worker 失败、永久模板错误、无接收人、Outbox 积压、清理滞后和审计/外部集成记录异常,并给出停止放量和恢复步骤。
- [ ] 下游接入契约明确稳定事件 ID、结构化载荷、受控通知类型、接收人解析、模板字段、过期策略和目标引用调用统一队列客户端时禁止传预序列化字节。 - [x] 下游接入契约明确稳定事件 ID、结构化载荷、受控通知类型、接收人解析、模板字段、过期策略和目标引用调用统一队列客户端时禁止传预序列化字节。
- [ ] 发布顺序明确为迁移与校验、Worker 与监控、后端 API、前端、下游生产者下游不得在消费者和监控就绪前制造不可见积压。 - [x] 发布顺序明确为迁移与校验、Worker 与监控、后端 API、前端、下游生产者下游不得在消费者和监控就绪前制造不可见积压。
- [ ] 新增管理端和 C 端 Handler 已同步路由、RouteSpec、两个 OpenAPI 文档生成器,并生成 OpenAPI 核对 `/read-all` 未被动态 ID 路由吞掉。 - [x] 新增管理端和 C 端 Handler 已同步路由、RouteSpec、两个 OpenAPI 文档生成器,并生成 OpenAPI 核对 `/read-all` 未被动态 ID 路由吞掉。
- [ ] 中文功能总结覆盖关键流程、前后端契约、异常闭环、监控、发布回滚和待决策项README 增加入口;测试数据使用隔离标识且不向真实用户生成通知。 - [x] 中文功能总结覆盖关键流程、前后端契约、异常闭环、监控、发布回滚和待决策项README 增加入口;测试数据使用隔离标识且不向真实用户生成通知。
迁移演练、真实 PostgreSQL/Redis/Relay/Asynq、真实认证 HTTP 与并发验证按本 Change 豁免保持未勾选,并转任务 6.1、6.3;前端人工验收转 6.6。当前已完成 OpenAPI 生成、`gofmt``git diff --check``go build ./...` 与 OpenSpec 校验。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,17 +4,19 @@
**Blocked by:** 01 — 建立店铺业务员归属持久化基线 **Blocked by:** 01 — 建立店铺业务员归属持久化基线
**Status:** ready-for-agent **Status:** 代码完成、验证延期
**架构通道:** Query。 **架构通道:** Query。
**完整业务边界:** 本票收口店铺列表、店铺详情和业务员候选三个读取用例及其 API 契约。明确不通过聚合根读取,不执行写操作,不把候选查询结果当作写侧最终校验,不扩大任何账号的数据权限。 **完整业务边界:** 本票收口店铺列表、店铺详情和业务员候选三个读取用例及其 API 契约。明确不通过聚合根读取,不执行写操作,不把候选查询结果当作写侧最终校验,不扩大任何账号的数据权限。
- [ ] 店铺列表支持按保存的业务员 ID 精确筛选,并与店铺名、编号、联系电话、上级、层级、状态等既有条件按 AND 组合,分页和排序契约保持不变。 - [x] 店铺列表支持按保存的业务员 ID 精确筛选,并与店铺名、编号、联系电话、上级、层级、状态等既有条件按 AND 组合,分页和排序契约保持不变。
- [ ] 列表和详情统一返回可空业务员 ID、账号名、固定前三后四手机号摘要及可用状态空归属返回空摘要停用或软删除账号尽可能返回历史摘要并标记不可用。 - [x] 列表和详情统一返回可空业务员 ID、账号名、固定前三后四手机号摘要及可用状态空归属返回空摘要停用或软删除账号尽可能返回历史摘要并标记不可用。
- [ ] 当前页业务员账号通过一次批量查询完成投影,包含必要的软删除只读数据,不发生逐店铺 N+1 查询。 - [x] 当前页业务员账号通过一次批量查询完成投影,包含必要的软删除只读数据,不发生逐店铺 N+1 查询。
- [ ] 若当前没有独立店铺详情接口,则提供受既有数据权限保护的详情接口,并确保候选、级联、资金概况等静态路由不会被动态 ID 路由吞掉。 - [x] 若当前没有独立店铺详情接口,则提供受既有数据权限保护的详情接口,并确保候选、级联、资金概况等静态路由不会被动态 ID 路由吞掉。
- [ ] 候选接口只允许超级管理员和平台账号调用,仅返回启用、未删除的普通平台账号,并提供用户名或手机号受控搜索、默认 20 和最大 100 的分页。 - [x] 候选接口只允许超级管理员和平台账号调用,仅返回启用、未删除的普通平台账号,并提供用户名或手机号受控搜索、默认 20 和最大 100 的分页。
- [ ] 代理和企业不能调用候选接口;候选仅返回 ID、账号名和手机号摘要不复用披露范围更大的账号列表响应。 - [x] 代理和企业不能调用候选接口;候选仅返回 ID、账号名和手机号摘要不复用披露范围更大的账号列表响应。
- [ ] PostgreSQL 与 HTTP 集成测试覆盖索引筛选、AND 条件、分页总数、历史投影、手机号脱敏、无 N+1、权限和静态路由顺序。 - [ ] 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 — 独立修改并审计店铺业务员归属 - 04 — 独立修改并审计店铺业务员归属
- 05 — 查询店铺业务员归属与候选账号 - 05 — 查询店铺业务员归属与候选账号
**Status:** ready-for-agent **Status:** 代码完成、验证延期
**架构通道:** 主通道为 Application + Port/Adapter辅助通道为 Query/Infrastructure。 **架构通道:** 主通道为 Application + Port/Adapter辅助通道为 Query/Infrastructure。
**完整业务边界:** 本票提供公共通知能力可调用的店铺业务员接收人解析接缝,并验证 UR#96 后端交付。明确不建设通知表、通知中心、铃铛或任何前端页面,不实现 UR#33 套餐临期和 UR#97 钱包低余额的触发规则,不发送外部渠道通知,也不建设自动转派。 **完整业务边界:** 本票提供公共通知能力可调用的店铺业务员接收人解析接缝,并验证 UR#96 后端交付。明确不建设通知表、通知中心、铃铛或任何前端页面,不实现 UR#33 套餐临期和 UR#97 钱包低余额的触发规则,不发送外部渠道通知,也不建设自动转派。
- [ ] 接收人解析按目标店铺当前保存的业务员 ID 查询账号,仅在账号仍为启用、未删除的普通平台账号时返回稳定账号 ID。 - [x] 接收人解析按目标店铺当前保存的业务员 ID 查询账号,仅在账号仍为启用、未删除的普通平台账号时返回稳定账号 ID。
- [ ] 店铺无归属、账号停用、软删除或类型已不符合时返回无可用业务员接收人,不清空店铺字段且不把永久无接收人当作无限重试错误。 - [x] 店铺无归属、账号停用、软删除或类型已不符合时返回无可用业务员接收人,不清空店铺字段且不把永久无接收人当作无限重试错误。
- [ ] 解析不读取父店铺、祖先店铺或创建人,不因上级后续变更而改变目标店铺的接收人结果。 - [x] 解析不读取父店铺、祖先店铺或创建人,不因上级后续变更而改变目标店铺的接收人结果。
- [ ] 接缝可被公共站内通知 Worker 复用,并与其“店铺主账号加当前可用业务员、按账号 ID 去重”的接收人规则兼容;本票不复制公共通知基础设施。 - [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,11 @@ default:
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md) - **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)
- **UR#60 店铺联系电话精确查询**:店铺列表支持 11 位 ASCII 联系电话精确筛选,统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](docs/ur60-shop-phone-search/功能总结.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)。 - **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 唯一性门禁及实名 `ApplyCardObservation` 事务闭环,包含数据库持久化的三次/10 分钟逆转确认、首次实名 Outbox 和 Worker 消费;流量/网络观测、轮询切换、事件序列及回调入口继续按七月总台账实施。详见 [功能总结](docs/ur94-card-state-events-callbacks/功能总结.md)。
- **UR#86 资产前代与后代换货标识**:统一资产详情返回稳定 `exchange_trace`,展示单节点前代与后代快照,并按关联资产当前权限控制可跳转 ID详见 [功能总结](docs/ur86-asset-exchange-trace/功能总结.md)。 - **UR#86 资产前代与后代换货标识**:统一资产详情返回稳定 `exchange_trace`,展示单节点前代与后代快照,并按关联资产当前权限控制可跳转 ID详见 [功能总结](docs/ur86-asset-exchange-trace/功能总结.md)。
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md) - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md) - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)

View File

@@ -14,9 +14,14 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.uber.org/zap" "go.uber.org/zap"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
"github.com/break/junhong_cmp_fiber/internal/bootstrap" "github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway" "github.com/break/junhong_cmp_fiber/internal/gateway"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
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/model"
"github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/polling"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
@@ -274,7 +279,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
stopResumeSvc.SetPollingCallback(lifecycleSvc) stopResumeSvc.SetPollingCallback(lifecycleSvc)
} }
return &workerRuntime{ runtime := &workerRuntime{
redisAddr: redisAddr, redisAddr: redisAddr,
redisClient: redisClient, redisClient: redisClient,
db: db, db: db,
@@ -291,6 +296,85 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
pollingBase: pollingBase, pollingBase: pollingBase,
lifecycleSvc: lifecycleSvc, 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))
}
}
// 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 退出时关闭共享客户端与数据库连接。 // close 在 Worker 退出时关闭共享客户端与数据库连接。
@@ -394,7 +478,7 @@ func startPollingScheduler(
appLogger, appLogger,
) )
// 注入套餐失效前流量同步器:最小化 iot_card.Service只需 gateway + store + redis + 流量扣减) // 注入套餐失效前流量同步器:复用公共卡观测写入,不在旧 Service 内重复扣减套餐。
trafficSyncer := iot_card_svc.New( trafficSyncer := iot_card_svc.New(
runtime.db, runtime.db,
runtime.pollingIotCardStore, runtime.pollingIotCardStore,
@@ -404,7 +488,7 @@ func startPollingScheduler(
nil, nil,
) )
trafficSyncer.SetRedisClient(runtime.redisClient) trafficSyncer.SetRedisClient(runtime.redisClient)
trafficSyncer.SetDataDeductor(runtime.workerResult.Services.UsageService) trafficSyncer.SetCardObservationService(runtime.workerResult.Services.CardObservation)
activationHandler.SetTrafficSyncer(trafficSyncer) activationHandler.SetTrafficSyncer(trafficSyncer)
pollingScheduler := polling.NewScheduler( pollingScheduler := polling.NewScheduler(
@@ -447,7 +531,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 return asynqScheduler
} }
@@ -474,6 +558,14 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
)); err != nil { )); err != nil {
return fmt.Errorf("注册数据清理定时任务失败: %w", err) 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( if _, err := asynqScheduler.Register(
"0 2 * * *", "0 2 * * *",
asynq.NewTask( asynq.NewTask(

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

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

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

View File

@@ -0,0 +1,119 @@
# UR#94 卡状态公共写入与运营商回调功能总结
## 当前完成范围
任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.34、2.35、2.36、2.37 已分别交付实名、流量、网络观测公共写入闭环及三个轮询入口切换。观测序列和运营商回调仍按 2.382.45 继续实施,不能因当前能力已落地而提前开放回调入口。
## 流量观测公共写入闭环
统一 `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;
```
停止条件:任一查询返回记录时不得执行回调路由发布,也不得任取一张卡继续迁移。数据责任人必须核对运营商原始资料、资产归属和历史业务事实,按单独受控方案修复后重新扫描。仅软删除记录与有效卡重复允许存在,但必须单独登记为已确认的非阻塞项。
## 回滚
down 迁移只把 `idx_iot_card_iccid_19/20` 恢复为 `000131` 的普通部分索引,不删除双列、不修改卡数据,也不触碰其他表索引。若回调已经开放并依赖精确唯一语义,应先关闭回调入口并确认没有并发写入,再评估回滚。
## 验证状态
- 已静态核对 up/down 文件成对、索引名与 `000131` 一致、唯一索引谓词与冲突扫描谓词一致。
- 已静态核对 `000179` up/down 成对、字段注释和 CHECK 约束一致,领域规则与持久化字段没有 Redis 事务依赖。
- 已静态核对 `000180` up/down 成对、副作用状态 CHECK 与常量一致,流量用例不再直接覆盖日流量落盘表。
- 已执行 `gofmt``go build ./...`;构建退出码为 0。
- 按七月测试环境豁免,本轮未连接真实 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 用例,
-- 并在同一事务内写入权威资金流水与可靠事件;禁止恢复本文件中的历史直写逻辑。
-- 锁定代理主钱包,确认余额和冻结余额 DO $$
SELECT * BEGIN
FROM tb_agent_wallet RAISE EXCEPTION '代理主钱包直写清理脚本已停用,请使用受控 Wallet Application 用例';
WHERE shop_id = :shop_id END
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;

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,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,241 @@
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"`
}
// 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, merged bool, err error)
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
}
// 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) {
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
}
if err := validateSeriesRequest(request); err != nil {
return "", false, err
}
candidateBaseTime := s.now().UTC()
seriesID, baseTime, merged, err := s.coordinator.Reserve(ctx, request, uuid.NewString(), candidateBaseTime)
if err != nil {
return "", false, err
}
// 重复触发仍以稳定任务 ID 补齐首次入队的局部失败;已存在任务由 Asynq 去重,不会延长原序列。
for attempt := 1; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
scheduledAt := baseTime.Add(constants.CardObservationAttemptDelay(attempt))
payload := SeriesTaskPayload{
SeriesID: seriesID, Attempt: attempt, ScheduledAt: scheduledAt,
Scene: request.Scene, ResourceType: request.ResourceType, ResourceID: request.ResourceID,
SyncType: request.SyncType, ExpectedValue: request.ExpectedValue, Source: request.Source,
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
}
if err := s.scheduler.Enqueue(ctx, payload); err != nil {
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 err
}
if completed {
return s.completeRemainingAttempts(ctx, payload, "序列已提前完成")
}
met, err := s.runner.ExpectationMet(ctx, payload)
if err != nil {
return err
}
if met {
return s.completeRemainingAttempts(ctx, payload, "本地快照已达到预期")
}
provider, err := s.runner.Provider(ctx, payload)
if err != nil {
return err
}
release, wait, acquired, err := s.coordinator.AcquireRequest(ctx, payload, provider)
if err != nil {
return 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) 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) {
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})
}
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

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

@@ -1,7 +1,11 @@
package bootstrap package bootstrap
import ( 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" 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/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/app" "github.com/break/junhong_cmp_fiber/internal/handler/app"
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
@@ -11,7 +15,9 @@ import (
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" 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" 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" systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig"
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling" pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling"
@@ -97,7 +103,11 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
return &Handlers{ return &Handlers{
Auth: authHandler.NewHandler(svc.Auth, validate), Auth: authHandler.NewHandler(svc.Auth, validate),
Account: admin.NewAccountHandler(svc.Account), Account: admin.NewAccountHandler(svc.Account),
Role: admin.NewRoleHandler(svc.Role, validate), 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), Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger), PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger), ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
@@ -108,10 +118,22 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger), 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), ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger), ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
Shop: admin.NewShopHandler(svc.Shop, validate), 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), ShopRole: admin.NewShopRoleHandler(svc.Shop),
AdminAuth: admin.NewAuthHandler(svc.Auth, validate), AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission), ShopCommission: func() *admin.ShopCommissionHandler {
handler := admin.NewShopCommissionHandler(svc.ShopCommission)
handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB))
return handler
}(),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate), CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting), CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise), Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
@@ -121,6 +143,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
IotCard: admin.NewIotCardHandler(svc.IotCard), IotCard: admin.NewIotCardHandler(svc.IotCard),
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport), IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
ExportTask: admin.NewExportTaskHandler(svc.ExportTask), ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
Device: admin.NewDeviceHandler(svc.Device), Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport), DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),

View File

@@ -5,6 +5,11 @@ import (
"go.uber.org/zap" "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/messaging/outbox"
walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet"
"github.com/break/junhong_cmp_fiber/internal/polling" "github.com/break/junhong_cmp_fiber/internal/polling"
accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account"
accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit" accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit"
@@ -137,6 +142,12 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger, deps.Logger,
assetAudit, assetAudit,
) )
cardObservationOutbox := outbox.NewRepository()
iotCard.SetCardObservationService(cardObservationApp.NewService(
deps.DB,
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
))
// 使用 PollingLifecycleService 替代 APICallback通过分片队列准确操作修复 api_callback.go 遗漏 protect 队列的 Bug3 // 使用 PollingLifecycleService 替代 APICallback通过分片队列准确操作修复 api_callback.go 遗漏 protect 队列的 Bug3
pollingConfigStore := postgres.NewPollingConfigStore(deps.DB) pollingConfigStore := postgres.NewPollingConfigStore(deps.DB)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger) pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, deps.Redis, deps.Logger)
@@ -147,10 +158,6 @@ func initServices(s *stores, deps *Dependencies) *services {
pollingQueueMgr := polling.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger) pollingQueueMgr := polling.NewPollingQueueManager(deps.Redis, constants.PollingShardCount, deps.Logger)
pollingLifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, s.IotCard, s.DeviceSimBinding, s.Device, deps.Logger) pollingLifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, s.IotCard, s.DeviceSimBinding, s.Device, deps.Logger)
iotCard.SetPollingCallback(pollingLifecycleSvc) iotCard.SetPollingCallback(pollingLifecycleSvc)
// 注入流量扣减回调,使手动刷新资产时能触发套餐流量扣减
usageService := packageSvc.NewUsageService(deps.DB, deps.Redis, s.PackageUsage, s.PackageUsageDailyRecord, s.DeviceSimBinding, deps.Logger)
iotCard.SetDataDeductor(usageService)
// 创建支付配置服务Order 和 Recharge 依赖) // 创建支付配置服务Order 和 Recharge 依赖)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, accountAudit, deps.Redis, deps.Logger) wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, accountAudit, deps.Redis, deps.Logger)
@@ -203,6 +210,38 @@ func initServices(s *stores, deps *Dependencies) *services {
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger) shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)
packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation) packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation)
orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone) orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone)
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) assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device) agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
@@ -227,7 +266,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger, deps.Logger,
customerBinding, 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), Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
ShopCommission: shopCommission, ShopCommission: shopCommission,
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest), CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
@@ -284,37 +323,12 @@ func initServices(s *stores, deps *Dependencies) *services {
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction), AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
StopResumeService: stopResumeService, StopResumeService: stopResumeService,
WechatConfig: wechatConfig, WechatConfig: wechatConfig,
AgentRecharge: agentRechargeSvc.New( AgentRecharge: agentRechargeService,
deps.DB,
s.AgentRecharge,
s.AgentWallet,
s.AgentWalletTransaction,
s.Shop,
wechatConfig,
accountAudit,
operationPassword,
deps.Redis,
deps.Logger,
),
PackageActivation: packageActivation, PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage), TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
OperationPassword: operationPassword, OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI, AgentOpenAPI: agentOpenAPI,
Refund: refundSvc.New( Refund: refundService,
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, CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
} }

View File

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

View File

@@ -1,6 +1,12 @@
package bootstrap package bootstrap
import ( 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" 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_calculation"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats" "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
@@ -81,6 +87,19 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.DataCleanupLog, stores.DataCleanupLog,
deps.Logger, deps.Logger,
) )
cardObservationOutbox := outbox.NewRepository()
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( orderService := orderSvc.New(
@@ -108,6 +127,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PersonalCustomer, stores.PersonalCustomer,
stores.PersonalCustomerPhone, stores.PersonalCustomerPhone,
) )
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
// 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机 // 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机
stopResumeService := iotCardSvc.NewStopResumeService( stopResumeService := iotCardSvc.NewStopResumeService(
@@ -125,6 +147,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
resetService.SetResumeCallback(stopResumeService) resetService.SetResumeCallback(stopResumeService)
return &queue.WorkerServices{ return &queue.WorkerServices{
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService,
CommissionCalculation: commissionCalculationService, CommissionCalculation: commissionCalculationService,
CommissionStats: commissionStatsService, CommissionStats: commissionStatsService,
UsageService: usageService, UsageService: usageService,

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 ( import (
"strings" "strings"
cardobservation "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/constants"
) )
// ParseCardNetworkStatus 将 Gateway 卡状态转换为系统网络状态。 // ParseCardNetworkStatus 将 Gateway 卡状态转换为系统网络状态。
// 只有网关明确返回“正常”才视为开机;“准备”或“待激活”不能按正常卡展示。 // 只有网关明确返回“正常”才视为开机;“准备”或“待激活”不能按正常卡展示。
func ParseCardNetworkStatus(cardStatus, extend string) (int, bool) { func ParseCardNetworkStatus(cardStatus, extend string) (int, bool) {
status := strings.TrimSpace(cardStatus) return cardobservation.MapGatewayNetworkStatus(cardStatus, extend)
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
} }
// IsGatewayCardStopped 判断 Gateway 是否明确返回停机状态。 // IsGatewayCardStopped 判断 Gateway 是否明确返回停机状态。

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,6 +11,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response" "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" "github.com/break/junhong_cmp_fiber/internal/model/dto"
roleService "github.com/break/junhong_cmp_fiber/internal/service/role" roleService "github.com/break/junhong_cmp_fiber/internal/service/role"
) )
@@ -18,9 +19,15 @@ import (
// RoleHandler 角色 Handler // RoleHandler 角色 Handler
type RoleHandler struct { type RoleHandler struct {
service *roleService.Service service *roleService.Service
defaultCreditService *roleApp.DefaultCreditService
validator *validator.Validate validator *validator.Validate
} }
// SetDefaultCreditService 设置角色默认信用模板应用服务。
func (h *RoleHandler) SetDefaultCreditService(service *roleApp.DefaultCreditService) {
h.defaultCreditService = service
}
// NewRoleHandler 创建角色 Handler // NewRoleHandler 创建角色 Handler
func NewRoleHandler(service *roleService.Service, validator *validator.Validate) *RoleHandler { func NewRoleHandler(service *roleService.Service, validator *validator.Validate) *RoleHandler {
return &RoleHandler{ return &RoleHandler{
@@ -254,3 +261,36 @@ func (h *RoleHandler) UpdateStatus(c *fiber.Ctx) error {
return response.Success(c, nil) 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 package admin
import ( import (
"math"
"strconv" "strconv"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"go.uber.org/zap" "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" "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" 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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -18,9 +22,33 @@ import (
// ShopHandler 店铺管理处理器。 // ShopHandler 店铺管理处理器。
type ShopHandler struct { type ShopHandler struct {
service *shopService.Service service *shopService.Service
createService *shopapp.CreateService
updateService *shopapp.UpdateService
ownerQuery *shopquery.BusinessOwnerQuery
changeCreditService *walletapp.ChangeCreditService
validator *validator.Validate 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 创建店铺管理处理器。 // NewShopHandler 创建店铺管理处理器。
func NewShopHandler(service *shopService.Service, validator *validator.Validate) *ShopHandler { func NewShopHandler(service *shopService.Service, validator *validator.Validate) *ShopHandler {
return &ShopHandler{service: service, validator: validator} return &ShopHandler{service: service, validator: validator}
@@ -49,7 +77,10 @@ func (h *ShopHandler) List(c *fiber.Ctx) error {
normalizeShopListPagination(&req) 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 { if err != nil {
return err return err
} }
@@ -66,7 +97,83 @@ func (h *ShopHandler) logListValidationFailure(c *fiber.Ctx, err error) {
logger.GetAppLogger().Warn("店铺列表参数验证失败", logger.GetAppLogger().Warn("店铺列表参数验证失败",
zap.String("method", c.Method()), zap.String("method", c.Method()),
zap.String("path", c.Path()), 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), zap.Error(err),
) )
} }
@@ -87,8 +194,20 @@ func (h *ShopHandler) Create(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败") 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 { if err != nil {
return err return err
} }
@@ -108,8 +227,19 @@ func (h *ShopHandler) Update(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败") 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 { if err != nil {
return err return err
} }

View File

@@ -6,6 +6,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto" "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" 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/errors"
"github.com/break/junhong_cmp_fiber/pkg/response" "github.com/break/junhong_cmp_fiber/pkg/response"
@@ -14,6 +15,7 @@ import (
// ShopCommissionHandler 代理商资金管理 Handler // ShopCommissionHandler 代理商资金管理 Handler
type ShopCommissionHandler struct { type ShopCommissionHandler struct {
service *shopCommissionService.Service service *shopCommissionService.Service
fundSummaryQuery *shopQuery.FundSummaryQuery
} }
// NewShopCommissionHandler 创建代理商资金管理 Handler // NewShopCommissionHandler 创建代理商资金管理 Handler
@@ -21,6 +23,11 @@ func NewShopCommissionHandler(service *shopCommissionService.Service) *ShopCommi
return &ShopCommissionHandler{service: service} return &ShopCommissionHandler{service: service}
} }
// SetFundSummaryQuery 注入代理商资金概况 Query。
func (h *ShopCommissionHandler) SetFundSummaryQuery(query *shopQuery.FundSummaryQuery) {
h.fundSummaryQuery = query
}
// ListFundSummary 代理商资金概况列表 // ListFundSummary 代理商资金概况列表
// GET /api/admin/shops/fund-summary // GET /api/admin/shops/fund-summary
func (h *ShopCommissionHandler) ListFundSummary(c *fiber.Ctx) error { 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) 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 { if err != nil {
return err return err
} }

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

@@ -27,7 +27,7 @@ import (
// AgentRechargeServiceInterface 代理充值服务接口 // AgentRechargeServiceInterface 代理充值服务接口
type AgentRechargeServiceInterface interface { 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 支付配置服务接口 // WechatConfigServiceInterface 支付配置服务接口
@@ -170,7 +170,7 @@ func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, outTradeNo,
return fmt.Errorf("充值订单服务未配置,无法处理订单: %s", outTradeNo) return fmt.Errorf("充值订单服务未配置,无法处理订单: %s", outTradeNo)
case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix): case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil { 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) return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo)
default: default:
@@ -473,7 +473,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
return c.Send(fuiou.BuildNotifyFailResponse("充值订单服务未配置")) return c.Send(fuiou.BuildNotifyFailResponse("充值订单服务未配置"))
case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix): case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil { 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())) 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)
}

View File

@@ -0,0 +1,336 @@
// Package cardobservation 提供卡观测的 Outbox、缓存和消费者适配器。
package cardobservation
import (
"context"
"strconv"
"time"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
func uintString(value uint) string {
return strconv.FormatUint(uint64(value), 10)
}
// EventWriter 将卡实名状态变化写入公共 Outbox。
type EventWriter struct {
outbox *outbox.Repository
}
// NewEventWriter 创建卡实名事件 Writer。
func NewEventWriter(repository *outbox.Repository) *EventWriter {
return &EventWriter{outbox: repository}
}
// Append 在调用方事务中追加卡实名状态变化事件。
func (w *EventWriter) AppendRealname(ctx context.Context, tx *gorm.DB, event cardapp.RealnameChangedEvent) 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.OutboxEventTypeCardRealnameChanged,
PayloadVersion: constants.CardRealnameChangedPayloadVersionV1,
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
return err
}
// AppendTraffic 在调用方事务中追加卡流量正增量事件。
func (w *EventWriter) AppendTraffic(ctx context.Context, tx *gorm.DB, event cardapp.TrafficIncrementedEvent) 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.OutboxEventTypeCardTrafficIncremented,
PayloadVersion: constants.CardTrafficIncrementedPayloadVersionV1,
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
return err
}
// AppendNetwork 在调用方事务中追加卡网络状态变化事件。
func (w *EventWriter) AppendNetwork(ctx context.Context, tx *gorm.DB, event cardapp.NetworkChangedEvent) 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.OutboxEventTypeCardNetworkChanged,
PayloadVersion: constants.CardNetworkChangedPayloadVersionV1,
AggregateType: "iot_card", AggregateID: uintString(event.CardID),
ResourceType: constants.AssetTypeIotCard, ResourceID: uintString(event.CardID),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
Payload: event,
})
return err
}
// CacheInvalidator 在提交后删除轮询卡缓存和旧 Redis 逆转计数。
type CacheInvalidator struct {
redis *redis.Client
logger *zap.Logger
}
// NewCacheInvalidator 创建卡观测缓存失效适配器。
func NewCacheInvalidator(redisClient *redis.Client, logger *zap.Logger) *CacheInvalidator {
return &CacheInvalidator{redis: redisClient, logger: logger}
}
// Invalidate 删除可由数据库重建的缓存;失败只告警,不伪造事务回滚。
func (i *CacheInvalidator) Invalidate(ctx context.Context, cardID uint) {
if i == nil || i.redis == nil {
return
}
if err := i.redis.Del(ctx, constants.RedisPollingCardInfoKey(cardID), constants.RedisPollingRealnameReversalCountKey(cardID)).Err(); err != nil && i.logger != nil {
i.logger.Warn("卡实名事实已提交但缓存失效失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
// RealnameActivator 激活卡或设备待实名套餐。
type RealnameActivator interface {
ActivateByRealname(ctx context.Context, carrierType string, carrierID uint) error
}
// TrafficDeductor 按卡流量正增量扣减套餐流量。
type TrafficDeductor interface {
DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error
}
// StopResumeEvaluator 根据卡的当前事实执行停复机评估。
type StopResumeEvaluator interface {
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
}
// DeviceBindingReader 查询卡当前绑定的设备。
type DeviceBindingReader interface {
GetActiveBindingByCardID(ctx context.Context, cardID uint) (*model.DeviceSimBinding, error)
}
// RealnameChangedConsumer 消费实名变化事件并串联现有幂等业务副作用。
type RealnameChangedConsumer struct {
db *gorm.DB
activator RealnameActivator
binding DeviceBindingReader
evaluator StopResumeEvaluator
}
// NetworkChangedConsumer 消费网络状态变化并按当前权威事实执行停复机评估。
type NetworkChangedConsumer struct {
db *gorm.DB
evaluator StopResumeEvaluator
}
// NewNetworkChangedConsumer 创建卡网络状态变化事件消费者。
func NewNetworkChangedConsumer(db *gorm.DB, evaluator StopResumeEvaluator) *NetworkChangedConsumer {
return &NetworkChangedConsumer{db: db, evaluator: evaluator}
}
// Consume 校验网络事件并按当前卡事实执行评估。
func (c *NetworkChangedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡网络事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardNetworkChanged || envelope.PayloadVersion != constants.CardNetworkChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡网络事件类型或版本不受支持")
}
var event cardapp.NetworkChangedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "卡网络事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.CardID == 0 || event.BeforeStatus == event.AfterStatus {
return errors.New(errors.CodeInvalidParam, "卡网络事件载荷不完整")
}
if c.evaluator == nil {
return nil
}
var card model.IotCard
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询网络变化后的卡事实失败")
}
return c.evaluator.EvaluateAndAct(ctx, &card)
}
// NewRealnameChangedConsumer 创建卡实名变化事件消费者。
func NewRealnameChangedConsumer(db *gorm.DB, activator RealnameActivator, binding DeviceBindingReader, evaluator StopResumeEvaluator) *RealnameChangedConsumer {
return &RealnameChangedConsumer{db: db, activator: activator, binding: binding, evaluator: evaluator}
}
// Consume 校验载荷,并以当前权威卡事实执行可重入副作用。
func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持")
}
var event cardapp.RealnameChangedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "卡实名事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.CardID == 0 || event.BeforeStatus == event.AfterStatus {
return errors.New(errors.CodeInvalidParam, "卡实名事件载荷不完整")
}
if event.FirstVerified && c.activator != nil {
if err := c.activator.ActivateByRealname(ctx, constants.AssetTypeIotCard, event.CardID); err != nil {
return err
}
if c.binding != nil {
binding, err := c.binding.GetActiveBindingByCardID(ctx, event.CardID)
if err == nil && binding != nil {
if err := c.activator.ActivateByRealname(ctx, constants.AssetTypeDevice, binding.DeviceID); err != nil {
return err
}
} else if err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "查询实名卡绑定设备失败")
}
}
}
if c.evaluator == nil {
return nil
}
var card model.IotCard
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询实名变化后的卡事实失败")
}
return c.evaluator.EvaluateAndAct(ctx, &card)
}
// TrafficIncrementedConsumer 消费卡流量正增量并推进幂等副作用进度。
type TrafficIncrementedConsumer struct {
db *gorm.DB
redis *redis.Client
deductor TrafficDeductor
evaluator StopResumeEvaluator
}
// NewTrafficIncrementedConsumer 创建卡流量正增量事件消费者。
func NewTrafficIncrementedConsumer(db *gorm.DB, redisClient *redis.Client, deductor TrafficDeductor, evaluator StopResumeEvaluator) *TrafficIncrementedConsumer {
return &TrafficIncrementedConsumer{db: db, redis: redisClient, deductor: deductor, evaluator: evaluator}
}
// Consume 按事件处理进度避免至少一次投递重复扣减套餐流量。
func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil || c.redis == nil || c.deductor == nil {
return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持")
}
var event cardapp.TrafficIncrementedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "卡流量事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.CardID == 0 || event.IncrementMB <= 0 {
return errors.New(errors.CodeInvalidParam, "卡流量事件载荷不完整")
}
receipt, err := c.acquireTrafficEffect(ctx, event.EventID)
if err != nil {
return err
}
if receipt.Status == constants.CardObservationEffectStatusCompleted {
return nil
}
if receipt.Status == constants.CardObservationEffectStatusProcessing {
dailyKey := constants.RedisCardDailyTrafficKey(event.CardID, event.ObservedAt.Format("2006-01-02"))
pipe := c.redis.TxPipeline()
pipe.IncrByFloat(ctx, dailyKey, event.IncrementMB)
pipe.Expire(ctx, dailyKey, 48*time.Hour)
if _, err := pipe.Exec(ctx); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "记录卡日流量缓冲失败,处理结果未知")
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDailySaved); err != nil {
return err
}
receipt.Status = constants.CardObservationEffectStatusDailySaved
}
if receipt.Status == constants.CardObservationEffectStatusDailySaved {
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusProcessing); err != nil {
return err
}
if err := c.deductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, event.CardID, event.IncrementMB); err != nil {
return err
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDeducted); err != nil {
return err
}
receipt.Status = constants.CardObservationEffectStatusDeducted
}
if c.evaluator != nil {
var card model.IotCard
if err := c.db.WithContext(ctx).Where("id = ?", event.CardID).First(&card).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询流量变化后的卡事实失败")
}
if err := c.evaluator.EvaluateAndAct(ctx, &card); err != nil {
return err
}
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDeducted, constants.CardObservationEffectStatusCompleted); err != nil {
return err
}
return nil
}
func (c *TrafficIncrementedConsumer) acquireTrafficEffect(ctx context.Context, eventID string) (*model.CardObservationEffect, error) {
var receipt model.CardObservationEffect
err := c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("event_id = ?", eventID).First(&receipt).Error
if err == gorm.ErrRecordNotFound {
receipt = model.CardObservationEffect{
EventID: eventID, EffectType: constants.CardObservationEffectTypeTrafficIncrement,
Status: constants.CardObservationEffectStatusProcessing,
}
return tx.Create(&receipt).Error
}
if err != nil {
return err
}
switch receipt.Status {
case constants.CardObservationEffectStatusCompleted, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusDeducted:
return nil
case constants.CardObservationEffectStatusProcessing:
return errors.New(errors.CodeConflict, "卡流量副作用仍在处理或结果未知")
default:
return tx.Model(&receipt).Where("status = ?", constants.CardObservationEffectStatusPending).
Update("status", constants.CardObservationEffectStatusProcessing).Error
}
})
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "获取卡流量副作用处理权失败")
}
return &receipt, nil
}
func (c *TrafficIncrementedConsumer) updateTrafficEffectStatus(ctx context.Context, id uint, beforeStatus, afterStatus int) error {
result := c.db.WithContext(ctx).Model(&model.CardObservationEffect{}).
Where("id = ? AND status = ?", id, beforeStatus).
Update("status", afterStatus)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量副作用进度失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量副作用进度已被其他消费者更新")
}
return nil
}

View File

@@ -0,0 +1,185 @@
package cardobservation
import (
"context"
"strconv"
"time"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesCoordinator 使用 Redis 原子协调活跃序列、幂等尝试和实际请求互斥。
type SeriesCoordinator struct {
redis *redis.Client
now func() time.Time
}
// NewSeriesCoordinator 创建观测序列 Redis 协调器。
func NewSeriesCoordinator(redisClient *redis.Client) *SeriesCoordinator {
return &SeriesCoordinator{redis: redisClient, now: time.Now}
}
// Reserve 原子保留同场景活跃序列;重复触发不会刷新 TTL。
func (c *SeriesCoordinator) Reserve(ctx context.Context, request cardapp.SeriesRequest, candidateSeriesID string, candidateBaseTime time.Time) (string, time.Time, bool, error) {
if c == nil || c.redis == nil {
return "", time.Time{}, false, errors.New(errors.CodeInternalError, "卡观测序列 Redis 未配置")
}
seriesKey := constants.RedisCardObservationSeriesKey(request.Scene, request.ResourceType, request.ResourceID, request.SyncType)
indexKey := constants.RedisCardObservationSeriesIndexKey(request.ResourceType, request.ResourceID, request.SyncType)
result, err := c.redis.Eval(ctx, `
local current = redis.call('HMGET', KEYS[1], 'series_id', 'base_time_ms')
if current[1] then return {current[1], current[2], '1'} end
redis.call('HSET', KEYS[1], 'series_id', ARGV[1], 'base_time_ms', ARGV[2])
redis.call('EXPIRE', KEYS[1], ARGV[3])
redis.call('SADD', KEYS[2], KEYS[1])
redis.call('EXPIRE', KEYS[2], ARGV[3])
return {ARGV[1], ARGV[2], '0'}
`, []string{seriesKey, indexKey}, candidateSeriesID, candidateBaseTime.UTC().UnixMilli(), int(constants.CardObservationSeriesTTL.Seconds())).StringSlice()
if err != nil || len(result) != 3 {
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, err, "保留卡观测序列失败")
}
baseTimeMS, parseErr := strconv.ParseInt(result[1], 10, 64)
if parseErr != nil {
return "", time.Time{}, false, errors.Wrap(errors.CodeInternalError, parseErr, "解析卡观测序列基准时间失败")
}
return result[0], time.UnixMilli(baseTimeMS).UTC(), result[2] == "1", nil
}
// IsCompleted 判断序列是否已由预期状态或回调提前完成。
func (c *SeriesCoordinator) IsCompleted(ctx context.Context, seriesID string) (bool, error) {
exists, err := c.redis.Exists(ctx, constants.RedisCardObservationSeriesCompletedKey(seriesID)).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "读取卡观测序列完成状态失败")
}
return exists > 0, nil
}
// ClaimAttempt 以 series_id + attempt 原子保证任务只执行一次。
func (c *SeriesCoordinator) ClaimAttempt(ctx context.Context, seriesID string, attempt int) (bool, error) {
claimed, err := c.redis.SetNX(ctx, constants.RedisCardObservationAttemptKey(seriesID, attempt), "processing", constants.CardObservationSeriesResultTTL).Result()
if err != nil {
return false, errors.Wrap(errors.CodeInternalError, err, "领取卡观测序列尝试失败")
}
return claimed, nil
}
// AcquireRequest 获取仅覆盖实际 Gateway 请求的互斥,并返回最小间隔剩余等待时间。
func (c *SeriesCoordinator) AcquireRequest(ctx context.Context, payload cardapp.SeriesTaskPayload, provider string) (func(), time.Duration, bool, error) {
token := uuid.NewString()
lockKey := constants.RedisCardObservationInflightKey(provider, payload.SyncType, payload.ResourceID)
// 流量观测复用现有卡级同步锁,避免事件、轮询和手动刷新并发读取同一上游读数。
if payload.SyncType == constants.CardObservationSyncTypeTraffic {
if cardID, parseErr := strconv.ParseUint(payload.ResourceID, 10, 64); parseErr == nil && cardID > 0 {
lockKey = constants.RedisCardTrafficSyncLockKey(uint(cardID))
}
}
lastKey := constants.RedisCardObservationLastRequestKey(provider, payload.SyncType, payload.ResourceID)
nowMS := c.now().UTC().UnixMilli()
result, err := c.redis.Eval(ctx, `
if redis.call('EXISTS', KEYS[1]) == 1 then return {-1} end
local now = tonumber(ARGV[2])
local minimum = tonumber(ARGV[3])
local last = tonumber(redis.call('GET', KEYS[2]) or '0')
local wait = math.max(0, last + minimum - now)
redis.call('PSETEX', KEYS[1], ARGV[4], ARGV[1])
redis.call('PSETEX', KEYS[2], minimum * 3, now + wait)
return {wait}
`, []string{lockKey, lastKey}, token, nowMS, constants.CardObservationGatewayMinInterval.Milliseconds(), constants.CardObservationGatewayLockTTL.Milliseconds()).Int64Slice()
if err != nil || len(result) != 1 {
return nil, 0, false, errors.Wrap(errors.CodeInternalError, err, "获取卡观测 Gateway 请求互斥失败")
}
if result[0] < 0 {
return func() {}, 0, false, nil
}
release := func() {
_, _ = c.redis.Eval(context.Background(), `
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
`, []string{lockKey}, token).Result()
}
return release, time.Duration(result[0]) * time.Millisecond, true, nil
}
// FinishAttempt 保存尝试终态,并在最后一次尝试后释放同场景活跃序列。
func (c *SeriesCoordinator) FinishAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
if err := c.redis.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, payload.Attempt), "completed", constants.CardObservationSeriesResultTTL).Err(); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "完成卡观测序列尝试失败")
}
if payload.Attempt < constants.CardObservationSeriesAttemptCount {
return nil
}
return c.releaseActiveSeries(ctx, payload, false)
}
// CompleteSeries 提前完成当前及剩余任务,并释放同场景合并键。
func (c *SeriesCoordinator) CompleteSeries(ctx context.Context, payload cardapp.SeriesTaskPayload) error {
if err := c.releaseActiveSeries(ctx, payload, true); err != nil {
return err
}
pipe := c.redis.TxPipeline()
for attempt := payload.Attempt; attempt <= constants.CardObservationSeriesAttemptCount; attempt++ {
pipe.Set(ctx, constants.RedisCardObservationAttemptKey(payload.SeriesID, attempt), "completed", constants.CardObservationSeriesResultTTL)
}
if _, err := pipe.Exec(ctx); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "标记卡观测剩余尝试完成失败")
}
return nil
}
func (c *SeriesCoordinator) releaseActiveSeries(ctx context.Context, payload cardapp.SeriesTaskPayload, completed bool) error {
seriesKey := constants.RedisCardObservationSeriesKey(payload.Scene, payload.ResourceType, payload.ResourceID, payload.SyncType)
indexKey := constants.RedisCardObservationSeriesIndexKey(payload.ResourceType, payload.ResourceID, payload.SyncType)
completedValue := "0"
if completed {
completedValue = "1"
}
if _, err := c.redis.Eval(ctx, `
if ARGV[2] == '1' then redis.call('SET', KEYS[3], '1', 'EX', ARGV[3]) end
if redis.call('HGET', KEYS[1], 'series_id') == ARGV[1] then
redis.call('DEL', KEYS[1])
redis.call('SREM', KEYS[2], KEYS[1])
end
return 1
`, []string{seriesKey, indexKey, constants.RedisCardObservationSeriesCompletedKey(payload.SeriesID)}, payload.SeriesID, completedValue, int(constants.CardObservationSeriesResultTTL.Seconds())).Result(); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "释放卡观测活跃序列失败")
}
return nil
}
// CompleteResourceSeries 提前完成同资源、同步类型下所有场景的活跃序列。
func (c *SeriesCoordinator) CompleteResourceSeries(ctx context.Context, resourceType, resourceID, syncType string) error {
indexKey := constants.RedisCardObservationSeriesIndexKey(resourceType, resourceID, syncType)
seriesKeys, err := c.redis.SMembers(ctx, indexKey).Result()
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "读取卡观测活跃序列索引失败")
}
for _, seriesKey := range seriesKeys {
seriesID, getErr := c.redis.HGet(ctx, seriesKey, "series_id").Result()
if getErr == redis.Nil {
_ = c.redis.SRem(ctx, indexKey, seriesKey).Err()
continue
}
if getErr != nil {
return errors.Wrap(errors.CodeInternalError, getErr, "读取卡观测活跃序列失败")
}
pipe := c.redis.TxPipeline()
pipe.Set(ctx, constants.RedisCardObservationSeriesCompletedKey(seriesID), "1", constants.CardObservationSeriesResultTTL)
pipe.Del(ctx, seriesKey)
pipe.SRem(ctx, indexKey, seriesKey)
if _, pipeErr := pipe.Exec(ctx); pipeErr != nil {
return errors.Wrap(errors.CodeInternalError, pipeErr, "提前完成卡观测资源序列失败")
}
}
return nil
}
var _ cardapp.SeriesCoordinator = (*SeriesCoordinator)(nil)
func formatCardID(cardID uint) string {
return strconv.FormatUint(uint64(cardID), 10)
}

View File

@@ -0,0 +1,259 @@
package cardobservation
import (
"context"
stderrors "errors"
"strconv"
"strings"
"time"
"gorm.io/gorm"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SeriesAttemptLogger 将未发送的序列结果写入统一 Integration Log。
type SeriesAttemptLogger struct {
repository *integrationlog.Repository
}
// NewSeriesAttemptLogger 创建未发送尝试日志适配器。
func NewSeriesAttemptLogger(repository *integrationlog.Repository) *SeriesAttemptLogger {
return &SeriesAttemptLogger{repository: repository}
}
// Record 写入互斥、限频或提前完成结果。
func (l *SeriesAttemptLogger) Record(ctx context.Context, payload cardapp.SeriesTaskPayload, result, reason string) error {
if l == nil || l.repository == nil {
return apperrors.New(apperrors.CodeInternalError, "卡观测 Integration Log 未配置")
}
resourceID := payload.ResourceID
source, scene, seriesID := payload.Source, payload.Scene, payload.SeriesID
requestID, correlationID := optionalText(payload.RequestID), optionalText(payload.CorrelationID)
_, err := l.repository.Start(ctx, integrationlog.Attempt{
IntegrationID: unsentIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, TriggerSource: &source,
TriggerScene: &scene, TriggerSeries: &seriesID, ScheduledAt: &payload.ScheduledAt,
Attempt: payload.Attempt, InitialResult: result, RequestID: requestID, CorrelationID: correlationID,
Metadata: map[string]any{"reason": reason, "sync_type": payload.SyncType},
})
return err
}
// RecordMerged 记录同场景重复触发被合并,不创建第二组三任务。
func (l *SeriesAttemptLogger) RecordMerged(ctx context.Context, request cardapp.SeriesRequest, seriesID string) error {
resourceID := request.ResourceID
source, scene := request.Source, request.Scene
now := time.Now().UTC()
_, err := l.repository.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operationForSyncType(request.SyncType), ResourceType: request.ResourceType,
ResourceID: &resourceID, TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
ScheduledAt: &now, Attempt: 1, InitialResult: constants.IntegrationResultMerged,
RequestID: optionalText(request.RequestID), CorrelationID: optionalText(request.CorrelationID),
Metadata: map[string]any{"reason": "同场景未结束序列已存在", "sync_type": request.SyncType},
})
return err
}
// SeriesRunner 负责卡本地预期判断、Gateway 查询和公共观测应用。
type SeriesRunner struct {
db *gorm.DB
gateway *gateway.Client
observation *cardapp.Service
integration *integrationlog.Repository
carrier *postgres.CarrierStore
}
// NewSeriesRunner 创建卡观测序列 Gateway 执行器。
func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *SeriesRunner {
return &SeriesRunner{db: db, gateway: gatewayClient, observation: observation, integration: integration, carrier: postgres.NewCarrierStore(db)}
}
// Provider 返回用于请求互斥的运营商接入标识。
func (r *SeriesRunner) Provider(ctx context.Context, payload cardapp.SeriesTaskPayload) (string, error) {
card, err := r.loadCard(ctx, payload)
if err != nil {
return "", err
}
provider := strings.ToLower(strings.TrimSpace(card.CarrierType))
if provider == "" {
provider = "carrier-" + strconv.FormatUint(uint64(card.CarrierID), 10)
}
return provider, nil
}
// ExpectationMet 在访问 Gateway 前读取本地权威快照。
func (r *SeriesRunner) ExpectationMet(ctx context.Context, payload cardapp.SeriesTaskPayload) (bool, error) {
expected := strings.ToLower(strings.TrimSpace(payload.ExpectedValue))
if expected == "" {
return false, nil
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return false, err
}
switch payload.SyncType {
case constants.CardObservationSyncTypeRealname:
return (expected == "verified" || expected == "1") && card.RealNameStatus == constants.RealNameStatusVerified, nil
case constants.CardObservationSyncTypeNetwork:
if expected == "online" || expected == "1" {
return card.NetworkStatus == constants.NetworkStatusOnline, nil
}
if expected == "offline" || expected == "stopped" || expected == "0" {
return card.NetworkStatus == constants.NetworkStatusOffline, nil
}
}
return false, nil
}
// Run 执行一次 Gateway 查询,并将结果交给公共卡观测应用服务。
func (r *SeriesRunner) Run(ctx context.Context, payload cardapp.SeriesTaskPayload) (cardapp.RunResult, error) {
if r == nil || r.db == nil || r.gateway == nil || r.observation == nil || r.integration == nil {
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInternalError, "卡观测序列 Gateway 能力未完整配置")
}
card, err := r.loadCard(ctx, payload)
if err != nil {
return cardapp.RunResult{}, err
}
attempt, err := r.startAttempt(ctx, payload, card)
if err != nil {
return cardapp.RunResult{}, err
}
startedAt := time.Now()
result, runErr := r.queryAndApply(ctx, payload, card, attempt.IntegrationID)
completionResult := constants.IntegrationResultSuccess
if runErr != nil {
completionResult = constants.IntegrationResultFailed
if isRateLimited(runErr) {
completionResult = constants.IntegrationResultRateLimited
result.RateLimited = true
}
}
_, completeErr := r.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: completionResult, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: result.StateChanged,
ResponseSummary: map[string]any{"sync_type": payload.SyncType, "applied": runErr == nil},
})
if completeErr != nil {
return result, completeErr
}
return result, runErr
}
func (r *SeriesRunner) startAttempt(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard) (*model.IntegrationLog, error) {
resourceID, source, scene, seriesID := payload.ResourceID, payload.Source, payload.Scene, payload.SeriesID
resourceKey := "card:" + formatCardID(card.ID)
return r.integration.Start(ctx, integrationlog.Attempt{
IntegrationID: gatewayIntegrationID(payload), Provider: constants.IntegrationProviderGateway,
Direction: constants.IntegrationDirectionOutbound, Operation: operationForSyncType(payload.SyncType),
ResourceType: payload.ResourceType, ResourceID: &resourceID, ResourceKey: &resourceKey,
TriggerSource: &source, TriggerScene: &scene, TriggerSeries: &seriesID,
ScheduledAt: &payload.ScheduledAt, Attempt: payload.Attempt,
RequestSummary: map[string]any{"card_id": card.ID, "sync_type": payload.SyncType},
RequestID: optionalText(payload.RequestID), CorrelationID: optionalText(payload.CorrelationID),
})
}
func (r *SeriesRunner) queryAndApply(ctx context.Context, payload cardapp.SeriesTaskPayload, card *model.IotCard, observationID string) (cardapp.RunResult, error) {
metadata := carddomain.ObservationMetadata{
ObservationID: observationID, Source: constants.CardObservationSourceBusinessEvent,
Scene: payload.Scene, ObservedAt: time.Now().UTC(), RequestID: payload.RequestID,
CorrelationID: payload.CorrelationID,
}
switch payload.SyncType {
case constants.CardObservationSyncTypeRealname:
response, err := r.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{CardID: card.ID, Verified: response.RealStatus, Metadata: metadata})
return cardapp.RunResult{StateChanged: decision.StatusChanged}, err
case constants.CardObservationSyncTypeTraffic:
response, err := r.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID})
if err != nil {
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: card.ID, GatewayReadingMB: float64(response.Used), ResetDay: r.carrier.GetDataResetDay(ctx, card.CarrierID), Metadata: metadata,
})
return cardapp.RunResult{StateChanged: decision.IncrementMB > 0 || decision.CrossMonth}, err
case constants.CardObservationSyncTypeNetwork:
response, err := r.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID})
if err != nil {
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{
CardID: card.ID, GatewayStatus: response.CardStatus, GatewayExtend: response.Extend,
GatewayIMEI: response.IMEI, Metadata: metadata,
})
return cardapp.RunResult{StateChanged: decision.StatusChanged}, err
default:
return cardapp.RunResult{}, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测执行器尚未注册")
}
}
func (r *SeriesRunner) loadCard(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.IotCard, error) {
if payload.ResourceType != constants.CardObservationResourceTypeCard {
return nil, apperrors.New(apperrors.CodeInvalidParam, "当前观测执行器仅支持 IoT 卡资源")
}
cardID, err := strconv.ParseUint(payload.ResourceID, 10, 64)
if err != nil || cardID == 0 {
return nil, apperrors.New(apperrors.CodeInvalidParam, "卡观测资源 ID 无效")
}
var card model.IotCard
if err := r.db.WithContext(ctx).Where("id = ?", uint(cardID)).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, apperrors.New(apperrors.CodeNotFound, "IoT卡不存在")
}
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询卡观测本地快照失败")
}
return &card, nil
}
func isRateLimited(err error) bool {
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeTooManyRequests {
return true
}
return strings.Contains(err.Error(), "429") || strings.Contains(strings.ToLower(err.Error()), "rate limit")
}
func operationForSyncType(syncType string) string {
switch syncType {
case constants.CardObservationSyncTypeRealname:
return constants.IntegrationOperationGatewayRealname
case constants.CardObservationSyncTypeTraffic:
return constants.IntegrationOperationGatewayTraffic
case constants.CardObservationSyncTypeNetwork:
return constants.IntegrationOperationGatewayNetwork
default:
return "query_device_info"
}
}
func gatewayIntegrationID(payload cardapp.SeriesTaskPayload) string {
return "card-series:" + payload.SeriesID + ":" + strconv.Itoa(payload.Attempt)
}
func unsentIntegrationID(payload cardapp.SeriesTaskPayload) string {
return gatewayIntegrationID(payload) + ":unsent"
}
func optionalText(value string) *string {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
return &value
}
var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil)
var _ cardapp.SeriesRunner = (*SeriesRunner)(nil)

View File

@@ -0,0 +1,72 @@
package notification
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CleanupService 按通知类别的保留期限分批删除过期通知事实。
type CleanupService struct {
db *gorm.DB
logger *zap.Logger
now func() time.Time
}
// NewCleanupService 创建通知保留清理服务。
func NewCleanupService(db *gorm.DB, logger *zap.Logger) *CleanupService {
if logger == nil {
logger = zap.NewNop()
}
return &CleanupService{db: db, logger: logger, now: time.Now}
}
// Run 按类别、创建时间和稳定主键执行有界分批清理。
func (s *CleanupService) Run(ctx context.Context) error {
policies := []struct {
category string
days int
}{
{constants.NotificationCategoryApproval, constants.NotificationApprovalRetentionDays},
{constants.NotificationCategoryExpiry, constants.NotificationExpiryRetentionDays},
{constants.NotificationCategorySync, constants.NotificationSyncRetentionDays},
{constants.NotificationCategorySystem, constants.NotificationSystemRetentionDays},
}
for _, policy := range policies {
deleted, err := s.cleanupCategory(ctx, policy.category, s.now().UTC().AddDate(0, 0, -policy.days))
if err != nil {
return err
}
s.logger.Info("站内通知保留清理完成",
zap.String("category", policy.category), zap.Int64("deleted_count", deleted))
}
return nil
}
func (s *CleanupService) cleanupCategory(ctx context.Context, category string, cutoff time.Time) (int64, error) {
var total int64
for batch := 0; batch < constants.NotificationCleanupMaxBatches; batch++ {
result := s.db.WithContext(ctx).Exec(`WITH candidates AS (
SELECT id FROM tb_notification
WHERE category = ? AND created_at < ?
ORDER BY created_at ASC, id ASC
LIMIT ?
)
DELETE FROM tb_notification AS notification
USING candidates
WHERE notification.id = candidates.id`, category, cutoff, constants.NotificationCleanupBatchSize)
if result.Error != nil {
return total, errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败")
}
total += result.RowsAffected
if result.RowsAffected < constants.NotificationCleanupBatchSize {
break
}
}
return total, nil
}

View File

@@ -0,0 +1,100 @@
package notification
import (
"context"
"sort"
"gorm.io/gorm"
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DynamicRecipientResolver 按稳定账号、平台角色或店铺解析当前可用后台账号。
type DynamicRecipientResolver struct {
db *gorm.DB
shopResolver shopapp.NotificationRecipientResolver
}
// NewDynamicRecipientResolver 创建后台通知动态接收人解析 Adapter。
func NewDynamicRecipientResolver(db *gorm.DB, shopResolver shopapp.NotificationRecipientResolver) *DynamicRecipientResolver {
return &DynamicRecipientResolver{db: db, shopResolver: shopResolver}
}
// Resolve 根据受控目标类型返回去重且按账号 ID 排序的当前可用接收人。
func (r *DynamicRecipientResolver) Resolve(ctx context.Context, targetKind string, targetID uint) ([]uint, error) {
if targetID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var ids []uint
var err error
switch targetKind {
case constants.NotificationTargetKindAccount:
ids, err = r.resolveAccount(ctx, targetID)
case constants.NotificationTargetKindPlatformRole:
ids, err = r.resolvePlatformRole(ctx, targetID)
case constants.NotificationTargetKindShop:
if r.shopResolver == nil {
return nil, errors.New(errors.CodeInternalError, "店铺通知接收人解析器未配置")
}
ids, err = r.shopResolver.ResolveNotificationRecipients(ctx, targetID)
default:
return nil, errors.New(errors.CodeInvalidParam, "通知接收目标类型不受支持")
}
if err != nil {
return nil, err
}
return uniqueSortedIDs(ids), nil
}
func (r *DynamicRecipientResolver) resolveAccount(ctx context.Context, accountID uint) ([]uint, error) {
var account model.Account
err := r.db.WithContext(ctx).Select("id").
Where("id = ? AND status = ?", accountID, constants.StatusEnabled).Take(&account).Error
if err == gorm.ErrRecordNotFound {
return []uint{}, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知申请人失败")
}
return []uint{account.ID}, nil
}
func (r *DynamicRecipientResolver) resolvePlatformRole(ctx context.Context, roleID uint) ([]uint, error) {
var accounts []model.Account
err := r.db.WithContext(ctx).Model(&model.Account{}).
Select("tb_account.id").
Joins("JOIN tb_account_role ar ON ar.account_id = tb_account.id AND ar.deleted_at IS NULL AND ar.status = ?", constants.StatusEnabled).
Joins("JOIN tb_role r ON r.id = ar.role_id AND r.deleted_at IS NULL AND r.status = ? AND r.role_type = ?",
constants.StatusEnabled, constants.RoleTypePlatform).
Where("ar.role_id = ? AND tb_account.status = ? AND tb_account.user_type IN ?",
roleID, constants.StatusEnabled, []int{constants.UserTypeSuperAdmin, constants.UserTypePlatform}).
Find(&accounts).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询平台角色通知接收人失败")
}
ids := make([]uint, 0, len(accounts))
for _, account := range accounts {
ids = append(ids, account.ID)
}
return ids, nil
}
func uniqueSortedIDs(ids []uint) []uint {
seen := make(map[uint]struct{}, len(ids))
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result
}

View File

@@ -0,0 +1,154 @@
// Package notification 提供站内通知模板注册、渲染和 PostgreSQL 持久化 Adapter。
package notification
import (
"bytes"
"errors"
"regexp"
"strings"
"text/template"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
var (
htmlTagPattern = regexp.MustCompile(`(?i)<\s*/?\s*[a-z][^>]*>`)
sensitiveTextPattern = regexp.MustCompile(`(?i)(password|operation_password|token|secret|credential|media_id|密码|令牌|密钥)\s*[:=]\s*\S+`)
longURLPattern = regexp.MustCompile(`(?i)https?://\S+`)
)
// Definition 定义一个受控通知类型的类别、级别、模板和允许资源引用。
type Definition struct {
Type string
Category string
Severity string
TitleTemplate string
BodyTemplate string
TemplateFields map[string]struct{}
RecipientKinds map[string]struct{}
AllowedRefTypes map[string]struct{}
}
// Rendered 是模板渲染后的纯文本快照。
type Rendered struct {
Category string
Type string
Severity string
Title string
Body string
}
// Registry 保存代码内受控通知类型注册关系。
type Registry struct {
definitions map[string]Definition
}
// NewRegistry 创建内置受控通知类型注册表。
func NewRegistry() *Registry {
return &Registry{definitions: map[string]Definition{
constants.NotificationTypeSystemNotice: {
Type: constants.NotificationTypeSystemNotice, Category: constants.NotificationCategorySystem,
Severity: constants.NotificationSeverityInfo,
TitleTemplate: "系统通知",
BodyTemplate: "有一项系统事项需要处理,请进入对应页面查看。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindAccount: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypeSystemConfig: {},
constants.NotificationRefTypeIntegrationLog: {},
},
},
constants.NotificationTypePackageExpiring: {
Type: constants.NotificationTypePackageExpiring, Category: constants.NotificationCategoryExpiry,
Severity: constants.NotificationSeverityWarning,
TitleTemplate: "套餐即将到期",
BodyTemplate: "您的套餐即将到期,请及时查看并处理。",
TemplateFields: map[string]struct{}{},
RecipientKinds: map[string]struct{}{constants.NotificationRecipientKindPersonalCustomer: {}},
AllowedRefTypes: map[string]struct{}{
constants.NotificationRefTypePackage: {},
constants.NotificationRefTypeAsset: {},
},
},
}}
}
// Render 校验注册类型、模板字段、资源引用与敏感内容后生成纯文本快照。
func (r *Registry) Render(notificationType string, data map[string]string, refType, recipientKind string) (Rendered, error) {
definition, exists := r.definitions[notificationType]
if !exists {
return Rendered{}, errors.New("通知类型未注册")
}
if _, allowed := definition.RecipientKinds[recipientKind]; !allowed {
return Rendered{}, errors.New("通知类型未向当前接收人开放")
}
if err := validateTemplateData(data, definition.TemplateFields); err != nil {
return Rendered{}, err
}
if refType != "" {
if _, allowed := definition.AllowedRefTypes[refType]; !allowed {
return Rendered{}, errors.New("通知资源类型未注册")
}
}
title, err := executeTemplate("通知标题", definition.TitleTemplate, data)
if err != nil {
return Rendered{}, err
}
body, err := executeTemplate("通知正文", definition.BodyTemplate, data)
if err != nil {
return Rendered{}, err
}
if title == "" || body == "" {
return Rendered{}, errors.New("通知模板字段为空")
}
if len([]rune(title)) > constants.NotificationMaxTitleLength {
return Rendered{}, errors.New("通知标题超过长度限制")
}
if len([]rune(body)) > constants.NotificationMaxBodyLength {
return Rendered{}, errors.New("通知正文超过长度限制")
}
if htmlTagPattern.MatchString(title) || htmlTagPattern.MatchString(body) {
return Rendered{}, errors.New("通知正文禁止包含 HTML")
}
if sensitiveTextPattern.MatchString(title) || sensitiveTextPattern.MatchString(body) || longURLPattern.MatchString(title) || longURLPattern.MatchString(body) {
return Rendered{}, errors.New("通知正文包含禁止的敏感内容")
}
return Rendered{
Category: definition.Category, Type: definition.Type, Severity: definition.Severity,
Title: title, Body: body,
}, nil
}
func executeTemplate(name, source string, data map[string]string) (string, error) {
tmpl, err := template.New(name).Option("missingkey=error").Parse(source)
if err != nil {
return "", err
}
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return "", errors.New("通知模板字段缺失")
}
return strings.TrimSpace(buffer.String()), nil
}
func validateTemplateData(data map[string]string, fields map[string]struct{}) error {
if data == nil && len(fields) > 0 {
return errors.New("通知模板数据不能为空")
}
for key := range data {
normalized := strings.ToLower(strings.TrimSpace(key))
switch normalized {
case "password", "operation_password", "token", "secret", "credential", "id_card", "callback", "media_id", "url":
return errors.New("通知模板数据包含禁止字段")
}
if _, allowed := fields[normalized]; !allowed {
return errors.New("通知模板数据包含未注册字段")
}
}
for field := range fields {
if strings.TrimSpace(data[field]) == "" {
return errors.New("通知模板必填字段缺失")
}
}
return nil
}

View File

@@ -0,0 +1,48 @@
package notification
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// Repository 提供站内通知幂等写入能力。
type Repository struct {
db *gorm.DB
}
// NewRepository 创建站内通知持久化 Adapter。
func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
// CreateIdempotent 以事件、接收人类型和接收人 ID 唯一键幂等写入通知。
func (r *Repository) CreateIdempotent(ctx context.Context, notification *model.Notification) (bool, error) {
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "event_id"}, {Name: "recipient_kind"}, {Name: "recipient_id"}},
DoNothing: true,
}).Create(notification)
return result.RowsAffected == 1, result.Error
}
// IsActiveAccount 判断明确后台账号是否仍启用且未软删除。
func (r *Repository) IsActiveAccount(ctx context.Context, accountID uint) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.Account{}).
Where("id = ? AND status = ?", accountID, constants.StatusEnabled).
Count(&count).Error
return count == 1, err
}
// IsActivePersonalCustomer 判断明确个人客户是否仍启用且未软删除。
func (r *Repository) IsActivePersonalCustomer(ctx context.Context, customerID uint) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.PersonalCustomer{}).
Where("id = ? AND status = ?", customerID, constants.StatusEnabled).
Count(&count).Error
return count == 1, err
}

View File

@@ -0,0 +1,71 @@
// Package shop 提供店铺通知接收人的 PostgreSQL Adapter。
package shop
import (
"context"
"sort"
"gorm.io/gorm"
shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RecipientResolver 按店铺当前独立归属解析主账号和可用平台业务员。
type RecipientResolver struct {
db *gorm.DB
}
var _ shopapp.NotificationRecipientResolver = (*RecipientResolver)(nil)
// NewRecipientResolver 创建店铺通知接收人解析 Adapter。
func NewRecipientResolver(db *gorm.DB) *RecipientResolver {
return &RecipientResolver{db: db}
}
// ResolveNotificationRecipients 返回启用的店铺主账号和当前可用业务员账号 ID。
//
// 解析只读取目标店铺当前保存的业务员 ID不读取父店铺、祖先店铺或创建人。
func (r *RecipientResolver) ResolveNotificationRecipients(ctx context.Context, shopID uint) ([]uint, error) {
if shopID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
var target struct {
BusinessOwnerAccountID *uint
}
err := r.db.WithContext(ctx).Model(&model.Shop{}).
Select("business_owner_account_id").Where("id = ?", shopID).Take(&target).Error
if err == gorm.ErrRecordNotFound {
return []uint{}, nil
}
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺通知归属失败")
}
query := r.db.WithContext(ctx).Model(&model.Account{}).
Select("id").
Where("status = ? AND shop_id = ? AND is_primary = ? AND user_type = ?",
constants.StatusEnabled, shopID, true, constants.UserTypeAgent)
if target.BusinessOwnerAccountID != nil {
query = query.Or("id = ? AND status = ? AND user_type = ?",
*target.BusinessOwnerAccountID, constants.StatusEnabled, constants.UserTypePlatform)
}
var accounts []model.Account
if err := query.Find(&accounts).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺通知接收人失败")
}
ids := make([]uint, 0, len(accounts))
seen := make(map[uint]struct{}, len(accounts))
for _, account := range accounts {
if _, exists := seen[account.ID]; exists {
continue
}
seen[account.ID] = struct{}{}
ids = append(ids, account.ID)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids, nil
}

View File

@@ -0,0 +1,59 @@
package wallet
import (
"context"
"github.com/bytedance/sonic"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CreditEventConsumer 校验已投递的代理主钱包入账事件具有对应权威资金流水。
type CreditEventConsumer struct {
db *gorm.DB
}
// NewCreditEventConsumer 创建代理主钱包入账事件消费者。
func NewCreditEventConsumer(db *gorm.DB) *CreditEventConsumer {
return &CreditEventConsumer{db: db}
}
// Consume 校验事件载荷及不可变流水,拒绝确认未知或损坏事件。
func (c *CreditEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "代理主钱包入账事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletCredited || envelope.PayloadVersion != constants.AgentMainWalletCreditedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账事件类型或版本不受支持")
}
var event walletapp.CreditedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包入账事件载荷无法解析")
}
validRecharge := event.ReferenceType == constants.ReferenceTypeTopup && event.TransactionType == constants.AgentTransactionTypeRecharge
validAdjustment := event.ReferenceType == constants.ReferenceTypeManualAdjustment && event.TransactionType == constants.AgentTransactionTypeAdjustment
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ReferenceID == 0 || event.Amount <= 0 || (!validRecharge && !validAdjustment) {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账事件载荷不完整")
}
var transaction model.AgentWalletTransaction
err := c.db.WithContext(ctx).Unscoped().
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.WalletID, event.ReferenceType, event.ReferenceID, event.TransactionType, constants.TransactionStatusSuccess).
First(&transaction).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包入账事件缺少权威资金流水")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包入账流水失败")
}
if transaction.Amount != event.Amount || transaction.BalanceBefore != event.BalanceBefore || transaction.BalanceAfter != event.BalanceAfter {
return errors.New(errors.CodeInternalError, "代理主钱包入账事件与权威资金流水不一致")
}
return nil
}

View File

@@ -0,0 +1,37 @@
package wallet
import (
"context"
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
"gorm.io/gorm"
)
// CreditEventWriter 将代理主钱包正向入账事实写入公共 Outbox。
type CreditEventWriter struct {
outbox *outbox.Repository
}
// NewCreditEventWriter 创建代理主钱包入账 Outbox Writer。
func NewCreditEventWriter(repository *outbox.Repository) *CreditEventWriter {
return &CreditEventWriter{outbox: repository}
}
// Append 在调用方业务事务中追加代理主钱包入账事件。
func (w *CreditEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.CreditedEvent) 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.OutboxEventTypeAgentMainWalletCredited,
PayloadVersion: constants.AgentMainWalletCreditedPayloadVersionV1,
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -0,0 +1,62 @@
package wallet
import (
"context"
"github.com/bytedance/sonic"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// DebitEventConsumer 校验已投递的代理主钱包扣款事件具有对应权威资金流水。
// 后续余额预警等消费者在此稳定接缝上扩展,不需要回读或改写订单扣款事务。
type DebitEventConsumer struct {
db *gorm.DB
}
// NewDebitEventConsumer 创建代理主钱包扣款事件消费者。
func NewDebitEventConsumer(db *gorm.DB) *DebitEventConsumer {
return &DebitEventConsumer{db: db}
}
// Consume 校验事件载荷及不可变流水,保证未知或损坏事件不会被静默确认。
func (c *DebitEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletDebited ||
envelope.PayloadVersion != constants.AgentMainWalletDebitedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件类型或版本不受支持")
}
var event walletapp.DebitedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包扣款事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ReferenceID == 0 ||
event.ReferenceType != constants.ReferenceTypeOrder || event.TransactionType != constants.AgentTransactionTypeDeduct ||
event.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包扣款事件载荷不完整")
}
var transaction model.AgentWalletTransaction
err := c.db.WithContext(ctx).Unscoped().
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.WalletID, event.ReferenceType, event.ReferenceID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
First(&transaction).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件缺少权威资金流水")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包扣款流水失败")
}
if transaction.Amount != -event.Amount || transaction.BalanceBefore != event.BalanceBefore ||
transaction.BalanceAfter != event.BalanceAfter {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件与权威资金流水不一致")
}
return nil
}

View File

@@ -0,0 +1,38 @@
// Package wallet 提供代理主钱包持久化与可靠事件 Adapter。
package wallet
import (
"context"
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
"gorm.io/gorm"
)
// DebitEventWriter 将代理主钱包扣款事实写入公共 Outbox。
type DebitEventWriter struct {
outbox *outbox.Repository
}
// NewDebitEventWriter 创建代理主钱包扣款 Outbox Writer。
func NewDebitEventWriter(repository *outbox.Repository) *DebitEventWriter {
return &DebitEventWriter{outbox: repository}
}
// Append 在调用方业务事务中追加代理主钱包扣款事件。
func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) 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.OutboxEventTypeAgentMainWalletDebited,
PayloadVersion: constants.AgentMainWalletDebitedPayloadVersionV1,
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -0,0 +1,98 @@
package wallet
import (
"context"
"math"
"github.com/bytedance/sonic"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RefundEventConsumer 校验已投递的代理主钱包退款事件具有对应权威资金流水。
type RefundEventConsumer struct {
db *gorm.DB
}
// NewRefundEventConsumer 创建代理主钱包退款事件消费者。
func NewRefundEventConsumer(db *gorm.DB) *RefundEventConsumer {
return &RefundEventConsumer{db: db}
}
// Consume 复核退款流水及正常订单的原扣款流水,拒绝确认未知或损坏事件。
func (c *RefundEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletRefunded ||
envelope.PayloadVersion != constants.AgentMainWalletRefundedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "代理主钱包退款事件类型或版本不受支持")
}
var event walletapp.RefundedEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包退款事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.WalletID == 0 || event.ShopID == 0 ||
event.OrderID == 0 || event.RefundID == 0 || event.Amount <= 0 || event.Version <= 0 ||
event.OriginalDeductAmount <= 0 || event.Amount > event.OriginalDeductAmount {
return errors.New(errors.CodeInvalidParam, "代理主钱包退款事件载荷不完整")
}
if event.Legacy == (event.OriginalDebitTransactionID > 0) {
return errors.New(errors.CodeInvalidParam, "代理主钱包退款事件原扣款标识不一致")
}
var refund model.AgentWalletTransaction
err := c.db.WithContext(ctx).Unscoped().
Where("agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.WalletID, constants.ReferenceTypeRefund, event.RefundID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess).
First(&refund).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件缺少权威资金流水")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包退款流水失败")
}
if refund.ShopID != event.ShopID || refund.Amount != event.Amount ||
refund.BalanceBefore != event.BalanceBefore || refund.BalanceAfter != event.BalanceAfter ||
!sameRefundUint(refund.RelatedShopID, event.RelatedShopID) ||
!sameRefundString(refund.TransactionSubtype, event.TransactionSubtype) ||
refund.AssetType != event.AssetType || refund.AssetID != event.AssetID || refund.AssetIdentifier != event.AssetIdentifier {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件与权威退款流水不一致")
}
if event.Legacy {
return nil
}
var debit model.AgentWalletTransaction
err = c.db.WithContext(ctx).Unscoped().
Where("id = ? AND agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.OriginalDebitTransactionID, event.WalletID, constants.ReferenceTypeOrder, event.OrderID,
constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess).
First(&debit).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件缺少原扣款流水")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包原扣款流水失败")
}
if debit.Amount >= 0 || debit.Amount == math.MinInt64 || event.OriginalDeductAmount != -debit.Amount ||
!sameRefundUint(debit.RelatedShopID, event.RelatedShopID) ||
!sameRefundString(debit.TransactionSubtype, event.TransactionSubtype) ||
debit.AssetType != event.AssetType || debit.AssetID != event.AssetID || debit.AssetIdentifier != event.AssetIdentifier {
return errors.New(errors.CodeInternalError, "代理主钱包退款事件与原扣款流水不一致")
}
return nil
}
func sameRefundUint(left, right *uint) bool {
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}
func sameRefundString(left, right *string) bool {
return (left == nil && right == nil) || (left != nil && right != nil && *left == *right)
}

View File

@@ -0,0 +1,37 @@
package wallet
import (
"context"
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
"gorm.io/gorm"
)
// RefundEventWriter 将代理主钱包退款事实写入公共 Outbox。
type RefundEventWriter struct {
outbox *outbox.Repository
}
// NewRefundEventWriter 创建代理主钱包退款 Outbox Writer。
func NewRefundEventWriter(repository *outbox.Repository) *RefundEventWriter {
return &RefundEventWriter{outbox: repository}
}
// Append 在调用方业务事务中追加代理主钱包退款事件。
func (w *RefundEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.RefundedEvent) 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.OutboxEventTypeAgentMainWalletRefunded,
PayloadVersion: constants.AgentMainWalletRefundedPayloadVersionV1,
AggregateType: "agent_wallet", AggregateID: strconv.FormatUint(uint64(event.WalletID), 10),
ResourceType: constants.ReferenceTypeRefund, ResourceID: strconv.FormatUint(uint64(event.RefundID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -0,0 +1,64 @@
package wallet
import (
"context"
"github.com/bytedance/sonic"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ReservationEventConsumer 校验预占事件与本地权威事实一致。
type ReservationEventConsumer struct {
db *gorm.DB
}
// NewReservationEventConsumer 创建代理主钱包预占事件消费者。
func NewReservationEventConsumer(db *gorm.DB) *ReservationEventConsumer {
return &ReservationEventConsumer{db: db}
}
// Consume 只确认具有匹配权威预占事实的事件。
func (c *ReservationEventConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占事件消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentMainWalletReservationChanged ||
envelope.PayloadVersion != constants.AgentMainWalletReservationPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "代理主钱包预占事件类型或版本不受支持")
}
var event walletapp.ReservationEvent
if err := sonic.Unmarshal(envelope.Payload, &event); err != nil {
return errors.Wrap(errors.CodeInvalidParam, err, "代理主钱包预占事件载荷无法解析")
}
if event.EventID != envelope.EventID || event.ReservationID == 0 || event.WalletID == 0 ||
event.Amount <= 0 || event.ReferenceType == "" || event.ReferenceID == 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包预占事件载荷不完整")
}
var reservation model.AgentWalletReservation
if err := c.db.WithContext(ctx).Where("id = ?", event.ReservationID).First(&reservation).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInternalError, "代理主钱包预占事件缺少权威事实")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占事实失败")
}
if reservation.AgentWalletID != event.WalletID || reservation.Amount != event.Amount ||
reservation.ReferenceType != event.ReferenceType || reservation.ReferenceID != event.ReferenceID ||
!reservationStatusContainsEvent(reservation.Status, event.Status) {
return errors.New(errors.CodeInternalError, "代理主钱包预占事件与权威事实不一致")
}
return nil
}
func reservationStatusContainsEvent(current, event int) bool {
if current == event {
return true
}
return event == constants.AgentWalletReservationStatusFrozen &&
(current == constants.AgentWalletReservationStatusReleased || current == constants.AgentWalletReservationStatusCompleted)
}

View File

@@ -0,0 +1,37 @@
package wallet
import (
"context"
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"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"
"gorm.io/gorm"
)
// ReservationEventWriter 将代理主钱包预占状态写入公共 Outbox。
type ReservationEventWriter struct {
outbox *outbox.Repository
}
// NewReservationEventWriter 创建代理主钱包预占 Outbox Writer。
func NewReservationEventWriter(repository *outbox.Repository) *ReservationEventWriter {
return &ReservationEventWriter{outbox: repository}
}
// Append 在调用方事务中追加预占状态事件。
func (w *ReservationEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) 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.OutboxEventTypeAgentMainWalletReservationChanged,
PayloadVersion: constants.AgentMainWalletReservationPayloadVersionV1,
AggregateType: "agent_wallet_reservation", AggregateID: strconv.FormatUint(uint64(event.ReservationID), 10),
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
}

View File

@@ -14,6 +14,8 @@ type AgentWallet struct {
WalletType string `gorm:"column:wallet_type;type:varchar(20);not null;comment:钱包类型(main-主钱包 | commission-分佣钱包)" json:"wallet_type"` WalletType string `gorm:"column:wallet_type;type:varchar(20);not null;comment:钱包类型(main-主钱包 | commission-分佣钱包)" json:"wallet_type"`
Balance int64 `gorm:"column:balance;type:bigint;not null;default:0;comment:余额(单位:分)" json:"balance"` Balance int64 `gorm:"column:balance;type:bigint;not null;default:0;comment:余额(单位:分)" json:"balance"`
FrozenBalance int64 `gorm:"column:frozen_balance;type:bigint;not null;default:0;comment:冻结余额(单位:分)" json:"frozen_balance"` FrozenBalance int64 `gorm:"column:frozen_balance;type:bigint;not null;default:0;comment:冻结余额(单位:分)" json:"frozen_balance"`
CreditEnabled bool `gorm:"column:credit_enabled;type:boolean;not null;default:false;comment:是否启用主钱包信用额度" json:"credit_enabled"`
CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:主钱包信用额度(单位:分)" json:"credit_limit"`
Currency string `gorm:"column:currency;type:varchar(10);not null;default:'CNY';comment:币种" json:"currency"` Currency string `gorm:"column:currency;type:varchar(10);not null;default:'CNY';comment:币种" json:"currency"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:钱包状态(1-正常 2-冻结 3-关闭)" json:"status"` Status int `gorm:"column:status;type:int;not null;default:1;comment:钱包状态(1-正常 2-冻结 3-关闭)" json:"status"`
Version int `gorm:"column:version;type:int;not null;default:0;comment:版本号(乐观锁)" json:"version"` Version int `gorm:"column:version;type:int;not null;default:0;comment:版本号(乐观锁)" json:"version"`
@@ -29,7 +31,8 @@ func (AgentWallet) TableName() string {
return "tb_agent_wallet" return "tb_agent_wallet"
} }
// GetAvailableBalance 获取可用余额 = balance - frozen_balance // GetAvailableBalance 获取旧写入口使用的现金可用金额。
// Deprecated: 信用钱包完整用例应使用 Wallet Domain 的 AvailableBalance。
func (w *AgentWallet) GetAvailableBalance() int64 { func (w *AgentWallet) GetAvailableBalance() int64 {
return w.Balance - w.FrozenBalance return w.Balance - w.FrozenBalance
} }
@@ -41,13 +44,13 @@ type AgentWalletTransaction struct {
AgentWalletID uint `gorm:"column:agent_wallet_id;not null;index;comment:代理钱包ID" json:"agent_wallet_id"` AgentWalletID uint `gorm:"column:agent_wallet_id;not null;index;comment:代理钱包ID" json:"agent_wallet_id"`
ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"` ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"`
UserID uint `gorm:"column:user_id;not null;comment:操作人用户ID" json:"user_id"` UserID uint `gorm:"column:user_id;not null;comment:操作人用户ID" json:"user_id"`
TransactionType string `gorm:"column:transaction_type;type:varchar(20);not null;comment:交易类型(recharge-充值 | deduct-扣款 | refund-退款 | commission-分佣 | withdrawal-提现)" json:"transaction_type"` TransactionType string `gorm:"column:transaction_type;type:varchar(20);not null;comment:交易类型(recharge-充值 | adjustment-人工调整 | deduct-扣款 | refund-退款 | commission-分佣 | withdrawal-提现)" json:"transaction_type"`
TransactionSubtype *string `gorm:"column:transaction_subtype;type:varchar(50);comment:交易子类型(细分 order_payment 场景)" json:"transaction_subtype,omitempty"` TransactionSubtype *string `gorm:"column:transaction_subtype;type:varchar(50);comment:交易子类型(细分 order_payment 场景)" json:"transaction_subtype,omitempty"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:变动金额(单位:分,正数为增加,负数为减少)" json:"amount"` Amount int64 `gorm:"column:amount;type:bigint;not null;comment:变动金额(单位:分,正数为增加,负数为减少)" json:"amount"`
BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变动前余额(单位:分)" json:"balance_before"` BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变动前余额(单位:分)" json:"balance_before"`
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变动后余额(单位:分)" json:"balance_after"` BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变动后余额(单位:分)" json:"balance_after"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:交易状态(1-成功 2-失败 3-处理中)" json:"status"` Status int `gorm:"column:status;type:int;not null;default:1;comment:交易状态(1-成功 2-失败 3-处理中)" json:"status"`
ReferenceType *string `gorm:"column:reference_type;type:varchar(50);comment:关联业务类型(order | commission | withdrawal | topup)" json:"reference_type,omitempty"` ReferenceType *string `gorm:"column:reference_type;type:varchar(50);comment:关联业务类型(order | commission | withdrawal | topup | refund | exchange | manual_adjustment)" json:"reference_type,omitempty"`
ReferenceID *uint `gorm:"column:reference_id;comment:关联业务ID" json:"reference_id,omitempty"` ReferenceID *uint `gorm:"column:reference_id;comment:关联业务ID" json:"reference_id,omitempty"`
RelatedShopID *uint `gorm:"column:related_shop_id;comment:关联店铺ID代购时记录下级店铺" json:"related_shop_id,omitempty"` RelatedShopID *uint `gorm:"column:related_shop_id;comment:关联店铺ID代购时记录下级店铺" json:"related_shop_id,omitempty"`
AssetType string `gorm:"column:asset_type;type:varchar(20);not null;default:'';comment:资产类型(iot_card-物联网卡 | device-设备)" json:"asset_type,omitempty"` AssetType string `gorm:"column:asset_type;type:varchar(20);not null;default:'';comment:资产类型(iot_card-物联网卡 | device-设备)" json:"asset_type,omitempty"`

View File

@@ -0,0 +1,25 @@
package model
import "time"
// AgentWalletReservation 记录代理主钱包资金预占及其唯一终态。
type AgentWalletReservation struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
AgentWalletID uint `gorm:"column:agent_wallet_id;not null;index:idx_agent_wallet_reservation_wallet" json:"agent_wallet_id"`
ShopID uint `gorm:"column:shop_id;not null;index:idx_agent_wallet_reservation_shop" json:"shop_id"`
Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"`
Status int `gorm:"column:status;type:int;not null;default:1;index:idx_agent_wallet_reservation_status" json:"status"`
ReferenceType string `gorm:"column:reference_type;type:varchar(50);not null;uniqueIndex:uq_agent_wallet_reservation_reference" json:"reference_type"`
ReferenceID uint `gorm:"column:reference_id;not null;uniqueIndex:uq_agent_wallet_reservation_reference" json:"reference_id"`
Creator uint `gorm:"column:creator;not null;default:0" json:"creator"`
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;autoUpdateTime" json:"updated_at"`
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index" json:"shop_id_tag"`
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index" json:"enterprise_id_tag,omitempty"`
}
// TableName 返回代理主钱包预占事实表名。
func (AgentWalletReservation) TableName() string {
return "tb_agent_wallet_reservation"
}

View File

@@ -0,0 +1,24 @@
package model
import "time"
// ApprovalDecisionDelivery 是标准审批决策交给业务消费者的幂等处理事实。
type ApprovalDecisionDelivery struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
InstanceID uint `gorm:"column:instance_id;not null;uniqueIndex:uq_approval_decision_delivery,priority:1" json:"instance_id"`
Decision string `gorm:"column:decision;type:varchar(32);not null;uniqueIndex:uq_approval_decision_delivery,priority:2" json:"decision"`
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex" json:"event_id"`
Status int `gorm:"column:status;type:int;not null;default:0;index:idx_approval_decision_delivery_claim,priority:1" json:"status"`
RetryCount int `gorm:"column:retry_count;type:int;not null;default:0" json:"retry_count"`
LeaseOwner *string `gorm:"column:lease_owner;type:varchar(100)" json:"lease_owner,omitempty"`
LeaseExpiresAt *time.Time `gorm:"column:lease_expires_at;type:timestamptz;index:idx_approval_decision_delivery_claim,priority:2" json:"lease_expires_at,omitempty"`
LastError string `gorm:"column:last_error;type:varchar(500);not null;default:''" json:"last_error,omitempty"`
ProcessedAt *time.Time `gorm:"column:processed_at;type:timestamptz" json:"processed_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 返回审批决策投递表名。
func (ApprovalDecisionDelivery) TableName() string {
return "tb_approval_decision_delivery"
}

View File

@@ -0,0 +1,31 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// ApprovalInstance 是渠道无关的通用审批实例持久化模型。
type ApprovalInstance struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
BusinessType string `gorm:"column:business_type;type:varchar(64);not null;uniqueIndex:uq_approval_instance_business,priority:1" json:"business_type"`
BusinessID uint `gorm:"column:business_id;not null;uniqueIndex:uq_approval_instance_business,priority:2" json:"business_id"`
SubmitterAccountID uint `gorm:"column:submitter_account_id;not null;index" json:"submitter_account_id"`
SubmitterSnapshot datatypes.JSON `gorm:"column:submitter_snapshot;type:jsonb;not null" json:"submitter_snapshot"`
Provider string `gorm:"column:provider;type:varchar(32);not null" json:"provider"`
ExternalRef string `gorm:"column:external_ref;type:varchar(128);not null;default:''" json:"external_ref"`
Status int `gorm:"column:status;type:int;not null;default:0;index:idx_approval_instance_status_changed,priority:1" json:"status"`
RequestSnapshot datatypes.JSON `gorm:"column:request_snapshot;type:jsonb;not null" json:"request_snapshot"`
DecisionSnapshot datatypes.JSON `gorm:"column:decision_snapshot;type:jsonb" json:"decision_snapshot,omitempty"`
CorrelationID string `gorm:"column:correlation_id;type:varchar(100);not null;index" json:"correlation_id"`
Version int `gorm:"column:version;type:int;not null;default:1" json:"version"`
StatusChangedAt time.Time `gorm:"column:status_changed_at;type:timestamptz;not null;index:idx_approval_instance_status_changed,priority:2" json:"status_changed_at"`
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
}
// TableName 返回通用审批实例表名。
func (ApprovalInstance) TableName() string {
return "tb_approval_instance"
}

View File

@@ -0,0 +1,18 @@
package model
import "gorm.io/gorm"
// CardObservationEffect 记录卡观测事件副作用的幂等处理进度。
// 处理中的记录代表结果未知,自动重试不得重复执行已提交的套餐扣减。
type CardObservationEffect struct {
gorm.Model
BaseModel `gorm:"embedded"`
EventID string `gorm:"column:event_id;type:varchar(180);not null;uniqueIndex:uk_card_observation_effect_event;comment:Outbox事件ID" json:"event_id"`
EffectType string `gorm:"column:effect_type;type:varchar(50);not null;comment:副作用类型" json:"effect_type"`
Status int `gorm:"column:status;type:int;not null;default:0;comment:处理状态 0-待处理 1-处理中或结果未知 2-日流量已记录 3-已扣减 4-已完成" json:"status"`
}
// TableName 指定表名。
func (CardObservationEffect) TableName() string {
return "tb_card_observation_effect"
}

View File

@@ -89,9 +89,15 @@ type AgentOpenAPIPackageItem struct {
// AgentOpenAPIWalletBalanceResponse 开放接口预充值钱包余额响应 // AgentOpenAPIWalletBalanceResponse 开放接口预充值钱包余额响应
type AgentOpenAPIWalletBalanceResponse struct { type AgentOpenAPIWalletBalanceResponse struct {
Balance int64 `json:"balance" description:"钱包余额(分)"` Balance int64 `json:"balance" description:"钱包账面余额(分)"`
FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"` FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"`
AvailableBalance int64 `json:"available_balance" description:"可用额(分)"` CashAvailableBalance int64 `json:"cash_available_balance" description:"现金可用额(分),等于账面余额减冻结余额"`
CreditEnabled bool `json:"credit_enabled" description:"是否启用主钱包信用额度"`
CreditLimit int64 `json:"credit_limit" description:"主钱包信用额度(分)"`
AvailableBalance int64 `json:"available_balance" description:"总可用金额(分),等于现金可用金额加生效信用额度"`
IsInDebt bool `json:"is_in_debt" description:"账面余额是否已形成欠款"`
DebtAmount int64 `json:"debt_amount" description:"欠款金额(分),仅取负账面余额绝对值"`
Version int `json:"version" description:"钱包乐观锁版本"`
Currency string `json:"currency" description:"币种"` Currency string `json:"currency" description:"币种"`
} }

View File

@@ -0,0 +1,94 @@
package dto
import "time"
// NotificationUnreadCountResponse 是后台账号未读数投影。
type NotificationUnreadCountResponse struct {
Count int64 `json:"count" description:"未读通知数量"`
DisplayCount string `json:"display_count" description:"徽标显示文本,超过 99 时为 99+"`
}
// NotificationListRequest 是后台通知基础分页参数。
type NotificationListRequest struct {
Category string `json:"category" query:"category" validate:"omitempty,oneof=approval expiry sync system" enums:"approval,expiry,sync,system" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
Type string `json:"type" query:"type" validate:"omitempty,max=100" maxlength:"100" description:"稳定通知类型"`
Severity string `json:"severity" query:"severity" validate:"omitempty,oneof=info warning error critical" enums:"info,warning,error,critical" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
IsRead *bool `json:"is_read" query:"is_read" description:"已读状态;不传时查询全部"`
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1最大 10000"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=50" minimum:"1" maximum:"50" description:"每页数量,默认 20最大 50"`
}
// NotificationItem 是后台账号可见的站内通知投影。
type NotificationItem struct {
ID uint `json:"id" description:"通知ID"`
Category string `json:"category" description:"通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
Type string `json:"type" description:"稳定通知类型"`
Severity string `json:"severity" description:"通知级别 (info:提示, warning:警告, error:错误, critical:严重)"`
Title string `json:"title" description:"纯文本标题"`
Body string `json:"body" description:"纯文本正文"`
RefType string `json:"ref_type" description:"受控资源类型"`
RefID string `json:"ref_id" description:"受控资源ID"`
RefKey string `json:"ref_key" description:"受控资源Key"`
IsRead bool `json:"is_read" description:"是否已读"`
ReadAt *time.Time `json:"read_at,omitempty" description:"首次已读时间ISO 8601"`
CreatedAt time.Time `json:"created_at" description:"创建时间ISO 8601"`
}
// NotificationListResponse 是后台通知基础分页结果。
type NotificationListResponse struct {
Items []NotificationItem `json:"items" description:"通知列表"`
Total int64 `json:"total" description:"总数量"`
Page int `json:"page" description:"页码"`
Size int `json:"size" description:"每页数量"`
}
// NotificationIDParams 是单条通知路径参数。
type NotificationIDParams struct {
ID uint `json:"id" path:"id" required:"true" description:"通知ID"`
}
// NotificationReadResponse 是单条通知幂等已读结果。
type NotificationReadResponse struct {
Success bool `json:"success" description:"请求是否成功;通知不存在、属于别人或已经已读也返回 true"`
}
// NotificationTargetResponse 是通知受控目标解析结果,不包含任意 URL。
type NotificationTargetResponse struct {
TargetType string `json:"target_type" description:"前端白名单目标类型;空表示不支持跳转"`
TargetID *uint `json:"target_id,omitempty" description:"受控数值目标ID"`
TargetKey string `json:"target_key,omitempty" description:"受控稳定目标Key"`
Available bool `json:"available" description:"当前账号是否仍可访问目标"`
}
// NotificationUnreadSummaryResponse 是后台账号未读通知的固定分类汇总。
type NotificationUnreadSummaryResponse struct {
Total int64 `json:"total" description:"未读通知总数"`
Approval int64 `json:"approval" description:"审批类未读数量"`
Expiry int64 `json:"expiry" description:"临期类未读数量"`
Sync int64 `json:"sync" description:"同步类未读数量"`
System int64 `json:"system" description:"系统类未读数量"`
}
// NotificationReadAllRequest 是后台批量已读请求。
type NotificationReadAllRequest struct {
Category string `json:"category" validate:"omitempty,oneof=approval expiry sync system" enums:"approval,expiry,sync,system" description:"可选通知类别 (approval:审批, expiry:临期, sync:同步, system:系统)"`
}
// NotificationReadAllResponse 是后台批量已读结果。
type NotificationReadAllResponse struct {
UpdatedCount int64 `json:"updated_count" description:"本次实际更新的通知数量"`
}
// PersonalNotificationListRequest 是个人客户通知的简化分页参数。
type PersonalNotificationListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1最大 10000"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=50" minimum:"1" maximum:"50" description:"每页数量,默认 20最大 50"`
}
// PersonalNotificationListResponse 是个人客户通知的简化分页结果。
type PersonalNotificationListResponse struct {
Items []NotificationItem `json:"items" description:"当前个人客户可见的业务通知列表"`
Total int64 `json:"total" description:"总数量"`
Page int `json:"page" description:"页码"`
Size int `json:"size" description:"每页数量"`
}

View File

@@ -36,12 +36,36 @@ type RoleResponse struct {
RoleDesc string `json:"role_desc" description:"角色描述"` RoleDesc string `json:"role_desc" description:"角色描述"`
RoleType int `json:"role_type" description:"角色类型 (1:平台角色, 2:客户角色)"` RoleType int `json:"role_type" description:"角色类型 (1:平台角色, 2:客户角色)"`
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
DefaultCreditEnabled bool `json:"default_credit_enabled" description:"是否启用新建代理默认信用;仅客户角色有效"`
DefaultCreditLimit int64 `json:"default_credit_limit" description:"新建代理默认信用额度(分);仅影响未来新建店铺"`
DefaultCreditScope string `json:"default_credit_scope" description:"模板生效范围,固定为 new_shops_only"`
Creator uint `json:"creator" description:"创建人ID"` Creator uint `json:"creator" description:"创建人ID"`
Updater uint `json:"updater" description:"更新人ID"` Updater uint `json:"updater" description:"更新人ID"`
CreatedAt string `json:"created_at" description:"创建时间"` CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"` UpdatedAt string `json:"updated_at" description:"更新时间"`
} }
// UpdateRoleDefaultCreditRequest 更新角色默认信用模板请求。
type UpdateRoleDefaultCreditRequest struct {
CreditEnabled *bool `json:"credit_enabled" validate:"required" required:"true" description:"是否启用新建代理默认信用"`
CreditLimit *int64 `json:"credit_limit" validate:"required,min=0" required:"true" minimum:"0" description:"新建代理默认信用额度关闭时必须为0开启时必须大于0"`
}
// UpdateRoleDefaultCreditParams 更新角色默认信用模板参数。
type UpdateRoleDefaultCreditParams struct {
IDReq
UpdateRoleDefaultCreditRequest
}
// RoleDefaultCreditResponse 角色默认信用模板响应。
type RoleDefaultCreditResponse struct {
RoleID uint `json:"role_id" description:"客户角色ID"`
CreditEnabled bool `json:"credit_enabled" description:"是否启用新建代理默认信用"`
CreditLimit int64 `json:"credit_limit" description:"新建代理默认信用额度(分)"`
Scope string `json:"scope" description:"模板生效范围,固定为 new_shops_only"`
AffectsExistingWallets bool `json:"affects_existing_wallets" description:"是否影响既有钱包,固定为 false"`
}
// RolePageResult 角色分页响应 // RolePageResult 角色分页响应
type RolePageResult struct { type RolePageResult struct {
Items []RoleResponse `json:"items" description:"角色列表"` Items []RoleResponse `json:"items" description:"角色列表"`

View File

@@ -20,7 +20,14 @@ type ShopFundSummaryItem struct {
Username string `json:"username" description:"主账号用户名"` Username string `json:"username" description:"主账号用户名"`
Phone string `json:"phone" description:"主账号手机号"` Phone string `json:"phone" description:"主账号手机号"`
MainBalance int64 `json:"main_balance" description:"预充值钱包余额(分)"` MainBalance int64 `json:"main_balance" description:"预充值钱包余额(分)"`
MainFrozenBalance int64 `json:"main_frozen_balance" description:"预充值钱包冻结余额(分,预留字段"` MainFrozenBalance int64 `json:"main_frozen_balance" description:"预充值钱包冻结余额(分)"`
CashAvailableBalance int64 `json:"cash_available_balance" description:"现金可用金额(分),等于主钱包余额减冻结金额"`
CreditEnabled bool `json:"credit_enabled" description:"是否启用主钱包信用额度"`
CreditLimit int64 `json:"credit_limit" description:"主钱包信用额度关闭信用时为0"`
AvailableBalance int64 `json:"available_balance" description:"总可用金额(分),等于现金可用金额加生效信用额度"`
IsInDebt bool `json:"is_in_debt" description:"主钱包账面余额是否为负数"`
DebtAmount int64 `json:"debt_amount" description:"主钱包欠款金额(分),仅取负账面余额绝对值"`
Version int `json:"version" description:"主钱包当前版本号,并发写冲突后应重新查询"`
TotalCommission int64 `json:"total_commission" description:"累计佣金总额(分)"` TotalCommission int64 `json:"total_commission" description:"累计佣金总额(分)"`
WithdrawnCommission int64 `json:"withdrawn_commission" description:"已提现佣金(分)"` WithdrawnCommission int64 `json:"withdrawn_commission" description:"已提现佣金(分)"`
UnwithdrawCommission int64 `json:"unwithdraw_commission" description:"未提现佣金(分)"` UnwithdrawCommission int64 `json:"unwithdraw_commission" description:"未提现佣金(分)"`

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