diff --git a/.scratch/tech-global-audit/审计覆盖基线.md b/.scratch/tech-global-audit/审计覆盖基线.md index 09d6bc6..8ac9917 100644 --- a/.scratch/tech-global-audit/审计覆盖基线.md +++ b/.scratch/tech-global-audit/审计覆盖基线.md @@ -24,6 +24,21 @@ | 外部回调、轮询和 Gateway 调用 | 状态变化、人工触发、连续失败或高风险异常时必须 | 业务状态仍在领域表 | 每次实际或未发送尝试都必须 | 需要可靠后续处理时必须 | | Domain 方法 | 不直接依赖审计基础设施,由 Application 写入 | 维护业务不变量 | 由 Application/Adapter 负责 | 只记录领域事件,由 Application 持久化 | +## 七月测试环境冻结期增量登记 + +`complete-july-iteration-test-release` 已明确把 Audit Event Writer 与发布门禁延期到任务 6.5。以下登记仅说明测试环境阶段的临时分类,不代表生产评审通过,也不得删除通知事实、Access Log 或公共 Outbox: + +| 入口 | Audit Event | Domain Ledger | Integration Log | Outbox | +|---|---|---|---|---| +| 明确后台账号通知事件消费与幂等写入 | N/A(测试环境冻结;生产前由 6.5 重新评审通知失败与系统告警治理) | `tb_notification` 是通知投递与接收人已读状态的权威事实 | N/A(无外部系统调用) | 消费公共 Outbox 的稳定事件,不复制 Outbox | +| 当前后台账号单条通知已读 | N/A(低风险个人阅读状态,普通已读操作只进入 Access Log) | `tb_notification.is_read/read_at` 是权威状态 | N/A | N/A | +| 当前后台账号未读数与基础列表 | N/A(普通读取,不返回其他接收人数据或敏感业务正文) | 只读 `tb_notification` 投影 | N/A | N/A | +| 代理主钱包订单统一扣款 | 延期(测试环境冻结;生产前由 6.5 为资金变更补齐同事务 Audit Event) | `tb_order`、`tb_agent_wallet`、`tb_agent_wallet_transaction`、`tb_payment` 与套餐使用记录在同一事务形成权威事实 | N/A(不调用外部系统) | 同事务写入 `wallet.agent_main.debited`,为余额预警等后续消费者提供稳定事实 | +| 代理主钱包订单资金预占、释放与完成扣除 | 延期(测试环境冻结;生产前由 6.5 为冻结及终态变更补齐同事务 Audit Event) | `tb_agent_wallet_reservation` 是预占金额、付款钱包与唯一终态的权威事实;钱包与完成扣除流水同事务更新 | N/A(不调用外部系统) | 同事务写入 `wallet.agent_main.reservation.changed`;完成扣除同时写入 `wallet.agent_main.debited`,消费者按权威事实幂等确认 | +| 代理主钱包充值与人工调整正向入账 | 延期(测试环境冻结;生产前由 6.5 为入账补齐同事务 Audit Event) | 充值/人工调整业务事实、`tb_agent_wallet` 与唯一成功流水在同一事务形成权威事实 | N/A(本接缝不调用支付或审批外部系统) | 同事务写入 `wallet.agent_main.credited`,消费者按成功流水复核;支付/审批 Integration Log 由 UR#34 外部流程负责 | +| 代理订单主钱包退款回充 | 延期(测试环境冻结;生产前由 6.5 为退款资金变更补齐同事务 Audit Event) | 原成功扣款流水定位付款钱包并限定金额;退款审批、`tb_agent_wallet` 与唯一成功退款流水同事务形成权威事实 | N/A(本资金接缝不调用渠道或审批外部系统) | 同事务写入 `wallet.agent_main.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 | +| 代理商资金概况信用投影 | N/A(普通受权读取;不返回其他数据范围的资金事实,不执行资金或配置变更) | 只读投影 `tb_shop`、主/佣金钱包、提现汇总和主账号;派生金额不另建事实表 | N/A(无外部系统调用) | N/A(纯 Query 不产生可靠副作用) | + ## 旧 Writer 与旧表写入口清单 ### 旧账号审计 diff --git a/.scratch/tech-inapp-notifications/PRD.md b/.scratch/tech-inapp-notifications/PRD.md index de04d60..cbecd1c 100644 --- a/.scratch/tech-inapp-notifications/PRD.md +++ b/.scratch/tech-inapp-notifications/PRD.md @@ -1,6 +1,6 @@ # PRD:TECH 公共站内通知与受控跳转 -Status: ready-for-agent +Status: 后端代码与契约完成、验证延期 --- diff --git a/.scratch/tech-inapp-notifications/issues/01-admin-direct-recipient-notification-loop.md b/.scratch/tech-inapp-notifications/issues/01-admin-direct-recipient-notification-loop.md index cf5c781..9a50232 100644 --- a/.scratch/tech-inapp-notifications/issues/01-admin-direct-recipient-notification-loop.md +++ b/.scratch/tech-inapp-notifications/issues/01-admin-direct-recipient-notification-loop.md @@ -4,18 +4,17 @@ **Blocked by:** `.scratch/tech-public-foundation/issues/03-outbox-at-least-once-delivery.md` — 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为简单写 Application,辅助通道为 Infrastructure 与 Query。 **完整业务边界:** 本票收口后台明确账号通知的存储、受控类型注册、Worker 幂等消费、未读数、基础列表和单条已读闭环。明确不实现角色或店铺动态接收人、个人客户通知、分类汇总、全部已读、目标解析、前端组件或具体业务场景触发规则,也不复制公共 Outbox 和 Relay。 -- [ ] 通知事实包含稳定事件 ID、接收人类型与 ID、类别、类型、级别、纯文本标题正文、受控资源引用、已读与过期时间,并通过事件 ID、接收人类型和接收人 ID 唯一约束防止重复消费。 -- [ ] 通知常量、中文说明、类型到类别、默认级别、模板和允许目标的注册关系统一管理;未注册类型、模板字段永久缺失或正文包含禁止敏感内容时不生成残缺通知。 -- [ ] Worker 只接受结构化载荷,重复事件和并发消费最多为同一后台账号生成一条通知;瞬时数据库错误返回任务错误,原业务事务不因通知写入失败而回滚。 -- [ ] 当前后台账号可以获得准确的 `count:int64` 和 `display_count:string`,其中 0、1~99、100 以上分别显示 `0`、十进制文本和 `99+`,且未读数只查询 PostgreSQL。 -- [ ] 后台列表只读取当前认证账号的未过期通知,固定按创建时间和 ID 倒序,默认每页 20、最大 50,并返回统一响应与 ISO 8601 时间。 -- [ ] 单条已读使用接收人条件和未读条件更新;别人通知、不存在通知和已读通知均幂等成功,首次写入的 `read_at` 在重复请求中保持不变。 -- [ ] PostgreSQL、Worker 和真实后台认证 HTTP 集成测试覆盖唯一约束、重复消费、过期排除、分页排序、接收人篡改、越权隔离及重复已读。 -- [ ] 新增后台 Handler 后完成路由、RouteSpec 和两个 OpenAPI 文档生成器注册,且静态路由顺序不会被动态通知 ID 路由吞掉。 - +- [x] 通知事实包含稳定事件 ID、接收人类型与 ID、类别、类型、级别、纯文本标题正文、受控资源引用、已读与过期时间,并通过事件 ID、接收人类型和接收人 ID 唯一约束防止重复消费。 +- [x] 通知常量、中文说明、类型到类别、默认级别、模板和允许目标的注册关系统一管理;未注册类型、模板字段永久缺失或正文包含禁止敏感内容时不生成残缺通知。 +- [x] Worker 只接受结构化载荷,重复事件和并发消费最多为同一后台账号生成一条通知;瞬时数据库错误返回任务错误,原业务事务不因通知写入失败而回滚。 +- [x] 当前后台账号可以获得准确的 `count:int64` 和 `display_count:string`,其中 0、1~99、100 以上分别显示 `0`、十进制文本和 `99+`,且未读数只查询 PostgreSQL。 +- [x] 后台列表只读取当前认证账号的未过期通知,固定按创建时间和 ID 倒序,默认每页 20、最大 50,并返回统一响应与 ISO 8601 时间。 +- [x] 单条已读使用接收人条件和未读条件更新;别人通知、不存在通知和已读通知均幂等成功,首次写入的 `read_at` 在重复请求中保持不变。 +- [ ] PostgreSQL、Worker 和真实后台认证 HTTP 集成测试覆盖唯一约束、重复消费、过期排除、分页排序、接收人篡改、越权隔离及重复已读。(按本 Change 测试环境豁免转任务 6.1) +- [x] 新增后台 Handler 后完成路由、RouteSpec 和两个 OpenAPI 文档生成器注册,且静态路由顺序不会被动态通知 ID 路由吞掉。 diff --git a/.scratch/tech-inapp-notifications/issues/02-admin-notification-filter-summary-read-all.md b/.scratch/tech-inapp-notifications/issues/02-admin-notification-filter-summary-read-all.md index 86f2912..fb338f0 100644 --- a/.scratch/tech-inapp-notifications/issues/02-admin-notification-filter-summary-read-all.md +++ b/.scratch/tech-inapp-notifications/issues/02-admin-notification-filter-summary-read-all.md @@ -4,17 +4,16 @@ **Blocked by:** 01 — 向明确后台账号可靠投递首条站内通知 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为 Query,辅助通道为简单写 Application。 **完整业务边界:** 本票收口后台通知中心所需的筛选、固定分类汇总和批量已读用例。明确不实现个人客户接口、动态接收人、目标跳转、前端页面、Redis 未读计数或管理员查看他人通知能力。 -- [ ] 列表支持类别、类型、严重级别、已读状态、页码和每页数量组合过滤,所有条件使用 AND 语义并保持创建时间、ID 倒序。 -- [ ] 非法类别、严重级别、已读参数或越界分页返回统一参数错误,不向客户端拼接底层校验信息。 -- [ ] 未读汇总固定返回 `total`、`approval`、`expiry`、`sync`、`system`,过期通知不计入任何分类。 -- [ ] 全部已读在类别为空时更新当前账号全部未过期未读通知,在类别有效时只更新该类别,并返回实际更新数量。 -- [ ] 批量更新使用当前接收人、未读状态、未过期和可选类别条件;重复调用返回零更新且保持成功,不覆盖既有 `read_at`。 -- [ ] `/read-all` 等静态路由先于 `/{id}` 动态路由注册,生成的 OpenAPI 与真实路由、请求参数和响应结构一致。 -- [ ] PostgreSQL 与真实后台认证 HTTP 集成测试覆盖组合筛选、固定汇总、最大分页、过期排除、非法类别、并发批量已读及无法操作他人通知。 - +- [x] 列表支持类别、类型、严重级别、已读状态、页码和每页数量组合过滤,所有条件使用 AND 语义并保持创建时间、ID 倒序。 +- [x] 非法类别、严重级别、已读参数或越界分页返回统一参数错误,不向客户端拼接底层校验信息。 +- [x] 未读汇总固定返回 `total`、`approval`、`expiry`、`sync`、`system`,过期通知不计入任何分类。 +- [x] 全部已读在类别为空时更新当前账号全部未过期未读通知,在类别有效时只更新该类别,并返回实际更新数量。 +- [x] 批量更新使用当前接收人、未读状态、未过期和可选类别条件;重复调用返回零更新且保持成功,不覆盖既有 `read_at`。 +- [x] `/read-all` 等静态路由先于 `/{id}` 动态路由注册,生成的 OpenAPI 与真实路由、请求参数和响应结构一致。 +- [ ] PostgreSQL 与真实后台认证 HTTP 集成测试覆盖组合筛选、固定汇总、最大分页、过期排除、非法类别、并发批量已读及无法操作他人通知。(按本 Change 测试环境豁免转任务 6.1) diff --git a/.scratch/tech-inapp-notifications/issues/03-personal-customer-notification-loop.md b/.scratch/tech-inapp-notifications/issues/03-personal-customer-notification-loop.md index 096487b..f2c5221 100644 --- a/.scratch/tech-inapp-notifications/issues/03-personal-customer-notification-loop.md +++ b/.scratch/tech-inapp-notifications/issues/03-personal-customer-notification-loop.md @@ -4,17 +4,16 @@ **Blocked by:** 01 — 向明确后台账号可靠投递首条站内通知 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为 Query,辅助通道为简单写 Application 与 Infrastructure。 **完整业务边界:** 本票收口个人客户通知的投递、读取和已读闭环,复用既有通知表、注册表和 Worker。明确不实现 C 端分类汇总、后台动态接收人、平台运维消息展示、C 端受控目标接口或前端组件。 -- [ ] Worker 能以 `personal_customer` 接收人类型和稳定客户 ID 幂等生成通知,同一事件的后台账号与个人客户通知相互独立。 -- [ ] 个人客户通知类型注册明确允许的业务类别和资源引用;`sync`、`system` 及未对 C 端开放的类型不会出现在个人客户查询中。 -- [ ] C 端未读数遵循 0、1~99、100 以上的显示规则,列表固定倒序、默认每页 20、最大 50,并排除过期通知。 -- [ ] 单条已读、全部已读只作用于当前认证客户;不存在、已删除、属于别人或已读的通知使用相同幂等安全语义。 -- [ ] 请求 DTO 不接受接收人 ID,额外或恶意接收人参数不能改变查询与更新范围。 -- [ ] 真实个人客户认证、Handler、Query、GORM 集成测试覆盖重复投递、运维类别隔离、跨客户越权、分页、过期排除、重复已读和批量已读。 -- [ ] 新增 C 端 Handler 后同步个人客户路由、RouteSpec 与两个 OpenAPI 文档生成器,接口统一挂载在约定认证上下文中。 - +- [x] Worker 能以 `personal_customer` 接收人类型和稳定客户 ID 幂等生成通知,同一事件的后台账号与个人客户通知相互独立。 +- [x] 个人客户通知类型注册明确允许的业务类别和资源引用;`sync`、`system` 及未对 C 端开放的类型不会出现在个人客户查询中。 +- [x] C 端未读数遵循 0、1~99、100 以上的显示规则,列表固定倒序、默认每页 20、最大 50,并排除过期通知。 +- [x] 单条已读、全部已读只作用于当前认证客户;不存在、已删除、属于别人或已读的通知使用相同幂等安全语义。 +- [x] 请求 DTO 不接受接收人 ID,额外或恶意接收人参数不能改变查询与更新范围。 +- [ ] 真实个人客户认证、Handler、Query、GORM 集成测试覆盖重复投递、运维类别隔离、跨客户越权、分页、过期排除、重复已读和批量已读。(按本 Change 测试环境豁免转任务 6.1) +- [x] 新增 C 端 Handler 后同步个人客户路由、RouteSpec 与两个 OpenAPI 文档生成器,接口统一挂载在约定认证上下文中。 diff --git a/.scratch/tech-inapp-notifications/issues/04-dynamic-recipient-resolution.md b/.scratch/tech-inapp-notifications/issues/04-dynamic-recipient-resolution.md index c35b5b0..b202ff7 100644 --- a/.scratch/tech-inapp-notifications/issues/04-dynamic-recipient-resolution.md +++ b/.scratch/tech-inapp-notifications/issues/04-dynamic-recipient-resolution.md @@ -5,19 +5,20 @@ **Blocked by:** - 01 — 向明确后台账号可靠投递首条站内通知 -- `.scratch/ur96-shop-business-owner/issues/06-shop-business-owner-notification-recipient-release.md` — 06 — 提供业务员通知接收人解析并完成发布验证 +- `.scratch/ur96-shop-business-owner/issues/06-business-owner-recipient-resolution-and-release.md` — 06 — 提供业务员通知接收人解析并完成发布验证 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为 Infrastructure Adapter,辅助通道为简单写 Application。 **完整业务边界:** 本票收口公共通知 Worker 对明确申请人、平台角色和店铺接收人的解析、可用性复核、去重及无接收人语义。明确不实现 UR#33、UR#97 或企微审批的业务触发规则,不改变账号、角色、店铺层级或业务员归属,不自动转派历史通知,也不发送短信或企微消息。 -- [ ] 明确申请人场景只使用业务事件携带的稳定系统账号 ID,并在消费时跳过已停用或软删除账号,不使用企微代提交身份替代真实业务提交人。 -- [ ] 角色场景批量解析当前启用、未删除且仍持有指定平台角色的账号,结果按稳定账号 ID 去重,不按用户名或手机号投递。 -- [ ] 店铺场景解析当前启用的店铺主账号,并复用 UR#96 接缝解析当前可用业务员;不沿父店铺、祖先店铺或代理数据权限向上扩散。 -- [ ] 同一账号同时以主账号、业务员或角色命中时只生成一条通知,同一事件的其他接收人仍分别拥有独立已读状态。 -- [ ] 暂无可用接收人记为 `no_recipient` 并成功结束,不进入无限重试;数据库等瞬时错误继续返回任务错误。 -- [ ] 已生成通知不会因账号关系后续变化而转移给新接收人,历史接收人仍可在自身认证上下文中读取原通知。 +- [x] 明确申请人场景只使用业务事件携带的稳定系统账号 ID,并在消费时跳过已停用或软删除账号,不使用企微代提交身份替代真实业务提交人。 +- [x] 角色场景批量解析当前启用、未删除且仍持有指定平台角色的账号,结果按稳定账号 ID 去重,不按用户名或手机号投递。 +- [x] 店铺场景解析当前启用的店铺主账号,并复用 UR#96 接缝解析当前可用业务员;不沿父店铺、祖先店铺或代理数据权限向上扩散。 +- [x] 同一账号同时以主账号、业务员或角色命中时只生成一条通知,同一事件的其他接收人仍分别拥有独立已读状态。 +- [x] 暂无可用接收人记为 `no_recipient` 并成功结束,不进入无限重试;数据库等瞬时错误继续返回任务错误。 +- [x] 已生成通知不会因账号关系后续变化而转移给新接收人,历史接收人仍可在自身认证上下文中读取原通知。 - [ ] Application、Worker 和 PostgreSQL 集成测试覆盖申请人、角色批量解析、店铺主账号与业务员去重、停用、软删除、关系失效、无接收人及重复投递。 +自动化与真实 PostgreSQL/Redis/Asynq 验证按本 Change 测试环境豁免转任务 6.1、6.3;本票已完成 `gofmt`、`git diff --check` 与 `go build ./...` 代码门禁。 diff --git a/.scratch/tech-inapp-notifications/issues/05-controlled-notification-target-resolution.md b/.scratch/tech-inapp-notifications/issues/05-controlled-notification-target-resolution.md index 8d4f372..4ecba27 100644 --- a/.scratch/tech-inapp-notifications/issues/05-controlled-notification-target-resolution.md +++ b/.scratch/tech-inapp-notifications/issues/05-controlled-notification-target-resolution.md @@ -7,17 +7,18 @@ - 01 — 向明确后台账号可靠投递首条站内通知 - `.scratch/tech-global-audit/issues/13-request-correlation-integration-timeline.md` — 13 — 交付请求、业务链路和外部集成时间线 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为 Query,辅助通道为 Application + Port/Adapter。 **完整业务边界:** 本票收口后台通知受控目标注册、解析、当前权限复核和安全不可用语义。明确不返回 URL、不实现前端路由构造、不把通知所有权当成目标资源权限、不创建独立卡同步执行页,也不迁移各目标业务详情的既有授权规则。 -- [ ] 通知只保存受控资源类型、数值 ID 或稳定 Key;目标响应只包含白名单 `target_type`、结构化 `target_id/target_key` 和 `available`,任何字段均不能承载任意 URL。 -- [ ] 第一版注册表至少覆盖退款详情、代理充值详情、企微审批详情、卡详情、设备详情、临期资产列表、店铺资金概况、审计外部集成和系统配置。 -- [ ] 目标解析先按当前接收人固定查询通知,再调用对应业务权限 Adapter 复核资源;拥有通知不授予目标资源访问权。 -- [ ] 别人通知、不存在通知和已删除通知不泄露通知事实;目标不存在、已删除或当前无权时统一返回 `available=false`,不返回资源差异信息。 -- [ ] `card_sync` 等同步消息解析为统一审计中心外部集成目标并携带受控资源或 Integration Log 标识,不指向不存在的同步执行页。 -- [ ] 未知通知引用或尚未支持的目标只允许展示正文,不产生开放重定向、自由路径或自动回退 URL。 +- [x] 通知只保存受控资源类型、数值 ID 或稳定 Key;目标响应只包含白名单 `target_type`、结构化 `target_id/target_key` 和 `available`,任何字段均不能承载任意 URL。 +- [x] 第一版注册表至少覆盖退款详情、代理充值详情、企微审批详情、卡详情、设备详情、临期资产列表、店铺资金概况、审计外部集成和系统配置。 +- [x] 目标解析先按当前接收人固定查询通知,再调用对应业务权限 Adapter 复核资源;拥有通知不授予目标资源访问权。 +- [x] 别人通知、不存在通知和已删除通知不泄露通知事实;目标不存在、已删除或当前无权时统一返回 `available=false`,不返回资源差异信息。 +- [x] `card_sync` 等同步消息解析为统一审计中心外部集成目标并携带受控资源或 Integration Log 标识,不指向不存在的同步执行页。 +- [x] 未知通知引用或尚未支持的目标只允许展示正文,不产生开放重定向、自由路径或自动回退 URL。 - [ ] 契约与越权测试覆盖全部白名单、未知引用、通知越权、目标删除、权限变化、外部集成目标,并断言响应和持久化数据不存在任意 URL。 +代理充值与企微审批目标类型已预注册,但其下游业务表和权限 Adapter 尚未由后续任务交付,因此当前安全返回 `available=false`。自动化与真实 PostgreSQL/HTTP 验证按本 Change 测试环境豁免转任务 6.1;本票已完成 OpenAPI 生成、`gofmt`、`git diff --check` 与 `go build ./...` 代码门禁。 diff --git a/.scratch/tech-inapp-notifications/issues/06-notification-retention-observability.md b/.scratch/tech-inapp-notifications/issues/06-notification-retention-observability.md index de203b6..5bc6a0a 100644 --- a/.scratch/tech-inapp-notifications/issues/06-notification-retention-observability.md +++ b/.scratch/tech-inapp-notifications/issues/06-notification-retention-observability.md @@ -8,17 +8,18 @@ - `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环 - `.scratch/tech-global-audit/issues/02-integration-log-attempt-loop.md` — 02 — 交付可恢复的 Integration Log 尝试闭环 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为 Infrastructure,辅助通道为简单写 Application。 **完整业务边界:** 本票收口通知展示期限、数据保留、分批清理、失败分类和统一审计/外部集成可观测接缝。明确不删除 Audit Event、Integration Log、领域流水或 Outbox,不提供用户删除接口,不建设管理员查看他人消息入口,也不改变公共 Relay 的租约算法。 -- [ ] 套餐临期、审批结果、同步异常和系统告警按约定计算展示与保留期限,审批结果不自动过期,系统告警展示期限不超过允许上限。 -- [ ] 清理任务按时间和稳定主键小批量删除超过数据保留期限的通知,可中断重跑且只清理通知事实,不级联业务资源或审计记录。 -- [ ] 暂无接收人记录 `no_recipient` 后成功结束;数据库、队列等瞬时错误按有限策略重试;模板永久缺失达到最大重试后进入失败监控且不写残缺正文。 -- [ ] 系统告警直接入队时必须携带预先生成的稳定事件 ID,重复执行仍由通知唯一键防重。 +- [x] 套餐临期、审批结果、同步异常和系统告警按约定计算展示与保留期限,审批结果不自动过期,系统告警展示期限不超过允许上限。 +- [x] 清理任务按时间和稳定主键小批量删除超过数据保留期限的通知,可中断重跑且只清理通知事实,不级联业务资源或审计记录。 +- [x] 暂无接收人记录 `no_recipient` 后成功结束;数据库、队列等瞬时错误按有限策略重试;模板永久缺失达到最大重试后进入失败监控且不写残缺正文。 +- [x] 系统告警直接入队时必须携带预先生成的稳定事件 ID,重复执行仍由通知唯一键防重。 - [ ] 模板或接收人解析失败、系统告警生成和管理性排查写入统一 Audit/Integration 接缝;普通通知读取和已读只进入 Access Log。 -- [ ] 日志、监控和审计只记录事件 ID、通知类型、失败类别、计数及安全资源标识,不记录敏感模板数据、完整回调、Token、Secret 或任意长期 URL。 +- [x] 日志、监控和审计只记录事件 ID、通知类型、失败类别、计数及安全资源标识,不记录敏感模板数据、完整回调、Token、Secret 或任意长期 URL。 - [ ] 测试覆盖各类期限边界、分批清理可重入、无接收人、瞬时失败、永久模板失败、最大重试、系统告警重复入队和业务事实不回滚。 +统一 Audit Event/Integration 管理性写入按本 Change 冻结转任务 6.5,不阻塞测试环境代码交付;普通 HTTP 仍由 Access Log 覆盖。自动化与真实 PostgreSQL/Redis/Asynq 验证转任务 6.1、6.3。本票已完成 `gofmt`、`git diff --check` 与 `go build ./...` 代码门禁。 diff --git a/.scratch/tech-inapp-notifications/issues/07-frontend-contract-acceptance-pack.md b/.scratch/tech-inapp-notifications/issues/07-frontend-contract-acceptance-pack.md index 35b91be..a968077 100644 --- a/.scratch/tech-inapp-notifications/issues/07-frontend-contract-acceptance-pack.md +++ b/.scratch/tech-inapp-notifications/issues/07-frontend-contract-acceptance-pack.md @@ -8,17 +8,18 @@ - 03 — 向个人客户投递并提供简化通知中心 - 05 — 交付通知受控目标解析与权限复核 -**Status:** ready-for-agent +**Status:** 契约完成、前端与人工验收延期 **架构通道:** 主通道为 Query/API 跨仓契约,辅助通道为前端验收契约。 **完整业务边界:** 本票收口当前后端仓库能够交付的 OpenAPI、交互状态、目标白名单说明和验收数据,不在本仓库实现前端组件。明确不引入 WebSocket/SSE,不承诺 Redis 未读计数,不为未知目标提供自由 URL,也不代替前端仓库自身的组件测试。 -- [ ] 契约明确布局挂载后立即请求、每 30 秒刷新、页面不可见暂停、恢复立即刷新,以及失败保留上次成功未读数且不闪回零。 -- [ ] 徽标验收覆盖 0、1、99、100,0 时隐藏、1~99 显示数字、100 显示 `99+`,并约定固定宽度避免布局抖动。 -- [ ] 后台抽屉按最近 10 条和约定分类展示,通知中心支持类别、类型、严重级别、已读状态、服务端分页及当前类别全部已读。 -- [ ] 点击顺序固定为先进入已读视觉状态并调用已读接口,再解析受控目标;已读失败以下次服务端刷新为准,目标失败不恢复未读。 -- [ ] 前端目标白名单只根据 `target_type` 和结构化标识构造内部路由,未知类型或 `available=false` 只展示正文且不跳转。 -- [ ] C 端契约只展示当前客户相关的审批结果、套餐、订单和资产消息,不暴露后台同步或系统运维分类。 -- [ ] OpenAPI、中文契约文档、示例响应与验收矩阵保持一致,并明确前端代码位于外部仓库、需按对应仓库流程实施和联调。 +- [x] 契约明确布局挂载后立即请求、每 30 秒刷新、页面不可见暂停、恢复立即刷新,以及失败保留上次成功未读数且不闪回零。 +- [x] 徽标验收覆盖 0、1、99、100,0 时隐藏、1~99 显示数字、100 显示 `99+`,并约定固定宽度避免布局抖动。 +- [x] 后台抽屉按最近 10 条和约定分类展示,通知中心支持类别、类型、严重级别、已读状态、服务端分页及当前类别全部已读。 +- [x] 点击顺序固定为先进入已读视觉状态并调用已读接口,再解析受控目标;已读失败以下次服务端刷新为准,目标失败不恢复未读。 +- [x] 前端目标白名单只根据 `target_type` 和结构化标识构造内部路由,未知类型或 `available=false` 只展示正文且不跳转。 +- [x] C 端契约只展示当前客户相关的审批结果、套餐、订单和资产消息,不暴露后台同步或系统运维分类。 +- [x] OpenAPI、中文契约文档、示例响应与验收矩阵保持一致,并明确前端代码位于外部仓库、需按对应仓库流程实施和联调。 +前端源码、浏览器联调与真实人工验收不在当前仓库,保持延期到任务 6.6。 diff --git a/.scratch/tech-inapp-notifications/issues/08-notification-release-gate.md b/.scratch/tech-inapp-notifications/issues/08-notification-release-gate.md index 1bb883e..859ceae 100644 --- a/.scratch/tech-inapp-notifications/issues/08-notification-release-gate.md +++ b/.scratch/tech-inapp-notifications/issues/08-notification-release-gate.md @@ -13,7 +13,7 @@ - `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约 - `.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md` — 19 — 执行一次性审计切换与停机发布门禁 -**Status:** ready-for-agent +**Status:** 代码与发布契约完成、验证延期 **架构通道:** 主通道为 Infrastructure,辅助通道为 Application、Query 与跨仓契约。 @@ -22,8 +22,10 @@ - [ ] 空数据库和兼容环境可执行通知正向迁移、索引校验与允许的结构回滚;已有通知事实后不得通过降级删表清理,应用回滚允许保留数据。 - [ ] PostgreSQL、Redis、公共 Relay 和 Asynq 端到端测试覆盖事务事件、至少一次投递、入队成功后重复、并发 Worker、多接收人、接收人去重和最终通知唯一性。 - [ ] 真实后台与个人客户认证测试覆盖所有公开接口、统一响应、静态路由顺序、接收人不可篡改、跨用户隔离、过期排除和受控目标权限变化。 -- [ ] 运行门禁覆盖 Worker 失败、永久模板错误、无接收人、Outbox 积压、清理滞后和审计/外部集成记录异常,并给出停止放量和恢复步骤。 -- [ ] 下游接入契约明确稳定事件 ID、结构化载荷、受控通知类型、接收人解析、模板字段、过期策略和目标引用;调用统一队列客户端时禁止传预序列化字节。 -- [ ] 发布顺序明确为迁移与校验、Worker 与监控、后端 API、前端、下游生产者;下游不得在消费者和监控就绪前制造不可见积压。 -- [ ] 新增管理端和 C 端 Handler 已同步路由、RouteSpec、两个 OpenAPI 文档生成器,并生成 OpenAPI 核对 `/read-all` 未被动态 ID 路由吞掉。 -- [ ] 中文功能总结覆盖关键流程、前后端契约、异常闭环、监控、发布回滚和待决策项,README 增加入口;测试数据使用隔离标识且不向真实用户生成通知。 +- [x] 运行门禁覆盖 Worker 失败、永久模板错误、无接收人、Outbox 积压、清理滞后和审计/外部集成记录异常,并给出停止放量和恢复步骤。 +- [x] 下游接入契约明确稳定事件 ID、结构化载荷、受控通知类型、接收人解析、模板字段、过期策略和目标引用;调用统一队列客户端时禁止传预序列化字节。 +- [x] 发布顺序明确为迁移与校验、Worker 与监控、后端 API、前端、下游生产者;下游不得在消费者和监控就绪前制造不可见积压。 +- [x] 新增管理端和 C 端 Handler 已同步路由、RouteSpec、两个 OpenAPI 文档生成器,并生成 OpenAPI 核对 `/read-all` 未被动态 ID 路由吞掉。 +- [x] 中文功能总结覆盖关键流程、前后端契约、异常闭环、监控、发布回滚和待决策项,README 增加入口;测试数据使用隔离标识且不向真实用户生成通知。 + +迁移演练、真实 PostgreSQL/Redis/Relay/Asynq、真实认证 HTTP 与并发验证按本 Change 豁免保持未勾选,并转任务 6.1、6.3;前端人工验收转 6.6。当前已完成 OpenAPI 生成、`gofmt`、`git diff --check`、`go build ./...` 与 OpenSpec 校验。 diff --git a/.scratch/ur45-exchange-asset-search/PRD.md b/.scratch/ur45-exchange-asset-search/PRD.md index eabc461..d42faab 100644 --- a/.scratch/ur45-exchange-asset-search/PRD.md +++ b/.scratch/ur45-exchange-asset-search/PRD.md @@ -1,6 +1,6 @@ # PRD:UR#45 换货资产标识与新旧资产独立搜索 -Status: ready-for-agent +Status: done(后端)— 前端/人工待验收 --- @@ -87,3 +87,4 @@ Status: ready-for-agent - UR#45 负责“快照规范与列表检索”;资产详情前代/后代关系由 UR#86 提供,店铺继承由 UR#98 提供。 - UR#86 应直接使用这里形成的不可变快照;历史非规范快照保持原样。 +- 后端实现、OpenAPI、中文文档及自动化验证已由提交 `55bdc3a` 交付;当前仓库不含前端源码,页面实施与浏览器人工验收仍待前端仓库完成。 diff --git a/.scratch/ur45-exchange-asset-search/issues/01-unify-exchange-asset-snapshots.md b/.scratch/ur45-exchange-asset-search/issues/01-unify-exchange-asset-snapshots.md index 064ec28..4fb5705 100644 --- a/.scratch/ur45-exchange-asset-search/issues/01-unify-exchange-asset-snapshots.md +++ b/.scratch/ur45-exchange-asset-search/issues/01-unify-exchange-asset-snapshots.md @@ -4,19 +4,19 @@ **Blocked by:** None — can start immediately. -**Status:** ready-for-agent +**Status:** done(后端)— 前端/人工待验收 **架构通道:** 主通道为复杂写,辅助通道为 Infrastructure Adapter。资产解析和权威标识选择作为换货写用例复用能力收口;既有 Service 可以作为迁移门面调用该能力,但规则不得继续散落在多个流程分支。 **完整业务边界:** 本票收口资产标识解析、权威快照选择以及物流创建、直接创建、物流发货三个快照写入入口,并包含对应自动化测试、OpenAPI 契约和中文发布说明。明确不迁移换货状态机、确认完成、取消、资料迁移、旧资产转新、客户绑定切换等旧逻辑;不回填或改写历史换货单,不依赖 UR#86 或 UR#98 的实现。 -- [ ] 使用 ICCID、接入号或虚拟号定位旧 IoT 卡时,新建物流换货单的旧资产快照均为该卡数据库中的完整 ICCID。 -- [ ] 直接换货的新旧资产和物流换货发货时的新资产均复用相同规范化能力,IoT 卡快照不保存请求原文、接入号或虚拟号。 -- [ ] 设备无论通过虚拟号、IMEI 或 SN 定位,快照都按“虚拟号 → IMEI → SN”的优先级选择首个非空稳定标识。 -- [ ] 标识解析仍执行既有资产权限、资产类型、状态和并发校验,不扩大可操作资产范围,也不改变换货生命周期规则。 -- [ ] 资产不存在、类型不匹配或数据库失败时返回既有统一错误体系中的脱敏错误,不向客户端透出底层错误。 -- [ ] 自动化测试覆盖卡的三种输入标识、设备的三种输入标识及设备标识优先级,并分别验证物流创建、直接创建和物流发货的持久化快照。 -- [ ] 端到端回归验证三个写入入口产生的快照可由现有换货详情或列表响应读取,且构造的历史非规范快照保持原值、不被自动回填。 -- [ ] OpenAPI 中创建换货和物流发货的请求标识说明、响应快照语义与实际实现一致,并完成文档重新生成验证。 -- [ ] UR#45 中文功能总结记录卡与设备快照规则、历史数据不回填策略、错误边界、发布与回滚注意事项;README 增加对应索引。 -- [ ] 所有新增或修改的导出符号、复杂逻辑注释和日志均使用中文,并通过相关 Go 测试与格式检查。 +- [x] 使用 ICCID、接入号或虚拟号定位旧 IoT 卡时,新建物流换货单的旧资产快照均为该卡数据库中的完整 ICCID。 +- [x] 直接换货的新旧资产和物流换货发货时的新资产均复用相同规范化能力,IoT 卡快照不保存请求原文、接入号或虚拟号。 +- [x] 设备无论通过虚拟号、IMEI 或 SN 定位,快照都按“虚拟号 → IMEI → SN”的优先级选择首个非空稳定标识。 +- [x] 标识解析仍执行既有资产权限、资产类型、状态和并发校验,不扩大可操作资产范围,也不改变换货生命周期规则。 +- [x] 资产不存在、类型不匹配或数据库失败时返回既有统一错误体系中的脱敏错误,不向客户端透出底层错误。 +- [x] 自动化测试覆盖卡的三种输入标识、设备的三种输入标识及设备标识优先级,并分别验证物流创建、直接创建和物流发货的持久化快照。 +- [x] 端到端回归验证三个写入入口产生的快照可由现有换货详情或列表响应读取,且构造的历史非规范快照保持原值、不被自动回填。 +- [x] OpenAPI 中创建换货和物流发货的请求标识说明、响应快照语义与实际实现一致,并完成文档重新生成验证。 +- [x] UR#45 中文功能总结记录卡与设备快照规则、历史数据不回填策略、错误边界、发布与回滚注意事项;README 增加对应索引。 +- [x] 所有新增或修改的导出符号、复杂逻辑注释和日志均使用中文,并通过相关 Go 测试与格式检查。 diff --git a/.scratch/ur45-exchange-asset-search/issues/02-independent-old-new-asset-search.md b/.scratch/ur45-exchange-asset-search/issues/02-independent-old-new-asset-search.md index c242b93..bb3ad58 100644 --- a/.scratch/ur45-exchange-asset-search/issues/02-independent-old-new-asset-search.md +++ b/.scratch/ur45-exchange-asset-search/issues/02-independent-old-new-asset-search.md @@ -4,24 +4,24 @@ **Blocked by:** None — can start immediately. -**Status:** ready-for-agent +**Status:** done(后端)— 前端/人工待验收 **架构通道:** 主通道为 Query。仅将本次明显复杂化的换货列表读取用例收口为查询能力,可直接使用 GORM、子查询或固定次数批量查询完成候选解析、权限过滤和 DTO 投影;不得让列表读取经过聚合根或执行写操作。 **完整业务边界:** 本票收口列表请求校验、候选资产解析、新旧资产主键过滤、权限、分页计数、排序、响应投影、错误转换、OpenAPI 发布、性能验证和前端联调验收说明。明确不迁移换货详情及其他读取接口,不创建换货聚合根,不修改换货写侧状态规则,不保留通用 `identifier` 的第二套长期搜索语义;不实现 UR#86 资产前代/后代关系或 UR#98 店铺继承。当前仓库未包含可实施该页面的前端工程,因此前端工作以接口契约和人工验收清单交付,不虚构前端代码改动。 -- [ ] 列表请求新增最长 100 字符的 `old_asset_keyword` 和 `new_asset_keyword`,并从新契约移除通用 `identifier`;空值不增加对应过滤条件。 -- [ ] 仅提供旧资产关键词时只按 `old_asset_type + old_asset_id` 过滤,绝不因新资产命中而返回;仅提供新资产关键词时规则对称。 -- [ ] 两个关键词同时提供时按 AND 组合,并与状态、流程类型、创建时间范围和分页条件按 AND 组合。 -- [ ] IoT 卡候选支持对 ICCID、接入号和虚拟号做包含匹配;设备候选支持对虚拟号、IMEI 和 SN 做包含匹配;候选资产必须排除软删除记录。 -- [ ] 换货单通过资产类型和资产主键命中,因此历史非规范快照不回填、不改写,但仍能通过所关联资产的任一受支持标识搜索到。 -- [ ] 无候选资产或无换货单命中时返回成功的空分页;候选查询或换货单查询发生数据库错误时返回脱敏 500,不得降级为空结果。 -- [ ] 最终换货单查询继续排除软删除记录并应用现有店铺数据范围;平台、超级管理员和代理账号的既有可见范围不被候选资产解析绕过。 -- [ ] 候选解析和换货单过滤使用数据库子查询或固定次数批量查询,不按换货单逐行反查资产;`total` 与 `items` 使用完全相同的过滤条件,结果按创建时间倒序。 -- [ ] Handler 对完整请求 DTO 执行校验;非法关键词长度、分页、状态、流程类型或时间参数统一返回 HTTP 400、`code=1001`、`msg=参数验证失败`,详细原因仅记录中文日志。 -- [ ] HTTP 集成测试覆盖仅旧关键词、仅新关键词、双关键词 AND、状态与时间组合、空参数、无匹配、非法参数、历史快照、数据库故障和各类账号数据权限。 -- [ ] 响应保持统一外层结构,并继续分别返回新旧资产类型、ID、快照标识、状态及状态名称。 -- [ ] OpenAPI 中的换货列表只公开 `old_asset_keyword` 和 `new_asset_keyword`,包含长度限制、AND 语义和中文说明,不再公开通用 `identifier`;重新生成文档并验证请求、响应和错误契约与实现一致。 -- [ ] 使用真实 PostgreSQL 和代表性大结果集验证查询次数固定、无逐行资产反查、`total` 与分页结果一致,并记录查询计划或等价证据;性能满足项目列表接口目标。 -- [ ] UR#45 中文功能总结补充搜索契约、权限与脱敏错误边界、性能结果、发布回滚方式和前端联调注意事项;README 中的 UR#45 索引可以定位该说明。 -- [ ] 前端人工验收清单明确要求:将单一资产输入框拆为旧资产和新资产输入框;空值不提交;双条件按 AND 提交;表格不混列新旧资产;前端不解析标识、不本地过滤当前页;清空、分页、空态和失败反馈沿用现有交互。 +- [x] 列表请求新增最长 100 字符的 `old_asset_keyword` 和 `new_asset_keyword`,并从新契约移除通用 `identifier`;空值不增加对应过滤条件。 +- [x] 仅提供旧资产关键词时只按 `old_asset_type + old_asset_id` 过滤,绝不因新资产命中而返回;仅提供新资产关键词时规则对称。 +- [x] 两个关键词同时提供时按 AND 组合,并与状态、流程类型、创建时间范围和分页条件按 AND 组合。 +- [x] IoT 卡候选支持对 ICCID、接入号和虚拟号做包含匹配;设备候选支持对虚拟号、IMEI 和 SN 做包含匹配;候选资产必须排除软删除记录。 +- [x] 换货单通过资产类型和资产主键命中,因此历史非规范快照不回填、不改写,但仍能通过所关联资产的任一受支持标识搜索到。 +- [x] 无候选资产或无换货单命中时返回成功的空分页;候选查询或换货单查询发生数据库错误时返回脱敏 500,不得降级为空结果。 +- [x] 最终换货单查询继续排除软删除记录并应用现有店铺数据范围;平台、超级管理员和代理账号的既有可见范围不被候选资产解析绕过。 +- [x] 候选解析和换货单过滤使用数据库子查询或固定次数批量查询,不按换货单逐行反查资产;`total` 与 `items` 使用完全相同的过滤条件,结果按创建时间倒序。 +- [x] Handler 对完整请求 DTO 执行校验;非法关键词长度、分页、状态、流程类型或时间参数统一返回 HTTP 400、`code=1001`、`msg=参数验证失败`,详细原因仅记录中文日志。 +- [x] HTTP 集成测试覆盖仅旧关键词、仅新关键词、双关键词 AND、状态与时间组合、空参数、无匹配、非法参数、历史快照、数据库故障和各类账号数据权限。 +- [x] 响应保持统一外层结构,并继续分别返回新旧资产类型、ID、快照标识、状态及状态名称。 +- [x] OpenAPI 中的换货列表只公开 `old_asset_keyword` 和 `new_asset_keyword`,包含长度限制、AND 语义和中文说明,不再公开通用 `identifier`;重新生成文档并验证请求、响应和错误契约与实现一致。 +- [x] 使用真实 PostgreSQL 和代表性大结果集验证查询次数固定、无逐行资产反查、`total` 与分页结果一致,并记录查询计划或等价证据;性能满足项目列表接口目标。 +- [x] UR#45 中文功能总结补充搜索契约、权限与脱敏错误边界、性能结果、发布回滚方式和前端联调注意事项;README 中的 UR#45 索引可以定位该说明。 +- [x] 前端人工验收清单明确要求:将单一资产输入框拆为旧资产和新资产输入框;空值不提交;双条件按 AND 提交;表格不混列新旧资产;前端不解析标识、不本地过滤当前页;清空、分页、空态和失败反馈沿用现有交互。 diff --git a/.scratch/ur96-shop-business-owner/PRD.md b/.scratch/ur96-shop-business-owner/PRD.md index 37723ac..236d460 100644 --- a/.scratch/ur96-shop-business-owner/PRD.md +++ b/.scratch/ur96-shop-business-owner/PRD.md @@ -1,6 +1,6 @@ # PRD:UR#96 店铺业务员归属、继承与筛选 -Status: ready-for-agent +Status: 代码完成、验证延期 --- diff --git a/.scratch/ur96-shop-business-owner/issues/01-shop-business-owner-persistence.md b/.scratch/ur96-shop-business-owner/issues/01-shop-business-owner-persistence.md index b32619a..d2c39bc 100644 --- a/.scratch/ur96-shop-business-owner/issues/01-shop-business-owner-persistence.md +++ b/.scratch/ur96-shop-business-owner/issues/01-shop-business-owner-persistence.md @@ -4,15 +4,15 @@ **Blocked by:** None — can start immediately -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** Infrastructure。 **完整业务边界:** 本票只建立店铺业务员归属的数据承载和索引能力。明确不实现账号候选校验、创建继承、编辑权限、查询投影、通知发送或账号停用后的级联处理,也不迁移未触碰的店铺旧代码。 -- [ ] 店铺数据结构支持可空业务员账号 ID,数据库类型、模型类型和空值语义一致。 -- [ ] 业务员账号 ID 建立非唯一普通索引,迁移不包含数据库外键、级联动作或存量数据回填。 -- [ ] 账号停用、软删除或重新启用不会自动清空或改写店铺保存的业务员 ID。 -- [ ] 迁移向上、向下及再次向上均可验证,应用回滚策略允许新增字段及既有归属数据继续保留。 -- [ ] PostgreSQL 集成测试验证空值保存、正 ID 保存、索引定义和软删除账号不影响历史关联。 -- [ ] 迁移、模型及相关常量和注释遵守项目数据库、Model 与中文注释规范。 +- [x] 店铺数据结构支持可空业务员账号 ID,数据库类型、模型类型和空值语义一致。 +- [x] 业务员账号 ID 建立非唯一普通索引,迁移不包含数据库外键、级联动作或存量数据回填。 +- [x] 账号停用、软删除或重新启用不会自动清空或改写店铺保存的业务员 ID。 +- [ ] 迁移向上、向下及再次向上均可验证,应用回滚策略允许新增字段及既有归属数据继续保留。(真实迁移验证按本 Change 豁免转任务 6.1/6.3) +- [ ] PostgreSQL 集成测试验证空值保存、正 ID 保存、索引定义和软删除账号不影响历史关联。(按本 Change 测试环境豁免转任务 6.1) +- [x] 迁移、模型及相关常量和注释遵守项目数据库、Model 与中文注释规范。 diff --git a/.scratch/ur96-shop-business-owner/issues/02-platform-create-shop-business-owner.md b/.scratch/ur96-shop-business-owner/issues/02-platform-create-shop-business-owner.md index 8ac7250..d9969c3 100644 --- a/.scratch/ur96-shop-business-owner/issues/02-platform-create-shop-business-owner.md +++ b/.scratch/ur96-shop-business-owner/issues/02-platform-create-shop-business-owner.md @@ -4,16 +4,16 @@ **Blocked by:** 01 — 建立店铺业务员归属持久化基线 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为简单写 Application,辅助通道为 Infrastructure。 **完整业务边界:** 本票迁移并收口平台操作者的店铺创建完整用例。明确不迁移店铺删除、启停及其他无关 CRUD,不创建 Shop 聚合,不实现代理账号的请求字段拒绝规则。 -- [ ] 创建命令真实区分业务员字段缺失、显式 `null`、零值和正整数,Handler 不自行猜测三态语义。 -- [ ] 超级管理员和平台账号显式传正 ID 时,只能绑定当前启用、未删除的普通平台账号;超级管理员、代理、企业、停用和软删除账号均不可人工新绑定。 -- [ ] 显式 `null` 创建为空归属;字段缺失且有上级时复制上级当前保存的原始 ID,字段缺失且无上级时创建为空归属。 -- [ ] 上级保存的业务员即使当前停用或软删除,字段缺失时仍按历史 ID 原值继承,不把继承误作人工候选校验。 -- [ ] 店铺、初始主账号、账号角色、店铺角色、两个钱包、业务员归属和关键成功审计在同一数据库事务内成功或回滚,任一步失败不留下半成品。 -- [ ] 创建事件记录归属来源 `inherited/explicit/empty`、上级店铺 ID 和最终业务员 ID,并符合统一审计写入契约。 -- [ ] Application、PostgreSQL 与 HTTP 集成测试覆盖平台创建矩阵、字段四态、候选失效、事务回滚和统一中文错误。 +- [x] 创建命令真实区分业务员字段缺失、显式 `null`、零值和正整数,Handler 不自行猜测三态语义。 +- [x] 超级管理员和平台账号显式传正 ID 时,只能绑定当前启用、未删除的普通平台账号;超级管理员、代理、企业、停用和软删除账号均不可人工新绑定。 +- [x] 显式 `null` 创建为空归属;字段缺失且有上级时复制上级当前保存的原始 ID,字段缺失且无上级时创建为空归属。 +- [x] 上级保存的业务员即使当前停用或软删除,字段缺失时仍按历史 ID 原值继承,不把继承误作人工候选校验。 +- [x] 店铺、初始主账号、账号角色、店铺角色、两个钱包和业务员归属在同一数据库事务内成功或回滚,任一步失败不留下半成品;Audit Event 按本 Change 冻结转任务 6.5。 +- [ ] 创建事件记录归属来源 `inherited/explicit/empty`、上级店铺 ID 和最终业务员 ID,并符合统一审计写入契约。(Audit Event 按本 Change 冻结转任务 6.5) +- [ ] Application、PostgreSQL 与 HTTP 集成测试覆盖平台创建矩阵、字段四态、候选失效、事务回滚和统一中文错误。(按本 Change 测试环境豁免转任务 6.1) diff --git a/.scratch/ur96-shop-business-owner/issues/03-agent-inherit-shop-business-owner.md b/.scratch/ur96-shop-business-owner/issues/03-agent-inherit-shop-business-owner.md index fe69680..821f86b 100644 --- a/.scratch/ur96-shop-business-owner/issues/03-agent-inherit-shop-business-owner.md +++ b/.scratch/ur96-shop-business-owner/issues/03-agent-inherit-shop-business-owner.md @@ -4,16 +4,16 @@ **Blocked by:** 02 — 平台创建店铺时设置或继承业务员 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为简单写 Application,辅助通道为既有权限 Adapter。 **完整业务边界:** 本票收口代理操作者进入同一店铺创建用例时的权限与继承规则。明确不扩大代理原有可发展层级或数据范围,不允许代理人工选择业务员,不实现父子动态查询或批量级联。 -- [ ] 代理创建前复用既有店铺层级和管理权限校验,所选上级必须处于调用者原本允许发展的范围。 -- [ ] 请求 JSON 出现业务员字段即返回统一禁止访问错误,显式 `null`、零值、原值和其他正 ID 不得被静默忽略。 -- [ ] 字段未出现时,上级有业务员则复制原始 ID,上级为空则新店铺为空;停用或软删除账号的历史 ID 仍可被复制。 -- [ ] 复制是创建时快照:父店铺之后设置、清空或更换业务员,不改变既有子店铺或孙级店铺。 -- [ ] 后续新建直属下级只读取其直属上级在本次创建时的当前保存值,不沿祖先动态追溯。 -- [ ] 无权上级、企业操作者及资源不存在使用统一安全错误语义,不泄露资源是否存在。 -- [ ] Application 与 HTTP 集成测试覆盖继承矩阵、恶意字段输入、无权上级、事务原子性及不级联回归。 +- [x] 代理创建前复用既有店铺层级和管理权限校验,所选上级必须处于调用者原本允许发展的范围。 +- [x] 请求 JSON 出现业务员字段即返回统一禁止访问错误,显式 `null`、零值、原值和其他正 ID 不得被静默忽略。 +- [x] 字段未出现时,上级有业务员则复制原始 ID,上级为空则新店铺为空;停用或软删除账号的历史 ID 仍可被复制。 +- [x] 复制是创建时快照:父店铺之后设置、清空或更换业务员,不改变既有子店铺或孙级店铺。 +- [x] 后续新建直属下级只读取其直属上级在本次创建时的当前保存值,不沿祖先动态追溯。 +- [x] 无权上级、企业操作者及资源不存在使用统一安全错误语义,不泄露资源是否存在。 +- [ ] Application 与 HTTP 集成测试覆盖继承矩阵、恶意字段输入、无权上级、事务原子性及不级联回归。(按本 Change 测试环境豁免转任务 6.1) diff --git a/.scratch/ur96-shop-business-owner/issues/04-update-and-audit-shop-business-owner.md b/.scratch/ur96-shop-business-owner/issues/04-update-and-audit-shop-business-owner.md index a228aba..5256beb 100644 --- a/.scratch/ur96-shop-business-owner/issues/04-update-and-audit-shop-business-owner.md +++ b/.scratch/ur96-shop-business-owner/issues/04-update-and-audit-shop-business-owner.md @@ -7,16 +7,16 @@ - 01 — 建立店铺业务员归属持久化基线 - `.scratch/tech-global-audit/issues/01-audit-event-write-loop.md` — 01 — 交付不可变 Audit Event 写入闭环 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为简单写 Application,辅助通道为统一审计 Port/Adapter。 **完整业务边界:** 本票迁移并收口店铺资料更新这一完整用例中的业务员归属变更。明确不迁移店铺删除、启停或其他无关 CRUD,不向父级、下级、孙级或同级传播变更,不承担公共审计基础设施建设。 -- [ ] 更新命令真实区分字段缺失、显式 `null`、零值和正整数;字段缺失保持原归属,`null` 清空,正 ID 校验后替换,零值按非法参数处理。 -- [ ] 超级管理员和平台账号只有在通过目标店铺既有资源权限检查后才能修改归属;企业、越权平台和资源不存在使用统一安全错误语义。 -- [ ] 代理更新其他资料且字段缺失时保留原归属,请求一旦出现该字段即返回统一禁止访问错误。 -- [ ] 设置正 ID 时在写事务内重新校验其仍为启用、未删除的普通平台账号,候选在提交前失效时不得更新店铺。 -- [ ] 实际值未变化时不生成业务员变更成功审计;设置、清空和更换记录稳定动作、前后 ID、账号名摘要和可用状态。 -- [ ] 店铺变化与关键成功审计同事务提交,审计失败时业务修改回滚;拒绝和失败按公共审计策略记录且不泄露底层错误。 -- [ ] 测试覆盖四类操作者、字段四态、并发候选失效、无变化更新、父子不级联、审计失败回滚和中文错误契约。 +- [x] 更新命令真实区分字段缺失、显式 `null`、零值和正整数;字段缺失保持原归属,`null` 清空,正 ID 校验后替换,零值按非法参数处理。 +- [x] 超级管理员和平台账号只有在通过目标店铺既有资源权限检查后才能修改归属;企业、越权平台和资源不存在使用统一安全错误语义。 +- [x] 代理更新其他资料且字段缺失时保留原归属,请求一旦出现该字段即返回统一禁止访问错误。 +- [x] 设置正 ID 时在写事务内重新校验其仍为启用、未删除的普通平台账号,候选在提交前失效时不得更新店铺。 +- [ ] 实际值未变化时不生成业务员变更成功审计;设置、清空和更换记录稳定动作、前后 ID、账号名摘要和可用状态。(Audit Event 按本 Change 冻结转任务 6.5) +- [ ] 店铺变化与关键成功审计同事务提交,审计失败时业务修改回滚;拒绝和失败按公共审计策略记录且不泄露底层错误。(Audit Event 按本 Change 冻结转任务 6.5) +- [ ] 测试覆盖四类操作者、字段四态、并发候选失效、无变化更新、父子不级联、审计失败回滚和中文错误契约。(按本 Change 测试环境豁免转任务 6.1) diff --git a/.scratch/ur96-shop-business-owner/issues/05-query-shop-business-owner.md b/.scratch/ur96-shop-business-owner/issues/05-query-shop-business-owner.md index 35c123b..4c77771 100644 --- a/.scratch/ur96-shop-business-owner/issues/05-query-shop-business-owner.md +++ b/.scratch/ur96-shop-business-owner/issues/05-query-shop-business-owner.md @@ -4,17 +4,19 @@ **Blocked by:** 01 — 建立店铺业务员归属持久化基线 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** Query。 **完整业务边界:** 本票收口店铺列表、店铺详情和业务员候选三个读取用例及其 API 契约。明确不通过聚合根读取,不执行写操作,不把候选查询结果当作写侧最终校验,不扩大任何账号的数据权限。 -- [ ] 店铺列表支持按保存的业务员 ID 精确筛选,并与店铺名、编号、联系电话、上级、层级、状态等既有条件按 AND 组合,分页和排序契约保持不变。 -- [ ] 列表和详情统一返回可空业务员 ID、账号名、固定前三后四手机号摘要及可用状态;空归属返回空摘要,停用或软删除账号尽可能返回历史摘要并标记不可用。 -- [ ] 当前页业务员账号通过一次批量查询完成投影,包含必要的软删除只读数据,不发生逐店铺 N+1 查询。 -- [ ] 若当前没有独立店铺详情接口,则提供受既有数据权限保护的详情接口,并确保候选、级联、资金概况等静态路由不会被动态 ID 路由吞掉。 -- [ ] 候选接口只允许超级管理员和平台账号调用,仅返回启用、未删除的普通平台账号,并提供用户名或手机号受控搜索、默认 20 和最大 100 的分页。 -- [ ] 代理和企业不能调用候选接口;候选仅返回 ID、账号名和手机号摘要,不复用披露范围更大的账号列表响应。 +- [x] 店铺列表支持按保存的业务员 ID 精确筛选,并与店铺名、编号、联系电话、上级、层级、状态等既有条件按 AND 组合,分页和排序契约保持不变。 +- [x] 列表和详情统一返回可空业务员 ID、账号名、固定前三后四手机号摘要及可用状态;空归属返回空摘要,停用或软删除账号尽可能返回历史摘要并标记不可用。 +- [x] 当前页业务员账号通过一次批量查询完成投影,包含必要的软删除只读数据,不发生逐店铺 N+1 查询。 +- [x] 若当前没有独立店铺详情接口,则提供受既有数据权限保护的详情接口,并确保候选、级联、资金概况等静态路由不会被动态 ID 路由吞掉。 +- [x] 候选接口只允许超级管理员和平台账号调用,仅返回启用、未删除的普通平台账号,并提供用户名或手机号受控搜索、默认 20 和最大 100 的分页。 +- [x] 代理和企业不能调用候选接口;候选仅返回 ID、账号名和手机号摘要,不复用披露范围更大的账号列表响应。 - [ ] PostgreSQL 与 HTTP 集成测试覆盖索引筛选、AND 条件、分页总数、历史投影、手机号脱敏、无 N+1、权限和静态路由顺序。 -- [ ] RouteSpec 和 OpenAPI 契约同步更新;若实现过程中新增 Handler 类型,则同步两个文档生成器,否则不得为此无谓新增 Handler。 +- [x] RouteSpec 和 OpenAPI 契约同步更新;若实现过程中新增 Handler 类型,则同步两个文档生成器,否则不得为此无谓新增 Handler。 + +自动化与真实 PostgreSQL/HTTP 验证按本 Change 测试环境豁免转任务 6.1;本票已完成 `gofmt`、`git diff --check` 与 `go build ./...` 代码门禁。 diff --git a/.scratch/ur96-shop-business-owner/issues/06-business-owner-recipient-resolution-and-release.md b/.scratch/ur96-shop-business-owner/issues/06-business-owner-recipient-resolution-and-release.md index 7aadd84..2b1da36 100644 --- a/.scratch/ur96-shop-business-owner/issues/06-business-owner-recipient-resolution-and-release.md +++ b/.scratch/ur96-shop-business-owner/issues/06-business-owner-recipient-resolution-and-release.md @@ -9,17 +9,19 @@ - 04 — 独立修改并审计店铺业务员归属 - 05 — 查询店铺业务员归属与候选账号 -**Status:** ready-for-agent +**Status:** 代码完成、验证延期 **架构通道:** 主通道为 Application + Port/Adapter,辅助通道为 Query/Infrastructure。 **完整业务边界:** 本票提供公共通知能力可调用的店铺业务员接收人解析接缝,并验证 UR#96 后端交付。明确不建设通知表、通知中心、铃铛或任何前端页面,不实现 UR#33 套餐临期和 UR#97 钱包低余额的触发规则,不发送外部渠道通知,也不建设自动转派。 -- [ ] 接收人解析按目标店铺当前保存的业务员 ID 查询账号,仅在账号仍为启用、未删除的普通平台账号时返回稳定账号 ID。 -- [ ] 店铺无归属、账号停用、软删除或类型已不符合时返回无可用业务员接收人,不清空店铺字段且不把永久无接收人当作无限重试错误。 -- [ ] 解析不读取父店铺、祖先店铺或创建人,不因上级后续变更而改变目标店铺的接收人结果。 -- [ ] 接缝可被公共站内通知 Worker 复用,并与其“店铺主账号加当前可用业务员、按账号 ID 去重”的接收人规则兼容;本票不复制公共通知基础设施。 +- [x] 接收人解析按目标店铺当前保存的业务员 ID 查询账号,仅在账号仍为启用、未删除的普通平台账号时返回稳定账号 ID。 +- [x] 店铺无归属、账号停用、软删除或类型已不符合时返回无可用业务员接收人,不清空店铺字段且不把永久无接收人当作无限重试错误。 +- [x] 解析不读取父店铺、祖先店铺或创建人,不因上级后续变更而改变目标店铺的接收人结果。 +- [x] 接缝可被公共站内通知 Worker 复用,并与其“店铺主账号加当前可用业务员、按账号 ID 去重”的接收人规则兼容;本票不复制公共通知基础设施。 - [ ] 集成测试覆盖可用、停用、软删除、重新启用、空归属、关系更换和父级变更不影响子店铺等场景。 - [ ] 后端验收覆盖平台显式设置和清空、代理继承、父级修改不级联、列表筛选、详情投影、候选权限、审计及创建事务原子性。 -- [ ] 更新 UR#96 中文总结文档和 README 入口,记录 API 契约、架构通道、通知复用边界、上线核验及应用回滚保留字段和历史归属的策略。 +- [x] 更新 UR#96 中文总结文档和 README 入口,记录 API 契约、架构通道、通知复用边界、上线核验及应用回滚保留字段和历史归属的策略。 - [ ] 发布前只读核验店铺层级异常和平台账号状态;发布后抽查显式设置、代理继承、不级联、历史不可用账号展示及接收人解析,不运行全量回填或级联脚本。 + +自动化、真实依赖与发布后人工核验按本 Change 测试环境豁免转任务 6.1、6.3、6.6;本票已完成 `gofmt`、`git diff --check` 与 `go build ./...` 代码门禁。 diff --git a/README.md b/README.md index 972efbd..3519f45 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,11 @@ default: - **商户管理**:完整的商户(Shop)和商户账号管理功能,支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能(详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)) - **UR#60 店铺联系电话精确查询**:店铺列表支持 11 位 ASCII 联系电话精确筛选,统一执行查询参数校验并返回一致的默认分页元数据,企业账号禁止访问五个核心店铺管理入口;详见 [功能总结](docs/ur60-shop-phone-search/功能总结.md)。 - **UR#45 换货资产快照与新旧资产独立搜索**:新换货单将卡快照统一为完整 ICCID、设备按虚拟号/IMEI/SN 固化稳定标识;列表使用 `old_asset_keyword` 与 `new_asset_keyword` 独立搜索并按 AND 组合;详见 [功能总结](docs/ur45-exchange-asset-search/功能总结.md)。 +- **公共站内通知**:已交付后台与个人客户通知闭环,以及按稳定账号、平台角色、店铺主账号和当前业务员解析的后台动态接收人;受控跳转和保留清理按七月总台账继续实施。详见 [功能总结](docs/tech-inapp-notifications/功能总结.md)。 +- **UR#96 店铺业务员归属**:店铺支持平台业务员设置/清空和创建时一次性继承,列表/详情批量投影业务员名称、手机号摘要及可用状态,并提供通知接收人解析接缝;详见 [功能总结](docs/ur96-shop-business-owner/功能总结.md)。 +- **渠道无关审批核心**:业务侧统一使用 Approval Port、通用审批实例和五类标准决策,不依赖企微等具体渠道字段;详见 [功能总结](docs/tech-approval-core/功能总结.md)。 +- **UR#38 代理主钱包信用额度**:已建立主钱包信用字段、迁移前置检查、数据库资金约束与 Wallet Domain 统一金额口径,完成订单扣款、资金预占、充值/人工调整入账、退款回充、信用资金投影和测试环境切换收口;旧主钱包 Store 写接缝、非事务店铺创建与直写运维脚本已收缩,自动化和真实依赖验收按七月总台账延期。详见 [功能总结](docs/ur38-agent-main-wallet-credit/功能总结.md)。 +- **UR#94 卡状态公共写入与运营商回调**:已完成 19/20 位 ICCID 唯一性门禁及实名 `ApplyCardObservation` 事务闭环,包含数据库持久化的三次/10 分钟逆转确认、首次实名 Outbox 和 Worker 消费;流量/网络观测、轮询切换、事件序列及回调入口继续按七月总台账实施。详见 [功能总结](docs/ur94-card-state-events-callbacks/功能总结.md)。 - **UR#86 资产前代与后代换货标识**:统一资产详情返回稳定 `exchange_trace`,展示单节点前代与后代快照,并按关联资产当前权限控制可跳转 ID;详见 [功能总结](docs/ur86-asset-exchange-trace/功能总结.md)。 - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;**登录响应包含菜单树和按钮权限**(menus/buttons),前端无需二次处理直接渲染侧边栏和控制按钮显示;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md) - **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制(Access Token 24h + Refresh Token 7天);包含登录、登出、Token 刷新、用户信息查询和密码修改功能;通过用户类型隔离确保后台(SuperAdmin、Platform、Agent)和 H5(Agent、Enterprise)的访问控制;详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index aadd009..631651a 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -14,9 +14,14 @@ import ( "github.com/redis/go-redis/v9" "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/gateway" + cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" + shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop" + walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/polling" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" @@ -274,7 +279,7 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L stopResumeSvc.SetPollingCallback(lifecycleSvc) } - return &workerRuntime{ + runtime := &workerRuntime{ redisAddr: redisAddr, redisClient: redisClient, db: db, @@ -291,6 +296,85 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L pollingBase: pollingBase, lifecycleSvc: lifecycleSvc, } + registerNotificationOutboxConsumer(runtime, appLogger) + registerWalletOutboxConsumer(runtime, appLogger) + registerCardObservationOutboxConsumer(runtime, appLogger) + return runtime +} + +// registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。 +func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { + stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface) + consumer := cardObservationInfra.NewRealnameChangedConsumer( + runtime.db, + runtime.workerResult.Services.ActivationService, + runtime.workerResult.Stores.DeviceSimBinding, + stopResumeService, + ) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardRealnameChanged, consumer); err != nil { + appLogger.Fatal("注册卡实名状态变化 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeCardRealnameChanged), zap.Error(err)) + } + trafficConsumer := cardObservationInfra.NewTrafficIncrementedConsumer( + runtime.db, + runtime.redisClient, + runtime.workerResult.Services.UsageService, + stopResumeService, + ) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardTrafficIncremented, trafficConsumer); err != nil { + appLogger.Fatal("注册卡流量正增量 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeCardTrafficIncremented), zap.Error(err)) + } + networkConsumer := cardObservationInfra.NewNetworkChangedConsumer(runtime.db, stopResumeService) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeCardNetworkChanged, networkConsumer); err != nil { + appLogger.Fatal("注册卡网络状态变化 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeCardNetworkChanged), zap.Error(err)) + } +} + +// registerWalletOutboxConsumer 注册代理主钱包资金事实消费者。 +func registerWalletOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { + debitConsumer := walletInfra.NewDebitEventConsumer(runtime.db) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentMainWalletDebited, debitConsumer); err != nil { + appLogger.Fatal("注册代理主钱包扣款 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeAgentMainWalletDebited), zap.Error(err)) + } + reservationConsumer := walletInfra.NewReservationEventConsumer(runtime.db) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentMainWalletReservationChanged, reservationConsumer); err != nil { + appLogger.Fatal("注册代理主钱包预占 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeAgentMainWalletReservationChanged), zap.Error(err)) + } + creditConsumer := walletInfra.NewCreditEventConsumer(runtime.db) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentMainWalletCredited, creditConsumer); err != nil { + appLogger.Fatal("注册代理主钱包入账 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeAgentMainWalletCredited), zap.Error(err)) + } + refundConsumer := walletInfra.NewRefundEventConsumer(runtime.db) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentMainWalletRefunded, refundConsumer); err != nil { + appLogger.Fatal("注册代理主钱包退款 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeAgentMainWalletRefunded), zap.Error(err)) + } +} + +// registerNotificationOutboxConsumer 注册明确、动态后台账号和个人客户通知的稳定 Outbox 消费者。 +func registerNotificationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { + repository := notificationInfra.NewRepository(runtime.db) + registry := notificationInfra.NewRegistry() + shopRecipientResolver := shopInfra.NewRecipientResolver(runtime.db) + dynamicRecipientResolver := notificationInfra.NewDynamicRecipientResolver(runtime.db, shopRecipientResolver) + consumer := notificationApp.NewDeliveryService(repository, registry, dynamicRecipientResolver, appLogger) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAdminDirectNotification, consumer); err != nil { + appLogger.Fatal("注册站内通知 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeAdminDirectNotification), zap.Error(err)) + } + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypePersonalCustomerDirectNotification, consumer); err != nil { + appLogger.Fatal("注册个人客户站内通知 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypePersonalCustomerDirectNotification), zap.Error(err)) + } + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAdminDynamicNotification, consumer); err != nil { + appLogger.Fatal("注册后台动态接收人站内通知 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeAdminDynamicNotification), zap.Error(err)) + } } // close 在 Worker 退出时关闭共享客户端与数据库连接。 @@ -394,7 +478,7 @@ func startPollingScheduler( appLogger, ) - // 注入套餐失效前流量同步器:最小化 iot_card.Service(只需 gateway + store + redis + 流量扣减) + // 注入套餐失效前流量同步器:复用公共卡观测写入,不在旧 Service 内重复扣减套餐。 trafficSyncer := iot_card_svc.New( runtime.db, runtime.pollingIotCardStore, @@ -404,7 +488,7 @@ func startPollingScheduler( nil, ) trafficSyncer.SetRedisClient(runtime.redisClient) - trafficSyncer.SetDataDeductor(runtime.workerResult.Services.UsageService) + trafficSyncer.SetCardObservationService(runtime.workerResult.Services.CardObservation) activationHandler.SetTrafficSyncer(trafficSyncer) pollingScheduler := polling.NewScheduler( @@ -447,7 +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 } @@ -474,6 +558,14 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error { )); err != nil { return fmt.Errorf("注册数据清理定时任务失败: %w", err) } + if _, err := asynqScheduler.Register("15 2 * * *", asynq.NewTask( + constants.TaskTypeNotificationCleanup, + nil, + asynq.MaxRetry(3), + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeNotificationCleanup)), + )); err != nil { + return fmt.Errorf("注册站内通知保留清理定时任务失败: %w", err) + } if _, err := asynqScheduler.Register( "0 2 * * *", asynq.NewTask( diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index addb4f1..dcc48b4 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -363,17 +363,35 @@ components: DtoAgentOpenAPIWalletBalanceResponse: properties: available_balance: - description: 可用余额(分) + description: 总可用金额(分),等于现金可用金额加生效信用额度 type: integer balance: - description: 钱包余额(分) + description: 钱包账面余额(分) + type: integer + cash_available_balance: + description: 现金可用金额(分),等于账面余额减冻结余额 + type: integer + credit_enabled: + description: 是否启用主钱包信用额度 + type: boolean + credit_limit: + description: 主钱包信用额度(分) type: integer currency: description: 币种 type: string + debt_amount: + description: 欠款金额(分),仅取负账面余额绝对值 + type: integer frozen_balance: description: 冻结余额(分) type: integer + is_in_debt: + description: 账面余额是否已形成欠款 + type: boolean + version: + description: 钱包乐观锁版本 + type: integer type: object DtoAgentOpenAPIWalletPackageOrderFailure: properties: @@ -3829,6 +3847,11 @@ components: description: 详细地址 maxLength: 255 type: string + business_owner_account_id: + description: 平台业务员账号ID;缺失时继承上级,null 表示空归属 + minimum: 0 + nullable: true + type: integer city: description: 城市 maxLength: 50 @@ -6348,6 +6371,138 @@ components: - code - asset_token type: object + DtoNotificationIDParams: + properties: + id: + description: 通知ID + minimum: 0 + type: integer + required: + - id + type: object + DtoNotificationItem: + properties: + body: + description: 纯文本正文 + type: string + category: + description: 通知类别 (approval:审批, expiry:临期, sync:同步, system:系统) + type: string + created_at: + description: 创建时间(ISO 8601) + format: date-time + type: string + id: + description: 通知ID + minimum: 0 + type: integer + is_read: + description: 是否已读 + type: boolean + read_at: + description: 首次已读时间(ISO 8601) + format: date-time + nullable: true + type: string + ref_id: + description: 受控资源ID + type: string + ref_key: + description: 受控资源Key + type: string + ref_type: + description: 受控资源类型 + type: string + severity: + description: 通知级别 (info:提示, warning:警告, error:错误, critical:严重) + type: string + title: + description: 纯文本标题 + type: string + type: + description: 稳定通知类型 + type: string + type: object + DtoNotificationListResponse: + properties: + items: + description: 通知列表 + items: + $ref: '#/components/schemas/DtoNotificationItem' + nullable: true + type: array + page: + description: 页码 + type: integer + size: + description: 每页数量 + type: integer + total: + description: 总数量 + type: integer + type: object + DtoNotificationReadAllRequest: + properties: + category: + description: 可选通知类别 (approval:审批, expiry:临期, sync:同步, system:系统) + type: string + type: object + DtoNotificationReadAllResponse: + properties: + updated_count: + description: 本次实际更新的通知数量 + type: integer + type: object + DtoNotificationReadResponse: + properties: + success: + description: 请求是否成功;通知不存在、属于别人或已经已读也返回 true + type: boolean + type: object + DtoNotificationTargetResponse: + properties: + available: + description: 当前账号是否仍可访问目标 + type: boolean + target_id: + description: 受控数值目标ID + minimum: 0 + nullable: true + type: integer + target_key: + description: 受控稳定目标Key + type: string + target_type: + description: 前端白名单目标类型;空表示不支持跳转 + type: string + type: object + DtoNotificationUnreadCountResponse: + properties: + count: + description: 未读通知数量 + type: integer + display_count: + description: 徽标显示文本,超过 99 时为 99+ + type: string + type: object + DtoNotificationUnreadSummaryResponse: + properties: + approval: + description: 审批类未读数量 + type: integer + expiry: + description: 临期类未读数量 + type: integer + sync: + description: 同步类未读数量 + type: integer + system: + description: 系统类未读数量 + type: integer + total: + description: 未读通知总数 + type: integer + type: object DtoOneTimeCommissionTierDTO: properties: amount: @@ -7032,6 +7187,24 @@ components: description: 请求路径 type: string type: object + DtoPersonalNotificationListResponse: + properties: + items: + description: 当前个人客户可见的业务通知列表 + items: + $ref: '#/components/schemas/DtoNotificationItem' + nullable: true + type: array + page: + description: 页码 + type: integer + size: + description: 每页数量 + type: integer + total: + description: 总数量 + type: integer + type: object DtoPollingAlertHistoryListResp: properties: items: @@ -7851,6 +8024,25 @@ components: maxLength: 500 type: string type: object + DtoRoleDefaultCreditResponse: + properties: + affects_existing_wallets: + description: 是否影响既有钱包,固定为 false + type: boolean + credit_enabled: + description: 是否启用新建代理默认信用 + type: boolean + credit_limit: + description: 新建代理默认信用额度(分) + type: integer + role_id: + description: 客户角色ID + minimum: 0 + type: integer + scope: + description: 模板生效范围,固定为 new_shops_only + type: string + type: object DtoRolePageResult: properties: items: @@ -7878,6 +8070,15 @@ components: description: 创建人ID minimum: 0 type: integer + default_credit_enabled: + description: 是否启用新建代理默认信用;仅客户角色有效 + type: boolean + default_credit_limit: + description: 新建代理默认信用额度(分);仅影响未来新建店铺 + type: integer + default_credit_scope: + description: 模板生效范围,固定为 new_shops_only + type: string id: description: 角色ID minimum: 0 @@ -7997,6 +8198,37 @@ components: required: - ssid type: object + DtoShopBusinessOwnerCandidate: + properties: + id: + description: 平台业务员账号ID + minimum: 0 + type: integer + phone_summary: + description: 手机号摘要(前三后四) + type: string + username: + description: 平台业务员账号名 + type: string + type: object + DtoShopBusinessOwnerCandidatePageResult: + properties: + items: + description: 候选账号列表 + items: + $ref: '#/components/schemas/DtoShopBusinessOwnerCandidate' + nullable: true + type: array + page: + description: 当前页码 + type: integer + size: + description: 每页数量 + type: integer + total: + description: 总数量 + type: integer + type: object DtoShopCascadeItem: properties: has_children: @@ -8076,22 +8308,69 @@ components: description: 总记录数 type: integer type: object + DtoShopCreditLimitResponse: + properties: + available_balance: + description: 总可用金额(分) + type: integer + balance: + description: 账面余额(分) + type: integer + credit_enabled: + description: 是否启用实际信用额度 + type: boolean + credit_limit: + description: 实际信用额度(分) + type: integer + frozen_balance: + description: 冻结金额(分) + type: integer + shop_id: + description: 店铺ID + minimum: 0 + type: integer + version: + description: 更新后的主钱包版本 + type: integer + wallet_id: + description: 代理主钱包ID + minimum: 0 + type: integer + type: object DtoShopFundSummaryItem: properties: + available_balance: + description: 总可用金额(分),等于现金可用金额加生效信用额度 + type: integer available_commission: description: 可提现佣金(分) type: integer + cash_available_balance: + description: 现金可用金额(分),等于主钱包余额减冻结金额 + type: integer created_at: description: 店铺创建时间 type: string + credit_enabled: + description: 是否启用主钱包信用额度 + type: boolean + credit_limit: + description: 主钱包信用额度(分),关闭信用时为0 + type: integer + debt_amount: + description: 主钱包欠款金额(分),仅取负账面余额绝对值 + type: integer frozen_commission: description: 冻结中佣金(分) type: integer + is_in_debt: + description: 主钱包账面余额是否为负数 + type: boolean main_balance: description: 预充值钱包余额(分) type: integer main_frozen_balance: - description: 预充值钱包冻结余额(分,预留字段) + description: 预充值钱包冻结余额(分) type: integer phone: description: 主账号手机号 @@ -8115,6 +8394,9 @@ components: username: description: 主账号用户名 type: string + version: + description: 主钱包当前版本号,并发写冲突后应重新查询 + type: integer withdrawing_commission: description: 提现中佣金(分) type: integer @@ -8189,6 +8471,20 @@ components: address: description: 详细地址 type: string + business_owner_account_id: + description: 平台业务员账号ID,null 表示未归属 + minimum: 0 + nullable: true + type: integer + business_owner_available: + description: 平台业务员当前是否可用于通知接收 + type: boolean + business_owner_phone_summary: + description: 平台业务员手机号摘要(前三后四) + type: string + business_owner_username: + description: 平台业务员账号名 + type: string city: description: 城市 type: string @@ -9405,6 +9701,21 @@ components: required: - retail_price type: object + DtoUpdateRoleDefaultCreditParams: + properties: + credit_enabled: + description: 是否启用新建代理默认信用 + nullable: true + type: boolean + credit_limit: + description: 新建代理默认信用额度(分);关闭时必须为0,开启时必须大于0 + minimum: 0 + nullable: true + type: integer + required: + - credit_enabled + - credit_limit + type: object DtoUpdateRoleParams: properties: role_desc: @@ -9432,12 +9743,38 @@ components: nullable: true type: integer type: object + DtoUpdateShopCreditLimitParams: + properties: + credit_enabled: + description: 是否启用实际信用额度 + nullable: true + type: boolean + credit_limit: + description: 实际信用额度(分);关闭时必须为0 + minimum: 0 + nullable: true + type: integer + version: + description: 主钱包乐观锁版本 + minimum: 0 + nullable: true + type: integer + required: + - credit_enabled + - credit_limit + - version + type: object DtoUpdateShopParams: properties: address: description: 详细地址 maxLength: 255 type: string + business_owner_account_id: + description: 平台业务员账号ID;缺失时保持不变,null 表示清空 + minimum: 0 + nullable: true + type: integer city: description: 城市 maxLength: 50 @@ -18044,6 +18381,430 @@ paths: summary: 批量回收单卡 tags: - IoT卡管理 + /api/admin/notifications: + get: + description: 仅返回当前认证后台账号的未过期通知,固定按创建时间和通知 ID 倒序。 + parameters: + - description: 通知类别 (approval:审批, expiry:临期, sync:同步, system:系统) + in: query + name: category + schema: + description: 通知类别 (approval:审批, expiry:临期, sync:同步, system:系统) + type: string + - description: 稳定通知类型 + in: query + name: type + schema: + description: 稳定通知类型 + type: string + - description: 通知级别 (info:提示, warning:警告, error:错误, critical:严重) + in: query + name: severity + schema: + description: 通知级别 (info:提示, warning:警告, error:错误, critical:严重) + type: string + - description: 已读状态;不传时查询全部 + in: query + name: is_read + schema: + description: 已读状态;不传时查询全部 + nullable: true + type: boolean + - description: 页码,默认 1,最大 10000 + in: query + name: page + schema: + description: 页码,默认 1,最大 10000 + maximum: 10000 + minimum: 1 + type: integer + - description: 每页数量,默认 20,最大 50 + in: query + name: page_size + schema: + description: 每页数量,默认 20,最大 50 + maximum: 50 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoNotificationListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询通知列表 + tags: + - 站内通知 + /api/admin/notifications/{id}/read: + put: + description: 仅更新当前认证后台账号的通知;通知不存在、属于别人或已经已读均幂等成功。 + parameters: + - description: 通知ID + in: path + name: id + required: true + schema: + description: 通知ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoNotificationIDParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoNotificationReadResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 标记单条通知已读 + tags: + - 站内通知 + /api/admin/notifications/{id}/target: + get: + description: 先校验通知属于当前认证后台账号,再复核目标资源当前权限;只返回白名单结构化目标,不返回任意 URL。 + parameters: + - description: 通知ID + in: path + name: id + required: true + schema: + description: 通知ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoNotificationTargetResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 解析通知受控目标 + tags: + - 站内通知 + /api/admin/notifications/read-all: + put: + description: 类别为空时更新当前认证后台账号全部未过期未读通知;指定有效类别时只更新该类别,重复调用幂等成功。 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoNotificationReadAllRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoNotificationReadAllResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 批量标记通知已读 + tags: + - 站内通知 + /api/admin/notifications/unread-count: + get: + description: 仅查询当前认证后台账号的未过期未读通知,超过 99 条时 display_count 返回 99+。 + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoNotificationUnreadCountResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询通知未读数 + tags: + - 站内通知 + /api/admin/notifications/unread-summary: + get: + description: 返回当前认证后台账号未过期通知的总未读数及审批、临期、同步、系统四个固定类别计数。 + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoNotificationUnreadSummaryResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询通知未读分类汇总 + tags: + - 站内通知 /api/admin/order-package-invalidate-tasks: get: parameters: @@ -22367,6 +23128,78 @@ paths: summary: 更新角色 tags: - 角色 + /api/admin/roles/{id}/default-credit: + put: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoUpdateRoleDefaultCreditParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoRoleDefaultCreditResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 更新客户角色的新建代理默认信用模板 + tags: + - 角色 /api/admin/roles/{id}/permissions: get: parameters: @@ -23279,6 +24112,14 @@ paths: minLength: 11 pattern: ^[0-9]{11}$ type: string + - description: 平台业务员账号ID精确筛选 + in: query + name: business_owner_account_id + schema: + description: 平台业务员账号ID精确筛选 + minimum: 1 + nullable: true + type: integer - description: 上级店铺ID in: query name: parent_id @@ -23464,6 +24305,73 @@ paths: summary: 删除店铺 tags: - 店铺管理 + get: + description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。 返回与店铺列表一致的业务员归属摘要。 + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoShopResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询店铺详情 + tags: + - 店铺管理 put: description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。 parameters: @@ -23536,6 +24444,79 @@ paths: summary: 更新店铺 tags: - 店铺管理 + /api/admin/shops/{id}/credit-limit: + put: + description: 后端不校验按钮权限;shop:credit-limit:manage 仅供前端控制按钮显示。 + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoUpdateShopCreditLimitParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoShopCreditLimitResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 调整既有店铺实际信用额度 + tags: + - 代理商资金管理 /api/admin/shops/{shop_id}/commission-daily-stats: get: parameters: @@ -24285,6 +25266,89 @@ paths: summary: 发起提现申请 tags: - 代理商资金管理 + /api/admin/shops/business-owner-candidates: + get: + description: 仅超级管理员和平台账号可查询;只返回当前启用、未删除的平台账号最小摘要。 + parameters: + - description: 用户名或手机号关键词 + in: query + name: keyword + schema: + description: 用户名或手机号关键词 + maxLength: 50 + type: string + - description: 页码,默认 1 + in: query + name: page + schema: + description: 页码,默认 1 + maximum: 10000 + minimum: 1 + type: integer + - description: 每页数量,默认 20,最大 100 + in: query + name: page_size + schema: + description: 每页数量,默认 20,最大 100 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoShopBusinessOwnerCandidatePageResult' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询店铺业务员候选 + tags: + - 店铺管理 /api/admin/shops/cascade: get: description: 仅超级管理员、平台账号和代理账号可访问,企业账号返回 403。 @@ -24370,6 +25434,7 @@ paths: - 店铺管理 /api/admin/shops/fund-summary: get: + description: 按当前店铺数据范围分页返回主钱包、佣金及信用资金事实;现金可用、总可用和欠款均由服务端计算,读取不代表具有调额权限。 parameters: - description: 页码(默认1) in: query @@ -26810,6 +27875,273 @@ paths: summary: 查询待处理换货单 tags: - 个人客户 - 换货 + /api/c/v1/notifications: + get: + description: 仅返回当前认证个人客户的未过期业务通知,固定按创建时间和通知 ID 倒序。 + parameters: + - description: 页码,默认 1,最大 10000 + in: query + name: page + schema: + description: 页码,默认 1,最大 10000 + maximum: 10000 + minimum: 1 + type: integer + - description: 每页数量,默认 20,最大 50 + in: query + name: page_size + schema: + description: 每页数量,默认 20,最大 50 + maximum: 50 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoPersonalNotificationListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询个人客户通知列表 + tags: + - 个人客户 - 站内通知 + /api/c/v1/notifications/{id}/read: + put: + description: 仅更新当前认证个人客户可见的通知;通知不存在、属于别人或已经已读均幂等成功。 + parameters: + - description: 通知ID + in: path + name: id + required: true + schema: + description: 通知ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoNotificationIDParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoNotificationReadResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 标记个人客户单条通知已读 + tags: + - 个人客户 - 站内通知 + /api/c/v1/notifications/read-all: + put: + description: 只更新当前认证个人客户可见的未过期未读业务通知,重复调用幂等成功。 + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoNotificationReadAllResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 全部标记个人客户通知已读 + tags: + - 个人客户 - 站内通知 + /api/c/v1/notifications/unread-count: + get: + description: 仅查询当前认证个人客户的未过期业务通知,平台同步和系统运维通知不会进入结果。 + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoNotificationUnreadCountResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询个人客户通知未读数 + tags: + - 个人客户 - 站内通知 /api/c/v1/orders: get: parameters: diff --git a/docs/tech-approval-core/功能总结.md b/docs/tech-approval-core/功能总结.md new file mode 100644 index 0000000..1401921 --- /dev/null +++ b/docs/tech-approval-core/功能总结.md @@ -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.10~2.12 和 4.W1~4.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.W1~4.W5 实现,不进入通用实例。 diff --git a/docs/tech-inapp-notifications/前端联调与验收契约.md b/docs/tech-inapp-notifications/前端联调与验收契约.md new file mode 100644 index 0000000..c6b870f --- /dev/null +++ b/docs/tech-inapp-notifications/前端联调与验收契约.md @@ -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 四个徽标边界无布局抖动。 +- 页面隐藏时无轮询,恢复后立即刷新;失败期间未读数不闪回零。 diff --git a/docs/tech-inapp-notifications/功能总结.md b/docs/tech-inapp-notifications/功能总结.md new file mode 100644 index 0000000..ac1488c --- /dev/null +++ b/docs/tech-inapp-notifications/功能总结.md @@ -0,0 +1,81 @@ +# 公共站内通知功能总结 + +> 当前状态:任务 2.1~2.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.7~2.8:前端契约、OpenAPI/中文文档最终收口和测试环境发布检查。 + +后台与 C 端的完整交互、目标白名单、示例和验收矩阵见 [前端联调与验收契约](前端联调与验收契约.md)。 + +测试环境发布顺序、运行门禁、恢复策略和下游事件接入方式见 [发布与下游接入清单](发布与下游接入清单.md)。 +- 2.7~2.8:前端契约、OpenAPI/文档最终生成、Worker/模板与测试环境发布检查。 diff --git a/docs/tech-inapp-notifications/发布与下游接入清单.md b/docs/tech-inapp-notifications/发布与下游接入清单.md new file mode 100644 index 0000000..f9622b6 --- /dev/null +++ b/docs/tech-inapp-notifications/发布与下游接入清单.md @@ -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,不能据此声明生产验收通过。 diff --git a/docs/ur38-agent-main-wallet-credit/功能总结.md b/docs/ur38-agent-main-wallet-credit/功能总结.md new file mode 100644 index 0000000..5456b6d --- /dev/null +++ b/docs/ur38-agent-main-wallet-credit/功能总结.md @@ -0,0 +1,149 @@ +# UR#38 代理主钱包信用额度功能总结 + +## 当前完成范围 + +任务 2.23~2.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.5,Domain 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/A;Access 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 ./...`,退出码为 0;Go 模块统计缓存出现只读警告,不影响构建结果。 +- 按本 Change 的测试环境豁免,未新增或运行 `_test.go`,未连接真实 PostgreSQL、Redis 或 Asynq;相关验证保留在 6.1、6.3。 diff --git a/docs/ur45-exchange-asset-search/功能总结.md b/docs/ur45-exchange-asset-search/功能总结.md index 8cf83b1..0b0d1d5 100644 --- a/docs/ur45-exchange-asset-search/功能总结.md +++ b/docs/ur45-exchange-asset-search/功能总结.md @@ -1,5 +1,7 @@ # UR#45 换货资产快照与新旧资产独立搜索功能总结 +> 交付状态:后端实现、OpenAPI 与前端联调契约已交付;前端页面实施和浏览器人工验收待完成。 + ## 本次交付范围 本次完成两张可独立发布的 Ticket: diff --git a/docs/ur94-card-state-events-callbacks/功能总结.md b/docs/ur94-card-state-events-callbacks/功能总结.md new file mode 100644 index 0000000..f49a1de --- /dev/null +++ b/docs/ur94-card-state-events-callbacks/功能总结.md @@ -0,0 +1,119 @@ +# UR#94 卡状态公共写入与运营商回调功能总结 + +## 当前完成范围 + +任务 2.33 已完成运营商回调启用前的 ICCID 精确唯一性门禁,任务 2.34、2.35、2.36、2.37 已分别交付实名、流量、网络观测公共写入闭环及三个轮询入口切换。观测序列和运营商回调仍按 2.38~2.45 继续实施,不能因当前能力已落地而提前开放回调入口。 + +## 流量观测公共写入闭环 + +统一 `ApplyTrafficObservation` 已收口手动 Gateway 刷新、套餐失效前同步和周期流量轮询使用的流量写入规则。 + +- 应用用例使用 PostgreSQL `FOR UPDATE` 串行化同一卡观测,并以旧 Gateway 读数作条件更新;检查时间、可信基线、自然月累计、生命周期累计和正增量 Outbox 在同一事务提交。 +- 领域规则保留运营商重置日当天及前一天窗口。非重置窗口的下降读数不覆盖可信基线、不累计流量,也不发布扣减事件;零增量只更新时间。 +- 自然月切换时保存上月系统累计并初始化本月累计;运营商周期读数与系统自然月累计保持两个独立口径。 +- 正增量只发布一次 `card.traffic.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 约束计数只能保存 0~2;达到第三次时状态变化与计数清零在同一事务完成。down 迁移只删除该约束和两个字段。 + +统一 Audit Event 已按七月总 Change 的最新范围决策移出本次上线,不是 2.34 或生产上线阻塞项;Integration Log、Outbox、Domain Ledger 和 Access Log 仍分别承担外部交互恢复、可靠投递、状态事实和 HTTP 调试职责。 + +## ICCID 精确唯一性 + +现有迁移 `000131` 已建立 `tb_iot_card.iccid_19/iccid_20` 和普通部分索引。迁移 `000178` 在不改变 ICCID 展示、导入、模糊查询和卡识别规则的前提下,将两个索引升级为未删除数据范围内的部分唯一索引: + +- `iccid_19`:全部未删除卡精确唯一;迁移前同时阻断空值、非 19 位和双列不一致。 +- `iccid_20`:未删除且非空时精确唯一;非空值必须是与原 ICCID 一致的 20 位值。 +- 软删除记录不阻塞相同 ICCID 的合法新记录。 +- 迁移不截断、不补位、不跨列匹配,也不自动删除、合并或修正冲突卡。 + +迁移在同一事务内锁定 `tb_iot_card`,先检查原 ICCID 长度、双列空值/长度及回填一致性,再按目标唯一索引相同的谓词扫描 19 位和 20 位冲突组,最后删除普通索引并以原名创建唯一索引。发现任一异常时只输出异常卡数和冲突组数的中文安全摘要并整体回滚,不留下半完成索引。 + +## 发布前异常清单 + +发布负责人在维护窗口执行迁移前,必须分别导出以下清单并指定数据修复责任人。查询结果包含完整 ICCID,只能存放在受控运维位置,不得写入应用日志或普通工单正文。 + +### 19 位冲突 + +```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,不能标记为生产验收通过。 diff --git a/docs/ur96-shop-business-owner/功能总结.md b/docs/ur96-shop-business-owner/功能总结.md new file mode 100644 index 0000000..5a412b8 --- /dev/null +++ b/docs/ur96-shop-business-owner/功能总结.md @@ -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。 diff --git a/docs/脚本/清理代理预充值.sql b/docs/脚本/清理代理预充值.sql index ea6bb35..e996456 100644 --- a/docs/脚本/清理代理预充值.sql +++ b/docs/脚本/清理代理预充值.sql @@ -1,66 +1,12 @@ -BEGIN; +-- 本脚本已停用。 +-- +-- 原实现会直接修改代理主钱包余额,绕过 Wallet Application、Domain Ledger、 +-- 幂等资金流水和 Outbox,因此在信用钱包切换后不再允许执行。 +-- 如需清理代理主钱包余额,必须先建立具有稳定业务单号的受控 Application 用例, +-- 并在同一事务内写入权威资金流水与可靠事件;禁止恢复本文件中的历史直写逻辑。 --- 锁定代理主钱包,确认余额和冻结余额 -SELECT * -FROM tb_agent_wallet -WHERE shop_id = :shop_id - AND wallet_type = 'main' - AND deleted_at IS NULL -FOR UPDATE; - --- 确认 frozen_balance = 0 且 balance > 0 后执行 -INSERT INTO tb_agent_wallet_transaction ( - agent_wallet_id, - shop_id, - user_id, - transaction_type, - amount, - balance_before, - balance_after, - status, - reference_type, - reference_id, - remark, - metadata, - creator, - shop_id_tag, - enterprise_id_tag, - created_at, - updated_at -) -SELECT - id, - shop_id, - :operator_user_id, - 'deduct', - -balance, - balance, - 0, - 1, - NULL, - NULL, - '平台清理代理预充值剩余余额,历史消费流水保留', - jsonb_build_object('reason', 'clear_agent_main_wallet_balance'), - :operator_user_id, - shop_id_tag, - enterprise_id_tag, - NOW(), - NOW() -FROM tb_agent_wallet -WHERE shop_id = :shop_id - AND wallet_type = 'main' - AND deleted_at IS NULL - AND balance > 0 - AND frozen_balance = 0; - -UPDATE tb_agent_wallet -SET balance = 0, - version = version + 1, - updated_at = NOW() -WHERE shop_id = :shop_id - AND wallet_type = 'main' - AND deleted_at IS NULL - AND balance > 0 - AND frozen_balance = 0; - -COMMIT; +DO $$ +BEGIN + RAISE EXCEPTION '代理主钱包直写清理脚本已停用,请使用受控 Wallet Application 用例'; +END +$$; diff --git a/internal/application/approval/create.go b/internal/application/approval/create.go new file mode 100644 index 0000000..bd3dc34 --- /dev/null +++ b/internal/application/approval/create.go @@ -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, "当前环境没有可用审批渠道") +} diff --git a/internal/application/approval/dispatch_decision.go b/internal/application/approval/dispatch_decision.go new file mode 100644 index 0000000..d04f093 --- /dev/null +++ b/internal/application/approval/dispatch_decision.go @@ -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 +} diff --git a/internal/application/approval/port.go b/internal/application/approval/port.go new file mode 100644 index 0000000..ad5510c --- /dev/null +++ b/internal/application/approval/port.go @@ -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 +} diff --git a/internal/application/approval/sync_decision.go b/internal/application/approval/sync_decision.go new file mode 100644 index 0000000..efa9632 --- /dev/null +++ b/internal/application/approval/sync_decision.go @@ -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 +} diff --git a/internal/application/cardobservation/apply.go b/internal/application/cardobservation/apply.go new file mode 100644 index 0000000..45a0ca9 --- /dev/null +++ b/internal/application/cardobservation/apply.go @@ -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 +} diff --git a/internal/application/cardobservation/network.go b/internal/application/cardobservation/network.go new file mode 100644 index 0000000..3d795cf --- /dev/null +++ b/internal/application/cardobservation/network.go @@ -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 +} diff --git a/internal/application/cardobservation/series.go b/internal/application/cardobservation/series.go new file mode 100644 index 0000000..45181d1 --- /dev/null +++ b/internal/application/cardobservation/series.go @@ -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 + } +} diff --git a/internal/application/cardobservation/traffic.go b/internal/application/cardobservation/traffic.go new file mode 100644 index 0000000..01603b5 --- /dev/null +++ b/internal/application/cardobservation/traffic.go @@ -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 +} diff --git a/internal/application/notification/delivery.go b/internal/application/notification/delivery.go new file mode 100644 index 0000000..9b2d346 --- /dev/null +++ b/internal/application/notification/delivery.go @@ -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 + } +} diff --git a/internal/application/notification/read.go b/internal/application/notification/read.go new file mode 100644 index 0000000..d0ebaac --- /dev/null +++ b/internal/application/notification/read.go @@ -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, + ) +} diff --git a/internal/application/notification/recipient.go b/internal/application/notification/recipient.go new file mode 100644 index 0000000..8a3fdfb --- /dev/null +++ b/internal/application/notification/recipient.go @@ -0,0 +1,8 @@ +package notification + +import "context" + +// DynamicRecipientResolver 定义后台通知动态接收人解析 Port。 +type DynamicRecipientResolver interface { + Resolve(ctx context.Context, targetKind string, targetID uint) ([]uint, error) +} diff --git a/internal/application/role/default_credit.go b/internal/application/role/default_credit.go new file mode 100644 index 0000000..6e5103e --- /dev/null +++ b/internal/application/role/default_credit.go @@ -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 +} diff --git a/internal/application/shop/create.go b/internal/application/shop/create.go new file mode 100644 index 0000000..a9e423b --- /dev/null +++ b/internal/application/shop/create.go @@ -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:] +} diff --git a/internal/application/shop/recipient.go b/internal/application/shop/recipient.go new file mode 100644 index 0000000..1a3c88d --- /dev/null +++ b/internal/application/shop/recipient.go @@ -0,0 +1,10 @@ +package shop + +import "context" + +// NotificationRecipientResolver 定义按店铺解析当前可用后台通知接收人的 Port。 +// +// 实现只返回稳定账号 ID;无可用接收人是正常结果,不应触发无限重试。 +type NotificationRecipientResolver interface { + ResolveNotificationRecipients(ctx context.Context, shopID uint) ([]uint, error) +} diff --git a/internal/application/shop/update.go b/internal/application/shop/update.go new file mode 100644 index 0000000..86e892f --- /dev/null +++ b/internal/application/shop/update.go @@ -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 +} diff --git a/internal/application/wallet/change_credit.go b/internal/application/wallet/change_credit.go new file mode 100644 index 0000000..d992657 --- /dev/null +++ b/internal/application/wallet/change_credit.go @@ -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(¤t).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 +} diff --git a/internal/application/wallet/debit.go b/internal/application/wallet/debit.go new file mode 100644 index 0000000..2b5a8bb --- /dev/null +++ b/internal/application/wallet/debit.go @@ -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, + } +} diff --git a/internal/application/wallet/posting.go b/internal/application/wallet/posting.go new file mode 100644 index 0000000..8134db8 --- /dev/null +++ b/internal/application/wallet/posting.go @@ -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, "查询代理主钱包入账流水失败") +} diff --git a/internal/application/wallet/refund.go b/internal/application/wallet/refund.go new file mode 100644 index 0000000..c795725 --- /dev/null +++ b/internal/application/wallet/refund.go @@ -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) +} diff --git a/internal/application/wallet/reservation.go b/internal/application/wallet/reservation.go new file mode 100644 index 0000000..2694ce1 --- /dev/null +++ b/internal/application/wallet/reservation.go @@ -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 +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 099162f..f51c3ab 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -1,7 +1,11 @@ package bootstrap import ( + notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" + roleApp "github.com/break/junhong_cmp_fiber/internal/application/role" + shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop" systemConfigApp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" + walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/handler/admin" "github.com/break/junhong_cmp_fiber/internal/handler/app" authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" @@ -11,7 +15,9 @@ import ( pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" + notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification" packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry" + shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop" systemConfigQuery "github.com/break/junhong_cmp_fiber/internal/query/systemconfig" clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" pollingSvcPkg "github.com/break/junhong_cmp_fiber/internal/service/polling" @@ -95,23 +101,39 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { ) return &Handlers{ - Auth: authHandler.NewHandler(svc.Auth, validate), - Account: admin.NewAccountHandler(svc.Account), - Role: admin.NewRoleHandler(svc.Role, validate), - Permission: admin.NewPermissionHandler(svc.Permission), - PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger), - ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger), - ClientAsset: app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger), - ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore), - ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger), - ClientExchange: app.NewClientExchangeHandler(svc.Exchange), - ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger), - ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger), - ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger), - Shop: admin.NewShopHandler(svc.Shop, validate), - ShopRole: admin.NewShopRoleHandler(svc.Shop), - AdminAuth: admin.NewAuthHandler(svc.Auth, validate), - ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission), + Auth: authHandler.NewHandler(svc.Auth, validate), + Account: admin.NewAccountHandler(svc.Account), + Role: func() *admin.RoleHandler { + handler := admin.NewRoleHandler(svc.Role, validate) + handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission)) + return handler + }(), + Permission: admin.NewPermissionHandler(svc.Permission), + PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger), + ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger), + ClientAsset: app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger), + ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore), + ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger), + ClientExchange: app.NewClientExchangeHandler(svc.Exchange), + ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger), + ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger), + ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger), + ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate), + Shop: func() *admin.ShopHandler { + handler := admin.NewShopHandler(svc.Shop, validate) + handler.SetCreateService(shopApp.NewCreateService(deps.DB)) + handler.SetUpdateService(shopApp.NewUpdateService(deps.DB)) + handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB)) + handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB)) + return handler + }(), + ShopRole: admin.NewShopRoleHandler(svc.Shop), + AdminAuth: admin.NewAuthHandler(svc.Auth, validate), + ShopCommission: func() *admin.ShopCommissionHandler { + handler := admin.NewShopCommissionHandler(svc.ShopCommission) + handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB)) + return handler + }(), CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate), CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting), Enterprise: admin.NewEnterpriseHandler(svc.Enterprise), @@ -121,6 +143,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { IotCard: admin.NewIotCardHandler(svc.IotCard), IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport), ExportTask: admin.NewExportTaskHandler(svc.ExportTask), + Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate), Device: admin.NewDeviceHandler(svc.Device), DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index e29a72e..909b9fa 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -5,6 +5,11 @@ import ( "go.uber.org/zap" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" "github.com/break/junhong_cmp_fiber/internal/polling" accountSvc "github.com/break/junhong_cmp_fiber/internal/service/account" accountAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/account_audit" @@ -137,6 +142,12 @@ func initServices(s *stores, deps *Dependencies) *services { deps.Logger, 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) pollingConfigStore := postgres.NewPollingConfigStore(deps.DB) 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) pollingLifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, s.IotCard, s.DeviceSimBinding, s.Device, deps.Logger) iotCard.SetPollingCallback(pollingLifecycleSvc) - // 注入流量扣减回调,使手动刷新资产时能触发套餐流量扣减 - usageService := packageSvc.NewUsageService(deps.DB, deps.Redis, s.PackageUsage, s.PackageUsageDailyRecord, s.DeviceSimBinding, deps.Logger) - iotCard.SetDataDeductor(usageService) - // 创建支付配置服务(Order 和 Recharge 依赖) wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, accountAudit, deps.Redis, deps.Logger) @@ -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) 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) + walletOutbox := outbox.NewRepository() + walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox) + orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil)) + orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil)) + agentRechargeService := agentRechargeSvc.New( + deps.DB, + s.AgentRecharge, + s.AgentWallet, + s.Shop, + wechatConfig, + accountAudit, + operationPassword, + deps.Redis, + deps.Logger, + ) + agentRechargeService.SetAgentWalletPostingService(walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil)) + refundService := refundSvc.New( + deps.DB, + s.RefundRequest, + s.Order, + s.CommissionRecord, + s.AgentWallet, + s.AgentWalletTransaction, + stopResumeService, + device, + packageActivation, + s.IotCard, + s.Device, + s.AssetWallet, + deps.Logger, + ) + refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil)) assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit) agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device) @@ -227,7 +266,7 @@ func initServices(s *stores, deps *Dependencies) *services { deps.Logger, customerBinding, ), - Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet), + Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role), Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger), ShopCommission: shopCommission, CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest), @@ -284,38 +323,13 @@ func initServices(s *stores, deps *Dependencies) *services { AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction), StopResumeService: stopResumeService, WechatConfig: wechatConfig, - AgentRecharge: agentRechargeSvc.New( - deps.DB, - s.AgentRecharge, - s.AgentWallet, - s.AgentWalletTransaction, - s.Shop, - wechatConfig, - accountAudit, - operationPassword, - deps.Redis, - deps.Logger, - ), - PackageActivation: packageActivation, - TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage), - OperationPassword: operationPassword, - AgentOpenAPI: agentOpenAPI, - Refund: refundSvc.New( - deps.DB, - s.RefundRequest, - s.Order, - s.CommissionRecord, - s.AgentWallet, - s.AgentWalletTransaction, - stopResumeService, - device, - packageActivation, - s.IotCard, - s.Device, - s.AssetWallet, - deps.Logger, - ), - CustomerBinding: customerBinding, - OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), + AgentRecharge: agentRechargeService, + PackageActivation: packageActivation, + TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage), + OperationPassword: operationPassword, + AgentOpenAPI: agentOpenAPI, + Refund: refundService, + CustomerBinding: customerBinding, + OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index f66035b..b14b331 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -24,6 +24,7 @@ type Handlers struct { ClientRealname *app.ClientRealnameHandler ClientDevice *app.ClientDeviceHandler ClientRechargeOrder *app.ClientRechargeOrderHandler + ClientNotification *app.ClientNotificationHandler Shop *admin.ShopHandler ShopRole *admin.ShopRoleHandler AdminAuth *admin.AuthHandler @@ -37,6 +38,7 @@ type Handlers struct { IotCard *admin.IotCardHandler IotCardImport *admin.IotCardImportHandler ExportTask *admin.ExportTaskHandler + Notification *admin.NotificationHandler Device *admin.DeviceHandler DeviceImport *admin.DeviceImportHandler AssetAllocationRecord *admin.AssetAllocationRecordHandler diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index aa81fff..ce72a7e 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -1,6 +1,12 @@ package bootstrap import ( + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" @@ -81,6 +87,19 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q stores.DataCleanupLog, 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( @@ -108,6 +127,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q stores.PersonalCustomer, stores.PersonalCustomerPhone, ) + walletOutbox := outbox.NewRepository() + walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox) + orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil)) // 创建停复机服务并注入回调:流量耗尽自动停机、套餐激活/重置/支付后自动复机 stopResumeService := iotCardSvc.NewStopResumeService( @@ -125,6 +147,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q resetService.SetResumeCallback(stopResumeService) return &queue.WorkerServices{ + CardObservation: cardObservationService, + CardObservationSeries: cardObservationSeriesService, CommissionCalculation: commissionCalculationService, CommissionStats: commissionStatsService, UsageService: usageService, diff --git a/internal/domain/approval/instance.go b/internal/domain/approval/instance.go new file mode 100644 index 0000000..c77d7e3 --- /dev/null +++ b/internal/domain/approval/instance.go @@ -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 +} diff --git a/internal/domain/approval/repository.go b/internal/domain/approval/repository.go new file mode 100644 index 0000000..0cce307 --- /dev/null +++ b/internal/domain/approval/repository.go @@ -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) +} diff --git a/internal/domain/cardobservation/doc.go b/internal/domain/cardobservation/doc.go new file mode 100644 index 0000000..3517266 --- /dev/null +++ b/internal/domain/cardobservation/doc.go @@ -0,0 +1,2 @@ +// Package cardobservation 提供卡状态观测的纯领域规则。 +package cardobservation diff --git a/internal/domain/cardobservation/network.go b/internal/domain/cardobservation/network.go new file mode 100644 index 0000000..e6330b5 --- /dev/null +++ b/internal/domain/cardobservation/network.go @@ -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) +} diff --git a/internal/domain/cardobservation/realname.go b/internal/domain/cardobservation/realname.go new file mode 100644 index 0000000..d039675 --- /dev/null +++ b/internal/domain/cardobservation/realname.go @@ -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, "实名观测来源无效") + } +} diff --git a/internal/domain/cardobservation/traffic.go b/internal/domain/cardobservation/traffic.go new file mode 100644 index 0000000..4f8aca8 --- /dev/null +++ b/internal/domain/cardobservation/traffic.go @@ -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() +} diff --git a/internal/domain/wallet/doc.go b/internal/domain/wallet/doc.go new file mode 100644 index 0000000..8c52a64 --- /dev/null +++ b/internal/domain/wallet/doc.go @@ -0,0 +1,2 @@ +// Package wallet 定义代理主钱包的资金边界与信用额度不变量。 +package wallet diff --git a/internal/domain/wallet/wallet.go b/internal/domain/wallet/wallet.go new file mode 100644 index 0000000..6e25b0f --- /dev/null +++ b/internal/domain/wallet/wallet.go @@ -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 +} diff --git a/internal/gateway/card_status.go b/internal/gateway/card_status.go index 80ef2e0..5b218d5 100644 --- a/internal/gateway/card_status.go +++ b/internal/gateway/card_status.go @@ -3,27 +3,14 @@ package gateway import ( "strings" + cardobservation "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" "github.com/break/junhong_cmp_fiber/pkg/constants" ) // ParseCardNetworkStatus 将 Gateway 卡状态转换为系统网络状态。 // 只有网关明确返回“正常”才视为开机;“准备”或“待激活”不能按正常卡展示。 func ParseCardNetworkStatus(cardStatus, extend string) (int, bool) { - status := strings.TrimSpace(cardStatus) - ext := strings.TrimSpace(extend) - - switch status { - case constants.GatewayCardStatusNormal: - return constants.NetworkStatusOnline, true - case constants.GatewayCardStatusStopped, constants.GatewayCardStatusReady: - return constants.NetworkStatusOffline, true - } - - if ext == constants.GatewayCardExtendPendingActivation { - return constants.NetworkStatusOffline, true - } - - return constants.NetworkStatusOffline, false + return cardobservation.MapGatewayNetworkStatus(cardStatus, extend) } // IsGatewayCardStopped 判断 Gateway 是否明确返回停机状态。 diff --git a/internal/handler/admin/notification.go b/internal/handler/admin/notification.go new file mode 100644 index 0000000..59e947c --- /dev/null +++ b/internal/handler/admin/notification.go @@ -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), + ) +} diff --git a/internal/handler/admin/role.go b/internal/handler/admin/role.go index 3be51b4..73d0640 100644 --- a/internal/handler/admin/role.go +++ b/internal/handler/admin/role.go @@ -11,14 +11,21 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/response" + roleApp "github.com/break/junhong_cmp_fiber/internal/application/role" "github.com/break/junhong_cmp_fiber/internal/model/dto" roleService "github.com/break/junhong_cmp_fiber/internal/service/role" ) // RoleHandler 角色 Handler type RoleHandler struct { - service *roleService.Service - validator *validator.Validate + service *roleService.Service + defaultCreditService *roleApp.DefaultCreditService + validator *validator.Validate +} + +// SetDefaultCreditService 设置角色默认信用模板应用服务。 +func (h *RoleHandler) SetDefaultCreditService(service *roleApp.DefaultCreditService) { + h.defaultCreditService = service } // NewRoleHandler 创建角色 Handler @@ -254,3 +261,36 @@ func (h *RoleHandler) UpdateStatus(c *fiber.Ctx) error { return response.Success(c, nil) } + +// UpdateDefaultCredit 更新客户角色的新建代理默认信用模板。 +// PUT /api/admin/roles/:id/default-credit +func (h *RoleHandler) UpdateDefaultCredit(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的角色 ID") + } + + var req dto.UpdateRoleDefaultCreditRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validator.Struct(&req); err != nil { + logger.GetAppLogger().Warn("角色默认信用参数验证失败", zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } + if h.defaultCreditService == nil { + return errors.New(errors.CodeInternalError, "角色默认信用服务未配置") + } + + role, err := h.defaultCreditService.Update(c.UserContext(), uint(id), *req.CreditEnabled, *req.CreditLimit) + if err != nil { + return err + } + return response.Success(c, dto.RoleDefaultCreditResponse{ + RoleID: role.ID, + CreditEnabled: role.DefaultCreditEnabled, + CreditLimit: role.DefaultCreditLimit, + Scope: "new_shops_only", + AffectsExistingWallets: false, + }) +} diff --git a/internal/handler/admin/shop.go b/internal/handler/admin/shop.go index 552d5fb..15c7a7f 100644 --- a/internal/handler/admin/shop.go +++ b/internal/handler/admin/shop.go @@ -1,13 +1,17 @@ package admin import ( + "math" "strconv" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "go.uber.org/zap" + shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop" + walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/model/dto" + shopquery "github.com/break/junhong_cmp_fiber/internal/query/shop" shopService "github.com/break/junhong_cmp_fiber/internal/service/shop" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -17,8 +21,32 @@ import ( // ShopHandler 店铺管理处理器。 type ShopHandler struct { - service *shopService.Service - validator *validator.Validate + service *shopService.Service + createService *shopapp.CreateService + updateService *shopapp.UpdateService + ownerQuery *shopquery.BusinessOwnerQuery + changeCreditService *walletapp.ChangeCreditService + validator *validator.Validate +} + +// SetChangeCreditService 注入既有店铺实际信用额度调整用例。 +func (h *ShopHandler) SetChangeCreditService(service *walletapp.ChangeCreditService) { + h.changeCreditService = service +} + +// SetCreateService 注入店铺创建 Application 事务脚本。 +func (h *ShopHandler) SetCreateService(service *shopapp.CreateService) { + h.createService = service +} + +// SetUpdateService 注入店铺更新 Application 事务脚本。 +func (h *ShopHandler) SetUpdateService(service *shopapp.UpdateService) { + h.updateService = service +} + +// SetBusinessOwnerQuery 注入店铺业务员归属 Query。 +func (h *ShopHandler) SetBusinessOwnerQuery(query *shopquery.BusinessOwnerQuery) { + h.ownerQuery = query } // NewShopHandler 创建店铺管理处理器。 @@ -49,7 +77,10 @@ func (h *ShopHandler) List(c *fiber.Ctx) error { normalizeShopListPagination(&req) - shops, total, err := h.service.ListShopResponses(c.UserContext(), &req) + if h.ownerQuery == nil { + return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置") + } + shops, total, err := h.ownerQuery.List(c.UserContext(), req) if err != nil { return err } @@ -66,7 +97,83 @@ func (h *ShopHandler) logListValidationFailure(c *fiber.Ctx, err error) { logger.GetAppLogger().Warn("店铺列表参数验证失败", zap.String("method", c.Method()), zap.String("path", c.Path()), - zap.String("query", c.Context().QueryArgs().String()), + zap.Bool("page_present", c.Context().QueryArgs().Has("page")), + zap.Bool("page_size_present", c.Context().QueryArgs().Has("page_size")), + zap.Error(err), + ) +} + +// Detail 查询店铺详情。 +// GET /api/admin/shops/:id +func (h *ShopHandler) Detail(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 || id > math.MaxInt64 { + return errors.New(errors.CodeInvalidParam) + } + if h.ownerQuery == nil { + return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置") + } + result, err := h.ownerQuery.Detail(c.UserContext(), uint(id)) + if err != nil { + return err + } + return response.Success(c, result) +} + +// UpdateCreditLimit 调整既有店铺代理主钱包实际信用额度。 +// PUT /api/admin/shops/:id/credit-limit +func (h *ShopHandler) UpdateCreditLimit(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 || id > math.MaxInt64 { + return errors.New(errors.CodeInvalidParam) + } + var request dto.UpdateShopCreditLimitRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if h.validator == nil || h.validator.Struct(&request) != nil { + return errors.New(errors.CodeInvalidParam) + } + if h.changeCreditService == nil { + return errors.New(errors.CodeInternalError, "店铺信用额度服务未配置") + } + result, err := h.changeCreditService.Execute(c.UserContext(), uint(id), *request.CreditEnabled, *request.CreditLimit, *request.Version) + if err != nil { + return err + } + return response.Success(c, result) +} + +// BusinessOwnerCandidates 查询当前可人工绑定的平台业务员候选。 +// GET /api/admin/shops/business-owner-candidates +func (h *ShopHandler) BusinessOwnerCandidates(c *fiber.Ctx) error { + var request dto.ShopBusinessOwnerCandidateRequest + if err := c.QueryParser(&request); err != nil { + h.logBusinessOwnerCandidateValidationFailure(c, err) + return errors.New(errors.CodeInvalidParam) + } + if h.validator == nil { + return errors.New(errors.CodeInternalError, "业务员候选校验器未配置") + } + if err := h.validator.Struct(request); err != nil { + h.logBusinessOwnerCandidateValidationFailure(c, err) + return errors.New(errors.CodeInvalidParam) + } + if h.ownerQuery == nil { + return errors.New(errors.CodeInternalError, "店铺业务员查询服务未配置") + } + items, total, page, pageSize, err := h.ownerQuery.Candidates(c.UserContext(), request) + if err != nil { + return err + } + return response.SuccessWithPagination(c, items, total, page, pageSize) +} + +func (h *ShopHandler) logBusinessOwnerCandidateValidationFailure(c *fiber.Ctx, err error) { + logger.GetAppLogger().Warn("店铺业务员候选参数验证失败", + zap.String("method", c.Method()), + zap.String("path", c.Path()), + zap.Bool("keyword_present", c.Context().QueryArgs().Has("keyword")), zap.Error(err), ) } @@ -87,8 +194,20 @@ func (h *ShopHandler) Create(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } + if h.validator == nil { + return errors.New(errors.CodeInternalError, "店铺创建校验器未配置") + } + if err := h.validator.Struct(&req); err != nil { + logger.GetAppLogger().Warn("店铺创建参数验证失败", + zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } - shop, err := h.service.Create(c.UserContext(), &req) + createService := h.createService + if createService == nil { + return errors.New(errors.CodeInternalError, "店铺创建服务未配置") + } + shop, err := createService.Create(c.UserContext(), &req) if err != nil { return err } @@ -108,8 +227,19 @@ func (h *ShopHandler) Update(c *fiber.Ctx) error { if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } + if h.validator == nil { + return errors.New(errors.CodeInternalError, "店铺更新校验器未配置") + } + if err := h.validator.Struct(&req); err != nil { + logger.GetAppLogger().Warn("店铺更新参数验证失败", + zap.String("method", c.Method()), zap.String("path", c.Path()), zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } - shop, err := h.service.Update(c.UserContext(), uint(id), &req) + if h.updateService == nil { + return errors.New(errors.CodeInternalError, "店铺更新服务未配置") + } + shop, err := h.updateService.Update(c.UserContext(), uint(id), &req) if err != nil { return err } diff --git a/internal/handler/admin/shop_commission.go b/internal/handler/admin/shop_commission.go index 4713c8b..a907a56 100644 --- a/internal/handler/admin/shop_commission.go +++ b/internal/handler/admin/shop_commission.go @@ -6,6 +6,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/break/junhong_cmp_fiber/internal/model/dto" + shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop" shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/response" @@ -13,7 +14,8 @@ import ( // ShopCommissionHandler 代理商资金管理 Handler type ShopCommissionHandler struct { - service *shopCommissionService.Service + service *shopCommissionService.Service + fundSummaryQuery *shopQuery.FundSummaryQuery } // NewShopCommissionHandler 创建代理商资金管理 Handler @@ -21,6 +23,11 @@ func NewShopCommissionHandler(service *shopCommissionService.Service) *ShopCommi return &ShopCommissionHandler{service: service} } +// SetFundSummaryQuery 注入代理商资金概况 Query。 +func (h *ShopCommissionHandler) SetFundSummaryQuery(query *shopQuery.FundSummaryQuery) { + h.fundSummaryQuery = query +} + // ListFundSummary 代理商资金概况列表 // GET /api/admin/shops/fund-summary func (h *ShopCommissionHandler) ListFundSummary(c *fiber.Ctx) error { @@ -29,7 +36,10 @@ func (h *ShopCommissionHandler) ListFundSummary(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam) } - result, err := h.service.ListShopFundSummary(c.UserContext(), &req) + if h.fundSummaryQuery == nil { + return errors.New(errors.CodeInternalError, "代理商资金概况查询能力未配置") + } + result, err := h.fundSummaryQuery.List(c.UserContext(), req) if err != nil { return err } diff --git a/internal/handler/app/client_notification.go b/internal/handler/app/client_notification.go new file mode 100644 index 0000000..501bf5c --- /dev/null +++ b/internal/handler/app/client_notification.go @@ -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), + ) +} diff --git a/internal/handler/callback/payment.go b/internal/handler/callback/payment.go index 89520ae..16700a1 100644 --- a/internal/handler/callback/payment.go +++ b/internal/handler/callback/payment.go @@ -27,7 +27,7 @@ import ( // AgentRechargeServiceInterface 代理充值服务接口 type AgentRechargeServiceInterface interface { - HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error + HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string, paidAmount int64) error } // WechatConfigServiceInterface 支付配置服务接口 @@ -170,7 +170,7 @@ func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, outTradeNo, return fmt.Errorf("充值订单服务未配置,无法处理订单: %s", outTradeNo) case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix): if h.agentRechargeService != nil { - return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID) + return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID, paidAmount) } return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo) default: @@ -473,7 +473,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error { return c.Send(fuiou.BuildNotifyFailResponse("充值订单服务未配置")) case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix): if h.agentRechargeService != nil { - if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil { + if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, model.ProviderTypeFuiou, notify.TransactionId, orderAmt); err != nil { return c.Send(fuiou.BuildNotifyFailResponse(err.Error())) } } diff --git a/internal/infrastructure/approval/decision_delivery.go b/internal/infrastructure/approval/decision_delivery.go new file mode 100644 index 0000000..ddaee52 --- /dev/null +++ b/internal/infrastructure/approval/decision_delivery.go @@ -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 +} diff --git a/internal/infrastructure/approval/repository.go b/internal/infrastructure/approval/repository.go new file mode 100644 index 0000000..63476c6 --- /dev/null +++ b/internal/infrastructure/approval/repository.go @@ -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 +} diff --git a/internal/infrastructure/approval/submission_event.go b/internal/infrastructure/approval/submission_event.go new file mode 100644 index 0000000..7a865e8 --- /dev/null +++ b/internal/infrastructure/approval/submission_event.go @@ -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 +} diff --git a/internal/infrastructure/approval/terminal_event.go b/internal/infrastructure/approval/terminal_event.go new file mode 100644 index 0000000..6cbe616 --- /dev/null +++ b/internal/infrastructure/approval/terminal_event.go @@ -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) +} diff --git a/internal/infrastructure/cardobservation/event.go b/internal/infrastructure/cardobservation/event.go new file mode 100644 index 0000000..da632df --- /dev/null +++ b/internal/infrastructure/cardobservation/event.go @@ -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 +} diff --git a/internal/infrastructure/cardobservation/series_coordinator.go b/internal/infrastructure/cardobservation/series_coordinator.go new file mode 100644 index 0000000..e530a1f --- /dev/null +++ b/internal/infrastructure/cardobservation/series_coordinator.go @@ -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) +} diff --git a/internal/infrastructure/cardobservation/series_runner.go b/internal/infrastructure/cardobservation/series_runner.go new file mode 100644 index 0000000..1c57de3 --- /dev/null +++ b/internal/infrastructure/cardobservation/series_runner.go @@ -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) diff --git a/internal/infrastructure/notification/cleanup.go b/internal/infrastructure/notification/cleanup.go new file mode 100644 index 0000000..efe8543 --- /dev/null +++ b/internal/infrastructure/notification/cleanup.go @@ -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 +} diff --git a/internal/infrastructure/notification/recipient_resolver.go b/internal/infrastructure/notification/recipient_resolver.go new file mode 100644 index 0000000..03d16ff --- /dev/null +++ b/internal/infrastructure/notification/recipient_resolver.go @@ -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 +} diff --git a/internal/infrastructure/notification/registry.go b/internal/infrastructure/notification/registry.go new file mode 100644 index 0000000..f1be751 --- /dev/null +++ b/internal/infrastructure/notification/registry.go @@ -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 +} diff --git a/internal/infrastructure/notification/repository.go b/internal/infrastructure/notification/repository.go new file mode 100644 index 0000000..ae33c4d --- /dev/null +++ b/internal/infrastructure/notification/repository.go @@ -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 +} diff --git a/internal/infrastructure/shop/recipient_resolver.go b/internal/infrastructure/shop/recipient_resolver.go new file mode 100644 index 0000000..e9476c2 --- /dev/null +++ b/internal/infrastructure/shop/recipient_resolver.go @@ -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 +} diff --git a/internal/infrastructure/wallet/credit_consumer.go b/internal/infrastructure/wallet/credit_consumer.go new file mode 100644 index 0000000..ae24c54 --- /dev/null +++ b/internal/infrastructure/wallet/credit_consumer.go @@ -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 +} diff --git a/internal/infrastructure/wallet/credit_event.go b/internal/infrastructure/wallet/credit_event.go new file mode 100644 index 0000000..3daa3aa --- /dev/null +++ b/internal/infrastructure/wallet/credit_event.go @@ -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 +} diff --git a/internal/infrastructure/wallet/debit_consumer.go b/internal/infrastructure/wallet/debit_consumer.go new file mode 100644 index 0000000..1f29e32 --- /dev/null +++ b/internal/infrastructure/wallet/debit_consumer.go @@ -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 +} diff --git a/internal/infrastructure/wallet/debit_event.go b/internal/infrastructure/wallet/debit_event.go new file mode 100644 index 0000000..eedeced --- /dev/null +++ b/internal/infrastructure/wallet/debit_event.go @@ -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 +} diff --git a/internal/infrastructure/wallet/refund_consumer.go b/internal/infrastructure/wallet/refund_consumer.go new file mode 100644 index 0000000..d2ce72f --- /dev/null +++ b/internal/infrastructure/wallet/refund_consumer.go @@ -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) +} diff --git a/internal/infrastructure/wallet/refund_event.go b/internal/infrastructure/wallet/refund_event.go new file mode 100644 index 0000000..cae19b9 --- /dev/null +++ b/internal/infrastructure/wallet/refund_event.go @@ -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 +} diff --git a/internal/infrastructure/wallet/reservation_consumer.go b/internal/infrastructure/wallet/reservation_consumer.go new file mode 100644 index 0000000..2022044 --- /dev/null +++ b/internal/infrastructure/wallet/reservation_consumer.go @@ -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) +} diff --git a/internal/infrastructure/wallet/reservation_event.go b/internal/infrastructure/wallet/reservation_event.go new file mode 100644 index 0000000..3055dce --- /dev/null +++ b/internal/infrastructure/wallet/reservation_event.go @@ -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 +} diff --git a/internal/model/agent_wallet.go b/internal/model/agent_wallet.go index e1fffe3..3ed480c 100644 --- a/internal/model/agent_wallet.go +++ b/internal/model/agent_wallet.go @@ -14,6 +14,8 @@ type AgentWallet struct { 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"` 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"` 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"` @@ -29,7 +31,8 @@ func (AgentWallet) TableName() string { return "tb_agent_wallet" } -// GetAvailableBalance 获取可用余额 = balance - frozen_balance +// GetAvailableBalance 获取旧写入口使用的现金可用金额。 +// Deprecated: 信用钱包完整用例应使用 Wallet Domain 的 AvailableBalance。 func (w *AgentWallet) GetAvailableBalance() int64 { 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"` 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"` - 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"` 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"` 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"` - 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"` 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"` @@ -71,27 +74,27 @@ func (AgentWalletTransaction) TableName() string { // AgentRechargeRecord 代理充值记录模型 // 记录所有代理充值操作 type AgentRechargeRecord struct { - ID uint `gorm:"column:id;primaryKey" json:"id"` - UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"` - AgentWalletID uint `gorm:"column:agent_wallet_id;not null;comment:代理钱包ID" json:"agent_wallet_id"` - ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"` - RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:ARCH+时间戳+随机数)" json:"recharge_no"` - Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小1分)" json:"amount"` - PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"` - PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"` - PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` - PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"` - Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"` + ID uint `gorm:"column:id;primaryKey" json:"id"` + UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"` + AgentWalletID uint `gorm:"column:agent_wallet_id;not null;comment:代理钱包ID" json:"agent_wallet_id"` + ShopID uint `gorm:"column:shop_id;not null;index;comment:店铺ID(冗余字段,便于查询)" json:"shop_id"` + RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:ARCH+时间戳+随机数)" json:"recharge_no"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:充值金额(单位:分,最小1分)" json:"amount"` + PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"` + PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"` + PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"` + PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"` PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表(线下支付时必填,最多5个,微信支付时为空)" json:"payment_voucher_key"` Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"` RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"` - PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` - CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` - ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` - EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` - CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` + PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` + ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` + EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` } // TableName 指定表名 diff --git a/internal/model/agent_wallet_reservation.go b/internal/model/agent_wallet_reservation.go new file mode 100644 index 0000000..f0ec0d9 --- /dev/null +++ b/internal/model/agent_wallet_reservation.go @@ -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" +} diff --git a/internal/model/approval_decision_delivery.go b/internal/model/approval_decision_delivery.go new file mode 100644 index 0000000..6110ca7 --- /dev/null +++ b/internal/model/approval_decision_delivery.go @@ -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" +} diff --git a/internal/model/approval_instance.go b/internal/model/approval_instance.go new file mode 100644 index 0000000..7da9cb0 --- /dev/null +++ b/internal/model/approval_instance.go @@ -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" +} diff --git a/internal/model/card_observation_effect.go b/internal/model/card_observation_effect.go new file mode 100644 index 0000000..1824228 --- /dev/null +++ b/internal/model/card_observation_effect.go @@ -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" +} diff --git a/internal/model/dto/agent_open_api_dto.go b/internal/model/dto/agent_open_api_dto.go index 402b779..c8f1967 100644 --- a/internal/model/dto/agent_open_api_dto.go +++ b/internal/model/dto/agent_open_api_dto.go @@ -89,10 +89,16 @@ type AgentOpenAPIPackageItem struct { // AgentOpenAPIWalletBalanceResponse 开放接口预充值钱包余额响应 type AgentOpenAPIWalletBalanceResponse struct { - Balance int64 `json:"balance" description:"钱包余额(分)"` - FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"` - AvailableBalance int64 `json:"available_balance" description:"可用余额(分)"` - Currency string `json:"currency" description:"币种"` + Balance int64 `json:"balance" description:"钱包账面余额(分)"` + FrozenBalance int64 `json:"frozen_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:"币种"` } // AgentOpenAPIWalletTransactionListRequest 开放接口预充值钱包流水请求 diff --git a/internal/model/dto/notification_dto.go b/internal/model/dto/notification_dto.go new file mode 100644 index 0000000..4e657e5 --- /dev/null +++ b/internal/model/dto/notification_dto.go @@ -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:"每页数量"` +} diff --git a/internal/model/dto/role_dto.go b/internal/model/dto/role_dto.go index be3430e..3e0ece9 100644 --- a/internal/model/dto/role_dto.go +++ b/internal/model/dto/role_dto.go @@ -31,15 +31,39 @@ type RoleListRequest struct { // RoleResponse 角色响应 type RoleResponse struct { - ID uint `json:"id" description:"角色ID"` - RoleName string `json:"role_name" description:"角色名称"` - RoleDesc string `json:"role_desc" description:"角色描述"` - RoleType int `json:"role_type" description:"角色类型 (1:平台角色, 2:客户角色)"` - Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` - Creator uint `json:"creator" description:"创建人ID"` - Updater uint `json:"updater" description:"更新人ID"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"角色ID"` + RoleName string `json:"role_name" description:"角色名称"` + RoleDesc string `json:"role_desc" description:"角色描述"` + RoleType int `json:"role_type" description:"角色类型 (1:平台角色, 2:客户角色)"` + 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"` + Updater uint `json:"updater" description:"更新人ID"` + CreatedAt string `json:"created_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 角色分页响应 diff --git a/internal/model/dto/shop_commission_dto.go b/internal/model/dto/shop_commission_dto.go index 20ba0ca..083b3bd 100644 --- a/internal/model/dto/shop_commission_dto.go +++ b/internal/model/dto/shop_commission_dto.go @@ -20,7 +20,14 @@ type ShopFundSummaryItem struct { Username string `json:"username" description:"主账号用户名"` Phone string `json:"phone" 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:"累计佣金总额(分)"` WithdrawnCommission int64 `json:"withdrawn_commission" description:"已提现佣金(分)"` UnwithdrawCommission int64 `json:"unwithdraw_commission" description:"未提现佣金(分)"` diff --git a/internal/model/dto/shop_dto.go b/internal/model/dto/shop_dto.go index 927d21f..ca2a34d 100644 --- a/internal/model/dto/shop_dto.go +++ b/internal/model/dto/shop_dto.go @@ -1,64 +1,112 @@ package dto +import "github.com/bytedance/sonic" + type ShopListRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` - ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"` - ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"` - ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询(11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"` - ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"` - Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"` - Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + ShopName string `json:"shop_name" query:"shop_name" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称模糊查询"` + ShopCode string `json:"shop_code" query:"shop_code" validate:"omitempty,max=50" maxLength:"50" description:"店铺编号精确查询"` + ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11,numeric,ascii" minLength:"11" maxLength:"11" pattern:"^[0-9]{11}$" description:"联系电话精确查询(11位 ASCII 数字;空值不启用筛选;与其他条件按 AND 组合)"` + BusinessOwnerAccountID *uint `json:"business_owner_account_id" query:"business_owner_account_id" validate:"omitempty,min=1" minimum:"1" description:"平台业务员账号ID精确筛选"` + ParentID *uint `json:"parent_id" query:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID"` + Level *int `json:"level" query:"level" validate:"omitempty,min=1,max=7" minimum:"1" maximum:"7" description:"店铺层级 (1-7级)"` + Status *int `json:"status" query:"status" validate:"omitempty,oneof=0 1" description:"状态 (0:禁用, 1:启用)"` } type CreateShopRequest struct { - ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"店铺名称"` - ShopCode string `json:"shop_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"店铺编号"` - ParentID *uint `json:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID(一级店铺可不填)"` - ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"` - ContactPhone string `json:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话"` - Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"` - City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"` - District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"` - Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"` - DefaultRoleID uint `json:"default_role_id" validate:"required,min=1" required:"true" minimum:"1" description:"店铺默认角色ID(必须是客户角色)"` - InitPassword string `json:"init_password" validate:"required,min=8,max=32" required:"true" minLength:"8" maxLength:"32" description:"初始账号密码"` - InitUsername string `json:"init_username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"初始账号用户名"` - InitPhone string `json:"init_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"初始账号手机号"` + ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"店铺名称"` + ShopCode string `json:"shop_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"店铺编号"` + ParentID *uint `json:"parent_id" validate:"omitempty,min=1" minimum:"1" description:"上级店铺ID(一级店铺可不填)"` + BusinessOwnerAccountID *uint `json:"business_owner_account_id" nullable:"true" description:"平台业务员账号ID;缺失时继承上级,null 表示空归属"` + BusinessOwnerAccountIDSet bool `json:"-"` + ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"` + ContactPhone string `json:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话"` + Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"` + City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"` + District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"` + Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"` + DefaultRoleID uint `json:"default_role_id" validate:"required,min=1" required:"true" minimum:"1" description:"店铺默认角色ID(必须是客户角色)"` + InitPassword string `json:"init_password" validate:"required,min=8,max=32" required:"true" minLength:"8" maxLength:"32" description:"初始账号密码"` + InitUsername string `json:"init_username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"初始账号用户名"` + InitPhone string `json:"init_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"初始账号手机号"` +} + +// UnmarshalJSON 解析创建店铺请求并保留业务员字段“缺失”和“显式 null”的差异。 +func (r *CreateShopRequest) UnmarshalJSON(data []byte) error { + type plain CreateShopRequest + var decoded plain + if err := sonic.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]any + if err := sonic.Unmarshal(data, &fields); err != nil { + return err + } + decoded.BusinessOwnerAccountIDSet = false + if _, exists := fields["business_owner_account_id"]; exists { + decoded.BusinessOwnerAccountIDSet = true + } + *r = CreateShopRequest(decoded) + return nil } type UpdateShopRequest struct { - ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"店铺名称"` - ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"` - ContactPhone string `json:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话"` - Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"` - City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"` - District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"` - Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"` - Status int `json:"status" validate:"required,oneof=0 1" required:"true" description:"状态 (0:禁用, 1:启用)"` + ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"店铺名称"` + BusinessOwnerAccountID *uint `json:"business_owner_account_id" nullable:"true" description:"平台业务员账号ID;缺失时保持不变,null 表示清空"` + BusinessOwnerAccountIDSet bool `json:"-"` + ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"` + ContactPhone string `json:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话"` + Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"` + City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"` + District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"` + Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"` + Status int `json:"status" validate:"oneof=0 1" required:"true" description:"状态 (0:禁用, 1:启用)"` +} + +// UnmarshalJSON 解析更新店铺请求并保留业务员字段“缺失”和“显式 null”的差异。 +func (r *UpdateShopRequest) UnmarshalJSON(data []byte) error { + type plain UpdateShopRequest + var decoded plain + if err := sonic.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]any + if err := sonic.Unmarshal(data, &fields); err != nil { + return err + } + decoded.BusinessOwnerAccountIDSet = false + if _, exists := fields["business_owner_account_id"]; exists { + decoded.BusinessOwnerAccountIDSet = true + } + *r = UpdateShopRequest(decoded) + return nil } // ShopResponse 店铺响应 type ShopResponse struct { - ID uint `json:"id" description:"店铺ID"` - ShopName string `json:"shop_name" description:"店铺名称"` - ShopCode string `json:"shop_code" description:"店铺编号"` - ParentID *uint `json:"parent_id,omitempty" description:"上级店铺ID"` - ParentShopName string `json:"parent_shop_name,omitempty" description:"上级店铺名称"` - Level int `json:"level" description:"店铺层级 (1-7级)"` - ContactName string `json:"contact_name" description:"联系人姓名"` - ContactPhone string `json:"contact_phone" description:"联系人电话"` - Province string `json:"province" description:"省份"` - City string `json:"city" description:"城市"` - District string `json:"district" description:"区县"` - Address string `json:"address" description:"详细地址"` - Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` - StatusName string `json:"status_name" description:"状态名称(中文)"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"店铺ID"` + ShopName string `json:"shop_name" description:"店铺名称"` + ShopCode string `json:"shop_code" description:"店铺编号"` + ParentID *uint `json:"parent_id,omitempty" description:"上级店铺ID"` + ParentShopName string `json:"parent_shop_name,omitempty" description:"上级店铺名称"` + BusinessOwnerAccountID *uint `json:"business_owner_account_id" description:"平台业务员账号ID,null 表示未归属"` + BusinessOwnerUsername string `json:"business_owner_username" description:"平台业务员账号名"` + BusinessOwnerPhoneSummary string `json:"business_owner_phone_summary" description:"平台业务员手机号摘要(前三后四)"` + BusinessOwnerAvailable bool `json:"business_owner_available" description:"平台业务员当前是否可用于通知接收"` + Level int `json:"level" description:"店铺层级 (1-7级)"` + ContactName string `json:"contact_name" description:"联系人姓名"` + ContactPhone string `json:"contact_phone" description:"联系人电话"` + Province string `json:"province" description:"省份"` + City string `json:"city" description:"城市"` + District string `json:"district" description:"区县"` + Address string `json:"address" description:"详细地址"` + Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } -// ShopPageResult 店铺分页响应 // ShopPageResult 店铺分页响应 type ShopPageResult struct { Items []ShopResponse `json:"items" description:"店铺列表"` @@ -86,3 +134,50 @@ type ShopCascadeItem struct { ShopName string `json:"shop_name" description:"店铺名称"` HasChildren bool `json:"has_children" description:"是否有下级店铺"` } + +// ShopBusinessOwnerCandidateRequest 是平台业务员候选分页查询。 +type ShopBusinessOwnerCandidateRequest struct { + Keyword string `json:"keyword" query:"keyword" validate:"omitempty,max=50" maxLength:"50" description:"用户名或手机号关键词"` + Page int `json:"page" query:"page" validate:"omitempty,min=1,max=10000" minimum:"1" maximum:"10000" description:"页码,默认 1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20,最大 100"` +} + +// ShopBusinessOwnerCandidate 是平台业务员候选最小投影。 +type ShopBusinessOwnerCandidate struct { + ID uint `json:"id" description:"平台业务员账号ID"` + Username string `json:"username" description:"平台业务员账号名"` + PhoneSummary string `json:"phone_summary" description:"手机号摘要(前三后四)"` +} + +// ShopBusinessOwnerCandidatePageResult 是平台业务员候选分页结果。 +type ShopBusinessOwnerCandidatePageResult struct { + Items []ShopBusinessOwnerCandidate `json:"items" description:"候选账号列表"` + Total int64 `json:"total" description:"总数量"` + Page int `json:"page" description:"当前页码"` + Size int `json:"size" description:"每页数量"` +} + +// UpdateShopCreditLimitRequest 调整既有店铺实际信用额度请求。 +type UpdateShopCreditLimitRequest 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"` + Version *int `json:"version" validate:"required,min=0" required:"true" minimum:"0" description:"主钱包乐观锁版本"` +} + +// UpdateShopCreditLimitParams 调整既有店铺实际信用额度参数。 +type UpdateShopCreditLimitParams struct { + IDReq + UpdateShopCreditLimitRequest +} + +// ShopCreditLimitResponse 店铺实际信用额度调整响应。 +type ShopCreditLimitResponse struct { + ShopID uint `json:"shop_id" description:"店铺ID"` + WalletID uint `json:"wallet_id" description:"代理主钱包ID"` + Balance int64 `json:"balance" description:"账面余额(分)"` + FrozenBalance int64 `json:"frozen_balance" description:"冻结金额(分)"` + CreditEnabled bool `json:"credit_enabled" description:"是否启用实际信用额度"` + CreditLimit int64 `json:"credit_limit" description:"实际信用额度(分)"` + AvailableBalance int64 `json:"available_balance" description:"总可用金额(分)"` + Version int `json:"version" description:"更新后的主钱包版本"` +} diff --git a/internal/model/iot_card.go b/internal/model/iot_card.go index 5ab6be0..e56d21c 100644 --- a/internal/model/iot_card.go +++ b/internal/model/iot_card.go @@ -36,6 +36,8 @@ type IotCard struct { EnablePolling bool `gorm:"column:enable_polling;type:boolean;default:true;comment:是否参与轮询 true-参与 false-不参与" json:"enable_polling"` LastDataCheckAt *time.Time `gorm:"column:last_data_check_at;comment:最后一次流量检查时间" json:"last_data_check_at"` LastRealNameCheckAt *time.Time `gorm:"column:last_real_name_check_at;comment:最后一次实名检查时间" json:"last_real_name_check_at"` + RealnameReversalCount int `gorm:"column:realname_reversal_count;type:int;default:0;not null;comment:实名逆转连续观测次数" json:"realname_reversal_count"` + RealnameReversalStartedAt *time.Time `gorm:"column:realname_reversal_started_at;comment:实名逆转当前确认窗口开始时间" json:"realname_reversal_started_at,omitempty"` LastProtectCheckAt *time.Time `gorm:"column:last_protect_check_at;comment:上次保护期一致性检查时间" json:"last_protect_check_at"` LastCardStatusCheckAt *time.Time `gorm:"column:last_card_status_check_at;comment:最后一次卡状态检查时间" json:"last_card_status_check_at"` LastSyncTime *time.Time `gorm:"column:last_sync_time;comment:最后一次与Gateway同步时间" json:"last_sync_time"` @@ -56,8 +58,8 @@ type IotCard struct { LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:运营商当前周期累计流量读数(MB,来自网关)" json:"last_gateway_reading_mb"` GatewayExtend string `gorm:"column:gateway_extend;type:text;not null;default:'';comment:Gateway 卡状态扩展字段,原样保存上游 extend,用于展示运营商侧实际停机原因" json:"gateway_extend"` // GatewayCardIMEI 为插拔卡业务中网关上报的设备 IMEI,非设备自身 IMEI,仅用于数据同步落库 - GatewayCardIMEI string `gorm:"column:gateway_card_imei;type:varchar(50);not null;default:'';comment:插拔卡业务 IMEI,网关卡状态接口同步,非设备 IMEI" json:"gateway_card_imei"` - RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"` + GatewayCardIMEI string `gorm:"column:gateway_card_imei;type:varchar(50);not null;default:'';comment:插拔卡业务 IMEI,网关卡状态接口同步,非设备 IMEI" json:"gateway_card_imei"` + RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"` // ICCID 双列存储,用于支持 19/20 位 ICCID 精确路由查询 // 所有卡必须有 ICCID19;仅 20 位运营商卡有 ICCID20(19 位卡为 nil → 数据库 NULL) diff --git a/internal/model/notification.go b/internal/model/notification.go new file mode 100644 index 0000000..fe15d5d --- /dev/null +++ b/internal/model/notification.go @@ -0,0 +1,28 @@ +package model + +import "time" + +// Notification 是站内通知的 PostgreSQL 持久化事实。 +type Notification struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"` + RecipientKind string `gorm:"column:recipient_kind;type:varchar(32);not null;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_kind"` + RecipientID uint `gorm:"column:recipient_id;not null;uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_id"` + Category string `gorm:"column:category;type:varchar(32);not null" json:"category"` + Type string `gorm:"column:type;type:varchar(100);not null" json:"type"` + Severity string `gorm:"column:severity;type:varchar(16);not null" json:"severity"` + Title string `gorm:"column:title;type:varchar(200);not null" json:"title"` + Body string `gorm:"column:body;type:text;not null" json:"body"` + RefType string `gorm:"column:ref_type;type:varchar(64);not null;default:''" json:"ref_type"` + RefID string `gorm:"column:ref_id;type:varchar(128);not null;default:''" json:"ref_id"` + RefKey string `gorm:"column:ref_key;type:varchar(128);not null;default:''" json:"ref_key"` + IsRead bool `gorm:"column:is_read;not null;default:false" json:"is_read"` + ReadAt *time.Time `gorm:"column:read_at;type:timestamptz" json:"read_at,omitempty"` + ExpiresAt *time.Time `gorm:"column:expires_at;type:timestamptz" json:"expires_at,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` +} + +// TableName 返回站内通知表名。 +func (Notification) TableName() string { + return "tb_notification" +} diff --git a/internal/model/order.go b/internal/model/order.go index d50fe39..e496ae7 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -16,6 +16,8 @@ type Order struct { // 订单基础信息 OrderNo string `gorm:"column:order_no;type:varchar(30);uniqueIndex:idx_order_no,where:deleted_at IS NULL;not null;comment:订单号(ORD+时间戳+6位随机数)" json:"order_no"` OrderType string `gorm:"column:order_type;type:varchar(20);not null;comment:订单类型 single_card-单卡购买 device-设备购买" json:"order_type"` + // IdempotencyKey 仅用于订单创建事务防重,不向客户端暴露。 + IdempotencyKey string `gorm:"column:idempotency_key;type:varchar(64);not null;default:'';index:idx_order_idempotency_key;comment:订单创建幂等指纹(SHA-256)" json:"-"` // 买家信息 BuyerType string `gorm:"column:buyer_type;type:varchar(20);not null;comment:买家类型 personal-个人客户 agent-代理商" json:"buyer_type"` diff --git a/internal/model/role.go b/internal/model/role.go index 7b588f7..95580ae 100644 --- a/internal/model/role.go +++ b/internal/model/role.go @@ -9,10 +9,12 @@ type Role struct { gorm.Model BaseModel `gorm:"embedded"` - RoleName string `gorm:"column:role_name;not null;size:50;comment:角色名称" json:"role_name"` - RoleDesc string `gorm:"column:role_desc;size:255;comment:角色描述" json:"role_desc"` - RoleType int `gorm:"column:role_type;not null;index;comment:角色类型 1=平台角色 2=客户角色" json:"role_type"` - Status int `gorm:"column:status;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"` + RoleName string `gorm:"column:role_name;not null;size:50;comment:角色名称" json:"role_name"` + RoleDesc string `gorm:"column:role_desc;size:255;comment:角色描述" json:"role_desc"` + RoleType int `gorm:"column:role_type;not null;index;comment:角色类型 1=平台角色 2=客户角色" json:"role_type"` + Status int `gorm:"column:status;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"` + DefaultCreditEnabled bool `gorm:"column:default_credit_enabled;type:boolean;not null;default:false;comment:是否启用新建代理默认信用" json:"default_credit_enabled"` + DefaultCreditLimit int64 `gorm:"column:default_credit_limit;type:bigint;not null;default:0;comment:新建代理默认信用额度(单位:分)" json:"default_credit_limit"` } // TableName 指定表名 diff --git a/internal/model/shop.go b/internal/model/shop.go index 86cfacb..1280934 100644 --- a/internal/model/shop.go +++ b/internal/model/shop.go @@ -7,18 +7,19 @@ import ( // Shop 店铺模型 type Shop struct { gorm.Model - BaseModel `gorm:"embedded"` - ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:店铺名称" json:"shop_name"` - ShopCode string `gorm:"column:shop_code;type:varchar(50);uniqueIndex:idx_shop_code,where:deleted_at IS NULL;comment:店铺编号" json:"shop_code"` - ParentID *uint `gorm:"column:parent_id;index;comment:上级店铺ID(NULL表示一级代理)" json:"parent_id,omitempty"` - Level int `gorm:"column:level;type:int;not null;default:1;comment:层级(1-7)" json:"level"` - ContactName string `gorm:"column:contact_name;type:varchar(50);comment:联系人姓名" json:"contact_name"` - ContactPhone string `gorm:"column:contact_phone;type:varchar(20);comment:联系人电话" json:"contact_phone"` - Province string `gorm:"column:province;type:varchar(50);comment:省份" json:"province"` - City string `gorm:"column:city;type:varchar(50);comment:城市" json:"city"` - District string `gorm:"column:district;type:varchar(50);comment:区县" json:"district"` - Address string `gorm:"column:address;type:varchar(255);comment:详细地址" json:"address"` - Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"` + BaseModel `gorm:"embedded"` + ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:店铺名称" json:"shop_name"` + ShopCode string `gorm:"column:shop_code;type:varchar(50);uniqueIndex:idx_shop_code,where:deleted_at IS NULL;comment:店铺编号" json:"shop_code"` + ParentID *uint `gorm:"column:parent_id;index;comment:上级店铺ID(NULL表示一级代理)" json:"parent_id,omitempty"` + BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id;index:idx_shop_business_owner_account_id;comment:平台业务员账号ID" json:"business_owner_account_id,omitempty"` + Level int `gorm:"column:level;type:int;not null;default:1;comment:层级(1-7)" json:"level"` + ContactName string `gorm:"column:contact_name;type:varchar(50);comment:联系人姓名" json:"contact_name"` + ContactPhone string `gorm:"column:contact_phone;type:varchar(20);comment:联系人电话" json:"contact_phone"` + Province string `gorm:"column:province;type:varchar(50);comment:省份" json:"province"` + City string `gorm:"column:city;type:varchar(50);comment:城市" json:"city"` + District string `gorm:"column:district;type:varchar(50);comment:区县" json:"district"` + Address string `gorm:"column:address;type:varchar(255);comment:详细地址" json:"address"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:状态 0=禁用 1=启用" json:"status"` } // TableName 指定表名 diff --git a/internal/query/approval/query.go b/internal/query/approval/query.go new file mode 100644 index 0000000..6aa7f9e --- /dev/null +++ b/internal/query/approval/query.go @@ -0,0 +1,218 @@ +// Package approval 提供渠道无关审批实例的读取模型和权限投影。 +package approval + +import ( + "context" + "strings" + "time" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + "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" +) + +// Viewer 是业务权限 Adapter 判定当前读取主体所需的最小身份。 +type Viewer struct { + AccountID uint + UserType int +} + +// BusinessReference 是通用审批实例指向的稳定业务引用。 +type BusinessReference struct { + InstanceID uint + BusinessType string + BusinessID uint +} + +// BusinessProjection 是业务权限 Adapter 返回的安全摘要和处理状态。 +type BusinessProjection struct { + Allowed bool + Summary string + ProcessingStatus int + ProcessingStatusName string + ProcessingSummary string +} + +// BusinessProjectionResolver 批量复核业务资源当前权限并投影处理摘要。 +type BusinessProjectionResolver interface { + Resolve(ctx context.Context, viewer Viewer, references []BusinessReference) (map[uint]BusinessProjection, error) +} + +// ExtensionReference 是渠道 Adapter 读取本地扩展快照所需的最小引用。 +type ExtensionReference struct { + InstanceID uint + Provider string + ExternalRef string +} + +// ChannelExtensionResolver 可为平台主体批量补充渠道专属本地快照。 +// 实现不得在 Query 请求中实时调用外部审批平台。 +type ChannelExtensionResolver interface { + Resolve(ctx context.Context, viewer Viewer, references []ExtensionReference) (map[uint]any, error) +} + +// Projection 是通用审批 Query 对业务列表和详情输出的稳定读取模型。 +type Projection struct { + ID uint `json:"id"` + BusinessType string `json:"business_type"` + BusinessID uint `json:"business_id"` + BusinessSummary string `json:"business_summary"` + SubmitterAccountID uint `json:"submitter_account_id"` + SubmitterName string `json:"submitter_name"` + Provider string `json:"provider"` + Status int `json:"status"` + StatusName string `json:"status_name"` + StatusChangedAt time.Time `json:"status_changed_at"` + ProcessingStatus int `json:"processing_status"` + ProcessingStatusName string `json:"processing_status_name"` + ProcessingSummary string `json:"processing_summary"` + ChannelExtension any `json:"channel_extension,omitempty"` +} + +// Query 批量读取通用审批事实,并通过业务 Adapter 复核当前权限。 +type Query struct { + db *gorm.DB + businessResolver BusinessProjectionResolver + extensionResolver ChannelExtensionResolver +} + +// NewQuery 创建通用审批 Query。 +func NewQuery(db *gorm.DB, businessResolver BusinessProjectionResolver, extensionResolver ChannelExtensionResolver) *Query { + return &Query{db: db, businessResolver: businessResolver, extensionResolver: extensionResolver} +} + +// GetByID 读取单条通用审批投影;资源不存在、引用失效或无权限统一返回禁止访问。 +func (q *Query) GetByID(ctx context.Context, instanceID uint) (*Projection, error) { + items, err := q.BatchByIDs(ctx, []uint{instanceID}) + if err != nil { + return nil, err + } + if len(items) != 1 { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return &items[0], nil +} + +// BatchByIDs 按调用方当前页实例 ID 批量返回有权读取的审批投影,并保持输入顺序。 +func (q *Query) BatchByIDs(ctx context.Context, instanceIDs []uint) ([]Projection, error) { + ids, err := normalizeInstanceIDs(instanceIDs) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return []Projection{}, nil + } + if q == nil || q.db == nil || q.businessResolver == nil { + return nil, errors.New(errors.CodeInternalError, "通用审批 Query 未完整配置") + } + + var records []model.ApprovalInstance + if err := q.db.WithContext(ctx).Where("id IN ?", ids).Find(&records).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例失败") + } + recordByID := make(map[uint]model.ApprovalInstance, len(records)) + references := make([]BusinessReference, 0, len(records)) + for _, record := range records { + recordByID[record.ID] = record + references = append(references, BusinessReference{ + InstanceID: record.ID, BusinessType: record.BusinessType, BusinessID: record.BusinessID, + }) + } + + viewer := Viewer{AccountID: middleware.GetUserIDFromContext(ctx), UserType: middleware.GetUserTypeFromContext(ctx)} + if viewer.AccountID == 0 || viewer.UserType == 0 { + return nil, errors.New(errors.CodeForbidden) + } + businessByID, err := q.businessResolver.Resolve(ctx, viewer, references) + if err != nil { + return nil, err + } + extensionByID, err := q.resolveExtensions(ctx, viewer, records, businessByID) + if err != nil { + return nil, err + } + + items := make([]Projection, 0, len(records)) + for _, id := range ids { + record, exists := recordByID[id] + business, allowed := businessByID[id] + if !exists || !allowed || !business.Allowed { + continue + } + items = append(items, project(record, business, extensionByID[id])) + } + return items, nil +} + +func (q *Query) resolveExtensions( + ctx context.Context, + viewer Viewer, + records []model.ApprovalInstance, + businessByID map[uint]BusinessProjection, +) (map[uint]any, error) { + if q.extensionResolver == nil || viewer.UserType == constants.UserTypeAgent || viewer.UserType == constants.UserTypeEnterprise { + return map[uint]any{}, nil + } + if viewer.UserType != constants.UserTypeSuperAdmin && viewer.UserType != constants.UserTypePlatform { + return map[uint]any{}, nil + } + references := make([]ExtensionReference, 0, len(records)) + for _, record := range records { + business, exists := businessByID[record.ID] + if !exists || !business.Allowed { + continue + } + references = append(references, ExtensionReference{ + InstanceID: record.ID, Provider: record.Provider, ExternalRef: record.ExternalRef, + }) + } + if len(references) == 0 { + return map[uint]any{}, nil + } + return q.extensionResolver.Resolve(ctx, viewer, references) +} + +func normalizeInstanceIDs(instanceIDs []uint) ([]uint, error) { + if len(instanceIDs) > constants.ApprovalQueryMaxBatchSize { + return nil, errors.New(errors.CodeInvalidParam, "单次最多查询 100 条审批摘要") + } + seen := make(map[uint]struct{}, len(instanceIDs)) + ids := make([]uint, 0, len(instanceIDs)) + for _, id := range instanceIDs { + if id == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids, nil +} + +func project(record model.ApprovalInstance, business BusinessProjection, extension any) Projection { + return Projection{ + ID: record.ID, BusinessType: record.BusinessType, BusinessID: record.BusinessID, + BusinessSummary: business.Summary, + SubmitterAccountID: record.SubmitterAccountID, SubmitterName: submitterName(record.SubmitterSnapshot), + Provider: record.Provider, Status: record.Status, StatusName: constants.GetApprovalStatusName(record.Status), + StatusChangedAt: record.StatusChangedAt, + ProcessingStatus: business.ProcessingStatus, ProcessingStatusName: business.ProcessingStatusName, + ProcessingSummary: business.ProcessingSummary, ChannelExtension: extension, + } +} + +func submitterName(snapshot []byte) string { + var value struct { + AccountName string `json:"account_name"` + } + if sonic.Unmarshal(snapshot, &value) != nil || strings.TrimSpace(value.AccountName) == "" { + return constants.ApprovalUnknownSubmitterName + } + return strings.TrimSpace(value.AccountName) +} diff --git a/internal/query/notification/query.go b/internal/query/notification/query.go new file mode 100644 index 0000000..2edcdf2 --- /dev/null +++ b/internal/query/notification/query.go @@ -0,0 +1,221 @@ +// Package notification 提供当前接收人的 PostgreSQL 站内通知读取投影。 +package notification + +import ( + "context" + "strconv" + "strings" + "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" +) + +// Query 提供后台账号未读数和基础列表查询。 +type Query struct { + db *gorm.DB + now func() time.Time +} + +// NewQuery 创建后台通知查询。 +func NewQuery(db *gorm.DB) *Query { + return &Query{db: db, now: time.Now} +} + +// UnreadCount 从 PostgreSQL 查询当前后台账号的准确未读数。 +func (q *Query) UnreadCount(ctx context.Context, recipientID uint) (*dto.NotificationUnreadCountResponse, error) { + if recipientID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + var count int64 + now := q.now().UTC() + err := q.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). + Count(&count).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知未读数失败") + } + return newUnreadCountResponse(count), nil +} + +// List 按创建时间和 ID 倒序查询当前后台账号的未过期通知。 +func (q *Query) List(ctx context.Context, recipientID uint, request dto.NotificationListRequest) (*dto.NotificationListResponse, error) { + if recipientID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + page, pageSize, offset, err := normalizeNotificationPagination(request.Page, request.PageSize) + if err != nil { + return nil, err + } + if !isNotificationCategory(request.Category) || !isNotificationSeverity(request.Severity) || len(request.Type) > 100 || strings.TrimSpace(request.Type) != request.Type { + return nil, errors.New(errors.CodeInvalidParam) + } + now := q.now().UTC() + base := q.db.WithContext(ctx).Model(&model.Notification{}). + Where("recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)", + constants.NotificationRecipientKindAccount, recipientID, now) + if request.Category != "" { + base = base.Where("category = ?", request.Category) + } + if request.Type != "" { + base = base.Where("type = ?", request.Type) + } + if request.Severity != "" { + base = base.Where("severity = ?", request.Severity) + } + if request.IsRead != nil { + base = base.Where("is_read = ?", *request.IsRead) + } + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知总数失败") + } + var records []model.Notification + if err := base.Order("created_at DESC, id DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知列表失败") + } + items := make([]dto.NotificationItem, 0, len(records)) + for _, record := range records { + items = append(items, dto.NotificationItem{ + ID: record.ID, Category: record.Category, Type: record.Type, Severity: record.Severity, + Title: record.Title, Body: record.Body, RefType: record.RefType, RefID: record.RefID, + RefKey: record.RefKey, IsRead: record.IsRead, ReadAt: record.ReadAt, CreatedAt: record.CreatedAt, + }) + } + return &dto.NotificationListResponse{Items: items, Total: total, Page: page, Size: pageSize}, nil +} + +// PersonalUnreadCount 查询当前个人客户可见业务通知的准确未读数。 +func (q *Query) PersonalUnreadCount(ctx context.Context, customerID uint) (*dto.NotificationUnreadCountResponse, error) { + if customerID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + var count int64 + err := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC()). + Where("is_read = ?", false). + Count(&count).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户通知未读数失败") + } + return newUnreadCountResponse(count), nil +} + +// PersonalList 查询当前个人客户可见的未过期业务通知简化列表。 +func (q *Query) PersonalList(ctx context.Context, customerID uint, request dto.PersonalNotificationListRequest) (*dto.PersonalNotificationListResponse, error) { + if customerID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + page, pageSize, offset, err := normalizeNotificationPagination(request.Page, request.PageSize) + if err != nil { + return nil, err + } + base := personalNotificationScope(q.db.WithContext(ctx).Model(&model.Notification{}), customerID, q.now().UTC()) + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户通知总数失败") + } + var records []model.Notification + if err := base.Order("created_at DESC, id DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户通知列表失败") + } + return &dto.PersonalNotificationListResponse{ + Items: notificationItems(records), Total: total, Page: page, Size: pageSize, + }, nil +} + +// UnreadSummary 使用单条 PostgreSQL 查询返回当前后台账号的固定分类汇总。 +func (q *Query) UnreadSummary(ctx context.Context, recipientID uint) (*dto.NotificationUnreadSummaryResponse, error) { + if recipientID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + var summary dto.NotificationUnreadSummaryResponse + err := q.db.WithContext(ctx).Model(&model.Notification{}). + Select(`COUNT(*) AS total, + COUNT(*) FILTER (WHERE category = ?) AS approval, + COUNT(*) FILTER (WHERE category = ?) AS expiry, + COUNT(*) FILTER (WHERE category = ?) AS sync, + COUNT(*) FILTER (WHERE category = ?) AS system`, + constants.NotificationCategoryApproval, + constants.NotificationCategoryExpiry, + constants.NotificationCategorySync, + constants.NotificationCategorySystem). + Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)", + constants.NotificationRecipientKindAccount, recipientID, false, q.now().UTC()). + Scan(&summary).Error + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知未读汇总失败") + } + return &summary, nil +} + +func isNotificationCategory(category string) bool { + switch category { + case "", constants.NotificationCategoryApproval, constants.NotificationCategoryExpiry, + constants.NotificationCategorySync, constants.NotificationCategorySystem: + return true + default: + return false + } +} + +func isNotificationSeverity(severity string) bool { + switch severity { + case "", constants.NotificationSeverityInfo, constants.NotificationSeverityWarning, + constants.NotificationSeverityError, constants.NotificationSeverityCritical: + return true + default: + return false + } +} + +func normalizeNotificationPagination(page, pageSize int) (int, int, int, error) { + if page <= 0 { + page = 1 + } + if page > constants.NotificationMaxPage { + return 0, 0, 0, errors.New(errors.CodeInvalidParam) + } + if pageSize <= 0 { + pageSize = constants.NotificationDefaultPageSize + } + if pageSize > constants.NotificationMaxPageSize { + return 0, 0, 0, errors.New(errors.CodeInvalidParam) + } + return page, pageSize, (page - 1) * pageSize, nil +} + +func personalNotificationScope(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, + ) +} + +func newUnreadCountResponse(count int64) *dto.NotificationUnreadCountResponse { + displayCount := strconv.FormatInt(count, 10) + if count > 99 { + displayCount = "99+" + } + return &dto.NotificationUnreadCountResponse{Count: count, DisplayCount: displayCount} +} + +func notificationItems(records []model.Notification) []dto.NotificationItem { + items := make([]dto.NotificationItem, 0, len(records)) + for _, record := range records { + items = append(items, dto.NotificationItem{ + ID: record.ID, Category: record.Category, Type: record.Type, Severity: record.Severity, + Title: record.Title, Body: record.Body, RefType: record.RefType, RefID: record.RefID, + RefKey: record.RefKey, IsRead: record.IsRead, ReadAt: record.ReadAt, CreatedAt: record.CreatedAt, + }) + } + return items +} diff --git a/internal/query/notification/target.go b/internal/query/notification/target.go new file mode 100644 index 0000000..9656d2b --- /dev/null +++ b/internal/query/notification/target.go @@ -0,0 +1,156 @@ +package notification + +import ( + "context" + "math" + "strconv" + + "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" +) + +// Target 先校验通知属于当前账号,再解析白名单结构化目标并复核当前权限。 +func (q *Query) Target(ctx context.Context, recipientID, notificationID uint) (*dto.NotificationTargetResponse, error) { + result := &dto.NotificationTargetResponse{} + if recipientID == 0 || notificationID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + var record model.Notification + err := q.db.WithContext(ctx).Select("ref_type", "ref_id", "ref_key"). + Where("id = ? AND recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)", + notificationID, constants.NotificationRecipientKindAccount, recipientID, q.now().UTC()).Take(&record).Error + if err == gorm.ErrRecordNotFound { + return result, nil + } + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通知目标失败") + } + return q.resolveTarget(ctx, record) +} + +func (q *Query) resolveTarget(ctx context.Context, record model.Notification) (*dto.NotificationTargetResponse, error) { + definition, exists := notificationTargetDefinitions()[record.RefType] + if !exists { + return &dto.NotificationTargetResponse{}, nil + } + result := &dto.NotificationTargetResponse{TargetType: definition.targetType} + if definition.keyTarget { + result.TargetKey = record.RefKey + } + if definition.idTarget { + id, valid := parseNotificationTargetID(record.RefID) + if !valid { + return result, nil + } + result.TargetID = &id + } + available, err := definition.available(q, ctx, record, result.TargetID) + if err != nil { + return nil, err + } + result.Available = available + return result, nil +} + +type targetDefinition struct { + targetType string + idTarget bool + keyTarget bool + available func(*Query, context.Context, model.Notification, *uint) (bool, error) +} + +func notificationTargetDefinitions() map[string]targetDefinition { + return map[string]targetDefinition{ + constants.NotificationRefTypeRefund: {targetType: constants.NotificationTargetTypeRefundDetail, idTarget: true, available: refundTargetAvailable}, + constants.NotificationRefTypeAgentRecharge: {targetType: constants.NotificationTargetTypeAgentRechargeDetail, idTarget: true, available: unavailableFutureTarget}, + constants.NotificationRefTypeWeComApproval: {targetType: constants.NotificationTargetTypeWeComApprovalDetail, idTarget: true, available: unavailableFutureTarget}, + constants.NotificationRefTypeIotCard: {targetType: constants.NotificationTargetTypeIotCardDetail, idTarget: true, available: iotCardTargetAvailable}, + constants.NotificationRefTypeDevice: {targetType: constants.NotificationTargetTypeDeviceDetail, idTarget: true, available: deviceTargetAvailable}, + constants.NotificationRefTypeExpiringAsset: {targetType: constants.NotificationTargetTypeExpiringAssetList, idTarget: true, available: shopTargetAvailable}, + constants.NotificationRefTypeShopFund: {targetType: constants.NotificationTargetTypeShopFundSummary, idTarget: true, available: shopTargetAvailable}, + constants.NotificationRefTypeIntegrationLog: {targetType: constants.NotificationTargetTypeIntegrationLog, keyTarget: true, available: integrationTargetAvailable}, + constants.NotificationRefTypeCardSync: {targetType: constants.NotificationTargetTypeIntegrationLog, keyTarget: true, available: integrationTargetAvailable}, + constants.NotificationRefTypeSystemConfig: {targetType: constants.NotificationTargetTypeSystemConfig, keyTarget: true, available: systemConfigTargetAvailable}, + } +} + +func parseNotificationTargetID(value string) (uint, bool) { + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil || parsed == 0 || parsed > math.MaxInt64 { + return 0, false + } + return uint(parsed), true +} + +func refundTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) { + query := q.db.WithContext(ctx).Model(&model.RefundRequest{}).Where("id = ?", *id) + switch middleware.GetUserTypeFromContext(ctx) { + case constants.UserTypeSuperAdmin, constants.UserTypePlatform: + case constants.UserTypeAgent: + query = query.Where("creator = ?", middleware.GetUserIDFromContext(ctx)) + default: + return false, nil + } + return targetExists(query, "查询退款通知目标失败") +} + +func iotCardTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) { + if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise { + return false, nil + } + query := middleware.ApplyShopFilter(ctx, q.db.WithContext(ctx).Model(&model.IotCard{})).Where("id = ?", *id) + return targetExists(query, "查询物联网卡通知目标失败") +} + +func deviceTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) { + if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise { + return false, nil + } + query := middleware.ApplyShopFilter(ctx, q.db.WithContext(ctx).Model(&model.Device{})).Where("id = ?", *id) + return targetExists(query, "查询设备通知目标失败") +} + +func shopTargetAvailable(q *Query, ctx context.Context, _ model.Notification, id *uint) (bool, error) { + if err := middleware.CanManageShop(ctx, *id); err != nil { + return false, nil + } + query := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{})).Where("id = ?", *id) + return targetExists(query, "查询店铺通知目标失败") +} + +func integrationTargetAvailable(q *Query, ctx context.Context, record model.Notification, _ *uint) (bool, error) { + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return false, nil + } + if record.RefKey == "" { + return false, nil + } + query := q.db.WithContext(ctx).Model(&model.IntegrationLog{}).Where("integration_id = ?", record.RefKey) + return targetExists(query, "查询外部集成通知目标失败") +} + +func systemConfigTargetAvailable(q *Query, ctx context.Context, record model.Notification, _ *uint) (bool, error) { + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin || record.RefKey == "" { + return false, nil + } + query := q.db.WithContext(ctx).Model(&model.SystemConfig{}).Where("config_key = ?", record.RefKey) + return targetExists(query, "查询系统配置通知目标失败") +} + +func unavailableFutureTarget(_ *Query, _ context.Context, _ model.Notification, _ *uint) (bool, error) { + return false, nil +} + +func targetExists(query *gorm.DB, summary string) (bool, error) { + var count int64 + if err := query.Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, summary) + } + return count > 0, nil +} diff --git a/internal/query/shop/business_owner.go b/internal/query/shop/business_owner.go new file mode 100644 index 0000000..e6655ab --- /dev/null +++ b/internal/query/shop/business_owner.go @@ -0,0 +1,219 @@ +// Package shop 提供店铺业务员归属与资金概况读取投影。 +package shop + +import ( + "context" + "strings" + + "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" +) + +// BusinessOwnerQuery 提供店铺业务员归属读取能力。 +type BusinessOwnerQuery struct { + db *gorm.DB +} + +// NewBusinessOwnerQuery 创建店铺业务员归属 Query。 +func NewBusinessOwnerQuery(db *gorm.DB) *BusinessOwnerQuery { + return &BusinessOwnerQuery{db: db} +} + +// List 查询调用者数据范围内的店铺,并批量投影上级和业务员摘要。 +func (q *BusinessOwnerQuery) List(ctx context.Context, request dto.ShopListRequest) ([]*dto.ShopResponse, int64, error) { + base := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{})) + base = applyShopFilters(base, request) + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺总数失败") + } + var shops []*model.Shop + offset := (request.Page - 1) * request.PageSize + if err := base.Order("created_at DESC, id DESC").Offset(offset).Limit(request.PageSize).Find(&shops).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺列表失败") + } + responses, err := q.project(ctx, shops) + if err != nil { + return nil, 0, err + } + return responses, total, nil +} + +// Detail 查询调用者数据范围内的一家店铺详情。 +func (q *BusinessOwnerQuery) Detail(ctx context.Context, shopID uint) (*dto.ShopResponse, error) { + if shopID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + var shop model.Shop + db := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{})) + if err := db.Where("id = ?", shopID).First(&shop).Error; err != nil { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + responses, err := q.project(ctx, []*model.Shop{&shop}) + if err != nil { + return nil, err + } + return responses[0], nil +} + +// Candidates 查询当前可人工绑定的平台业务员最小投影。 +func (q *BusinessOwnerQuery) Candidates(ctx context.Context, request dto.ShopBusinessOwnerCandidateRequest) ([]dto.ShopBusinessOwnerCandidate, int64, int, int, error) { + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return nil, 0, 0, 0, errors.New(errors.CodeForbidden, "无权限查询业务员候选") + } + page, pageSize := request.Page, request.PageSize + if page == 0 { + page = constants.DefaultPage + } + if pageSize == 0 { + pageSize = constants.DefaultPageSize + } + base := q.db.WithContext(ctx).Model(&model.Account{}). + Where("user_type = ? AND status = ?", constants.UserTypePlatform, constants.StatusEnabled) + if request.Keyword != "" { + keyword := "%" + request.Keyword + "%" + base = base.Where("username ILIKE ? OR phone ILIKE ?", keyword, keyword) + } + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员候选总数失败") + } + var accounts []model.Account + if err := base.Order("id ASC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&accounts).Error; err != nil { + return nil, 0, 0, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员候选失败") + } + items := make([]dto.ShopBusinessOwnerCandidate, 0, len(accounts)) + for _, account := range accounts { + items = append(items, dto.ShopBusinessOwnerCandidate{ + ID: account.ID, Username: account.Username, PhoneSummary: maskPhone(account.Phone), + }) + } + return items, total, page, pageSize, nil +} + +func applyShopFilters(db *gorm.DB, request dto.ShopListRequest) *gorm.DB { + if request.ShopName != "" { + db = db.Where("shop_name LIKE ?", "%"+request.ShopName+"%") + } + if request.ShopCode != "" { + db = db.Where("shop_code = ?", request.ShopCode) + } + if request.ContactPhone != "" { + db = db.Where("contact_phone = ?", request.ContactPhone) + } + if request.BusinessOwnerAccountID != nil { + db = db.Where("business_owner_account_id = ?", *request.BusinessOwnerAccountID) + } + if request.ParentID != nil { + db = db.Where("parent_id = ?", *request.ParentID) + } + if request.Level != nil { + db = db.Where("level = ?", *request.Level) + } + if request.Status != nil { + db = db.Where("status = ?", *request.Status) + } + return db +} + +func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) ([]*dto.ShopResponse, error) { + parentIDs, ownerIDs := collectProjectionIDs(shops) + parentNames, err := q.loadParentNames(ctx, parentIDs) + if err != nil { + return nil, err + } + owners, err := q.loadBusinessOwners(ctx, ownerIDs) + if err != nil { + return nil, err + } + responses := make([]*dto.ShopResponse, 0, len(shops)) + for _, shop := range shops { + response := &dto.ShopResponse{ + ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID, + BusinessOwnerAccountID: shop.BusinessOwnerAccountID, 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"), + } + if shop.ParentID != nil { + response.ParentShopName = parentNames[*shop.ParentID] + } + if shop.BusinessOwnerAccountID != nil { + if owner, exists := owners[*shop.BusinessOwnerAccountID]; exists { + response.BusinessOwnerUsername = owner.Username + response.BusinessOwnerPhoneSummary = maskPhone(owner.Phone) + response.BusinessOwnerAvailable = owner.UserType == constants.UserTypePlatform && owner.Status == constants.StatusEnabled && !owner.DeletedAt.Valid + } + } + responses = append(responses, response) + } + return responses, nil +} + +func collectProjectionIDs(shops []*model.Shop) ([]uint, []uint) { + parents := make(map[uint]struct{}) + owners := make(map[uint]struct{}) + for _, shop := range shops { + if shop.ParentID != nil { + parents[*shop.ParentID] = struct{}{} + } + if shop.BusinessOwnerAccountID != nil { + owners[*shop.BusinessOwnerAccountID] = struct{}{} + } + } + return mapKeys(parents), mapKeys(owners) +} + +func (q *BusinessOwnerQuery) loadParentNames(ctx context.Context, ids []uint) (map[uint]string, error) { + result := make(map[uint]string, len(ids)) + if len(ids) == 0 { + return result, nil + } + var shops []model.Shop + db := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{})) + if err := db.Select("id", "shop_name").Where("id IN ?", ids).Find(&shops).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺摘要失败") + } + for _, shop := range shops { + result[shop.ID] = shop.ShopName + } + return result, nil +} + +func (q *BusinessOwnerQuery) loadBusinessOwners(ctx context.Context, ids []uint) (map[uint]model.Account, error) { + result := make(map[uint]model.Account, len(ids)) + if len(ids) == 0 { + return result, nil + } + var accounts []model.Account + if err := q.db.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&accounts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询业务员摘要失败") + } + for _, account := range accounts { + result[account.ID] = account + } + return result, nil +} + +func mapKeys(values map[uint]struct{}) []uint { + keys := make([]uint, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} + +func maskPhone(phone string) string { + phone = strings.TrimSpace(phone) + if len(phone) < 7 { + return "" + } + return phone[:3] + "****" + phone[len(phone)-4:] +} diff --git a/internal/query/shop/fund_summary.go b/internal/query/shop/fund_summary.go new file mode 100644 index 0000000..9e0d3b1 --- /dev/null +++ b/internal/query/shop/fund_summary.go @@ -0,0 +1,294 @@ +package shop + +import ( + "context" + "math" + "strings" + + "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" +) + +// FundSummaryQuery 提供数据权限范围内的店铺资金概况读取投影。 +type FundSummaryQuery struct { + db *gorm.DB +} + +// NewFundSummaryQuery 创建店铺资金概况 Query。 +func NewFundSummaryQuery(db *gorm.DB) *FundSummaryQuery { + return &FundSummaryQuery{db: db} +} + +// List 分页查询店铺,并以固定批次数投影主钱包、佣金、提现和主账号信息。 +func (q *FundSummaryQuery) List(ctx context.Context, req dto.ShopFundSummaryListReq) (*dto.ShopFundSummaryPageResult, error) { + page, pageSize := normalizeFundSummaryPage(req.Page, req.PageSize) + base := middleware.ApplyShopIDFilter(ctx, q.db.WithContext(ctx).Model(&model.Shop{})) + base = applyFundSummaryFilters(base, req) + + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺资金概况总数失败") + } + var shops []model.Shop + if err := base.Order("created_at DESC, id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&shops).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺资金概况列表失败") + } + if len(shops) == 0 { + return &dto.ShopFundSummaryPageResult{Items: []dto.ShopFundSummaryItem{}, Total: total, Page: page, Size: pageSize}, nil + } + + shopIDs := make([]uint, 0, len(shops)) + for index := range shops { + shopIDs = append(shopIDs, shops[index].ID) + } + wallets, err := q.loadWallets(ctx, shopIDs) + if err != nil { + return nil, err + } + withdrawals, err := q.loadWithdrawals(ctx, shopIDs) + if err != nil { + return nil, err + } + accounts, err := q.loadPrimaryAccounts(ctx, shopIDs) + if err != nil { + return nil, err + } + + items := make([]dto.ShopFundSummaryItem, 0, len(shops)) + for index := range shops { + item, err := projectFundSummary(shops[index], wallets[shops[index].ID], withdrawals[shops[index].ID], accounts[shops[index].ID]) + if err != nil { + return nil, err + } + items = append(items, item) + } + return &dto.ShopFundSummaryPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil +} + +type fundWallets struct { + main *model.AgentWallet + commission *model.AgentWallet +} + +type withdrawalAmounts struct { + approved int64 + pending int64 +} + +type withdrawalAggregate struct { + ShopID uint + Status int + Amount int64 +} + +func normalizeFundSummaryPage(page, pageSize int) (int, int) { + if page <= 0 { + page = constants.DefaultPage + } + if pageSize <= 0 { + pageSize = constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + pageSize = constants.MaxPageSize + } + return page, pageSize +} + +func applyFundSummaryFilters(db *gorm.DB, req dto.ShopFundSummaryListReq) *gorm.DB { + if shopName := strings.TrimSpace(req.ShopName); shopName != "" { + db = db.Where("shop_name ILIKE ?", "%"+shopName+"%") + } + if username := strings.TrimSpace(req.Username); username != "" { + db = db.Where(`EXISTS ( + SELECT 1 FROM tb_account AS account_filter + WHERE account_filter.shop_id = tb_shop.id + AND account_filter.is_primary = TRUE + AND account_filter.deleted_at IS NULL + AND account_filter.username ILIKE ? + )`, "%"+username+"%") + } + return db +} + +func (q *FundSummaryQuery) loadWallets(ctx context.Context, shopIDs []uint) (map[uint]fundWallets, error) { + var records []model.AgentWallet + if err := q.db.WithContext(ctx). + Where("shop_id IN ? AND wallet_type IN ?", shopIDs, []string{constants.AgentWalletTypeMain, constants.AgentWalletTypeCommission}). + Order("id ASC").Find(&records).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询店铺钱包失败") + } + result := make(map[uint]fundWallets, len(shopIDs)) + for index := range records { + wallet := &records[index] + pair := result[wallet.ShopID] + switch wallet.WalletType { + case constants.AgentWalletTypeMain: + if pair.main == nil { + pair.main = wallet + } + case constants.AgentWalletTypeCommission: + if pair.commission == nil { + pair.commission = wallet + } + } + result[wallet.ShopID] = pair + } + return result, nil +} + +func (q *FundSummaryQuery) loadWithdrawals(ctx context.Context, shopIDs []uint) (map[uint]withdrawalAmounts, error) { + var records []withdrawalAggregate + if err := q.db.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). + Select("shop_id, status, COALESCE(SUM(amount), 0) AS amount"). + Where("shop_id IN ? AND status IN ?", shopIDs, []int{constants.WithdrawalStatusApproved, constants.WithdrawalStatusPending}). + Group("shop_id, status").Scan(&records).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询店铺提现汇总失败") + } + result := make(map[uint]withdrawalAmounts, len(shopIDs)) + for _, record := range records { + amounts := result[record.ShopID] + if record.Status == constants.WithdrawalStatusApproved { + amounts.approved = record.Amount + } else if record.Status == constants.WithdrawalStatusPending { + amounts.pending = record.Amount + } + result[record.ShopID] = amounts + } + return result, nil +} + +func (q *FundSummaryQuery) loadPrimaryAccounts(ctx context.Context, shopIDs []uint) (map[uint]*model.Account, error) { + var records []model.Account + if err := q.db.WithContext(ctx). + Where("shop_id IN ? AND is_primary = ? AND deleted_at IS NULL", shopIDs, true). + Order("id ASC").Find(&records).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询店铺主账号失败") + } + result := make(map[uint]*model.Account, len(shopIDs)) + for index := range records { + account := &records[index] + if account.ShopID != nil && result[*account.ShopID] == nil { + result[*account.ShopID] = account + } + } + return result, nil +} + +func projectFundSummary(shop model.Shop, wallets fundWallets, withdrawals withdrawalAmounts, account *model.Account) (dto.ShopFundSummaryItem, error) { + mainProjection, err := projectMainWallet(wallets.main) + if err != nil { + return dto.ShopFundSummaryItem{}, err + } + commissionProjection, err := projectCommissionWallet(wallets.commission, withdrawals) + if err != nil { + return dto.ShopFundSummaryItem{}, err + } + item := dto.ShopFundSummaryItem{ + ShopID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, + MainBalance: mainProjection.balance, MainFrozenBalance: mainProjection.frozen, + CashAvailableBalance: mainProjection.cashAvailable, CreditEnabled: mainProjection.creditEnabled, + CreditLimit: mainProjection.creditLimit, AvailableBalance: mainProjection.available, + IsInDebt: mainProjection.isInDebt, DebtAmount: mainProjection.debtAmount, Version: mainProjection.version, + TotalCommission: commissionProjection.total, WithdrawnCommission: withdrawals.approved, + UnwithdrawCommission: commissionProjection.unwithdrawn, FrozenCommission: commissionProjection.frozen, + WithdrawingCommission: withdrawals.pending, AvailableCommission: commissionProjection.available, + CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), + } + if account != nil { + item.Username = account.Username + item.Phone = account.Phone + } + return item, nil +} + +type mainWalletProjection struct { + balance int64 + frozen int64 + cashAvailable int64 + creditEnabled bool + creditLimit int64 + available int64 + isInDebt bool + debtAmount int64 + version int +} + +func projectMainWallet(wallet *model.AgentWallet) (mainWalletProjection, error) { + if wallet == nil { + return mainWalletProjection{}, nil + } + cashAvailable, ok := safeFundSub(wallet.Balance, wallet.FrozenBalance) + if !ok { + return mainWalletProjection{}, errors.New(errors.CodeInternalError, "计算主钱包现金可用金额时发生整数溢出") + } + effectiveCredit := int64(0) + if wallet.CreditEnabled { + effectiveCredit = wallet.CreditLimit + } + available, ok := safeFundAdd(cashAvailable, effectiveCredit) + if !ok { + return mainWalletProjection{}, errors.New(errors.CodeInternalError, "计算主钱包总可用金额时发生整数溢出") + } + debtAmount := int64(0) + if wallet.Balance < 0 { + if wallet.Balance == math.MinInt64 { + return mainWalletProjection{}, errors.New(errors.CodeInternalError, "计算主钱包欠款金额时发生整数溢出") + } + debtAmount = -wallet.Balance + } + return mainWalletProjection{ + balance: wallet.Balance, frozen: wallet.FrozenBalance, cashAvailable: cashAvailable, + creditEnabled: wallet.CreditEnabled, creditLimit: wallet.CreditLimit, available: available, + isInDebt: wallet.Balance < 0, debtAmount: debtAmount, version: wallet.Version, + }, nil +} + +type commissionProjection struct { + total int64 + unwithdrawn int64 + frozen int64 + available int64 +} + +func projectCommissionWallet(wallet *model.AgentWallet, withdrawals withdrawalAmounts) (commissionProjection, error) { + balance, frozen := int64(0), int64(0) + if wallet != nil { + balance = wallet.Balance + frozen = wallet.FrozenBalance + } + unwithdrawn, ok := safeFundAdd(balance, frozen) + if !ok { + return commissionProjection{}, errors.New(errors.CodeInternalError, "计算未提现佣金时发生整数溢出") + } + total, ok := safeFundAdd(unwithdrawn, withdrawals.approved) + if !ok { + return commissionProjection{}, errors.New(errors.CodeInternalError, "计算累计佣金时发生整数溢出") + } + available, ok := safeFundSub(balance, withdrawals.pending) + if !ok { + return commissionProjection{}, errors.New(errors.CodeInternalError, "计算可提现佣金时发生整数溢出") + } + if available < 0 { + available = 0 + } + return commissionProjection{total: total, unwithdrawn: unwithdrawn, frozen: frozen, available: available}, nil +} + +func safeFundAdd(left, right int64) (int64, bool) { + if (right > 0 && left > math.MaxInt64-right) || (right < 0 && left < math.MinInt64-right) { + return 0, false + } + return left + right, true +} + +func safeFundSub(left, right int64) (int64, bool) { + if (right > 0 && left < math.MinInt64+right) || (right < 0 && left > math.MaxInt64+right) { + return 0, false + } + return left - right, true +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index f2f7cc7..942516b 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -31,6 +31,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.ShopCommission != nil { registerShopCommissionRoutes(authGroup, handlers.ShopCommission, doc, basePath) } + if handlers.Shop != nil { + registerShopDetailRoute(authGroup, handlers.Shop, doc, basePath) + } if handlers.CommissionWithdrawal != nil { registerCommissionWithdrawalRoutes(authGroup, handlers.CommissionWithdrawal, doc, basePath) } @@ -56,6 +59,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.ExportTask != nil { registerExportTaskRoutes(authGroup, handlers.ExportTask, doc, basePath) } + if handlers.Notification != nil { + registerNotificationRoutes(authGroup, handlers.Notification, doc, basePath) + } if handlers.Device != nil { registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath) } diff --git a/internal/routes/notification.go b/internal/routes/notification.go new file mode 100644 index 0000000..c8cf0df --- /dev/null +++ b/internal/routes/notification.go @@ -0,0 +1,68 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// registerNotificationRoutes 注册当前后台账号的站内通知路由。 +func registerNotificationRoutes(router fiber.Router, handler *admin.NotificationHandler, doc *openapi.Generator, basePath string) { + notifications := router.Group("/notifications") + groupPath := basePath + "/notifications" + + // 静态路径必须先于通知 ID 动态路径,避免被动态参数吞掉。 + Register(notifications, doc, groupPath, "GET", "/unread-count", handler.UnreadCount, RouteSpec{ + Summary: "查询通知未读数", + Description: "仅查询当前认证后台账号的未过期未读通知,超过 99 条时 display_count 返回 99+。", + Tags: []string{"站内通知"}, + Output: new(dto.NotificationUnreadCountResponse), + Auth: true, + }) + + Register(notifications, doc, groupPath, "GET", "/unread-summary", handler.UnreadSummary, RouteSpec{ + Summary: "查询通知未读分类汇总", + Description: "返回当前认证后台账号未过期通知的总未读数及审批、临期、同步、系统四个固定类别计数。", + Tags: []string{"站内通知"}, + Output: new(dto.NotificationUnreadSummaryResponse), + Auth: true, + }) + + Register(notifications, doc, groupPath, "GET", "", handler.List, RouteSpec{ + Summary: "查询通知列表", + Description: "仅返回当前认证后台账号的未过期通知,固定按创建时间和通知 ID 倒序。", + Tags: []string{"站内通知"}, + Input: new(dto.NotificationListRequest), + Output: new(dto.NotificationListResponse), + Auth: true, + }) + + Register(notifications, doc, groupPath, "PUT", "/read-all", handler.MarkAllRead, RouteSpec{ + Summary: "批量标记通知已读", + Description: "类别为空时更新当前认证后台账号全部未过期未读通知;指定有效类别时只更新该类别,重复调用幂等成功。", + Tags: []string{"站内通知"}, + Input: new(dto.NotificationReadAllRequest), + Output: new(dto.NotificationReadAllResponse), + Auth: true, + }) + + Register(notifications, doc, groupPath, "GET", "/:id/target", handler.Target, RouteSpec{ + Summary: "解析通知受控目标", + Description: "先校验通知属于当前认证后台账号,再复核目标资源当前权限;只返回白名单结构化目标,不返回任意 URL。", + Tags: []string{"站内通知"}, + Input: new(dto.NotificationIDParams), + Output: new(dto.NotificationTargetResponse), + Auth: true, + }) + + Register(notifications, doc, groupPath, "PUT", "/:id/read", handler.MarkRead, RouteSpec{ + Summary: "标记单条通知已读", + Description: "仅更新当前认证后台账号的通知;通知不存在、属于别人或已经已读均幂等成功。", + Tags: []string{"站内通知"}, + Input: new(dto.NotificationIDParams), + Output: new(dto.NotificationReadResponse), + Auth: true, + }) +} diff --git a/internal/routes/personal.go b/internal/routes/personal.go index cede97b..6a5906a 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -115,6 +115,9 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, // 需要认证的路由 authGroup := router.Group("") authGroup.Use(personalAuthMiddleware.Authenticate()) + if handlers.ClientNotification != nil { + registerPersonalNotificationRoutes(authGroup, handlers.ClientNotification, doc, basePath) + } // 获取个人资料 Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{ diff --git a/internal/routes/personal_notification.go b/internal/routes/personal_notification.go new file mode 100644 index 0000000..17df16c --- /dev/null +++ b/internal/routes/personal_notification.go @@ -0,0 +1,50 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// registerPersonalNotificationRoutes 注册当前个人客户的简化站内通知路由。 +func registerPersonalNotificationRoutes(router fiber.Router, handler *apphandler.ClientNotificationHandler, doc *openapi.Generator, basePath string) { + notifications := router.Group("/notifications") + groupPath := basePath + "/notifications" + + Register(notifications, doc, groupPath, "GET", "/unread-count", handler.UnreadCount, RouteSpec{ + Summary: "查询个人客户通知未读数", + Description: "仅查询当前认证个人客户的未过期业务通知,平台同步和系统运维通知不会进入结果。", + Tags: []string{"个人客户 - 站内通知"}, + Output: new(dto.NotificationUnreadCountResponse), + Auth: true, + }) + + Register(notifications, doc, groupPath, "GET", "", handler.List, RouteSpec{ + Summary: "查询个人客户通知列表", + Description: "仅返回当前认证个人客户的未过期业务通知,固定按创建时间和通知 ID 倒序。", + Tags: []string{"个人客户 - 站内通知"}, + Input: new(dto.PersonalNotificationListRequest), + Output: new(dto.PersonalNotificationListResponse), + Auth: true, + }) + + // 静态路径必须先于通知 ID 动态路径,避免被动态参数吞掉。 + Register(notifications, doc, groupPath, "PUT", "/read-all", handler.MarkAllRead, RouteSpec{ + Summary: "全部标记个人客户通知已读", + Description: "只更新当前认证个人客户可见的未过期未读业务通知,重复调用幂等成功。", + Tags: []string{"个人客户 - 站内通知"}, + Output: new(dto.NotificationReadAllResponse), + Auth: true, + }) + + Register(notifications, doc, groupPath, "PUT", "/:id/read", handler.MarkRead, RouteSpec{ + Summary: "标记个人客户单条通知已读", + Description: "仅更新当前认证个人客户可见的通知;通知不存在、属于别人或已经已读均幂等成功。", + Tags: []string{"个人客户 - 站内通知"}, + Input: new(dto.NotificationIDParams), + Output: new(dto.NotificationReadResponse), + Auth: true, + }) +} diff --git a/internal/routes/role.go b/internal/routes/role.go index c7f339f..8f096a5 100644 --- a/internal/routes/role.go +++ b/internal/routes/role.go @@ -55,6 +55,14 @@ func registerRoleRoutes(api fiber.Router, h *admin.RoleHandler, doc *openapi.Gen Auth: true, }) + Register(roles, doc, groupPath, "PUT", "/:id/default-credit", h.UpdateDefaultCredit, RouteSpec{ + Summary: "更新客户角色的新建代理默认信用模板", + Tags: []string{"角色"}, + Input: new(dto.UpdateRoleDefaultCreditParams), + Output: new(dto.RoleDefaultCreditResponse), + Auth: true, + }) + Register(roles, doc, groupPath, "DELETE", "/:id", h.Delete, RouteSpec{ Summary: "删除角色", Tags: []string{"角色"}, diff --git a/internal/routes/shop.go b/internal/routes/shop.go index 88f9981..c49752d 100644 --- a/internal/routes/shop.go +++ b/internal/routes/shop.go @@ -33,6 +33,15 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op Auth: true, }) + Register(shops, doc, groupPath, "GET", "/business-owner-candidates", coreShopManagement(handler.BusinessOwnerCandidates), RouteSpec{ + Summary: "查询店铺业务员候选", + Description: "仅超级管理员和平台账号可查询;只返回当前启用、未删除的平台账号最小摘要。", + Tags: []string{"店铺管理"}, + Input: new(dto.ShopBusinessOwnerCandidateRequest), + Output: new(dto.ShopBusinessOwnerCandidatePageResult), + Auth: true, + }) + Register(shops, doc, groupPath, "PUT", "/:id", coreShopManagement(handler.Update), RouteSpec{ Summary: "更新店铺", Description: constants.ShopManagementAccessDescription, @@ -42,6 +51,15 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op Auth: true, }) + Register(shops, doc, groupPath, "PUT", "/:id/credit-limit", handler.UpdateCreditLimit, RouteSpec{ + Summary: "调整既有店铺实际信用额度", + Description: "后端不校验按钮权限;shop:credit-limit:manage 仅供前端控制按钮显示。", + Tags: []string{"代理商资金管理"}, + Input: new(dto.UpdateShopCreditLimitParams), + Output: new(dto.ShopCreditLimitResponse), + Auth: true, + }) + Register(shops, doc, groupPath, "DELETE", "/:id", coreShopManagement(handler.Delete), RouteSpec{ Summary: "删除店铺", Description: constants.ShopManagementAccessDescription, @@ -61,6 +79,19 @@ func registerShopRoutes(router fiber.Router, handler *admin.ShopHandler, doc *op }) } +func registerShopDetailRoute(router fiber.Router, handler *admin.ShopHandler, doc *openapi.Generator, basePath string) { + shops := router.Group("/shops") + groupPath := basePath + "/shops" + Register(shops, doc, groupPath, "GET", "/:id", coreShopManagement(handler.Detail), RouteSpec{ + Summary: "查询店铺详情", + Description: constants.ShopManagementAccessDescription + " 返回与店铺列表一致的业务员归属摘要。", + Tags: []string{"店铺管理"}, + Input: new(dto.IDReq), + Output: new(dto.ShopResponse), + Auth: true, + }) +} + func coreShopManagement(handler fiber.Handler) fiber.Handler { return func(c *fiber.Ctx) error { if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise { @@ -103,12 +134,13 @@ func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommis shops := router.Group("/shops") groupPath := basePath + "/shops" - Register(shops, doc, groupPath, "GET", "/fund-summary", handler.ListFundSummary, RouteSpec{ - Summary: "代理商资金概况", - Tags: []string{"代理商资金管理"}, - Input: new(dto.ShopFundSummaryListReq), - Output: new(dto.ShopFundSummaryPageResult), - Auth: true, + Register(shops, doc, groupPath, "GET", "/fund-summary", agentFundManagement(handler.ListFundSummary), RouteSpec{ + Summary: "代理商资金概况", + Description: "按当前店铺数据范围分页返回主钱包、佣金及信用资金事实;现金可用、总可用和欠款均由服务端计算,读取不代表具有调额权限。", + Tags: []string{"代理商资金管理"}, + Input: new(dto.ShopFundSummaryListReq), + Output: new(dto.ShopFundSummaryPageResult), + Auth: true, }) Register(shops, doc, groupPath, "GET", "/:shop_id/withdrawal-requests", handler.ListWithdrawalRequests, RouteSpec{ @@ -169,3 +201,12 @@ func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommis Auth: true, }) } + +func agentFundManagement(handler fiber.Handler) fiber.Handler { + return func(c *fiber.Ctx) error { + if middleware.GetUserTypeFromContext(c.UserContext()) == constants.UserTypeEnterprise { + return errors.New(errors.CodeForbidden, constants.AgentFundManagementForbiddenMessage) + } + return handler(c) + } +} diff --git a/internal/service/agent_open_api/service.go b/internal/service/agent_open_api/service.go index fd92814..c29c54a 100644 --- a/internal/service/agent_open_api/service.go +++ b/internal/service/agent_open_api/service.go @@ -7,6 +7,7 @@ import ( "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/internal/model/dto" assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" @@ -267,12 +268,39 @@ func (s *Service) GetWalletBalance(ctx context.Context) (*dto.AgentOpenAPIWallet } return nil, errors.Wrap(errors.CodeInternalError, 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, + } + if err := aggregate.Validate(); err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "代理主钱包资金状态异常") + } + cashAvailable, err := aggregate.CashAvailableBalance() + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "计算主钱包现金可用金额失败") + } + available, err := aggregate.AvailableBalance() + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "计算主钱包总可用金额失败") + } + debtAmount, err := aggregate.DebtAmount() + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "计算主钱包欠款金额失败") + } return &dto.AgentOpenAPIWalletBalanceResponse{ - Balance: wallet.Balance, - FrozenBalance: wallet.FrozenBalance, - AvailableBalance: wallet.GetAvailableBalance(), - Currency: walletCurrency(wallet.Currency), + Balance: wallet.Balance, + FrozenBalance: wallet.FrozenBalance, + CashAvailableBalance: cashAvailable, + CreditEnabled: wallet.CreditEnabled, + CreditLimit: wallet.CreditLimit, + AvailableBalance: available, + IsInDebt: aggregate.IsInDebt(), + DebtAmount: debtAmount, + Version: wallet.Version, + Currency: walletCurrency(wallet.Currency), }, nil } diff --git a/internal/service/agent_recharge/service.go b/internal/service/agent_recharge/service.go index 7a30531..0f63357 100644 --- a/internal/service/agent_recharge/service.go +++ b/internal/service/agent_recharge/service.go @@ -6,12 +6,14 @@ import ( "context" "fmt" "math/rand" + "strings" "time" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" + walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -41,7 +43,7 @@ type Service struct { db *gorm.DB agentRechargeStore *postgres.AgentRechargeStore agentWalletStore *postgres.AgentWalletStore - agentWalletTxStore *postgres.AgentWalletTransactionStore + agentWalletPosting *walletapp.PostingService shopStore *postgres.ShopStore wechatConfigService WechatConfigServiceInterface auditService AuditServiceInterface @@ -55,7 +57,6 @@ func New( db *gorm.DB, agentRechargeStore *postgres.AgentRechargeStore, agentWalletStore *postgres.AgentWalletStore, - agentWalletTxStore *postgres.AgentWalletTransactionStore, shopStore *postgres.ShopStore, wechatConfigService WechatConfigServiceInterface, auditService AuditServiceInterface, @@ -67,7 +68,6 @@ func New( db: db, agentRechargeStore: agentRechargeStore, agentWalletStore: agentWalletStore, - agentWalletTxStore: agentWalletTxStore, shopStore: shopStore, wechatConfigService: wechatConfigService, auditService: auditService, @@ -77,6 +77,11 @@ func New( } } +// SetAgentWalletPostingService 注入代理主钱包统一入账用例。 +func (s *Service) SetAgentWalletPostingService(service *walletapp.PostingService) { + s.agentWalletPosting = service +} + // Create 创建代理充值订单 // POST /api/admin/agent-recharges func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) { @@ -199,12 +204,6 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline return nil, errors.New(errors.CodeInvalidParam, "该订单状态不允许确认支付") } - // 查询钱包(事务内需要用到 version) - wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID) - if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败") - } - now := time.Now() err = s.db.Transaction(func(tx *gorm.DB) error { // 条件更新充值记录状态 @@ -222,41 +221,20 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline return errors.New(errors.CodeInvalidParam, "充值记录状态已变更") } - // 增加钱包余额(乐观锁) - balanceResult := tx.Model(&model.AgentWallet{}). - Where("id = ? AND version = ?", wallet.ID, wallet.Version). - Updates(map[string]interface{}{ - "balance": gorm.Expr("balance + ?", record.Amount), - "version": gorm.Expr("version + 1"), - }) - if balanceResult.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败") + if s.agentWalletPosting == nil { + return errors.New(errors.CodeInternalError, "代理主钱包入账能力未配置") } - if balanceResult.RowsAffected == 0 { - return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试") + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value } - - // 创建钱包交易记录 - remark := "线下充值确认" - refType := "topup" - txRecord := &model.AgentWalletTransaction{ - AgentWalletID: wallet.ID, - ShopID: record.ShopID, - UserID: userID, - TransactionType: constants.AgentTransactionTypeRecharge, - Amount: record.Amount, - BalanceBefore: wallet.Balance, - BalanceAfter: wallet.Balance + record.Amount, - Status: constants.TransactionStatusSuccess, - ReferenceType: &refType, - ReferenceID: &record.ID, - Remark: &remark, - Creator: userID, - ShopIDTag: wallet.ShopIDTag, - EnterpriseIDTag: wallet.EnterpriseIDTag, - } - if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败") + if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{ + ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount, + ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID, + TransactionType: constants.AgentTransactionTypeRecharge, UserID: userID, Creator: userID, + Remark: "线下充值确认", RequestID: requestID, CorrelationID: record.RechargeNo, + }); err != nil { + return err } return nil @@ -293,7 +271,8 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline // HandlePaymentCallback 处理支付回调 // 幂等处理:status != 待支付则直接返回成功 -func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error { +func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string, paidAmount int64) error { + paymentTransactionID = strings.TrimSpace(paymentTransactionID) record, err := s.agentRechargeStore.GetByRechargeNo(ctx, rechargeNo) if err != nil { if err == gorm.ErrRecordNotFound { @@ -302,25 +281,23 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败") } - // 幂等检查 + if err := validateAgentRechargePayment(record, paymentMethod, paymentTransactionID, paidAmount); err != nil { + return err + } + + // 已完成订单仅在第三方交易号一致时按同一回调幂等成功。 if record.Status != constants.RechargeStatusPending { - s.logger.Info("代理充值订单已处理,跳过", - zap.String("recharge_no", rechargeNo), - zap.Int("status", record.Status), - ) - return nil + if record.Status == constants.RechargeStatusCompleted && record.PaymentTransactionID != nil && *record.PaymentTransactionID == strings.TrimSpace(paymentTransactionID) { + s.logger.Info("代理充值支付回调幂等命中", zap.String("recharge_no", rechargeNo), zap.Int("status", record.Status)) + return nil + } + return errors.New(errors.CodeInvalidStatus, "充值订单状态不允许确认支付") } - - wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID) - if err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败") - } - now := time.Now() err = s.db.Transaction(func(tx *gorm.DB) error { // 条件更新(WHERE status = 1) result := tx.Model(&model.AgentRechargeRecord{}). - Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending). + Where("id = ? AND status = ? AND payment_method <> ? AND amount = ?", record.ID, constants.RechargeStatusPending, constants.RechargeMethodOffline, paidAmount). Updates(map[string]interface{}{ "status": constants.RechargeStatusCompleted, "payment_transaction_id": paymentTransactionID, @@ -331,44 +308,31 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新充值记录状态失败") } if result.RowsAffected == 0 { - return nil + var current model.AgentRechargeRecord + if err := tx.WithContext(ctx).Unscoped(). + Where("id = ?", record.ID).First(¤t).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "复核充值记录终态失败") + } + if current.Status == constants.RechargeStatusCompleted && current.PaymentTransactionID != nil && *current.PaymentTransactionID == paymentTransactionID { + return nil + } + return errors.New(errors.CodeConflict, "充值订单已被其他请求处理") } - // 增加钱包余额(乐观锁) - balanceResult := tx.Model(&model.AgentWallet{}). - Where("id = ? AND version = ?", wallet.ID, wallet.Version). - Updates(map[string]interface{}{ - "balance": gorm.Expr("balance + ?", record.Amount), - "version": gorm.Expr("version + 1"), - }) - if balanceResult.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败") + if s.agentWalletPosting == nil { + return errors.New(errors.CodeInternalError, "代理主钱包入账能力未配置") } - if balanceResult.RowsAffected == 0 { - return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试") + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value } - - // 创建交易记录 - remark := "在线支付充值" - refType := "topup" - txRecord := &model.AgentWalletTransaction{ - AgentWalletID: wallet.ID, - ShopID: record.ShopID, - UserID: record.UserID, - TransactionType: constants.AgentTransactionTypeRecharge, - Amount: record.Amount, - BalanceBefore: wallet.Balance, - BalanceAfter: wallet.Balance + record.Amount, - Status: constants.TransactionStatusSuccess, - ReferenceType: &refType, - ReferenceID: &record.ID, - Remark: &remark, - Creator: record.UserID, - ShopIDTag: wallet.ShopIDTag, - EnterpriseIDTag: wallet.EnterpriseIDTag, - } - if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败") + if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{ + ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount, + ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID, + TransactionType: constants.AgentTransactionTypeRecharge, UserID: record.UserID, Creator: record.UserID, + Remark: "在线支付充值", RequestID: requestID, CorrelationID: record.RechargeNo, + }); err != nil { + return err } return nil @@ -387,6 +351,29 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, return nil } +func validateAgentRechargePayment(record *model.AgentRechargeRecord, paymentMethod, paymentTransactionID string, paidAmount int64) error { + if record == nil || strings.TrimSpace(paymentTransactionID) == "" || paidAmount <= 0 || paidAmount != record.Amount { + return errors.New(errors.CodeInvalidParam, "代理充值支付回调金额或交易号不匹配") + } + if record.PaymentMethod == constants.RechargeMethodOffline || record.PaymentConfigID == nil || record.PaymentChannel == nil { + return errors.New(errors.CodeInvalidStatus, "线下充值订单不能通过支付回调确认") + } + channel := strings.TrimSpace(*record.PaymentChannel) + switch strings.TrimSpace(paymentMethod) { + case model.PaymentMethodWechat: + if record.PaymentMethod != constants.RechargeMethodWechat || (channel != model.ProviderTypeWechat && channel != model.ProviderTypeWechatV2) { + return errors.New(errors.CodeInvalidParam, "代理充值支付渠道不匹配") + } + case model.ProviderTypeFuiou: + if record.PaymentMethod != constants.RechargeMethodWechat || channel != model.ProviderTypeFuiou { + return errors.New(errors.CodeInvalidParam, "代理充值支付渠道不匹配") + } + default: + return errors.New(errors.CodeInvalidParam, "代理充值支付渠道不受支持") + } + return nil +} + // Reject 驳回代理充值订单 // 仅 status=1(待支付)的订单可驳回,驳回后状态变为 6(已驳回),为终态 func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) error { diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 5077ae1..c1952b8 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -6,6 +6,8 @@ import ( "strings" "time" + 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/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -16,6 +18,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" @@ -40,13 +43,6 @@ type PollingCallback interface { OnCardDisabled(ctx context.Context, cardID uint) } -// DataDeductor 流量扣减回调接口 -// 用于在手动刷新流量后触发套餐扣减,避免循环依赖 -type DataDeductor interface { - // DeductDataUsage 按优先级扣减套餐流量 - DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error -} - // RealnameActivator 实名激活回调接口 // 用于在手动实名后触发待实名套餐激活,避免循环依赖 type RealnameActivator interface { @@ -65,7 +61,6 @@ type Service struct { gatewayClient *gateway.Client logger *zap.Logger pollingCallback PollingCallback - dataDeductor DataDeductor realnameActivator RealnameActivator stopResumeService StopResumeServiceInterface deviceSimBindingStore *postgres.DeviceSimBindingStore @@ -75,6 +70,12 @@ type Service struct { enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore enterpriseStore *postgres.EnterpriseStore packageExpiryQuery *packageexpiry.Query + cardObservation *cardapp.Service +} + +// SetCardObservationService 注入统一卡实名观测写入用例。 +func (s *Service) SetCardObservationService(service *cardapp.Service) { + s.cardObservation = service } // SetPackageExpiryQuery 注入套餐最终到期查询,供列表使用批量投影。 @@ -129,12 +130,6 @@ func (s *Service) SetEnterpriseStore(store *postgres.EnterpriseStore) { s.enterpriseStore = store } -// SetDataDeductor 设置流量扣减回调 -// 在应用启动时由 bootstrap 调用,注入套餐扣减服务 -func (s *Service) SetDataDeductor(deductor DataDeductor) { - s.dataDeductor = deductor -} - // SetRealnameActivator 设置实名激活回调 // 在应用启动时由 bootstrap 注入套餐激活服务 func (s *Service) SetRealnameActivator(activator RealnameActivator) { @@ -1563,17 +1558,29 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) }) if err != nil { s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(err)) + } else if strings.TrimSpace(statusResp.ICCID) == "" { + s.logger.Warn("刷新卡数据:网络状态响应缺少 ICCID,跳过本次网络观测", zap.Uint("card_id", card.ID)) + } else if s.cardObservation == nil { + return errors.New(errors.CodeInternalError, "卡网络观测能力未配置") } else { - gatewayExtend := strings.TrimSpace(statusResp.Extend) - updates["gateway_extend"] = gatewayExtend - networkStatus, ok := gateway.ParseCardNetworkStatus(statusResp.CardStatus, gatewayExtend) - if !ok { + requestID := requestIDFromContext(ctx) + decision, applyErr := s.cardObservation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{ + CardID: card.ID, GatewayStatus: statusResp.CardStatus, + GatewayExtend: statusResp.Extend, GatewayIMEI: statusResp.IMEI, + Metadata: carddomain.ObservationMetadata{ + ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, + Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, + RequestID: requestID, CorrelationID: requestID, + }, + }) + if applyErr != nil { + return applyErr + } + if !decision.StatusKnown { s.logger.Warn("刷新卡数据:未知 Gateway 卡状态", zap.String("iccid", iccid), zap.String("card_status", statusResp.CardStatus), - zap.String("extend", gatewayExtend)) - } else { - updates["network_status"] = networkStatus + zap.String("extend", strings.TrimSpace(statusResp.Extend))) } } @@ -1583,12 +1590,23 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) }) if err != nil { s.logger.Warn("刷新卡数据:查询实名状态失败", zap.String("iccid", iccid), zap.Error(err)) + } else if strings.TrimSpace(realnameResp.ICCID) == "" { + s.logger.Warn("刷新卡数据:实名响应缺少 ICCID,跳过本次实名观测", zap.Uint("card_id", card.ID)) + } else if s.cardObservation == nil { + return errors.New(errors.CodeInternalError, "卡实名观测能力未配置") } else { - realNameStatus := parseGatewayRealnameStatus(realnameResp.RealStatus) - updates["real_name_status"] = realNameStatus - // 检测 0→1 变化:旧状态非已实名且新状态为已实名,补写 first_realname_at - if card.RealNameStatus != constants.RealNameStatusVerified && realNameStatus == constants.RealNameStatusVerified { - updates["first_realname_at"] = syncTime + requestID := requestIDFromContext(ctx) + _, applyErr := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ + CardID: card.ID, + Verified: realnameResp.RealStatus, + Metadata: carddomain.ObservationMetadata{ + ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, + Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, + RequestID: requestID, CorrelationID: requestID, + }, + }) + if applyErr != nil { + return applyErr } } @@ -1598,8 +1616,23 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) }) if err != nil { s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(err)) + } else if s.cardObservation == nil { + return errors.New(errors.CodeInternalError, "卡流量观测能力未配置") } else { - flowIncrementMB = s.calculateRefreshFlowUpdates(card, float64(flowResp.Used), syncTime, updates) + requestID := requestIDFromContext(ctx) + resetDay := postgres.NewCarrierStore(s.db).GetDataResetDay(ctx, card.CarrierID) + decision, applyErr := s.cardObservation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ + CardID: card.ID, GatewayReadingMB: float64(flowResp.Used), ResetDay: resetDay, + Metadata: carddomain.ObservationMetadata{ + ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, + Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, + RequestID: requestID, CorrelationID: requestID, + }, + }) + if applyErr != nil { + return applyErr + } + flowIncrementMB = decision.IncrementMB } } @@ -1607,16 +1640,6 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) return errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败") } - // 有增量时触发套餐流量扣减 - if flowIncrementMB > 0 && s.dataDeductor != nil { - if err := s.dataDeductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, card.ID, flowIncrementMB); err != nil { - s.logger.Warn("手动刷新:套餐流量扣减失败", - zap.Uint("card_id", card.ID), - zap.Float64("increment_mb", flowIncrementMB), - zap.Error(err)) - } - } - // 失效轮询缓存,确保轮询系统读到最新的 network_status/real_name_status/流量 if s.pollingCallback != nil { s.pollingCallback.OnCardStatusChanged(ctx, card.ID) @@ -1629,97 +1652,6 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) return nil } -// calculateRefreshFlowUpdates 计算手动刷新时的流量增量并填充 updates -// 算法与轮询 calculateFlowUpdates 一致:增量 = 当前网关读数 - 上次网关读数 -// 返回本次增量值(MB),用于触发套餐扣减 -func (s *Service) calculateRefreshFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time, updates map[string]any) float64 { - increment := gatewayFlowMB - card.LastGatewayReadingMB - shouldUpdateGatewayReading := true - - if increment < 0 { - // 当前值比上次小,检查是否为上游运营商正常重置 - resetDay := s.getCarrierResetDay(card.CarrierID) - if isRefreshResetWindow(now, resetDay) { - // 在重置日窗口内,本次原始值即为增量 - increment = gatewayFlowMB - s.logger.Info("手动刷新:检测到上游运营商重置", - zap.Uint("card_id", card.ID), - zap.Float64("gateway_flow", gatewayFlowMB), - zap.Float64("last_reading", card.LastGatewayReadingMB), - zap.Int("reset_day", resetDay)) - } else { - // 非重置日出现值下降,异常,不计入 - s.logger.Warn("手动刷新:流量异常,非重置日出现值下降", - zap.Uint("card_id", card.ID), - zap.Float64("gateway_flow", gatewayFlowMB), - zap.Float64("last_reading", card.LastGatewayReadingMB)) - increment = 0 - shouldUpdateGatewayReading = false - } - } - - if shouldUpdateGatewayReading { - updates["last_gateway_reading_mb"] = gatewayFlowMB - } - - // 检测跨自然月 - currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - isCrossMonth := card.CurrentMonthStartDate == nil || - card.CurrentMonthStartDate.Before(currentMonthStart) - - if isCrossMonth { - s.logger.Info("手动刷新:检测到跨月,重置流量计数", - zap.Uint("card_id", card.ID), - zap.Float64("last_month_total", card.CurrentMonthUsageMB)) - updates["last_month_total_mb"] = card.CurrentMonthUsageMB - updates["current_month_start_date"] = currentMonthStart - if increment > 0 { - updates["current_month_usage_mb"] = increment - } else { - updates["current_month_usage_mb"] = float64(0) - } - } else if increment > 0 { - // 同月内,增量累加(使用 gorm.Expr 保证原子性) - updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment) - } - - // 更新卡生命周期总用量 - if increment > 0 { - updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment)) - } - - // 首次流量查询初始化 - if card.CurrentMonthStartDate == nil { - updates["current_month_start_date"] = currentMonthStart - } - - return increment -} - -// getCarrierResetDay 读取运营商的上游流量重置日 -func (s *Service) getCarrierResetDay(carrierID uint) int { - var carrier model.Carrier - if err := s.db.Select("data_reset_day").First(&carrier, carrierID).Error; err != nil { - return 1 - } - if carrier.DataResetDay == 0 { - return 1 - } - return carrier.DataResetDay -} - -// isRefreshResetWindow 判断今天是否在运营商重置日窗口内 -// 窗口 = 重置日当天 + 前一天(容错网关数据延迟) -func isRefreshResetWindow(now time.Time, resetDay int) bool { - today := now.Day() - if today == resetDay { - return true - } - resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location()) - prevDay := resetDate.AddDate(0, 0, -1).Day() - return today == prevDay -} - // parseGatewayRealnameStatus 将网关返回的实名状态布尔值转换为 real_name_status 数值 // true=已实名(1),false=未实名(0) func parseGatewayRealnameStatus(realStatus bool) int { @@ -2190,22 +2122,20 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r } oldStatus := card.RealNameStatus - statusChanged := oldStatus != realNameStatus now := time.Now() - - fields := map[string]any{ - "last_real_name_check_at": now, + if s.cardObservation == nil { + return nil, errors.New(errors.CodeInternalError, "卡实名观测能力未配置") } - if statusChanged { - fields["real_name_status"] = realNameStatus - } - if statusChanged && - oldStatus != constants.RealNameStatusVerified && - realNameStatus == constants.RealNameStatusVerified { - fields["first_realname_at"] = now - } - - if err := s.iotCardStore.UpdateFields(ctx, cardID, fields); err != nil { + requestID := requestIDFromContext(ctx) + if _, err := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ + CardID: cardID, + Verified: realNameStatus == constants.RealNameStatusVerified, + Metadata: carddomain.ObservationMetadata{ + ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualOverride, + Scene: constants.CardObservationSceneManualRefresh, ObservedAt: now, + RequestID: requestID, CorrelationID: requestID, + }, + }); err != nil { wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名状态失败") errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ @@ -2248,10 +2178,6 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r return nil, wrapErr } - if statusChanged { - s.handleManualRealnameStatusChanged(ctx, oldStatus, freshCard) - } - if s.pollingCallback != nil { s.pollingCallback.OnCardStatusChanged(ctx, cardID) } @@ -2281,6 +2207,14 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r return freshCard, nil } +func requestIDFromContext(ctx context.Context) string { + requestID := middleware.GetRequestIDFromContext(ctx) + if requestID == nil { + return "" + } + return *requestID +} + // handleManualRealnameStatusChanged 处理手动实名状态变更后的联动逻辑 func (s *Service) handleManualRealnameStatusChanged(ctx context.Context, oldStatus int, card *model.IotCard) { if card == nil { diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 2f4b750..e808acc 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -2,12 +2,15 @@ package order import ( "context" + "crypto/sha256" + "encoding/binary" "fmt" "sort" "strconv" "strings" "time" + walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -23,6 +26,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/payment" "github.com/break/junhong_cmp_fiber/pkg/queue" "github.com/break/junhong_cmp_fiber/pkg/wechat" + "github.com/google/uuid" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" @@ -60,6 +64,18 @@ type Service struct { assetIdentifierStore *postgres.AssetIdentifierStore personalCustomerStore *postgres.PersonalCustomerStore personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore + agentWalletDebit *walletapp.DebitService + agentWalletReservation *walletapp.ReservationService +} + +// SetAgentWalletDebitService 注入代理主钱包统一扣款用例。 +func (s *Service) SetAgentWalletDebitService(service *walletapp.DebitService) { + s.agentWalletDebit = service +} + +// SetAgentWalletReservationService 注入代理主钱包统一预占用例。 +func (s *Service) SetAgentWalletReservationService(service *walletapp.ReservationService) { + s.agentWalletReservation = service } func New( @@ -188,16 +204,17 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde } carrierType, carrierID := resolveAdminCarrierInfoFromVars(orderType, iotCardID, deviceID) - existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs) + existingOrderID, lockToken, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs) + lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID) + if lockToken != "" { + defer s.releaseOrderCreateLock(ctx, lockKey, lockToken) + } if err != nil { return nil, err } if existingOrderID > 0 { return s.Get(ctx, existingOrderID) } - // 获取到分布式锁后,确保无论成功还是失败都释放 - lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID) - defer s.redis.Del(ctx, lockKey) // offline 订单不检查强充:平台直接操作,不涉及消费者支付门槛,不产生一次性佣金触发条件 if req.PaymentMethod != model.PaymentMethodOffline { @@ -486,10 +503,16 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde operatorShopID = *operatorID } buyerShopID := orderBuyerID + order.IdempotencyKey = orderIdempotencyDigest(idempotencyKey) - if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil { + existingOrderID, err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID) + if err != nil { return nil, err } + if existingOrderID > 0 { + s.markOrderCreated(ctx, idempotencyKey, existingOrderID) + return s.Get(ctx, existingOrderID) + } s.markOrderCreated(ctx, idempotencyKey, order.ID) return s.buildOrderResponse(ctx, order, items), nil @@ -548,16 +571,17 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest // 幂等性检查:防止同一买家对同一载体短时间内重复下单 carrierType, carrierID := resolveCarrierInfo(req) - existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs) + existingOrderID, lockToken, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs) + lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID) + if lockToken != "" { + defer s.releaseOrderCreateLock(ctx, lockKey, lockToken) + } if err != nil { return nil, err } if existingOrderID > 0 { return s.Get(ctx, existingOrderID) } - // 获取到分布式锁后,确保无论成功还是失败都释放 - lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID) - defer s.redis.Del(ctx, lockKey) forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult) if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount { @@ -767,10 +791,16 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest } operatorShopID := *operatorID buyerShopID := orderBuyerID + order.IdempotencyKey = orderIdempotencyDigest(idempotencyKey) - if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil { + existingOrderID, err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID) + if err != nil { return nil, err } + if existingOrderID > 0 { + s.markOrderCreated(ctx, idempotencyKey, existingOrderID) + return s.Get(ctx, existingOrderID) + } s.markOrderCreated(ctx, idempotencyKey, order.ID) return s.buildOrderResponse(ctx, order, items), nil @@ -989,32 +1019,23 @@ func (s *Service) getCostPrice(ctx context.Context, shopID uint, packageID uint) return allocation.CostPrice, nil } -// createWalletTransaction 创建钱包流水记录 -// ctx: 上下文 -// tx: 事务对象 -// wallet: 扣款前的钱包快照 -// order: 订单快照 -// amount: 扣款金额(正数) -// purchaseRole: 订单角色 -// relatedShopID: 关联店铺ID(代购场景填充下级店铺ID) -func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, order *model.Order, amount int64, purchaseRole string, relatedShopID *uint) error { - var subtype *string +// debitAgentMainWalletInTx 通过统一 Wallet Application 接缝扣款并写入流水与 Outbox。 +func (s *Service) debitAgentMainWalletInTx(ctx context.Context, tx *gorm.DB, order *model.Order, shopID uint, amount int64, relatedShopID *uint) error { + if s.agentWalletDebit == nil { + return errors.New(errors.CodeInternalError, "代理主钱包扣款能力未配置") + } + subtype := "" remark := "购买套餐" + purchaseRole := order.PurchaseRole if purchaseRole == "" { purchaseRole = model.PurchaseRoleSelfPurchase } - // 根据订单角色确定交易子类型和备注 switch purchaseRole { case model.PurchaseRoleSelfPurchase: - subtypeVal := constants.WalletTransactionSubtypeSelfPurchase - subtype = &subtypeVal - + subtype = constants.WalletTransactionSubtypeSelfPurchase case model.PurchaseRolePurchaseForSubordinate: - subtypeVal := constants.WalletTransactionSubtypePurchaseForSubordinate - subtype = &subtypeVal - - // 查询下级店铺名称,填充到备注 + subtype = constants.WalletTransactionSubtypePurchaseForSubordinate if relatedShopID != nil { var shop model.Shop if err := tx.Where("id = ?", *relatedShopID).First(&shop).Error; err == nil { @@ -1027,34 +1048,19 @@ func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wall userID := middleware.GetUserIDFromContext(ctx) assetType, assetID, assetIdentifier := buildAgentWalletTransactionAssetSnapshot(order) - - // 创建钱包流水记录 - transaction := &model.AgentWalletTransaction{ - AgentWalletID: wallet.ID, - ShopID: wallet.ShopID, - UserID: userID, - TransactionType: constants.AgentTransactionTypeDeduct, - TransactionSubtype: subtype, - Amount: -amount, // 扣款为负数 - BalanceBefore: wallet.Balance, - BalanceAfter: wallet.Balance - amount, - Status: constants.TransactionStatusSuccess, - ReferenceType: strPtr(constants.ReferenceTypeOrder), - ReferenceID: &order.ID, - RelatedShopID: relatedShopID, - AssetType: assetType, - AssetID: assetID, - AssetIdentifier: assetIdentifier, - Remark: &remark, - Creator: userID, - ShopIDTag: wallet.ShopIDTag, - EnterpriseIDTag: wallet.EnterpriseIDTag, + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value } - - if err := tx.Create(transaction).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包流水失败") + _, err := s.agentWalletDebit.DebitInTx(ctx, tx, walletapp.DebitCommand{ + ShopID: shopID, Amount: amount, ReferenceType: constants.ReferenceTypeOrder, ReferenceID: order.ID, + UserID: userID, Creator: userID, TransactionSubtype: subtype, RelatedShopID: relatedShopID, + AssetType: assetType, AssetID: assetID, AssetIdentifier: assetIdentifier, Remark: remark, + RequestID: requestID, CorrelationID: order.OrderNo, + }) + if err != nil { + return err } - return nil } @@ -1108,44 +1114,33 @@ func strPtr(s string) *string { return &s } -// createOrderWithWalletPayment 使用钱包支付创建订单并完成支付 -// 包含余额检查、扣款、创建流水、激活套餐等操作,在事务中执行 +// createOrderWithWalletPayment 使用钱包支付创建订单并完成支付。 +// 订单、扣款流水、Payment、套餐处理与 Outbox 在同一事务提交。 // ctx: 上下文 // order: 订单对象 // items: 订单明细列表 // operatorShopID: 操作者店铺ID(扣款的店铺) // buyerShopID: 买家店铺ID(代购场景下级店铺ID) -func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model.Order, items []*model.OrderItem, operatorShopID uint, buyerShopID uint) error { +func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model.Order, items []*model.OrderItem, operatorShopID uint, buyerShopID uint) (uint, error) { if order.ActualPaidAmount == nil { - return errors.New(errors.CodeInternalError, "实际支付金额不能为空") + return 0, errors.New(errors.CodeInternalError, "实际支付金额不能为空") } actualAmount := *order.ActualPaidAmount + var existingOrderID uint - // 事务内:加锁读取钱包 + 余额检查 + 创建订单 + 扣款 + 创建流水 + 激活套餐 - // 使用 FOR UPDATE 悲观锁替代乐观锁,避免并发请求因 version 过期导致误报余额不足 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // 1. 加锁读取钱包,并发请求排队等待,不会冲突 - var wallet model.AgentWallet - if err := tx.Set("gorm:query_option", "FOR UPDATE"). - Where("shop_id = ? AND wallet_type = ?", operatorShopID, constants.AgentWalletTypeMain). - First(&wallet).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeWalletNotFound, "钱包不存在") - } - return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") + matchedOrderID, err := lockAndFindRecentWalletOrder(ctx, tx, order.IdempotencyKey) + if err != nil { + return err } - - // 2. 余额检查(加锁后读到的是最新余额,结果准确) - if wallet.Balance < actualAmount { - return errors.New(errors.CodeInsufficientBalance, "余额不足") + if matchedOrderID > 0 { + existingOrderID = matchedOrderID + return nil } - - // 3. 创建订单 if err := tx.Create(order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败") } - // 4. 创建订单明细 for _, item := range items { item.OrderID = order.ID } @@ -1153,24 +1148,16 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败") } - // 5. 扣减钱包余额(FOR UPDATE 已保证串行,无需 version 条件) - if err := tx.Model(&wallet).Updates(map[string]any{ - "balance": gorm.Expr("balance - ?", actualAmount), - "version": gorm.Expr("version + 1"), - }).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "扣减钱包余额失败") - } - - // 6. 创建钱包流水 var relatedShopID *uint if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate { relatedShopID = &buyerShopID } - if err := s.createWalletTransaction(ctx, tx, &wallet, order, actualAmount, order.PurchaseRole, relatedShopID); err != nil { + if err := s.debitAgentMainWalletInTx(ctx, tx, order, operatorShopID, actualAmount, relatedShopID); err != nil { + return err + } + if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet, actualAmount); err != nil { return err } - - // 7. 激活套餐 if err := s.activatePackage(ctx, tx, order); err != nil { return err } @@ -1179,7 +1166,10 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model }) if err != nil { - return err + return 0, err + } + if existingOrderID > 0 { + return existingOrderID, nil } // 3. 事务外:所有已支付且适用差价佣金的订单都进入佣金计算 @@ -1187,7 +1177,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model s.enqueueCommissionCalculation(ctx, order.ID) } - return nil + return 0, nil } func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Order, items []*model.OrderItem) error { @@ -1480,16 +1470,22 @@ func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error { }) } -// unfreezeWalletForCancel 取消订单时解冻钱包余额 -// 根据买家类型和订单金额确定解冻金额和目标钱包 +// unfreezeWalletForCancel 取消订单时解冻钱包余额。 +// 代理主钱包以预占事实中的钱包和金额为权威,避免代购订单误用买方店铺。 func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, order *model.Order) error { if order.BuyerType == model.BuyerTypeAgent { - // 代理商钱包(店铺钱包) - wallet, err := s.agentWalletStore.GetMainWallet(ctx, order.BuyerID) - if err != nil { - return errors.Wrap(errors.CodeWalletNotFound, err, "查询代理钱包失败") + if s.agentWalletReservation == nil { + return errors.New(errors.CodeInternalError, "代理主钱包预占能力未配置") } - return s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, order.TotalAmount) + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value + } + return s.agentWalletReservation.ReleaseInTx(ctx, tx, walletapp.ReservationCommand{ + ReferenceType: constants.ReferenceTypeOrder, ReferenceID: order.ID, + UserID: middleware.GetUserIDFromContext(ctx), Creator: middleware.GetUserIDFromContext(ctx), + RequestID: requestID, CorrelationID: order.OrderNo, Remark: "取消订单释放预占资金", + }) } else if order.BuyerType == model.BuyerTypePersonal { // 个人客户钱包(卡/设备钱包) var resourceType string @@ -1524,16 +1520,25 @@ func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, orde return nil } -func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string) error { +func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string, amount int64) error { + paidAt := order.PaidAt + if paidAt == nil { + now := time.Now() + paidAt = &now + } payment := &model.Payment{ PaymentNo: order.OrderNo, OrderID: order.ID, OrderType: model.PaymentOrderTypePackage, PaymentMethod: paymentMethod, - Amount: order.TotalAmount, + Amount: amount, Status: model.PaymentRecordStatusPaid, + PaidAt: paidAt, } - return tx.Create(payment).Error + if err := tx.Create(payment).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包支付记录失败") + } + return nil } func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error { @@ -1553,7 +1558,10 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, return errors.New(errors.CodeInvalidStatus, "代购订单无需支付") } - existingPayment, _ := s.paymentStore.GetByOrderIDAndStatus(ctx, orderID, model.PaymentRecordStatusPaid) + existingPayment, err := s.paymentStore.GetByOrderIDAndStatus(ctx, orderID, model.PaymentRecordStatusPaid) + if err != nil && err != gorm.ErrRecordNotFound { + return errors.Wrap(errors.CodeDatabaseError, err, "查询订单支付记录失败") + } if existingPayment != nil { return nil } @@ -1584,19 +1592,6 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, shouldEnqueueCommission := false if resourceType == "shop" { - // 代理钱包系统(店铺) - wallet, err := s.agentWalletStore.GetMainWallet(ctx, resourceID) - if err != nil { - if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeWalletNotFound, "钱包不存在") - } - return err - } - - if wallet.Balance < order.TotalAmount { - return errors.New(errors.CodeInsufficientBalance, "余额不足") - } - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.Order{}). Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending). @@ -1631,26 +1626,14 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, actualPaidAmountSnapshot := actualPaidAmount order.ActualPaidAmount = &actualPaidAmountSnapshot + order.PaidAt = &now shouldEnqueueCommission = true - walletResult := tx.Model(&model.AgentWallet{}). - Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version). - Updates(map[string]any{ - "balance": gorm.Expr("balance - ?", order.TotalAmount), - "version": gorm.Expr("version + 1"), - }) - if walletResult.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败") - } - if walletResult.RowsAffected == 0 { - return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突") - } - - if err := s.createWalletTransaction(ctx, tx, wallet, order, order.TotalAmount, order.PurchaseRole, nil); err != nil { + if err := s.debitAgentMainWalletInTx(ctx, tx, order, resourceID, order.TotalAmount, nil); err != nil { return err } - if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet); err != nil { + if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet, order.TotalAmount); err != nil { return err } @@ -1742,7 +1725,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, return errors.Wrap(errors.CodeDatabaseError, err, "创建扣款流水失败") } - if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet); err != nil { + if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet, order.TotalAmount); err != nil { return err } @@ -2160,14 +2143,24 @@ func buildOrderIdempotencyKey(buyerType string, buyerID uint, orderType string, buyerType, buyerID, orderType, carrierType, carrierID, strings.Join(idStrs, ",")) } +func orderIdempotencyDigest(key string) string { + digest := sha256.Sum256([]byte(key)) + return fmt.Sprintf("%x", digest) +} + +func orderIdempotencyAdvisoryKey(digest string) int64 { + value := sha256.Sum256([]byte(digest)) + return int64(binary.BigEndian.Uint64(value[:8])) +} + const ( orderIdempotencyTTL = 3 * time.Minute - orderCreateLockTTL = 10 * time.Second + orderCreateLockTTL = orderIdempotencyTTL ) // checkOrderIdempotency 检查订单是否已创建(Redis SETNX + 分布式锁) -// 返回已存在的 orderID(>0 表示重复请求),或 0 表示可以创续创建 -func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) (uint, error) { +// 返回已存在的 orderID、当前请求持有的锁令牌以及错误。 +func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) (uint, string, error) { idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, orderType, carrierType, carrierID, packageIDs) redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey) @@ -2179,21 +2172,22 @@ func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, b s.logger.Info("订单幂等性命中,返回已有订单", zap.Uint("order_id", uint(orderID)), zap.String("idempotency_key", idempotencyKey)) - return uint(orderID), nil + return uint(orderID), "", nil } } + if err != nil && err != redis.Nil { + return 0, "", errors.Wrap(errors.CodeServiceUnavailable, err, "订单幂等缓存不可用") + } // 第 2 层:分布式锁,防止并发创建 lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID) - locked, err := s.redis.SetNX(ctx, lockKey, time.Now().String(), orderCreateLockTTL).Result() + lockToken := uuid.NewString() + locked, err := s.redis.SetNX(ctx, lockKey, lockToken, orderCreateLockTTL).Result() if err != nil { - s.logger.Warn("获取订单创建分布式锁失败,继续执行", - zap.Error(err), - zap.String("lock_key", lockKey)) - return 0, nil + return 0, "", errors.Wrap(errors.CodeServiceUnavailable, err, "获取订单创建锁失败") } if !locked { - return 0, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交") + return 0, "", errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交") } // 第 3 层:加锁后二次检测,防止锁等待期间已被处理 @@ -2204,14 +2198,50 @@ func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, b s.logger.Info("订单幂等性二次检测命中", zap.Uint("order_id", uint(orderID)), zap.String("idempotency_key", idempotencyKey)) - return uint(orderID), nil + return uint(orderID), lockToken, nil } } + if err != nil && err != redis.Nil { + return 0, lockToken, errors.Wrap(errors.CodeServiceUnavailable, err, "复核订单幂等标记失败") + } - return 0, nil + return 0, lockToken, nil } -// markOrderCreated 订单创建成功后标记 Redis 并释放分布式锁 +// releaseOrderCreateLock 仅释放当前请求持有的订单创建锁。 +func (s *Service) releaseOrderCreateLock(ctx context.Context, lockKey, lockToken string) { + const compareAndDelete = ` +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +end +return 0` + if err := s.redis.Eval(ctx, compareAndDelete, []string{lockKey}, lockToken).Err(); err != nil { + s.logger.Warn("释放订单创建锁失败", zap.String("lock_key", lockKey), zap.Error(err)) + } +} + +func lockAndFindRecentWalletOrder(ctx context.Context, tx *gorm.DB, idempotencyKey string) (uint, error) { + if idempotencyKey == "" { + return 0, errors.New(errors.CodeInternalError, "钱包订单缺少持久化幂等指纹") + } + if err := tx.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(?)", orderIdempotencyAdvisoryKey(idempotencyKey)).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "锁定钱包订单幂等指纹失败") + } + var existing model.Order + err := tx.WithContext(ctx). + Where("idempotency_key = ? AND created_at >= CURRENT_TIMESTAMP - (? * INTERVAL '1 second')", + idempotencyKey, int64(orderIdempotencyTTL/time.Second)). + Order("id DESC").First(&existing).Error + if err == nil { + return existing.ID, nil + } + if err == gorm.ErrRecordNotFound { + return 0, nil + } + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询近期重复钱包订单失败") +} + +// markOrderCreated 在订单事务提交后写入 Redis 快速幂等标记。 func (s *Service) markOrderCreated(ctx context.Context, idempotencyKey string, orderID uint) { redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey) if err := s.redis.Set(ctx, redisKey, strconv.FormatUint(uint64(orderID), 10), orderIdempotencyTTL).Err(); err != nil { diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 11a10a6..ac2232f 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -13,6 +13,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/store" @@ -41,6 +42,7 @@ type Service struct { iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore assetWalletStore *postgres.AssetWalletStore + agentWalletRefundService *walletapp.RefundService logger *zap.Logger } @@ -77,6 +79,11 @@ func New( } } +// SetAgentWalletRefundService 注入代理主钱包统一退款回充用例。 +func (s *Service) SetAgentWalletRefundService(service *walletapp.RefundService) { + s.agentWalletRefundService = service +} + // Create 创建退款申请 // 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录 func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) { @@ -299,75 +306,33 @@ func (s *Service) refundWalletPayment(ctx context.Context, tx *gorm.DB, refund * // refundAgentWalletPayment 将代理钱包支付的订单退款退回原扣款主钱包。 func (s *Service) refundAgentWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { - wallet, relatedShopID, subtype, err := s.resolveAgentRefundWallet(tx, order) - if err != nil { - return err + if s.agentWalletRefundService == nil { + return errors.New(errors.CodeInternalError, "代理主钱包退款能力未配置") } - balanceBefore := wallet.Balance - if err := s.agentWalletStore.AddBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "退回代理预充值钱包失败") + legacyPayerShopID, legacyRelatedShopID, _ := resolveAgentWalletRefundShopID(order) + legacyDeductAmount := order.TotalAmount + if order.ActualPaidAmount != nil && *order.ActualPaidAmount > 0 { + legacyDeductAmount = *order.ActualPaidAmount + } + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value + } + correlationID := refund.RefundNo + if correlationID == "" { + correlationID = order.OrderNo } - - refType := constants.ReferenceTypeRefund - refID := refund.ID remark := fmt.Sprintf("订单%s退款退回预充值钱包", order.OrderNo) assetType, assetID, assetIdentifier := buildRefundWalletTransactionAssetSnapshot(order) - transaction := &model.AgentWalletTransaction{ - AgentWalletID: wallet.ID, - ShopID: wallet.ShopID, - UserID: operatorID, - TransactionType: constants.AgentTransactionTypeRefund, - TransactionSubtype: subtype, - Amount: amount, - BalanceBefore: balanceBefore, - BalanceAfter: balanceBefore + amount, - Status: constants.TransactionStatusSuccess, - ReferenceType: &refType, - ReferenceID: &refID, - RelatedShopID: relatedShopID, - AssetType: assetType, - AssetID: assetID, - AssetIdentifier: assetIdentifier, - Remark: &remark, - Creator: operatorID, - ShopIDTag: wallet.ShopIDTag, - EnterpriseIDTag: wallet.EnterpriseIDTag, - } - if err := s.agentWalletTransactionStore.CreateWithTx(ctx, tx, transaction); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建代理钱包退款流水失败") - } - - return nil -} - -// resolveAgentRefundWallet 优先按原扣款流水定位回款钱包,兼容历史缺失扣款流水的订单。 -func (s *Service) resolveAgentRefundWallet(tx *gorm.DB, order *model.Order) (*model.AgentWallet, *uint, *string, error) { - var deductTx model.AgentWalletTransaction - err := tx.Where("reference_type = ? AND reference_id = ? AND transaction_type = ?", - constants.ReferenceTypeOrder, order.ID, constants.AgentTransactionTypeDeduct). - Order("id ASC"). - First(&deductTx).Error - if err == nil { - wallet, err := lockAgentMainWalletByID(tx, deductTx.AgentWalletID) - if err != nil { - return nil, nil, nil, err - } - return wallet, deductTx.RelatedShopID, deductTx.TransactionSubtype, nil - } - if err != gorm.ErrRecordNotFound { - return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询原代理钱包扣款流水失败") - } - - payerShopID, relatedShopID, err := resolveAgentWalletRefundShopID(order) - if err != nil { - return nil, nil, nil, err - } - wallet, err := lockAgentMainWalletByShopID(tx, payerShopID) - if err != nil { - return nil, nil, nil, err - } - return wallet, relatedShopID, nil, nil + _, err := s.agentWalletRefundService.RefundInTx(ctx, tx, walletapp.RefundCommand{ + OrderID: order.ID, RefundID: refund.ID, Amount: amount, + LegacyPayerShopID: legacyPayerShopID, LegacyDeductAmount: legacyDeductAmount, + LegacyRelatedShopID: legacyRelatedShopID, UserID: operatorID, Creator: operatorID, + AssetType: assetType, AssetID: assetID, AssetIdentifier: assetIdentifier, + Remark: remark, RequestID: requestID, CorrelationID: correlationID, + }) + return err } // resolveAgentWalletRefundShopID 兼容旧订单:没有扣款流水时按订单角色推导原扣款店铺。 @@ -389,34 +354,6 @@ func resolveAgentWalletRefundShopID(order *model.Order) (uint, *uint, error) { return order.BuyerID, nil, nil } -// lockAgentMainWalletByID 锁定代理主钱包,保证余额快照与后续入账一致。 -func lockAgentMainWalletByID(tx *gorm.DB, walletID uint) (*model.AgentWallet, error) { - var wallet model.AgentWallet - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("id = ? AND wallet_type = ?", walletID, constants.AgentWalletTypeMain). - First(&wallet).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeWalletNotFound, "代理预充值钱包不存在") - } - return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理预充值钱包失败") - } - return &wallet, nil -} - -// lockAgentMainWalletByShopID 按店铺锁定代理主钱包。 -func lockAgentMainWalletByShopID(tx *gorm.DB, shopID uint) (*model.AgentWallet, error) { - var wallet model.AgentWallet - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain). - First(&wallet).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeWalletNotFound, "代理预充值钱包不存在") - } - return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理预充值钱包失败") - } - return &wallet, nil -} - // refundAssetWalletPayment 将个人资产钱包支付的订单退款退回原资产钱包。 func (s *Service) refundAssetWalletPayment(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, amount int64, operatorID uint) error { wallet, err := s.resolveAssetRefundWallet(tx, order) diff --git a/internal/service/role/service.go b/internal/service/role/service.go index 2ba991c..49639b9 100644 --- a/internal/service/role/service.go +++ b/internal/service/role/service.go @@ -160,7 +160,7 @@ func (s *Service) Delete(ctx context.Context, id uint) error { } // List 查询角色列表 -func (s *Service) List(ctx context.Context, req *dto.RoleListRequest) ([]*model.Role, int64, error) { +func (s *Service) List(ctx context.Context, req *dto.RoleListRequest) ([]*dto.RoleResponse, int64, error) { opts := &store.QueryOptions{ Page: req.Page, PageSize: req.PageSize, @@ -184,7 +184,15 @@ func (s *Service) List(ctx context.Context, req *dto.RoleListRequest) ([]*model. filters["status"] = *req.Status } - return s.roleStore.List(ctx, opts, filters) + roles, total, err := s.roleStore.List(ctx, opts, filters) + if err != nil { + return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询角色列表失败") + } + result := make([]*dto.RoleResponse, 0, len(roles)) + for _, role := range roles { + result = append(result, toResponse(role)) + } + return result, total, nil } // AssignPermissions 为角色分配权限 @@ -358,15 +366,18 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error { // toResponse 将 model.Role 转换为 dto.RoleResponse func toResponse(role *model.Role) *dto.RoleResponse { return &dto.RoleResponse{ - ID: role.ID, - RoleName: role.RoleName, - RoleDesc: role.RoleDesc, - RoleType: role.RoleType, - Status: role.Status, - Creator: role.Creator, - Updater: role.Updater, - CreatedAt: role.CreatedAt.Format(time.RFC3339), - UpdatedAt: role.UpdatedAt.Format(time.RFC3339), + ID: role.ID, + RoleName: role.RoleName, + RoleDesc: role.RoleDesc, + RoleType: role.RoleType, + Status: role.Status, + DefaultCreditEnabled: role.DefaultCreditEnabled, + DefaultCreditLimit: role.DefaultCreditLimit, + DefaultCreditScope: "new_shops_only", + Creator: role.Creator, + Updater: role.Updater, + CreatedAt: role.CreatedAt.Format(time.RFC3339), + UpdatedAt: role.UpdatedAt.Format(time.RFC3339), } } diff --git a/internal/service/shop/service.go b/internal/service/shop/service.go index f6efc28..d99afd3 100644 --- a/internal/service/shop/service.go +++ b/internal/service/shop/service.go @@ -10,17 +10,14 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" - "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) type Service struct { - shopStore *postgres.ShopStore - accountStore *postgres.AccountStore - shopRoleStore *postgres.ShopRoleStore - roleStore *postgres.RoleStore - accountRoleStore *postgres.AccountRoleStore - agentWalletStore *postgres.AgentWalletStore + shopStore *postgres.ShopStore + accountStore *postgres.AccountStore + shopRoleStore *postgres.ShopRoleStore + roleStore *postgres.RoleStore } func New( @@ -28,177 +25,15 @@ func New( accountStore *postgres.AccountStore, shopRoleStore *postgres.ShopRoleStore, roleStore *postgres.RoleStore, - accountRoleStore *postgres.AccountRoleStore, - agentWalletStore *postgres.AgentWalletStore, ) *Service { return &Service{ - shopStore: shopStore, - accountStore: accountStore, - shopRoleStore: shopRoleStore, - roleStore: roleStore, - accountRoleStore: accountRoleStore, - agentWalletStore: agentWalletStore, + shopStore: shopStore, + accountStore: accountStore, + shopRoleStore: shopRoleStore, + roleStore: roleStore, } } -func (s *Service) Create(ctx context.Context, req *dto.CreateShopRequest) (*dto.ShopResponse, error) { - currentUserID := middleware.GetUserIDFromContext(ctx) - if currentUserID == 0 { - return nil, errors.New(errors.CodeUnauthorized, "未授权访问") - } - - existing, err := s.shopStore.GetByCode(ctx, req.ShopCode) - if err == nil && existing != nil { - return nil, errors.New(errors.CodeShopCodeExists, "店铺编号已存在") - } - - level := 1 - parentShopName := "" - if req.ParentID != nil { - parent, err := s.shopStore.GetByID(ctx, *req.ParentID) - if err != nil { - return nil, errors.New(errors.CodeInvalidParentID, "上级店铺不存在或无效") - } - parentShopName = parent.ShopName - level = parent.Level + 1 - if level > constants.ShopMaxLevel { - return nil, errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级") - } - } - - existingAccount, err := s.accountStore.GetByUsername(ctx, req.InitUsername) - if err == nil && existingAccount != nil { - return nil, errors.New(errors.CodeUsernameExists, "初始账号用户名已存在") - } - - existingAccount, err = s.accountStore.GetByPhone(ctx, req.InitPhone) - if err == nil && existingAccount != nil { - return nil, errors.New(errors.CodePhoneExists, "初始账号手机号已存在") - } - - // 验证默认角色:必须存在、是客户角色且已启用 - defaultRole, err := s.roleStore.GetByID(ctx, req.DefaultRoleID) - if err != nil { - return nil, errors.New(errors.CodeNotFound, "请选择默认角色") - } - if defaultRole.RoleType != constants.RoleTypeCustomer { - return nil, errors.New(errors.CodeInvalidParam, "店铺默认角色必须是客户角色") - } - if defaultRole.Status != constants.StatusEnabled { - return nil, errors.New(errors.CodeInvalidParam, "默认角色已禁用") - } - - shop := &model.Shop{ - ShopName: req.ShopName, - ShopCode: req.ShopCode, - ParentID: req.ParentID, - Level: level, - ContactName: req.ContactName, - ContactPhone: req.ContactPhone, - Province: req.Province, - City: req.City, - District: req.District, - Address: req.Address, - Status: constants.ShopStatusEnabled, - } - shop.Creator = currentUserID - shop.Updater = currentUserID - - if err := s.shopStore.Create(ctx, shop); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建店铺失败") - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.InitPassword), bcrypt.DefaultCost) - if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败") - } - - account := &model.Account{ - Username: req.InitUsername, - Phone: req.InitPhone, - Password: string(hashedPassword), - UserType: constants.UserTypeAgent, - ShopID: &shop.ID, - Status: constants.StatusEnabled, - IsPrimary: true, - } - account.Creator = currentUserID - account.Updater = currentUserID - - if err := s.accountStore.Create(ctx, account); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建初始账号失败") - } - - // 为初始账号分配默认角色 - accountRole := &model.AccountRole{ - AccountID: account.ID, - RoleID: req.DefaultRoleID, - Status: constants.StatusEnabled, - Creator: currentUserID, - Updater: currentUserID, - } - if err := s.accountRoleStore.Create(ctx, accountRole); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "为初始账号分配角色失败") - } - - // 设置店铺默认角色 - shopRole := &model.ShopRole{ - ShopID: shop.ID, - RoleID: req.DefaultRoleID, - Status: constants.StatusEnabled, - Creator: currentUserID, - Updater: currentUserID, - } - if err := s.shopRoleStore.BatchCreate(ctx, []*model.ShopRole{shopRole}); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "设置店铺默认角色失败") - } - - // 初始化店铺代理钱包:主钱包 + 分佣钱包 - // 新店铺必须有两个钱包才能参与充值和分佣体系 - wallets := []*model.AgentWallet{ - { - ShopID: shop.ID, - WalletType: constants.AgentWalletTypeMain, - Balance: 0, - Currency: "CNY", - Status: constants.AgentWalletStatusNormal, - ShopIDTag: shop.ID, - }, - { - ShopID: shop.ID, - WalletType: constants.AgentWalletTypeCommission, - Balance: 0, - Currency: "CNY", - Status: constants.AgentWalletStatusNormal, - ShopIDTag: shop.ID, - }, - } - for _, wallet := range wallets { - if err := s.agentWalletStore.Create(ctx, wallet); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "初始化店铺钱包失败") - } - } - - return &dto.ShopResponse{ - ID: shop.ID, - ShopName: shop.ShopName, - ShopCode: shop.ShopCode, - ParentID: shop.ParentID, - ParentShopName: parentShopName, - 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"), - }, nil -} - func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopRequest) (*dto.ShopResponse, error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index 4cd2126..a259431 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -56,147 +56,6 @@ func New( } } -// ListShopFundSummary 代理商资金概况列表(含预充值余额和佣金钱包) -// GET /shops/fund-summary -func (s *Service) ListShopFundSummary(ctx context.Context, req *dto.ShopFundSummaryListReq) (*dto.ShopFundSummaryPageResult, error) { - opts := &store.QueryOptions{ - Page: req.Page, - PageSize: req.PageSize, - OrderBy: "created_at DESC", - } - if opts.Page == 0 { - opts.Page = 1 - } - if opts.PageSize == 0 { - opts.PageSize = constants.DefaultPageSize - } - - filters := make(map[string]interface{}) - if req.ShopName != "" { - filters["shop_name"] = req.ShopName - } - - shops, total, err := s.shopStore.List(ctx, opts, filters) - if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺列表失败") - } - - if len(shops) == 0 { - return &dto.ShopFundSummaryPageResult{ - Items: []dto.ShopFundSummaryItem{}, - Total: 0, - Page: opts.Page, - Size: opts.PageSize, - }, nil - } - - shopIDs := make([]uint, 0, len(shops)) - for _, shop := range shops { - shopIDs = append(shopIDs, shop.ID) - } - - // 批量获取主钱包(预充值钱包)余额 - mainWallets, err := s.agentWalletStore.GetShopMainWalletBatch(ctx, shopIDs) - if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询主钱包余额失败") - } - - walletSummaries, err := s.agentWalletStore.GetShopCommissionSummaryBatch(ctx, shopIDs) - if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询店铺钱包汇总失败") - } - - withdrawnAmounts, err := s.commissionWithdrawalReqStore.SumAmountByShopIDsAndStatus(ctx, shopIDs, constants.WithdrawalStatusApproved) - if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询已提现金额失败") - } - - withdrawingAmounts, err := s.commissionWithdrawalReqStore.SumAmountByShopIDsAndStatus(ctx, shopIDs, constants.WithdrawalStatusPending) - if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询提现中金额失败") - } - - primaryAccounts, err := s.accountStore.GetPrimaryAccountsByShopIDs(ctx, shopIDs) - if err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询主账号失败") - } - - accountMap := make(map[uint]*model.Account) - for _, acc := range primaryAccounts { - if acc.ShopID != nil { - accountMap[*acc.ShopID] = acc - } - } - - items := make([]dto.ShopFundSummaryItem, 0, len(shops)) - for _, shop := range shops { - if req.Username != "" { - acc := accountMap[shop.ID] - if acc == nil || !containsSubstring(acc.Username, req.Username) { - total-- - continue - } - } - - item := s.buildFundSummaryItem(shop, mainWallets[shop.ID], walletSummaries[shop.ID], withdrawnAmounts[shop.ID], withdrawingAmounts[shop.ID], accountMap[shop.ID]) - items = append(items, item) - } - - return &dto.ShopFundSummaryPageResult{ - Items: items, - Total: total, - Page: opts.Page, - Size: opts.PageSize, - }, nil -} - -// buildFundSummaryItem 构造资金概况条目 -func (s *Service) buildFundSummaryItem(shop *model.Shop, mainWallet, commissionWallet *model.AgentWallet, withdrawnAmount, withdrawingAmount int64, account *model.Account) dto.ShopFundSummaryItem { - // 主钱包余额(若未充值则为0) - var mainBalance, mainFrozenBalance int64 - if mainWallet != nil { - mainBalance = mainWallet.Balance - mainFrozenBalance = mainWallet.FrozenBalance - } - - // 佣金钱包 - var balance, frozenBalance int64 - if commissionWallet != nil { - balance = commissionWallet.Balance - frozenBalance = commissionWallet.FrozenBalance - } - - totalCommission := balance + frozenBalance + withdrawnAmount - unwithdrawCommission := totalCommission - withdrawnAmount - availableCommission := balance - withdrawingAmount - if availableCommission < 0 { - availableCommission = 0 - } - - var username, phone string - if account != nil { - username = account.Username - phone = account.Phone - } - - return dto.ShopFundSummaryItem{ - ShopID: shop.ID, - ShopName: shop.ShopName, - ShopCode: shop.ShopCode, - Username: username, - Phone: phone, - MainBalance: mainBalance, - MainFrozenBalance: mainFrozenBalance, - TotalCommission: totalCommission, - WithdrawnCommission: withdrawnAmount, - UnwithdrawCommission: unwithdrawCommission, - FrozenCommission: frozenBalance, - WithdrawingCommission: withdrawingAmount, - AvailableCommission: availableCommission, - CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), - } -} - // ListShopWithdrawalRequests 查询代理商提现记录 // GET /shops/:id/withdrawal-requests func (s *Service) ListShopWithdrawalRequests(ctx context.Context, shopID uint, req *dto.ShopWithdrawalRequestListReq) (*dto.ShopWithdrawalRequestPageResult, error) { @@ -832,16 +691,3 @@ func generateWithdrawalNo() string { now := time.Now() return fmt.Sprintf("W%s%04d", now.Format("20060102150405"), rand.Intn(10000)) } - -func containsSubstring(s, substr string) bool { - return len(s) >= len(substr) && (s == substr || len(substr) == 0 || (len(s) > 0 && len(substr) > 0 && contains(s, substr))) -} - -func contains(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/internal/store/postgres/account_store.go b/internal/store/postgres/account_store.go index 94764c0..731b982 100644 --- a/internal/store/postgres/account_store.go +++ b/internal/store/postgres/account_store.go @@ -263,7 +263,7 @@ func (s *AccountStore) GetPrimaryAccountsByShopIDs(ctx context.Context, shopIDs Where("shop_id IN ? AND is_primary = ?", shopIDs, true) // 注意:此处不再应用数据权限过滤 // 因为调用方(Service 层)已经保证了 shopIDs 的合法性 - // 在 ListShopFundSummary 场景中,店铺列表已由 Service 层过滤 + // 在资金概况等批量投影场景中,调用方已经保证 shopIDs 位于当前数据范围内 if err := query.Find(&accounts).Error; err != nil { return nil, err } diff --git a/internal/store/postgres/agent_wallet_store.go b/internal/store/postgres/agent_wallet_store.go index 3bb54f2..aa15413 100644 --- a/internal/store/postgres/agent_wallet_store.go +++ b/internal/store/postgres/agent_wallet_store.go @@ -80,12 +80,12 @@ func (s *AgentWalletStore) CreateWithTx(ctx context.Context, tx *gorm.DB, wallet return tx.WithContext(ctx).Create(wallet).Error } -// DeductFrozenBalanceWithTx 从冻结余额扣款(带事务) -// 用于提现完成后,从冻结余额中扣除金额 +// DeductFrozenBalanceWithTx 从分佣钱包冻结余额扣款(带事务)。 +// 代理主钱包必须使用 Wallet Application,禁止通过旧 Store 写接缝变更。 func (s *AgentWalletStore) DeductFrozenBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error { // 扣除冻结余额和总余额 result := tx.WithContext(ctx).Model(&model.AgentWallet{}). - Where("id = ? AND frozen_balance >= ?", walletID, amount). + Where("id = ? AND wallet_type = ? AND frozen_balance >= ?", walletID, constants.AgentWalletTypeCommission, amount). Updates(map[string]interface{}{ "balance": gorm.Expr("balance - ?", amount), "frozen_balance": gorm.Expr("frozen_balance - ?", amount), @@ -106,12 +106,12 @@ func (s *AgentWalletStore) DeductFrozenBalanceWithTx(ctx context.Context, tx *go return nil } -// UnfreezeBalanceWithTx 解冻余额到可用余额(带事务) -// 用于提现取消,将冻结余额转回可用余额 +// UnfreezeBalanceWithTx 解冻分佣钱包余额(带事务)。 +// 代理主钱包必须使用 Wallet Application,禁止通过旧 Store 写接缝变更。 func (s *AgentWalletStore) UnfreezeBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error { // 减少冻结余额(总余额不变) result := tx.WithContext(ctx).Model(&model.AgentWallet{}). - Where("id = ? AND frozen_balance >= ?", walletID, amount). + Where("id = ? AND wallet_type = ? AND frozen_balance >= ?", walletID, constants.AgentWalletTypeCommission, amount). Updates(map[string]interface{}{ "frozen_balance": gorm.Expr("frozen_balance - ?", amount), "updated_at": time.Now(), @@ -131,37 +131,11 @@ func (s *AgentWalletStore) UnfreezeBalanceWithTx(ctx context.Context, tx *gorm.D return nil } -// FreezeBalanceWithTx 冻结余额(带事务,使用乐观锁) -// 用于提现申请,将可用余额转为冻结状态 -func (s *AgentWalletStore) FreezeBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error { - // 增加冻结余额(总余额不变),使用乐观锁 - result := tx.WithContext(ctx).Model(&model.AgentWallet{}). - Where("id = ? AND balance - frozen_balance >= ? AND version = ?", walletID, amount, version). - Updates(map[string]interface{}{ - "frozen_balance": gorm.Expr("frozen_balance + ?", amount), - "version": gorm.Expr("version + 1"), - "updated_at": time.Now(), - }) - - if result.Error != nil { - return result.Error - } - - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound // 可用余额不足或版本冲突 - } - - // 删除缓存 - s.clearWalletCache(ctx, walletID) - - return nil -} - -// AddBalanceWithTx 增加余额(带事务) -// 用于充值、退款等增加余额的操作 +// AddBalanceWithTx 增加分佣钱包余额(带事务)。 +// 代理主钱包充值、退款和人工调整必须使用 Wallet Application。 func (s *AgentWalletStore) AddBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64) error { result := tx.WithContext(ctx).Model(&model.AgentWallet{}). - Where("id = ?", walletID). + Where("id = ? AND wallet_type = ?", walletID, constants.AgentWalletTypeCommission). Updates(map[string]interface{}{ "balance": gorm.Expr("balance + ?", amount), "version": gorm.Expr("version + 1"), @@ -182,32 +156,6 @@ func (s *AgentWalletStore) AddBalanceWithTx(ctx context.Context, tx *gorm.DB, wa return nil } -// DeductBalanceWithTx 扣除余额(带事务,使用乐观锁) -// 用于扣款操作,检查可用余额是否充足 -func (s *AgentWalletStore) DeductBalanceWithTx(ctx context.Context, tx *gorm.DB, walletID uint, amount int64, version int) error { - // 使用乐观锁,检查可用余额是否充足 - result := tx.WithContext(ctx).Model(&model.AgentWallet{}). - Where("id = ? AND balance - frozen_balance >= ? AND version = ?", walletID, amount, version). - Updates(map[string]interface{}{ - "balance": gorm.Expr("balance - ?", amount), - "version": gorm.Expr("version + 1"), - "updated_at": time.Now(), - }) - - if result.Error != nil { - return result.Error - } - - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound // 余额不足或版本冲突 - } - - // 删除缓存 - s.clearWalletCache(ctx, walletID) - - return nil -} - // GetShopCommissionSummaryBatch 批量获取店铺佣金钱包汇总 // 返回 map[shopID]*AgentWallet func (s *AgentWalletStore) GetShopCommissionSummaryBatch(ctx context.Context, shopIDs []uint) (map[uint]*model.AgentWallet, error) { diff --git a/internal/task/card_observation_series.go b/internal/task/card_observation_series.go new file mode 100644 index 0000000..faa0a4b --- /dev/null +++ b/internal/task/card_observation_series.go @@ -0,0 +1,41 @@ +package task + +import ( + "context" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + "go.uber.org/zap" + + cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// CardObservationSeriesHandler 处理固定零重试的卡观测序列尝试。 +type CardObservationSeriesHandler struct { + service *cardapp.SeriesAttemptService + logger *zap.Logger +} + +// NewCardObservationSeriesHandler 创建卡观测序列任务处理器。 +func NewCardObservationSeriesHandler(service *cardapp.SeriesAttemptService, logger *zap.Logger) *CardObservationSeriesHandler { + return &CardObservationSeriesHandler{service: service, logger: logger} +} + +// Handle 解析结构化载荷并执行单次观测。 +func (h *CardObservationSeriesHandler) Handle(ctx context.Context, task *asynq.Task) error { + if h == nil || h.service == nil { + return errors.New(errors.CodeInternalError, "卡观测序列任务服务未配置") + } + var payload cardapp.SeriesTaskPayload + if err := sonic.Unmarshal(task.Payload(), &payload); err != nil { + h.logger.Error("解析卡观测序列任务载荷失败", zap.Error(err)) + return errors.Wrap(errors.CodeInvalidParam, err, "卡观测序列任务载荷无法解析") + } + if err := h.service.Execute(ctx, payload); err != nil { + h.logger.Warn("卡观测序列当前尝试失败", + zap.String("series_id", payload.SeriesID), zap.Int("attempt", payload.Attempt), zap.Error(err)) + return err + } + return nil +} diff --git a/internal/task/notification_cleanup.go b/internal/task/notification_cleanup.go new file mode 100644 index 0000000..e37a305 --- /dev/null +++ b/internal/task/notification_cleanup.go @@ -0,0 +1,31 @@ +package task + +import ( + "context" + + "github.com/hibiken/asynq" + "go.uber.org/zap" + + notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" +) + +// NotificationCleanupHandler 处理低峰通知保留清理任务。 +type NotificationCleanupHandler struct { + service *notificationinfra.CleanupService + logger *zap.Logger +} + +// NewNotificationCleanupHandler 创建通知保留清理任务处理器。 +func NewNotificationCleanupHandler(service *notificationinfra.CleanupService, logger *zap.Logger) *NotificationCleanupHandler { + return &NotificationCleanupHandler{service: service, logger: logger} +} + +// Handle 执行有界、可重入的通知分批清理。 +func (h *NotificationCleanupHandler) Handle(ctx context.Context, _ *asynq.Task) error { + h.logger.Info("开始执行站内通知保留清理") + if err := h.service.Run(ctx); err != nil { + h.logger.Error("站内通知保留清理失败", zap.String("failure_category", "database"), zap.Error(err)) + return err + } + return nil +} diff --git a/internal/task/polling_carddata_handler.go b/internal/task/polling_carddata_handler.go index dc04930..6bef3ad 100644 --- a/internal/task/polling_carddata_handler.go +++ b/internal/task/polling_carddata_handler.go @@ -2,211 +2,110 @@ package task import ( "context" + "strings" "time" "github.com/hibiken/asynq" "go.uber.org/zap" - "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/model" - iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" - packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" ) -// PollingCarddataHandler 流量检查任务处理器 -// 职责:调 Gateway 查流量 → 写 DB → 扣减套餐流量 → 调 EvaluateAndAct 判断停复机 -// 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct +// PollingCarddataHandler 负责流量轮询编排,查询结果统一交给卡观测应用服务。 type PollingCarddataHandler struct { - base *PollingBase - gateway *gateway.Client - iotCardStore *postgres.IotCardStore - carrierStore *postgres.CarrierStore - usageService *packagepkg.UsageService - stopResumeSvc iot_card_svc.StopResumeServiceInterface + base *PollingBase + gateway *gateway.Client + carrier *postgres.CarrierStore + observation *cardapp.Service + integration *integrationlog.Repository } -// NewPollingCarddataHandler 创建流量检查任务处理器 -func NewPollingCarddataHandler( - base *PollingBase, - gw *gateway.Client, - iotCardStore *postgres.IotCardStore, - carrierStore *postgres.CarrierStore, - usageService *packagepkg.UsageService, - stopResumeSvc iot_card_svc.StopResumeServiceInterface, -) *PollingCarddataHandler { - return &PollingCarddataHandler{ - base: base, - gateway: gw, - iotCardStore: iotCardStore, - carrierStore: carrierStore, - usageService: usageService, - stopResumeSvc: stopResumeSvc, - } +// NewPollingCarddataHandler 创建流量检查任务处理器。 +func NewPollingCarddataHandler(base *PollingBase, gw *gateway.Client, carrier *postgres.CarrierStore, observation *cardapp.Service, integration *integrationlog.Repository) *PollingCarddataHandler { + return &PollingCarddataHandler{base: base, gateway: gw, carrier: carrier, observation: observation, integration: integration} } -// Handle 处理流量检查任务 -func (h *PollingCarddataHandler) Handle(ctx context.Context, t *asynq.Task) error { - startTime := time.Now() - - cardID, ok := parseTaskPayload(t.Payload(), h.base.logger) +// Handle 处理流量检查任务。 +func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) error { + startedAt := time.Now() + cardID, ok := parseTaskPayload(task.Payload(), h.base.logger) if !ok { return nil } - if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingCarddata) { h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) } defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCarddata) - - locked, lockErr := h.base.acquireCardTrafficSyncLock(ctx, cardID) - if lockErr != nil { - h.base.logger.Warn("获取卡流量同步锁失败,延迟重入队", zap.Uint("card_id", cardID), zap.Error(lockErr)) - return h.base.queueMgr.Requeue(ctx, cardID, constants.TaskTypePollingCarddata, time.Now().Add(5*time.Second)) - } - if !locked { - h.base.logger.Info("卡流量同步正在执行,延迟重入队", zap.Uint("card_id", cardID)) + locked, err := h.base.acquireCardTrafficSyncLock(ctx, cardID) + if err != nil || !locked { + h.base.logger.Warn("卡流量同步锁不可用,延迟重入队", zap.Uint("card_id", cardID), zap.Error(err)) return h.base.queueMgr.Requeue(ctx, cardID, constants.TaskTypePollingCarddata, time.Now().Add(5*time.Second)) } defer h.base.releaseCardTrafficSyncLock(ctx, cardID) h.base.invalidateCardCache(ctx, cardID) - card, err := h.iotCardStore.GetByID(ctx, cardID) + card, err := h.base.iotCardStore.GetByID(ctx, cardID) if err != nil { if isNotFound(err) { return nil } - h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(err)) - h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime)) + return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err) + } + if h.gateway == nil { return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) } - - var gatewayFlowMB float64 - if h.gateway != nil { - result, gwErr := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID}) - if gwErr != nil { - h.base.logger.Warn("查询流量失败", - zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(gwErr)) - h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime)) - return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) - } - gatewayFlowMB = float64(result.Used) - } else { - gatewayFlowMB = card.CurrentMonthUsageMB + attemptStartedAt := time.Now() + attempt, err := startGatewayAttempt(ctx, h.integration, cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling) + if err != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "建立 Gateway 流量 Integration Log 失败", err) + } + result, err := h.gateway.QueryFlow(ctx, &gateway.FlowQueryReq{CardNo: card.ICCID}) + if err != nil { + _ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt) + return h.failAndRequeue(ctx, cardID, startedAt, "查询流量失败", err) + } + if strings.TrimSpace(result.ICCID) == "" { + _ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt) + return h.failAndRequeue(ctx, cardID, startedAt, "流量查询响应缺少 ICCID", nil) + } + if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 流量 Integration Log 失败", logErr) + } + if h.observation == nil || h.carrier == nil { + return h.failAndRequeue(ctx, cardID, startedAt, "卡流量观测能力未配置", nil) } - now := time.Now() - resetDay := h.carrierStore.GetDataResetDay(ctx, card.CarrierID) - updates, flowIncrementMB, isCrossMonth := h.calculateFlowUpdates(card, gatewayFlowMB, now, resetDay) - updates["last_data_check_at"] = now - if h.gateway != nil { - updates["last_sync_time"] = now + decision, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ + CardID: cardID, GatewayReadingMB: float64(result.Used), ResetDay: h.carrier.GetDataResetDay(ctx, card.CarrierID), + Metadata: carddomain.ObservationMetadata{ + ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling, + Scene: constants.CardObservationSceneTrafficPolling, ObservedAt: now, CorrelationID: attempt.IntegrationID, + }, + }) + if err != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "应用流量观测失败", err) } - - if h.base.verboseLog && h.gateway != nil { - h.base.logger.Info("流量轮询详情", - zap.Uint("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.Float64("gateway_flow_mb", gatewayFlowMB), - zap.Float64("increment_mb", flowIncrementMB), - zap.Bool("is_cross_month", isCrossMonth)) + if h.base.verboseLog { + h.base.logger.Info("流量轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), + zap.Float64("gateway_flow_mb", float64(result.Used)), zap.Float64("increment_mb", decision.IncrementMB), + zap.Bool("is_cross_month", decision.CrossMonth), zap.Bool("reading_accepted", decision.ReadingAccepted)) } - - if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, updates); updateErr != nil { - h.base.logger.Error("更新卡流量信息失败", zap.Uint("card_id", cardID), zap.Error(updateErr)) - h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startTime)) - return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) - } - - go h.base.insertDataUsageRecord(context.Background(), cardID, flowIncrementMB, now) - - if refreshedCard, loadErr := h.iotCardStore.GetByID(ctx, cardID); loadErr == nil { - writeCardToCache(h.base, constants.RedisPollingCardInfoKey(cardID), refreshedCard) - } else { - h.base.invalidateCardCache(ctx, cardID) - h.base.logger.Warn("刷新卡缓存失败", zap.Uint("card_id", cardID), zap.Error(loadErr)) - } - - if flowIncrementMB > 0 && h.usageService != nil { - if deductErr := h.usageService.DeductDataUsage(ctx, constants.AssetTypeIotCard, cardID, flowIncrementMB); deductErr != nil { - h.base.logger.Warn("套餐流量扣减失败", - zap.Uint("card_id", cardID), zap.Float64("increment_mb", flowIncrementMB), zap.Error(deductErr)) - } - } - - if h.stopResumeSvc != nil { - freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID) - if loadErr == nil { - if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil { - h.base.logger.Warn("流量检查后停复机评估失败", - zap.Uint("card_id", cardID), zap.Error(evalErr)) - } - } - } - - h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startTime)) + h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startedAt)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) } -// calculateFlowUpdates 计算流量更新字段、增量和是否跨月 -// 决策13:完整迁移跨月流量边界检测逻辑(月份切换检测、上月总量保存、当月计数器重置) -// 第三个返回值 isCrossMonth 供调用方同步更新 Redis 缓存,避免下次误判跨月 -func (h *PollingCarddataHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time, resetDay int) (map[string]any, float64, bool) { - updates := make(map[string]any) - - increment := gatewayFlowMB - card.LastGatewayReadingMB - shouldUpdateGatewayReading := true - if increment < 0 { - if isResetWindow(now, resetDay) { - increment = gatewayFlowMB - h.base.logger.Info("检测到上游运营商重置", - zap.Uint("card_id", card.ID), - zap.Float64("gateway_flow", gatewayFlowMB), - zap.Float64("last_reading", card.LastGatewayReadingMB), - zap.Int("reset_day", resetDay)) - } else { - h.base.logger.Warn("流量异常:非重置日出现值下降", - zap.Uint("card_id", card.ID), - zap.Float64("gateway_flow", gatewayFlowMB), - zap.Float64("last_reading", card.LastGatewayReadingMB)) - increment = 0 - shouldUpdateGatewayReading = false - } +func (h *PollingCarddataHandler) failAndRequeue(ctx context.Context, cardID uint, startedAt time.Time, message string, err error) error { + fields := []zap.Field{zap.Uint("card_id", cardID)} + if err != nil { + fields = append(fields, zap.Error(err)) } - - if shouldUpdateGatewayReading { - updates["last_gateway_reading_mb"] = gatewayFlowMB - } - - currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) - isCrossMonth := card.CurrentMonthStartDate == nil || - card.CurrentMonthStartDate.Before(currentMonthStart) - - if isCrossMonth { - h.base.logger.Info("检测到跨月,重置流量计数", - zap.Uint("card_id", card.ID), - zap.Float64("last_month_total", card.CurrentMonthUsageMB)) - updates["last_month_total_mb"] = card.CurrentMonthUsageMB - updates["current_month_start_date"] = currentMonthStart - if increment > 0 { - updates["current_month_usage_mb"] = increment - } else { - updates["current_month_usage_mb"] = float64(0) - } - } else if increment > 0 { - updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment) - } - - if increment > 0 { - updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment)) - } - - if card.CurrentMonthStartDate == nil { - updates["current_month_start_date"] = currentMonthStart - } - - return updates, increment, isCrossMonth + h.base.logger.Warn(message, fields...) + h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startedAt)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata) } diff --git a/internal/task/polling_cardstatus_handler.go b/internal/task/polling_cardstatus_handler.go index 41f05ce..576f86d 100644 --- a/internal/task/polling_cardstatus_handler.go +++ b/internal/task/polling_cardstatus_handler.go @@ -8,181 +8,108 @@ import ( "github.com/hibiken/asynq" "go.uber.org/zap" + cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/model" - iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" - "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" ) -// shouldStopPollingForRisk 判断轮询到风险 gateway_extend 时是否应停止该卡的轮询 -// 仅独立卡(is_standalone=true)命中风险状态时才停止;绑定设备的卡不受此逻辑约束 -func shouldStopPollingForRisk(card *model.IotCard, newGatewayExtend string) bool { - if !card.IsStandalone { +// PollingCardStatusHandler 负责网络状态轮询编排,查询结果统一交给卡观测应用服务。 +type PollingCardStatusHandler struct { + base *PollingBase + gateway *gateway.Client + observation *cardapp.Service + integration *integrationlog.Repository +} + +// shouldStopPollingForRisk 保留旧测试与调用方的纯判断兼容入口,规则权威在卡观测领域层。 +func shouldStopPollingForRisk(card *model.IotCard, extend string) bool { + if card == nil { return false } - extend := strings.TrimSpace(newGatewayExtend) - return extend == constants.GatewayCardExtendRiskStop || - extend == constants.GatewayCardExtendCancelled + return carddomain.ShouldStopPollingForRisk(card.IsStandalone, strings.TrimSpace(extend)) } -// PollingCardStatusHandler 卡开停机状态轮询任务处理器 -// 职责:调 Gateway 查卡状态(正常/停机/准备)→ 映射为 network_status → 写 DB → 触发停复机评估 -// 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct -type PollingCardStatusHandler struct { - base *PollingBase - gateway *gateway.Client - iotCardStore *postgres.IotCardStore - stopResumeSvc iot_card_svc.StopResumeServiceInterface +// NewPollingCardStatusHandler 创建卡状态轮询任务处理器。 +func NewPollingCardStatusHandler(base *PollingBase, gw *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *PollingCardStatusHandler { + return &PollingCardStatusHandler{base: base, gateway: gw, observation: observation, integration: integration} } -// NewPollingCardStatusHandler 创建卡状态轮询任务处理器 -func NewPollingCardStatusHandler( - base *PollingBase, - gw *gateway.Client, - iotCardStore *postgres.IotCardStore, - stopResumeSvc iot_card_svc.StopResumeServiceInterface, -) *PollingCardStatusHandler { - return &PollingCardStatusHandler{ - base: base, - gateway: gw, - iotCardStore: iotCardStore, - stopResumeSvc: stopResumeSvc, - } -} - -// Handle 处理卡状态轮询任务 -func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) error { - startTime := time.Now() - - cardID, ok := parseTaskPayload(t.Payload(), h.base.logger) +// Handle 处理卡状态轮询任务。 +func (h *PollingCardStatusHandler) Handle(ctx context.Context, task *asynq.Task) error { + startedAt := time.Now() + cardID, ok := parseTaskPayload(task.Payload(), h.base.logger) if !ok { return nil } - if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingCardStatus) { h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) } defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCardStatus) - card, err := h.base.getCardWithCache(ctx, cardID) if err != nil { if isNotFound(err) { return nil } - h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(err)) - h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startTime)) + return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err) + } + if h.gateway == nil { return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) } - - newNetworkStatus := card.NetworkStatus - gatewayCardStatus := "" - gatewayExtend := card.GatewayExtend - gatewayCardIMEI := "" - statusQueried := false - if h.gateway != nil { - result, gwErr := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) - if gwErr != nil { - h.base.logger.Warn("查询卡状态失败", - zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(gwErr)) - h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startTime)) - return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) - } - gatewayCardStatus = result.CardStatus - gatewayExtend = strings.TrimSpace(result.Extend) - gatewayCardIMEI = strings.TrimSpace(result.IMEI) - statusQueried = true - parsedStatus, ok := gateway.ParseCardNetworkStatus(result.CardStatus, gatewayExtend) - if !ok { - h.base.logger.Warn("卡状态轮询:未知 Gateway 卡状态", - zap.Uint("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.String("card_status", result.CardStatus), - zap.String("extend", gatewayExtend)) - } else { - newNetworkStatus = parsedStatus - } - if h.base.verboseLog { - h.base.logger.Info("卡状态轮询详情", - zap.Uint("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.String("card_status", result.CardStatus), - zap.String("extend", gatewayExtend), - zap.Int("new_network_status", newNetworkStatus), - zap.Bool("changed", newNetworkStatus != card.NetworkStatus)) - } + attemptStartedAt := time.Now() + attempt, err := startGatewayAttempt(ctx, h.integration, cardID, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling) + if err != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "建立 Gateway 网络 Integration Log 失败", err) + } + result, err := h.gateway.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) + if err != nil { + _ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt) + return h.failAndRequeue(ctx, cardID, startedAt, "查询卡状态失败", err) + } + if strings.TrimSpace(result.ICCID) == "" { + _ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt) + return h.failAndRequeue(ctx, cardID, startedAt, "卡状态查询响应缺少 ICCID", nil) + } + if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 网络 Integration Log 失败", logErr) + } + if h.observation == nil { + return h.failAndRequeue(ctx, cardID, startedAt, "卡网络观测能力未配置", nil) } - - statusChanged := newNetworkStatus != card.NetworkStatus now := time.Now() - - // 无论状态是否变化,都更新最后一次检查时间 - fields := map[string]any{"last_card_status_check_at": now} - if statusQueried { - fields["gateway_extend"] = gatewayExtend - fields["last_sync_time"] = now - if gatewayCardIMEI != "" { - fields["gateway_card_imei"] = gatewayCardIMEI - } + decision, err := h.observation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{ + CardID: cardID, GatewayStatus: result.CardStatus, GatewayExtend: result.Extend, GatewayIMEI: result.IMEI, + Metadata: carddomain.ObservationMetadata{ + ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling, + Scene: constants.CardObservationSceneNetworkPolling, ObservedAt: now, CorrelationID: attempt.IntegrationID, + }, + }) + if err != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "应用网络状态观测失败", err) } - if statusChanged { - fields["network_status"] = newNetworkStatus - // 网关侧停机事件:补写 stop_reason,便于状态追踪和区分手动停机 - if newNetworkStatus == constants.NetworkStatusOffline && - card.StopReason == "" && - gateway.IsGatewayCardStopped(gatewayCardStatus) { - fields["stop_reason"] = constants.StopReasonCarrierStopped - } + if h.base.verboseLog || !decision.StatusKnown { + h.base.logger.Info("卡状态轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), + zap.String("card_status", result.CardStatus), zap.String("extend", strings.TrimSpace(result.Extend)), + zap.Int("new_network_status", decision.AfterStatus), zap.Bool("status_known", decision.StatusKnown), + zap.Bool("changed", decision.StatusChanged), zap.Bool("stop_polling", decision.StopPolling)) } - if updateErr := h.iotCardStore.UpdateFields(ctx, cardID, fields); updateErr != nil { - h.base.logger.Warn("更新卡状态信息失败", zap.Uint("card_id", cardID), zap.Error(updateErr)) - h.base.invalidateCardCache(ctx, cardID) - h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startTime)) - return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) + h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startedAt)) + if decision.StopPolling { + h.base.logger.Info("独立卡命中风险状态,已关闭轮询", zap.Uint("card_id", cardID), zap.String("gateway_extend", decision.GatewayExtend)) + return nil } - - if statusQueried || statusChanged { - cacheUpdates := map[string]any{} - if statusQueried { - cacheUpdates["gateway_extend"] = gatewayExtend - } - if statusChanged { - cacheUpdates["network_status"] = newNetworkStatus - } - h.base.updateCardCache(ctx, cardID, cacheUpdates) - } - - // 独立卡写入风险 gateway_extend 后:关闭轮询,终止循环 - if statusQueried && shouldStopPollingForRisk(card, gatewayExtend) { - if updateErr := h.iotCardStore.UpdatePollingStatus(ctx, cardID, false); updateErr != nil { - h.base.logger.Warn("关闭风险卡轮询状态失败", - zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend), zap.Error(updateErr)) - } else { - h.base.logger.Info("独立卡命中风险状态,已关闭轮询", - zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend)) - } - h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startTime)) - return nil // 不再入队,终止轮询循环 - } - - if statusChanged { - h.base.logger.Info("卡状态已变化", - zap.Uint("card_id", cardID), - zap.Int("old_status", card.NetworkStatus), - zap.Int("new_status", newNetworkStatus)) - - if h.stopResumeSvc != nil { - freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID) - if loadErr == nil { - if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil { - h.base.logger.Warn("卡状态变化后停复机评估失败", - zap.Uint("card_id", cardID), zap.Error(evalErr)) - } - } - } - } - - h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startTime)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) +} + +func (h *PollingCardStatusHandler) failAndRequeue(ctx context.Context, cardID uint, startedAt time.Time, message string, err error) error { + fields := []zap.Field{zap.Uint("card_id", cardID)} + if err != nil { + fields = append(fields, zap.Error(err)) + } + h.base.logger.Warn(message, fields...) + h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, false, time.Since(startedAt)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus) } diff --git a/internal/task/polling_integration_log.go b/internal/task/polling_integration_log.go new file mode 100644 index 0000000..b0b36af --- /dev/null +++ b/internal/task/polling_integration_log.go @@ -0,0 +1,45 @@ +package task + +import ( + "context" + "strconv" + "time" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// startGatewayAttempt 在实际调用 Gateway 前建立 Integration Log 尝试事实。 +func startGatewayAttempt(ctx context.Context, repository *integrationlog.Repository, cardID uint, operation, scene string) (*model.IntegrationLog, error) { + if repository == nil { + return nil, errors.New(errors.CodeInternalError, "Gateway Integration Log 未配置") + } + resourceID := strconv.FormatUint(uint64(cardID), 10) + triggerSource := constants.CardObservationSourcePolling + triggerScene := scene + return repository.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound, + Operation: operation, ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID, + TriggerSource: &triggerSource, TriggerScene: &triggerScene, + RequestSummary: map[string]any{"card_id": cardID}, Metadata: map[string]any{"scene": scene}, + InitialResult: constants.IntegrationResultPending, + }) +} + +// completeGatewayAttempt 记录 Gateway 查询成功或明确失败,不把响应正文写入日志。 +func completeGatewayAttempt(ctx context.Context, repository *integrationlog.Repository, attempt *model.IntegrationLog, success bool, startedAt time.Time) error { + if repository == nil || attempt == nil { + return errors.New(errors.CodeInternalError, "Gateway Integration Log 尝试不存在") + } + result := constants.IntegrationResultFailed + if success { + result = constants.IntegrationResultSuccess + } + _, err := repository.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: result, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: false, + ResponseSummary: map[string]any{"success": success}, + }) + return err +} diff --git a/internal/task/polling_realname_handler.go b/internal/task/polling_realname_handler.go index 30e949a..1d46a91 100644 --- a/internal/task/polling_realname_handler.go +++ b/internal/task/polling_realname_handler.go @@ -2,58 +2,39 @@ package task import ( "context" + "strings" "time" - "github.com/bytedance/sonic" "github.com/hibiken/asynq" "go.uber.org/zap" + cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" "github.com/break/junhong_cmp_fiber/internal/gateway" - iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" - "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/pkg/constants" ) -// PollingRealnameHandler 实名检查任务处理器 -// 职责:调 Gateway 查实名状态 → 写 DB → 触发首次实名激活任务 -// 不做:停复机决策(实名状态 0→1 时通过 EvaluateAndAct 触发 not_realname 复机) +// PollingRealnameHandler 负责实名轮询编排,查询结果统一交给卡观测应用服务。 type PollingRealnameHandler struct { - base *PollingBase - gateway *gateway.Client - iotCardStore *postgres.IotCardStore - asynqClient *asynq.Client - stopResumeSvc iot_card_svc.StopResumeServiceInterface - deviceSimBindingStore *postgres.DeviceSimBindingStore + base *PollingBase + gateway *gateway.Client + observation *cardapp.Service + integration *integrationlog.Repository } -// NewPollingRealnameHandler 创建实名检查任务处理器 -func NewPollingRealnameHandler( - base *PollingBase, - gw *gateway.Client, - iotCardStore *postgres.IotCardStore, - asynqClient *asynq.Client, - stopResumeSvc iot_card_svc.StopResumeServiceInterface, - deviceSimBindingStore *postgres.DeviceSimBindingStore, -) *PollingRealnameHandler { - return &PollingRealnameHandler{ - base: base, - gateway: gw, - iotCardStore: iotCardStore, - asynqClient: asynqClient, - stopResumeSvc: stopResumeSvc, - deviceSimBindingStore: deviceSimBindingStore, - } +// NewPollingRealnameHandler 创建实名检查任务处理器。 +func NewPollingRealnameHandler(base *PollingBase, gw *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *PollingRealnameHandler { + return &PollingRealnameHandler{base: base, gateway: gw, observation: observation, integration: integration} } -// Handle 处理实名检查任务 -func (h *PollingRealnameHandler) Handle(ctx context.Context, t *asynq.Task) error { - startTime := time.Now() - - cardID, ok := parseTaskPayload(t.Payload(), h.base.logger) +// Handle 处理实名检查任务。 +func (h *PollingRealnameHandler) Handle(ctx context.Context, task *asynq.Task) error { + startedAt := time.Now() + cardID, ok := parseTaskPayload(task.Payload(), h.base.logger) if !ok { return nil } - if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingRealname) { h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) @@ -65,192 +46,57 @@ func (h *PollingRealnameHandler) Handle(ctx context.Context, t *asynq.Task) erro if isNotFound(err) { return nil } - h.base.logger.Error("获取卡信息失败", zap.Uint("card_id", cardID), zap.Error(err)) - h.base.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime)) + return h.failAndRequeue(ctx, cardID, startedAt, "获取卡信息失败", err) + } + if card.CardCategory == constants.CardCategoryIndustry || h.gateway == nil { return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) } - - if card.CardCategory == constants.CardCategoryIndustry { - return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) + attemptStartedAt := time.Now() + attempt, err := startGatewayAttempt(ctx, h.integration, cardID, constants.IntegrationOperationGatewayRealname, constants.CardObservationSceneRealnamePolling) + if err != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "建立 Gateway 实名 Integration Log 失败", err) } - - var newStatus int - var realStatusBool bool - if h.gateway != nil { - result, gwErr := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) - if gwErr != nil { - h.base.logger.Warn("查询实名状态失败", - zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(gwErr)) - h.base.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime)) - return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) - } - // 上游接口故障时 Gateway 可能返回 data=null, - // sonic 将其解析为零值结构体(ICCID="" RealStatus=false), - // 需通过 ICCID 是否回填来判断响应是否有效。 - if result.ICCID == "" { - h.base.logger.Warn("实名查询响应异常:ICCID 为空,疑似上游接口故障,跳过本次同步", - zap.Uint("card_id", cardID), - zap.String("iccid", card.ICCID)) - h.base.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime)) - return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) - } - - realStatusBool = result.RealStatus - if result.RealStatus { - newStatus = constants.RealNameStatusVerified - } else { - newStatus = constants.RealNameStatusNotVerified - } - - if h.base.verboseLog { - h.base.logger.Info("实名状态轮询详情", - zap.Uint("card_id", cardID), - zap.String("iccid", card.ICCID), - zap.Bool("real_status", realStatusBool), - zap.Int("new_status", newStatus), - zap.Bool("changed", newStatus != card.RealNameStatus)) - } - } else { - newStatus = card.RealNameStatus + result, err := h.gateway.QueryRealnameStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) + if err != nil { + _ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt) + return h.failAndRequeue(ctx, cardID, startedAt, "查询实名状态失败", err) + } + if strings.TrimSpace(result.ICCID) == "" { + _ = completeGatewayAttempt(ctx, h.integration, attempt, false, attemptStartedAt) + return h.failAndRequeue(ctx, cardID, startedAt, "实名查询响应缺少 ICCID", nil) + } + if logErr := completeGatewayAttempt(ctx, h.integration, attempt, true, attemptStartedAt); logErr != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "完成 Gateway 实名 Integration Log 失败", logErr) + } + if h.observation == nil { + return h.failAndRequeue(ctx, cardID, startedAt, "卡实名观测能力未配置", nil) } - - statusChanged := newStatus != card.RealNameStatus - isFirstRealname := statusChanged && - card.RealNameStatus != constants.RealNameStatusVerified && - newStatus == constants.RealNameStatusVerified - now := time.Now() - fields := map[string]any{"last_real_name_check_at": now} - if h.gateway != nil { - fields["last_sync_time"] = now + decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ + CardID: cardID, Verified: result.RealStatus, + Metadata: carddomain.ObservationMetadata{ + ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling, + Scene: constants.CardObservationSceneRealnamePolling, ObservedAt: now, CorrelationID: attempt.IntegrationID, + }, + }) + if err != nil { + return h.failAndRequeue(ctx, cardID, startedAt, "应用实名观测失败", err) } - if statusChanged { - fields["real_name_status"] = newStatus + if h.base.verboseLog { + h.base.logger.Info("实名状态轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), + zap.Bool("real_status", result.RealStatus), zap.Int("new_status", decision.AfterStatus), + zap.Bool("changed", decision.StatusChanged), zap.Bool("reversal_pending", decision.ReversalPending)) } - if isFirstRealname { - fields["first_realname_at"] = now - } - if err := h.iotCardStore.UpdateFields(ctx, cardID, fields); err != nil { - h.base.logger.Error("更新卡实名信息失败", zap.Uint("card_id", cardID), zap.Error(err)) - h.base.invalidateCardCache(ctx, cardID) - h.base.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startTime)) - return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) - } - - if statusChanged { - h.base.updateCardCache(ctx, cardID, map[string]any{"real_name_status": newStatus}) - h.base.logger.Info("实名状态已变化", - zap.Uint("card_id", cardID), - zap.Int("old_status", card.RealNameStatus), - zap.Int("new_status", newStatus)) - - if isFirstRealname { - // 0→1 时清除逆转计数器 - h.base.redis.Del(ctx, constants.RedisPollingRealnameReversalCountKey(cardID)) - // 0→1:首次实名,触发套餐激活并立即复机评估(不等待下一个 carddata/package 周期) - h.triggerFirstRealnameActivation(ctx, cardID) - h.triggerDeviceRealnameActivation(ctx, cardID) - if h.stopResumeSvc != nil { - freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID) - if loadErr == nil { - if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil { - h.base.logger.Warn("实名完成后触发复机评估失败", - zap.Uint("card_id", cardID), zap.Error(evalErr)) - } - } - } - } else if newStatus == constants.RealNameStatusNotVerified { - reversalKey := constants.RedisPollingRealnameReversalCountKey(cardID) - count, incrErr := h.base.redis.Incr(ctx, reversalKey).Result() - if incrErr == nil { - h.base.redis.Expire(ctx, reversalKey, 10*time.Minute) - } - h.base.logger.Warn("检测到实名逆转", - zap.Uint("card_id", cardID), - zap.Int64("reversal_count", count), - zap.Int("threshold", constants.PollingRealnameReversalThreshold)) - if count >= int64(constants.PollingRealnameReversalThreshold) { - h.base.redis.Del(ctx, reversalKey) - h.base.logger.Warn("实名逆转达到阈值,触发停机评估", - zap.Uint("card_id", cardID)) - if h.stopResumeSvc != nil { - freshCard, loadErr := h.iotCardStore.GetByID(ctx, cardID) - if loadErr == nil { - if evalErr := h.stopResumeSvc.EvaluateAndAct(ctx, freshCard); evalErr != nil { - h.base.logger.Warn("实名逆转后触发停机评估失败", - zap.Uint("card_id", cardID), zap.Error(evalErr)) - } - } - } - } - } - } - - h.base.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startTime)) + h.base.updateStats(ctx, constants.TaskTypePollingRealname, true, time.Since(startedAt)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) } -// triggerFirstRealnameActivation 首次实名后触发套餐激活任务 -func (h *PollingRealnameHandler) triggerFirstRealnameActivation(ctx context.Context, cardID uint) { - if h.asynqClient == nil { - return - } - payload := map[string]any{ - "carrier_type": "iot_card", - "carrier_id": cardID, - "activation_type": "realname", - "timestamp": time.Now().Unix(), - } - payloadBytes, err := sonic.Marshal(payload) +func (h *PollingRealnameHandler) failAndRequeue(ctx context.Context, cardID uint, startedAt time.Time, message string, err error) error { + fields := []zap.Field{zap.Uint("card_id", cardID)} if err != nil { - h.base.logger.Warn("序列化首次实名激活任务载荷失败", zap.Uint("card_id", cardID), zap.Error(err)) - return - } - task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes, - asynq.MaxRetry(3), - asynq.Timeout(60*time.Second), - asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageFirstActivation)), - ) - if _, err := h.asynqClient.EnqueueContext(ctx, task); err != nil { - h.base.logger.Warn("提交首次实名激活任务失败", zap.Uint("card_id", cardID), zap.Error(err)) - } -} - -// triggerDeviceRealnameActivation 卡首次实名后,触发其所属设备的设备级套餐激活任务 -// 若卡不属于任何设备(独立卡),静默跳过 -func (h *PollingRealnameHandler) triggerDeviceRealnameActivation(ctx context.Context, cardID uint) { - if h.asynqClient == nil || h.deviceSimBindingStore == nil { - return - } - binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID) - if err != nil { - if isNotFound(err) { - return - } - h.base.logger.Warn("查询卡绑定设备失败,跳过设备级套餐激活", - zap.Uint("card_id", cardID), zap.Error(err)) - return - } - deviceID := binding.DeviceID - payload := map[string]any{ - "carrier_type": "device", - "carrier_id": deviceID, - "activation_type": "realname", - "timestamp": time.Now().Unix(), - } - payloadBytes, err := sonic.Marshal(payload) - if err != nil { - h.base.logger.Warn("序列化设备实名激活任务载荷失败", - zap.Uint("card_id", cardID), zap.Uint("device_id", deviceID), zap.Error(err)) - return - } - task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes, - asynq.MaxRetry(3), - asynq.Timeout(60*time.Second), - asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageFirstActivation)), - ) - if _, err := h.asynqClient.EnqueueContext(ctx, task); err != nil { - h.base.logger.Warn("提交设备实名激活任务失败", - zap.Uint("card_id", cardID), zap.Uint("device_id", deviceID), zap.Error(err)) + fields = append(fields, zap.Error(err)) } + h.base.logger.Warn(message, fields...) + h.base.updateStats(ctx, constants.TaskTypePollingRealname, false, time.Since(startedAt)) + return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname) } diff --git a/migrations/000165_create_public_outbox.up.sql b/migrations/000165_create_public_outbox.up.sql index b2ccac6..9382000 100644 --- a/migrations/000165_create_public_outbox.up.sql +++ b/migrations/000165_create_public_outbox.up.sql @@ -41,4 +41,26 @@ CREATE INDEX idx_outbox_event_request_id ON tb_outbox_event (request_id) WHERE r CREATE INDEX idx_outbox_event_correlation_id ON tb_outbox_event (correlation_id) WHERE correlation_id <> ''; COMMENT ON TABLE tb_outbox_event IS '公共可靠事件 Outbox'; +COMMENT ON COLUMN tb_outbox_event.id IS 'Outbox记录主键ID'; +COMMENT ON COLUMN tb_outbox_event.event_id IS '跨重试保持稳定的事件唯一ID'; +COMMENT ON COLUMN tb_outbox_event.event_type IS '稳定事件类型编码'; +COMMENT ON COLUMN tb_outbox_event.payload_version IS '事件载荷结构版本'; +COMMENT ON COLUMN tb_outbox_event.aggregate_type IS '产生事件的聚合或业务事实类型'; +COMMENT ON COLUMN tb_outbox_event.aggregate_id IS '产生事件的聚合或业务事实ID'; +COMMENT ON COLUMN tb_outbox_event.resource_type IS '事件主要资源类型'; +COMMENT ON COLUMN tb_outbox_event.resource_id IS '事件主要资源ID'; +COMMENT ON COLUMN tb_outbox_event.business_key IS '业务幂等键,空字符串表示未提供'; +COMMENT ON COLUMN tb_outbox_event.request_id IS '来源HTTP请求ID,空字符串表示无请求上下文'; +COMMENT ON COLUMN tb_outbox_event.correlation_id IS '跨事务和异步链路关联ID'; +COMMENT ON COLUMN tb_outbox_event.payload IS '结构化事件载荷快照'; COMMENT ON COLUMN tb_outbox_event.status IS '投递状态 1=待投递, 2=投递中, 3=已投递, 4=投递失败'; +COMMENT ON COLUMN tb_outbox_event.retry_count IS '已发生的投递重试次数'; +COMMENT ON COLUMN tb_outbox_event.max_retries IS '允许的最大投递重试次数'; +COMMENT ON COLUMN tb_outbox_event.next_attempt_at IS '下一次允许领取投递的时间'; +COMMENT ON COLUMN tb_outbox_event.lease_owner IS '当前Relay投递租约持有者'; +COMMENT ON COLUMN tb_outbox_event.lease_expires_at IS '当前Relay投递租约过期时间'; +COMMENT ON COLUMN tb_outbox_event.last_error_code IS '最近一次投递失败的稳定错误编码'; +COMMENT ON COLUMN tb_outbox_event.last_error_summary IS '最近一次投递失败的安全摘要'; +COMMENT ON COLUMN tb_outbox_event.delivered_at IS '事件成功提交到任务队列的时间'; +COMMENT ON COLUMN tb_outbox_event.created_at IS 'Outbox记录创建时间'; +COMMENT ON COLUMN tb_outbox_event.updated_at IS 'Outbox记录更新时间'; diff --git a/migrations/000166_create_system_config.up.sql b/migrations/000166_create_system_config.up.sql index ea43c0c..20d5afd 100644 --- a/migrations/000166_create_system_config.up.sql +++ b/migrations/000166_create_system_config.up.sql @@ -19,4 +19,15 @@ CREATE TABLE tb_system_config ( CREATE INDEX idx_system_config_module_key ON tb_system_config (module, config_key); COMMENT ON TABLE tb_system_config IS '受控系统配置表'; +COMMENT ON COLUMN tb_system_config.id IS '系统配置主键ID'; +COMMENT ON COLUMN tb_system_config.config_key IS '全局唯一的稳定配置Key'; +COMMENT ON COLUMN tb_system_config.config_value IS '按值类型保存的配置值'; COMMENT ON COLUMN tb_system_config.value_type IS '值类型 string/int/bool/json'; +COMMENT ON COLUMN tb_system_config.module IS '配置所属业务模块'; +COMMENT ON COLUMN tb_system_config.description IS '配置用途中文说明'; +COMMENT ON COLUMN tb_system_config.is_readonly IS '是否禁止通过在线接口修改'; +COMMENT ON COLUMN tb_system_config.is_sensitive IS '是否为响应、日志和审计需要脱敏的配置'; +COMMENT ON COLUMN tb_system_config.creator IS '首次创建配置的账号ID'; +COMMENT ON COLUMN tb_system_config.updater IS '最近更新配置的账号ID'; +COMMENT ON COLUMN tb_system_config.created_at IS '系统配置创建时间'; +COMMENT ON COLUMN tb_system_config.updated_at IS '系统配置更新时间'; diff --git a/migrations/000167_create_audit_integration_log.up.sql b/migrations/000167_create_audit_integration_log.up.sql index db32355..1983c28 100644 --- a/migrations/000167_create_audit_integration_log.up.sql +++ b/migrations/000167_create_audit_integration_log.up.sql @@ -52,4 +52,35 @@ CREATE INDEX idx_integration_log_request ON tb_integration_log (request_id, crea CREATE INDEX idx_integration_log_correlation ON tb_integration_log (correlation_id, created_at ASC) WHERE correlation_id IS NOT NULL; COMMENT ON TABLE tb_integration_log IS '外部集成调用、回调及未发送尝试记录'; +COMMENT ON COLUMN tb_integration_log.id IS '外部集成记录主键ID'; +COMMENT ON COLUMN tb_integration_log.integration_id IS '跨重试保持稳定的外部集成记录ID'; +COMMENT ON COLUMN tb_integration_log.idempotency_key IS '外部操作业务幂等键,空值表示该操作无幂等键'; +COMMENT ON COLUMN tb_integration_log.provider IS '外部服务提供方稳定编码'; +COMMENT ON COLUMN tb_integration_log.direction IS '交互方向:inbound入站,outbound出站'; +COMMENT ON COLUMN tb_integration_log.operation IS '外部接口或回调操作稳定编码'; +COMMENT ON COLUMN tb_integration_log.external_id IS '外部系统返回的业务或请求标识'; +COMMENT ON COLUMN tb_integration_log.resource_type IS '本地主要业务资源类型'; +COMMENT ON COLUMN tb_integration_log.resource_id IS '本地主要业务资源ID'; +COMMENT ON COLUMN tb_integration_log.resource_key IS '本地主要业务资源稳定键'; +COMMENT ON COLUMN tb_integration_log.trigger_source IS '触发来源,如接口、回调、轮询或人工恢复'; +COMMENT ON COLUMN tb_integration_log.trigger_scene IS '触发外部交互的业务场景'; +COMMENT ON COLUMN tb_integration_log.trigger_series IS '同一计划或补偿序列的稳定标识'; +COMMENT ON COLUMN tb_integration_log.scheduled_at IS '计划执行外部交互的时间'; +COMMENT ON COLUMN tb_integration_log.started_at IS '实际开始外部交互的时间'; +COMMENT ON COLUMN tb_integration_log.attempt IS '同一幂等操作的技术尝试序号'; COMMENT ON COLUMN tb_integration_log.result IS '尝试结果,pending 仅为内部执行态,其余为公开终态'; +COMMENT ON COLUMN tb_integration_log.http_status IS '外部HTTP响应状态码'; +COMMENT ON COLUMN tb_integration_log.provider_code IS '外部服务返回的稳定结果码'; +COMMENT ON COLUMN tb_integration_log.provider_message IS '脱敏截断后的外部结果摘要'; +COMMENT ON COLUMN tb_integration_log.request_summary IS '删除敏感信息后的请求摘要'; +COMMENT ON COLUMN tb_integration_log.response_summary IS '删除敏感信息后的响应摘要'; +COMMENT ON COLUMN tb_integration_log.content_hash IS '请求或回调正文的安全摘要'; +COMMENT ON COLUMN tb_integration_log.duration_ms IS '本次外部交互耗时毫秒数'; +COMMENT ON COLUMN tb_integration_log.state_changed IS '本次交互是否导致本地业务状态变化'; +COMMENT ON COLUMN tb_integration_log.metadata IS '不含敏感值的结构化扩展元数据'; +COMMENT ON COLUMN tb_integration_log.recovery_strategy IS '结果未知或失败后的恢复策略摘要'; +COMMENT ON COLUMN tb_integration_log.request_id IS '来源HTTP请求ID'; +COMMENT ON COLUMN tb_integration_log.correlation_id IS '跨事务和异步链路关联ID'; +COMMENT ON COLUMN tb_integration_log.audit_event_id IS '预留的审计事件ID,仅代码显式维护且不建立外键'; +COMMENT ON COLUMN tb_integration_log.created_at IS '外部集成记录创建时间'; +COMMENT ON COLUMN tb_integration_log.updated_at IS '外部集成记录更新时间'; diff --git a/migrations/000168_create_notification.down.sql b/migrations/000168_create_notification.down.sql new file mode 100644 index 0000000..e4951c6 --- /dev/null +++ b/migrations/000168_create_notification.down.sql @@ -0,0 +1,19 @@ +BEGIN; + +-- 在同一事务取得排他锁后检查并删除,避免检查与删表之间写入新通知事实。 +DO $$ +BEGIN + IF to_regclass('tb_notification') IS NULL THEN + RETURN; + END IF; + + LOCK TABLE tb_notification IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_notification LIMIT 1) THEN + RAISE EXCEPTION 'tb_notification 已存在通知事实,禁止删表回滚,请停止生产者后向前修复'; + END IF; + + DROP TABLE tb_notification; +END +$$; + +COMMIT; diff --git a/migrations/000168_create_notification.up.sql b/migrations/000168_create_notification.up.sql new file mode 100644 index 0000000..38553fd --- /dev/null +++ b/migrations/000168_create_notification.up.sql @@ -0,0 +1,55 @@ +-- 创建公共站内通知事实表;不建立外键,接收人与资源引用由应用层显式校验。 +CREATE TABLE tb_notification ( + id bigserial PRIMARY KEY, + event_id varchar(64) NOT NULL, + recipient_kind varchar(32) NOT NULL, + recipient_id bigint NOT NULL, + category varchar(32) NOT NULL, + type varchar(100) NOT NULL, + severity varchar(16) NOT NULL, + title varchar(200) NOT NULL, + body text NOT NULL, + ref_type varchar(64) NOT NULL DEFAULT '', + ref_id varchar(128) NOT NULL DEFAULT '', + ref_key varchar(128) NOT NULL DEFAULT '', + is_read boolean NOT NULL DEFAULT FALSE, + read_at timestamptz, + expires_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + CONSTRAINT uq_notification_event_recipient UNIQUE (event_id, recipient_kind, recipient_id), + CONSTRAINT ck_notification_recipient_kind CHECK (recipient_kind IN ('account', 'personal_customer')), + CONSTRAINT ck_notification_recipient_id CHECK (recipient_id > 0), + CONSTRAINT ck_notification_required_text CHECK (event_id <> '' AND type <> '' AND title <> '' AND body <> ''), + CONSTRAINT ck_notification_reference CHECK ( + (ref_type = '' AND ref_id = '' AND ref_key = '') OR + (ref_type <> '' AND (ref_id <> '' OR ref_key <> '')) + ), + CONSTRAINT ck_notification_category CHECK (category IN ('approval', 'expiry', 'sync', 'system')), + CONSTRAINT ck_notification_severity CHECK (severity IN ('info', 'warning', 'error', 'critical')), + CONSTRAINT ck_notification_read_time CHECK ((is_read = FALSE AND read_at IS NULL) OR (is_read = TRUE AND read_at IS NOT NULL)) +); + +CREATE INDEX idx_notification_recipient_unread + ON tb_notification (recipient_kind, recipient_id, is_read, created_at DESC, id DESC); +CREATE INDEX idx_notification_recipient_category + ON tb_notification (recipient_kind, recipient_id, category, created_at DESC, id DESC); +CREATE INDEX idx_notification_expires_at + ON tb_notification (expires_at, id) WHERE expires_at IS NOT NULL; + +COMMENT ON TABLE tb_notification IS '公共站内通知事实'; +COMMENT ON COLUMN tb_notification.id IS '通知主键ID'; +COMMENT ON COLUMN tb_notification.event_id IS '上游稳定事件ID,与接收人共同防重'; +COMMENT ON COLUMN tb_notification.recipient_kind IS '接收人类型:account后台账号,personal_customer个人客户'; +COMMENT ON COLUMN tb_notification.recipient_id IS '接收人账号或个人客户ID'; +COMMENT ON COLUMN tb_notification.category IS '通知分类:审批、临期、同步或系统'; +COMMENT ON COLUMN tb_notification.type IS '稳定通知类型编码'; +COMMENT ON COLUMN tb_notification.severity IS '通知严重级别:普通、警告、错误或严重'; +COMMENT ON COLUMN tb_notification.title IS '发送时固化的纯文本标题'; +COMMENT ON COLUMN tb_notification.body IS '发送时固化的纯文本正文'; +COMMENT ON COLUMN tb_notification.ref_type IS '受控关联资源类型,空字符串表示无关联资源'; +COMMENT ON COLUMN tb_notification.ref_id IS '受控关联资源ID,禁止保存任意URL'; +COMMENT ON COLUMN tb_notification.ref_key IS '受控关联资源稳定键,禁止保存任意URL'; +COMMENT ON COLUMN tb_notification.is_read IS '是否已被接收人读取'; +COMMENT ON COLUMN tb_notification.read_at IS '接收人首次读取时间'; +COMMENT ON COLUMN tb_notification.expires_at IS '通知停止展示时间,空值表示不自动过期'; +COMMENT ON COLUMN tb_notification.created_at IS '通知创建时间'; diff --git a/migrations/000169_add_shop_business_owner.down.sql b/migrations/000169_add_shop_business_owner.down.sql new file mode 100644 index 0000000..5f64979 --- /dev/null +++ b/migrations/000169_add_shop_business_owner.down.sql @@ -0,0 +1,27 @@ +BEGIN; + +-- 已产生业务员归属事实后禁止删列;应用回滚应保留字段并采用前向修复。 +DO $$ +BEGIN + IF to_regclass('tb_shop') IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM pg_attribute + WHERE attrelid = to_regclass('tb_shop') + AND attname = 'business_owner_account_id' + AND NOT attisdropped + ) THEN + RETURN; + END IF; + + LOCK TABLE tb_shop IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_shop WHERE business_owner_account_id IS NOT NULL LIMIT 1) THEN + RAISE EXCEPTION 'tb_shop 已存在业务员归属事实,禁止删列回滚,请保留字段并向前修复'; + END IF; + + DROP INDEX IF EXISTS idx_shop_business_owner_account_id; + ALTER TABLE tb_shop DROP COLUMN business_owner_account_id; +END +$$; + +COMMIT; diff --git a/migrations/000169_add_shop_business_owner.up.sql b/migrations/000169_add_shop_business_owner.up.sql new file mode 100644 index 0000000..89e6f55 --- /dev/null +++ b/migrations/000169_add_shop_business_owner.up.sql @@ -0,0 +1,8 @@ +-- 店铺保存独立的平台业务员归属;不建立外键,不回填存量店铺。 +ALTER TABLE tb_shop + ADD COLUMN business_owner_account_id bigint; + +CREATE INDEX idx_shop_business_owner_account_id + ON tb_shop (business_owner_account_id); + +COMMENT ON COLUMN tb_shop.business_owner_account_id IS '平台内部业务员账号ID,仅用于业务归属与通知接收'; diff --git a/migrations/000170_create_approval_core.down.sql b/migrations/000170_create_approval_core.down.sql new file mode 100644 index 0000000..48f2d9f --- /dev/null +++ b/migrations/000170_create_approval_core.down.sql @@ -0,0 +1,28 @@ +BEGIN; + +-- 审批事实产生后禁止删表回滚,避免业务单失去审批依据。 +DO $$ +BEGIN + IF to_regclass('tb_approval_instance') IS NULL AND to_regclass('tb_approval_decision_delivery') IS NULL THEN + RETURN; + END IF; + + IF to_regclass('tb_approval_decision_delivery') IS NOT NULL THEN + LOCK TABLE tb_approval_decision_delivery IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_approval_decision_delivery LIMIT 1) THEN + RAISE EXCEPTION 'tb_approval_decision_delivery 已存在决策投递事实,禁止删表回滚,请停止生产者后向前修复'; + END IF; + END IF; + IF to_regclass('tb_approval_instance') IS NOT NULL THEN + LOCK TABLE tb_approval_instance IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_approval_instance LIMIT 1) THEN + RAISE EXCEPTION 'tb_approval_instance 已存在审批事实,禁止删表回滚,请停止生产者后向前修复'; + END IF; + END IF; + + DROP TABLE IF EXISTS tb_approval_decision_delivery; + DROP TABLE IF EXISTS tb_approval_instance; +END +$$; + +COMMIT; diff --git a/migrations/000170_create_approval_core.up.sql b/migrations/000170_create_approval_core.up.sql new file mode 100644 index 0000000..bba4591 --- /dev/null +++ b/migrations/000170_create_approval_core.up.sql @@ -0,0 +1,100 @@ +-- 创建渠道无关通用审批实例;渠道模板、节点、审批人和外部凭据由 Adapter 自行管理。 +CREATE TABLE tb_approval_instance ( + id bigserial PRIMARY KEY, + business_type varchar(64) NOT NULL, + business_id bigint NOT NULL, + submitter_account_id bigint NOT NULL, + submitter_snapshot jsonb NOT NULL, + provider varchar(32) NOT NULL, + external_ref varchar(128) NOT NULL DEFAULT '', + status integer NOT NULL DEFAULT 0, + request_snapshot jsonb NOT NULL, + decision_snapshot jsonb, + correlation_id varchar(100) NOT NULL, + version integer NOT NULL DEFAULT 1, + status_changed_at timestamptz NOT NULL DEFAULT NOW(), + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + CONSTRAINT uq_approval_instance_business UNIQUE (business_type, business_id), + CONSTRAINT ck_approval_instance_business CHECK (business_type <> '' AND business_id > 0), + CONSTRAINT ck_approval_instance_submitter CHECK (submitter_account_id > 0 AND jsonb_typeof(submitter_snapshot) = 'object'), + CONSTRAINT ck_approval_instance_provider CHECK (provider <> ''), + CONSTRAINT ck_approval_instance_status CHECK (status IN (0, 1, 2, 3, 4, 5, 6, 7, 8)), + CONSTRAINT ck_approval_instance_request_snapshot CHECK (jsonb_typeof(request_snapshot) = 'object'), + CONSTRAINT ck_approval_instance_decision_snapshot CHECK (decision_snapshot IS NULL OR jsonb_typeof(decision_snapshot) = 'object'), + CONSTRAINT ck_approval_instance_correlation CHECK (correlation_id <> ''), + CONSTRAINT ck_approval_instance_version CHECK (version > 0) +); + +CREATE UNIQUE INDEX uq_approval_instance_external_ref + ON tb_approval_instance (provider, external_ref) WHERE external_ref <> ''; +CREATE INDEX idx_approval_instance_status_changed + ON tb_approval_instance (status, status_changed_at, id); +CREATE INDEX idx_approval_instance_submitter + ON tb_approval_instance (submitter_account_id, created_at DESC, id DESC); +CREATE INDEX idx_approval_instance_correlation + ON tb_approval_instance (correlation_id); + +-- 标准决策投递单独保存处理租约,使通过后撤销可在已通过之后再次幂等交给业务消费者。 +CREATE TABLE tb_approval_decision_delivery ( + id bigserial PRIMARY KEY, + instance_id bigint NOT NULL, + decision varchar(32) NOT NULL, + event_id varchar(64) NOT NULL, + status integer NOT NULL DEFAULT 0, + retry_count integer NOT NULL DEFAULT 0, + lease_owner varchar(100), + lease_expires_at timestamptz, + last_error varchar(500) NOT NULL DEFAULT '', + processed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + CONSTRAINT uq_approval_decision_delivery UNIQUE (instance_id, decision), + CONSTRAINT uq_approval_decision_delivery_event UNIQUE (event_id), + CONSTRAINT ck_approval_decision_delivery_instance CHECK (instance_id > 0), + CONSTRAINT ck_approval_decision_delivery_decision CHECK (decision IN ('approved', 'rejected', 'cancelled', 'deleted', 'revoked_after_approved')), + CONSTRAINT ck_approval_decision_delivery_status CHECK (status IN (0, 1, 2, 3)), + CONSTRAINT ck_approval_decision_delivery_retry CHECK (retry_count >= 0), + CONSTRAINT ck_approval_decision_delivery_lease CHECK ( + (status = 1 AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL) OR + (status <> 1 AND lease_owner IS NULL AND lease_expires_at IS NULL) + ), + CONSTRAINT ck_approval_decision_delivery_processed CHECK ( + (status = 2 AND processed_at IS NOT NULL) OR + (status <> 2 AND processed_at IS NULL) + ) +); + +CREATE INDEX idx_approval_decision_delivery_claim + ON tb_approval_decision_delivery (status, lease_expires_at, id); + +COMMENT ON TABLE tb_approval_instance IS '渠道无关通用审批实例'; +COMMENT ON COLUMN tb_approval_instance.id IS '通用审批实例主键ID'; +COMMENT ON COLUMN tb_approval_instance.business_type IS '稳定业务类型编码'; +COMMENT ON COLUMN tb_approval_instance.business_id IS '业务单主键ID,与业务类型共同唯一'; +COMMENT ON COLUMN tb_approval_instance.submitter_account_id IS '真实业务提交人账号ID'; +COMMENT ON COLUMN tb_approval_instance.submitter_snapshot IS '真实业务提交人名称、角色和归属快照'; +COMMENT ON COLUMN tb_approval_instance.provider IS '审批渠道稳定编码,不包含渠道密钥或模板信息'; +COMMENT ON COLUMN tb_approval_instance.external_ref IS '渠道返回的通用外部实例引用'; +COMMENT ON COLUMN tb_approval_instance.status IS '通用审批生命周期状态:0提交中,1审批中,2已通过,3已拒绝,4已撤销,5通过后撤销,6已删除,7提交失败,8提交结果未知'; +COMMENT ON COLUMN tb_approval_instance.request_snapshot IS '业务申请时冻结的渠道无关请求快照'; +COMMENT ON COLUMN tb_approval_instance.decision_snapshot IS '首次标准终态时冻结的渠道无关决策快照'; +COMMENT ON COLUMN tb_approval_instance.correlation_id IS '贯穿业务申请、渠道交互和异步处理的关联ID'; +COMMENT ON COLUMN tb_approval_instance.version IS '通用审批实例乐观锁版本'; +COMMENT ON COLUMN tb_approval_instance.status_changed_at IS '通用审批状态最近变化时间'; +COMMENT ON COLUMN tb_approval_instance.created_at IS '通用审批实例创建时间'; +COMMENT ON COLUMN tb_approval_instance.updated_at IS '通用审批实例更新时间'; + +COMMENT ON TABLE tb_approval_decision_delivery IS '通用审批标准决策业务投递事实'; +COMMENT ON COLUMN tb_approval_decision_delivery.id IS '决策投递主键ID'; +COMMENT ON COLUMN tb_approval_decision_delivery.instance_id IS '通用审批实例ID,通过代码显式维护关联'; +COMMENT ON COLUMN tb_approval_decision_delivery.decision IS '渠道无关标准决策'; +COMMENT ON COLUMN tb_approval_decision_delivery.event_id IS '对应公共Outbox的稳定事件ID'; +COMMENT ON COLUMN tb_approval_decision_delivery.status IS '业务消费状态:0待处理,1处理中,2成功,3失败'; +COMMENT ON COLUMN tb_approval_decision_delivery.retry_count IS '业务消费失败重试次数'; +COMMENT ON COLUMN tb_approval_decision_delivery.lease_owner IS '当前业务消费者租约持有者'; +COMMENT ON COLUMN tb_approval_decision_delivery.lease_expires_at IS '当前业务消费者租约过期时间'; +COMMENT ON COLUMN tb_approval_decision_delivery.last_error IS '最近一次业务消费失败的安全摘要'; +COMMENT ON COLUMN tb_approval_decision_delivery.processed_at IS '业务消费者幂等处理成功时间'; +COMMENT ON COLUMN tb_approval_decision_delivery.created_at IS '决策投递创建时间'; +COMMENT ON COLUMN tb_approval_decision_delivery.updated_at IS '决策投递更新时间'; diff --git a/migrations/000171_add_agent_main_wallet_credit.down.sql b/migrations/000171_add_agent_main_wallet_credit.down.sql new file mode 100644 index 0000000..ed16072 --- /dev/null +++ b/migrations/000171_add_agent_main_wallet_credit.down.sql @@ -0,0 +1,60 @@ +BEGIN; + +LOCK TABLE tb_agent_wallet IN ACCESS EXCLUSIVE MODE; + +-- 出现信用配置、负余额或信用占用后,旧逻辑已无法安全解释资金事实,禁止回滚。 +DO $$ +DECLARE + unsafe_count bigint; +BEGIN + SELECT COUNT(*) + INTO unsafe_count + FROM tb_agent_wallet + WHERE credit_enabled + OR credit_limit <> 0 + OR balance < 0 + OR frozen_balance > balance; + + IF unsafe_count > 0 THEN + RAISE EXCEPTION '已有 % 个钱包产生信用配置或旧逻辑无法解释的资金事实,禁止回滚信用字段,请清偿后再评估', unsafe_count; + END IF; +END +$$; + +DROP INDEX IF EXISTS idx_agent_wallet_main_credit_enabled; + +ALTER TABLE tb_agent_wallet + DROP CONSTRAINT IF EXISTS chk_agent_wallet_credit_config, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_credit_scope, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_nonnegative, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_arithmetic_range, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_available_balance, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_version_nonnegative, + DROP COLUMN IF EXISTS credit_enabled, + DROP COLUMN IF EXISTS credit_limit; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'tb_agent_wallet'::regclass + AND conname = 'chk_agent_wallet_balance' + ) THEN + ALTER TABLE tb_agent_wallet + ADD CONSTRAINT chk_agent_wallet_balance CHECK (balance >= 0); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'tb_agent_wallet'::regclass + AND conname = 'chk_agent_wallet_frozen_balance' + ) THEN + ALTER TABLE tb_agent_wallet + ADD CONSTRAINT chk_agent_wallet_frozen_balance CHECK ( + frozen_balance >= 0 AND frozen_balance <= balance + ); + END IF; +END +$$; + +COMMIT; diff --git a/migrations/000171_add_agent_main_wallet_credit.up.sql b/migrations/000171_add_agent_main_wallet_credit.up.sql new file mode 100644 index 0000000..6baf3b0 --- /dev/null +++ b/migrations/000171_add_agent_main_wallet_credit.up.sql @@ -0,0 +1,112 @@ +BEGIN; + +LOCK TABLE tb_agent_wallet IN ACCESS EXCLUSIVE MODE; + +ALTER TABLE tb_agent_wallet + ADD COLUMN IF NOT EXISTS credit_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS credit_limit bigint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_agent_wallet.credit_enabled IS '是否启用代理主钱包信用额度;分佣钱包固定关闭'; +COMMENT ON COLUMN tb_agent_wallet.credit_limit IS '代理主钱包信用额度,单位为分;关闭信用时固定为0'; + +-- 迁移在目标库读取真实约束定义,不依赖归档迁移中的约束名称。 +DO $$ +DECLARE + balance_attnum smallint; + constraint_row record; + normalized_definition text; +BEGIN + SELECT attnum + INTO balance_attnum + FROM pg_attribute + WHERE attrelid = 'tb_agent_wallet'::regclass + AND attname = 'balance' + AND NOT attisdropped; + + FOR constraint_row IN + SELECT conname, conkey, pg_get_constraintdef(oid) AS definition + FROM pg_constraint + WHERE conrelid = 'tb_agent_wallet'::regclass + AND contype = 'c' + LOOP + normalized_definition := regexp_replace(lower(constraint_row.definition), '[[:space:]()]', '', 'g'); + IF (array_length(constraint_row.conkey, 1) = 1 + AND constraint_row.conkey[1] = balance_attnum + AND normalized_definition LIKE 'check%balance%>=%0%') + OR normalized_definition LIKE '%frozen_balance%<=%balance%' THEN + RAISE NOTICE '移除代理钱包历史约束 %:%', constraint_row.conname, constraint_row.definition; + EXECUTE format('ALTER TABLE tb_agent_wallet DROP CONSTRAINT %I', constraint_row.conname); + END IF; + END LOOP; +END +$$; + +ALTER TABLE tb_agent_wallet + DROP CONSTRAINT IF EXISTS chk_agent_wallet_credit_config, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_credit_scope, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_nonnegative, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_arithmetic_range, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_available_balance, + DROP CONSTRAINT IF EXISTS chk_agent_wallet_version_nonnegative; + +-- 历史钱包默认关闭信用;任何既有异常都必须先形成清单并修复,禁止迁移静默改账。 +DO $$ +DECLARE + abnormal_count bigint; +BEGIN + SELECT COUNT(*) + INTO abnormal_count + 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)); + + IF abnormal_count > 0 THEN + RAISE EXCEPTION '代理钱包信用迁移发现 % 条异常数据,请先导出钱包ID、约束定义并修复后重试', abnormal_count; + END IF; +END +$$; + +ALTER TABLE tb_agent_wallet + ADD CONSTRAINT chk_agent_wallet_credit_config CHECK ( + (credit_enabled = false AND credit_limit = 0) OR + (credit_enabled = true AND credit_limit > 0) + ), + ADD CONSTRAINT chk_agent_wallet_credit_scope CHECK ( + wallet_type = 'main' OR (credit_enabled = false AND credit_limit = 0) + ), + ADD CONSTRAINT chk_agent_wallet_frozen_nonnegative CHECK (frozen_balance >= 0), + ADD CONSTRAINT chk_agent_wallet_arithmetic_range CHECK ( + wallet_type <> 'main' OR ( + balance > -9223372036854775808::numeric AND + balance::numeric - frozen_balance::numeric BETWEEN -9223372036854775808::numeric AND 9223372036854775807::numeric AND + balance::numeric - frozen_balance::numeric + + CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0::numeric END + BETWEEN -9223372036854775808::numeric AND 9223372036854775807::numeric + ) + ), + ADD CONSTRAINT chk_agent_wallet_available_balance CHECK ( + (wallet_type = 'main' AND + balance::numeric - frozen_balance::numeric + + CASE WHEN credit_enabled THEN credit_limit::numeric ELSE 0::numeric END >= 0) OR + (wallet_type = 'commission' AND balance >= 0 AND frozen_balance <= balance) + ), + ADD CONSTRAINT chk_agent_wallet_version_nonnegative CHECK (version >= 0); + +CREATE INDEX IF NOT EXISTS idx_agent_wallet_main_credit_enabled + ON tb_agent_wallet (shop_id) + WHERE wallet_type = 'main' AND credit_enabled = true AND deleted_at IS NULL; + +COMMENT ON CONSTRAINT chk_agent_wallet_credit_config ON tb_agent_wallet IS '保证信用开关与额度组合一致'; +COMMENT ON CONSTRAINT chk_agent_wallet_credit_scope ON tb_agent_wallet IS '保证只有代理主钱包可以启用信用额度'; +COMMENT ON CONSTRAINT chk_agent_wallet_frozen_nonnegative ON tb_agent_wallet IS '保证冻结金额不为负数'; +COMMENT ON CONSTRAINT chk_agent_wallet_arithmetic_range ON tb_agent_wallet IS '保证现金可用、总可用和欠款金额均可在int64范围内安全计算'; +COMMENT ON CONSTRAINT chk_agent_wallet_available_balance ON tb_agent_wallet IS '保证主钱包总可用金额和分佣钱包现金可用金额不为负数'; +COMMENT ON CONSTRAINT chk_agent_wallet_version_nonnegative ON tb_agent_wallet IS '保证钱包乐观锁版本不为负数'; +COMMENT ON INDEX idx_agent_wallet_main_credit_enabled IS '启用信用额度的有效代理主钱包索引'; + +COMMIT; diff --git a/migrations/000172_add_role_default_credit.down.sql b/migrations/000172_add_role_default_credit.down.sql new file mode 100644 index 0000000..df3bc56 --- /dev/null +++ b/migrations/000172_add_role_default_credit.down.sql @@ -0,0 +1,36 @@ +BEGIN; + +DO $$ +DECLARE + configured_count bigint; + assigned_count bigint; +BEGIN + SELECT COUNT(*) INTO configured_count + FROM tb_role + WHERE default_credit_enabled OR default_credit_limit <> 0; + IF configured_count > 0 THEN + RAISE EXCEPTION '已有 % 个角色配置新建代理默认信用,禁止删除模板字段', configured_count; + END IF; + + SELECT COUNT(*) INTO assigned_count + FROM tb_role_permission rp + JOIN tb_permission p ON p.id = rp.perm_id + WHERE p.perm_code = 'role:default-credit:manage' + AND rp.deleted_at IS NULL; + IF assigned_count > 0 THEN + RAISE EXCEPTION '角色默认信用权限已分配给 % 个角色,禁止回滚权限定义', assigned_count; + END IF; +END +$$; + +DELETE FROM tb_permission + WHERE perm_code = 'role:default-credit:manage' + AND deleted_at IS NULL; + +ALTER TABLE tb_role + DROP CONSTRAINT IF EXISTS chk_role_default_credit_config, + DROP CONSTRAINT IF EXISTS chk_role_default_credit_scope, + DROP COLUMN IF EXISTS default_credit_enabled, + DROP COLUMN IF EXISTS default_credit_limit; + +COMMIT; diff --git a/migrations/000172_add_role_default_credit.up.sql b/migrations/000172_add_role_default_credit.up.sql new file mode 100644 index 0000000..d95a966 --- /dev/null +++ b/migrations/000172_add_role_default_credit.up.sql @@ -0,0 +1,46 @@ +BEGIN; + +ALTER TABLE tb_role + ADD COLUMN IF NOT EXISTS default_credit_enabled boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS default_credit_limit bigint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN tb_role.default_credit_enabled IS '是否启用新建代理默认信用模板;只影响未来新建店铺'; +COMMENT ON COLUMN tb_role.default_credit_limit IS '新建代理默认信用额度,单位为分;不追溯修改既有钱包'; + +ALTER TABLE tb_role + DROP CONSTRAINT IF EXISTS chk_role_default_credit_config, + DROP CONSTRAINT IF EXISTS chk_role_default_credit_scope; + +ALTER TABLE tb_role + ADD CONSTRAINT chk_role_default_credit_config CHECK ( + (default_credit_enabled = false AND default_credit_limit = 0) OR + (default_credit_enabled = true AND default_credit_limit > 0) + ), + ADD CONSTRAINT chk_role_default_credit_scope CHECK ( + role_type = 2 OR (default_credit_enabled = false AND default_credit_limit = 0) + ); + +COMMENT ON CONSTRAINT chk_role_default_credit_config ON tb_role IS '保证角色默认信用开关和额度组合一致'; +COMMENT ON CONSTRAINT chk_role_default_credit_scope ON tb_role IS '保证只有客户角色可以保存新建代理默认信用模板'; + +INSERT INTO tb_permission ( + created_at, updated_at, creator, updater, + perm_name, perm_code, perm_type, platform, + available_for_role_types, url, parent_id, sort, status +) +VALUES ( + NOW(), NOW(), 0, 0, + '配置角色默认信用', 'role:default-credit:manage', 2, 'web', + '1', '/api/admin/roles/:id/default-credit', NULL, 0, 1 +) +ON CONFLICT (perm_code) WHERE deleted_at IS NULL +DO UPDATE SET + perm_name = EXCLUDED.perm_name, + perm_type = EXCLUDED.perm_type, + platform = EXCLUDED.platform, + available_for_role_types = EXCLUDED.available_for_role_types, + url = EXCLUDED.url, + status = EXCLUDED.status, + updated_at = NOW(); + +COMMIT; diff --git a/migrations/000173_add_shop_credit_permission.down.sql b/migrations/000173_add_shop_credit_permission.down.sql new file mode 100644 index 0000000..5cc16e2 --- /dev/null +++ b/migrations/000173_add_shop_credit_permission.down.sql @@ -0,0 +1,17 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_role_permission rp + JOIN tb_permission p ON p.id = rp.perm_id + WHERE p.perm_code = 'shop:credit-limit:manage' + AND rp.deleted_at IS NULL + ) THEN + RAISE EXCEPTION '店铺实际信用额度权限已分配,禁止回滚权限定义'; + END IF; +END +$$; + +DELETE FROM tb_permission + WHERE perm_code = 'shop:credit-limit:manage' + AND deleted_at IS NULL; diff --git a/migrations/000173_add_shop_credit_permission.up.sql b/migrations/000173_add_shop_credit_permission.up.sql new file mode 100644 index 0000000..1854acd --- /dev/null +++ b/migrations/000173_add_shop_credit_permission.up.sql @@ -0,0 +1,17 @@ +INSERT INTO tb_permission ( + created_at, updated_at, creator, updater, perm_name, perm_code, + perm_type, platform, available_for_role_types, url, parent_id, sort, status +) +VALUES ( + NOW(), NOW(), 0, 0, '调整店铺实际信用额度', 'shop:credit-limit:manage', + 2, 'web', '1', '/api/admin/shops/:id/credit-limit', NULL, 0, 1 +) +ON CONFLICT (perm_code) WHERE deleted_at IS NULL +DO UPDATE SET + perm_name = EXCLUDED.perm_name, + perm_type = EXCLUDED.perm_type, + platform = EXCLUDED.platform, + available_for_role_types = EXCLUDED.available_for_role_types, + url = EXCLUDED.url, + status = EXCLUDED.status, + updated_at = NOW(); diff --git a/migrations/000174_add_agent_wallet_order_debit_unique.down.sql b/migrations/000174_add_agent_wallet_order_debit_unique.down.sql new file mode 100644 index 0000000..8c664f5 --- /dev/null +++ b/migrations/000174_add_agent_wallet_order_debit_unique.down.sql @@ -0,0 +1,17 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_outbox_event + WHERE event_type = 'wallet.agent_main.debited' + ) THEN + RAISE EXCEPTION '已存在代理主钱包统一扣款事实,禁止移除订单防重约束'; + END IF; +END +$$; + +DROP INDEX IF EXISTS uq_agent_wallet_order_debit; +DROP INDEX IF EXISTS idx_order_idempotency_key; + +ALTER TABLE tb_order + DROP COLUMN IF EXISTS idempotency_key; diff --git a/migrations/000174_add_agent_wallet_order_debit_unique.up.sql b/migrations/000174_add_agent_wallet_order_debit_unique.up.sql new file mode 100644 index 0000000..4fe7904 --- /dev/null +++ b/migrations/000174_add_agent_wallet_order_debit_unique.up.sql @@ -0,0 +1,35 @@ +ALTER TABLE tb_order + ADD COLUMN idempotency_key VARCHAR(64) NOT NULL DEFAULT ''; + +COMMENT ON COLUMN tb_order.idempotency_key IS '订单创建幂等指纹(SHA-256),用于钱包订单事务级防重'; + +CREATE INDEX idx_order_idempotency_key + ON tb_order (idempotency_key, created_at DESC) + WHERE idempotency_key <> ''; + +COMMENT ON INDEX idx_order_idempotency_key IS '支持在幂等窗口内定位已提交的钱包订单'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_agent_wallet_transaction + WHERE reference_type = 'order' + AND transaction_type = 'deduct' + AND status = 1 + AND reference_id IS NOT NULL + GROUP BY reference_type, reference_id, transaction_type + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION '存在重复的代理主钱包订单扣款流水,禁止创建唯一索引'; + END IF; +END +$$; + +CREATE UNIQUE INDEX uq_agent_wallet_order_debit + ON tb_agent_wallet_transaction (reference_type, reference_id, transaction_type) + WHERE reference_type = 'order' + AND transaction_type = 'deduct' + AND status = 1; + +COMMENT ON INDEX uq_agent_wallet_order_debit IS '保证每个订单最多存在一条成功扣款流水,软删除不能绕过资金幂等'; diff --git a/migrations/000175_create_agent_wallet_reservation.down.sql b/migrations/000175_create_agent_wallet_reservation.down.sql new file mode 100644 index 0000000..f66ad6d --- /dev/null +++ b/migrations/000175_create_agent_wallet_reservation.down.sql @@ -0,0 +1,19 @@ +BEGIN; + +-- 在同一事务取得排他锁后检查并删除,避免检查与删表之间写入新预占事实。 +DO $$ +BEGIN + IF to_regclass('tb_agent_wallet_reservation') IS NULL THEN + RETURN; + END IF; + + LOCK TABLE tb_agent_wallet_reservation IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_agent_wallet_reservation LIMIT 1) THEN + RAISE EXCEPTION 'tb_agent_wallet_reservation 已存在代理主钱包预占事实,禁止删表回滚,请停止生产者后向前修复'; + END IF; + + DROP TABLE tb_agent_wallet_reservation; +END +$$; + +COMMIT; diff --git a/migrations/000175_create_agent_wallet_reservation.up.sql b/migrations/000175_create_agent_wallet_reservation.up.sql new file mode 100644 index 0000000..50b4039 --- /dev/null +++ b/migrations/000175_create_agent_wallet_reservation.up.sql @@ -0,0 +1,65 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_agent_wallet + WHERE deleted_at IS NULL + AND wallet_type = 'main' + AND frozen_balance > 0 + ) THEN + RAISE EXCEPTION '存在无法关联稳定业务引用的历史主钱包冻结金额,禁止自动切换统一冻结能力'; + END IF; + IF EXISTS ( + SELECT 1 + FROM tb_order + WHERE deleted_at IS NULL + AND buyer_type = 'agent' + AND payment_method = 'wallet' + AND payment_status = 1 + ) THEN + RAISE EXCEPTION '存在缺少预占事实的历史待支付代理钱包订单,禁止自动切换统一冻结能力'; + END IF; +END +$$; + +CREATE TABLE tb_agent_wallet_reservation ( + id BIGSERIAL PRIMARY KEY, + agent_wallet_id BIGINT NOT NULL, + shop_id BIGINT NOT NULL, + amount BIGINT NOT NULL, + status INT NOT NULL DEFAULT 1, + reference_type VARCHAR(50) NOT NULL, + reference_id BIGINT NOT NULL, + creator BIGINT NOT NULL DEFAULT 0, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + shop_id_tag BIGINT NOT NULL, + enterprise_id_tag BIGINT, + CONSTRAINT ck_agent_wallet_reservation_amount CHECK (amount > 0), + CONSTRAINT ck_agent_wallet_reservation_status CHECK (status IN (1, 2, 3)), + CONSTRAINT uq_agent_wallet_reservation_reference UNIQUE (reference_type, reference_id) +); + +COMMENT ON TABLE tb_agent_wallet_reservation IS '代理主钱包资金预占事实表'; +COMMENT ON COLUMN tb_agent_wallet_reservation.id IS '预占事实ID'; +COMMENT ON COLUMN tb_agent_wallet_reservation.agent_wallet_id IS '代理主钱包ID(无外键)'; +COMMENT ON COLUMN tb_agent_wallet_reservation.shop_id IS '钱包所属店铺ID'; +COMMENT ON COLUMN tb_agent_wallet_reservation.amount IS '预占金额(单位:分)'; +COMMENT ON COLUMN tb_agent_wallet_reservation.status IS '预占状态:1-已冻结 2-已释放 3-已完成扣除'; +COMMENT ON COLUMN tb_agent_wallet_reservation.reference_type IS '稳定业务引用类型'; +COMMENT ON COLUMN tb_agent_wallet_reservation.reference_id IS '稳定业务引用ID'; +COMMENT ON COLUMN tb_agent_wallet_reservation.creator IS '创建人账号ID'; +COMMENT ON COLUMN tb_agent_wallet_reservation.completed_at IS '释放或完成扣除时间'; +COMMENT ON COLUMN tb_agent_wallet_reservation.created_at IS '创建时间'; +COMMENT ON COLUMN tb_agent_wallet_reservation.updated_at IS '更新时间'; +COMMENT ON COLUMN tb_agent_wallet_reservation.shop_id_tag IS '店铺数据权限标签'; +COMMENT ON COLUMN tb_agent_wallet_reservation.enterprise_id_tag IS '企业数据权限标签'; + +CREATE INDEX idx_agent_wallet_reservation_wallet ON tb_agent_wallet_reservation (agent_wallet_id, created_at DESC); +CREATE INDEX idx_agent_wallet_reservation_shop ON tb_agent_wallet_reservation (shop_id, created_at DESC); +CREATE INDEX idx_agent_wallet_reservation_status ON tb_agent_wallet_reservation (status, created_at ASC); + +COMMENT ON INDEX idx_agent_wallet_reservation_wallet IS '按钱包查询预占事实'; +COMMENT ON INDEX idx_agent_wallet_reservation_shop IS '按店铺查询预占事实'; +COMMENT ON INDEX idx_agent_wallet_reservation_status IS '按预占状态扫描待处理事实'; diff --git a/migrations/000176_add_agent_wallet_credit_posting_unique.down.sql b/migrations/000176_add_agent_wallet_credit_posting_unique.down.sql new file mode 100644 index 0000000..10eec03 --- /dev/null +++ b/migrations/000176_add_agent_wallet_credit_posting_unique.down.sql @@ -0,0 +1,24 @@ +BEGIN; + +-- 停止入账生产者后在同一事务锁定资金流水与 Outbox,避免检查和删索引之间提交新事实。 +LOCK TABLE tb_agent_wallet_transaction, tb_agent_recharge_record, tb_outbox_event IN ACCESS EXCLUSIVE MODE; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_outbox_event + WHERE event_type = 'wallet.agent_main.credited' + ) THEN + RAISE EXCEPTION '已存在代理主钱包统一入账事实,禁止移除入账防重约束'; + END IF; +END +$$; + +DROP INDEX IF EXISTS uq_agent_recharge_provider_transaction; +DROP INDEX IF EXISTS uq_agent_wallet_credit_posting_reference; + +COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_type IS '交易类型:recharge-充值 deduct-扣款 refund-退款 commission-分佣 withdrawal-提现'; +COMMENT ON COLUMN tb_agent_wallet_transaction.reference_type IS '关联业务类型:order commission withdrawal topup refund exchange'; + +COMMIT; diff --git a/migrations/000176_add_agent_wallet_credit_posting_unique.up.sql b/migrations/000176_add_agent_wallet_credit_posting_unique.up.sql new file mode 100644 index 0000000..0e055a6 --- /dev/null +++ b/migrations/000176_add_agent_wallet_credit_posting_unique.up.sql @@ -0,0 +1,49 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_agent_wallet_transaction + WHERE reference_type IN ('topup', 'manual_adjustment') + AND transaction_type IN ('recharge', 'adjustment') + AND status = 1 + AND reference_id IS NOT NULL + GROUP BY reference_type, reference_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION '存在重复的代理主钱包充值或人工调整入账流水,禁止创建唯一索引'; + END IF; +END +$$; + +COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_type IS '交易类型:recharge-充值 adjustment-人工调整 deduct-扣款 refund-退款 commission-分佣 withdrawal-提现'; +COMMENT ON COLUMN tb_agent_wallet_transaction.reference_type IS '关联业务类型:order commission withdrawal topup refund exchange manual_adjustment'; + +CREATE UNIQUE INDEX uq_agent_wallet_credit_posting_reference + ON tb_agent_wallet_transaction (reference_type, reference_id) + WHERE reference_type IN ('topup', 'manual_adjustment') + AND transaction_type IN ('recharge', 'adjustment') + AND status = 1; + +COMMENT ON INDEX uq_agent_wallet_credit_posting_reference IS '保证每个充值或人工调整业务引用最多存在一条成功入账流水,软删除不能绕过资金幂等'; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_agent_recharge_record + WHERE payment_transaction_id IS NOT NULL + AND payment_transaction_id <> '' + GROUP BY payment_channel, payment_transaction_id + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION '存在同一支付渠道重复使用的代理充值第三方交易号,禁止创建唯一索引'; + END IF; +END +$$; + +CREATE UNIQUE INDEX uq_agent_recharge_provider_transaction + ON tb_agent_recharge_record (payment_channel, payment_transaction_id) + WHERE payment_transaction_id IS NOT NULL + AND payment_transaction_id <> ''; + +COMMENT ON INDEX uq_agent_recharge_provider_transaction IS '保证同一支付渠道的第三方交易号最多绑定一张代理充值单'; diff --git a/migrations/000177_add_agent_wallet_refund_unique.down.sql b/migrations/000177_add_agent_wallet_refund_unique.down.sql new file mode 100644 index 0000000..fcd5dcf --- /dev/null +++ b/migrations/000177_add_agent_wallet_refund_unique.down.sql @@ -0,0 +1,20 @@ +BEGIN; + +-- 停止退款生产者后在同一事务锁定资金流水与 Outbox,避免检查和删索引之间提交新事实。 +LOCK TABLE tb_agent_wallet_transaction, tb_outbox_event IN ACCESS EXCLUSIVE MODE; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_outbox_event + WHERE event_type = 'wallet.agent_main.refunded' + ) THEN + RAISE EXCEPTION '已存在代理主钱包统一退款事实,禁止移除退款防重约束'; + END IF; +END +$$; + +DROP INDEX IF EXISTS uq_agent_wallet_refund_reference; + +COMMIT; diff --git a/migrations/000177_add_agent_wallet_refund_unique.up.sql b/migrations/000177_add_agent_wallet_refund_unique.up.sql new file mode 100644 index 0000000..f709896 --- /dev/null +++ b/migrations/000177_add_agent_wallet_refund_unique.up.sql @@ -0,0 +1,27 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_agent_wallet_transaction + WHERE reference_type = 'refund' + AND transaction_type = 'refund' + AND status = 1 + AND reference_id IS NOT NULL + GROUP BY reference_type, reference_id, transaction_type + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION '存在重复的代理主钱包成功退款流水,禁止创建唯一索引'; + END IF; +END +$$; + +COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_type IS '交易类型:recharge-充值 adjustment-人工调整 deduct-扣款 refund-退款 commission-分佣 withdrawal-提现'; +COMMENT ON COLUMN tb_agent_wallet_transaction.reference_type IS '关联业务类型:order commission withdrawal topup refund exchange manual_adjustment'; + +CREATE UNIQUE INDEX uq_agent_wallet_refund_reference + ON tb_agent_wallet_transaction (reference_type, reference_id, transaction_type) + WHERE reference_type = 'refund' + AND transaction_type = 'refund' + AND status = 1; + +COMMENT ON INDEX uq_agent_wallet_refund_reference IS '保证每张退款单最多存在一条代理主钱包成功退款流水,软删除不能绕过资金幂等'; diff --git a/migrations/000178_make_iot_card_iccid_exact_unique.down.sql b/migrations/000178_make_iot_card_iccid_exact_unique.down.sql new file mode 100644 index 0000000..fa34fd9 --- /dev/null +++ b/migrations/000178_make_iot_card_iccid_exact_unique.down.sql @@ -0,0 +1,20 @@ +BEGIN; + +LOCK TABLE tb_iot_card IN ACCESS EXCLUSIVE MODE; + +-- 只恢复 000131 的普通部分索引形态,不删除双列或修改任何卡数据。 +DROP INDEX IF EXISTS idx_iot_card_iccid_19; +DROP INDEX IF EXISTS idx_iot_card_iccid_20; + +CREATE INDEX idx_iot_card_iccid_19 + ON tb_iot_card (iccid_19) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_iot_card_iccid_20 + ON tb_iot_card (iccid_20) + WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL; + +COMMENT ON INDEX idx_iot_card_iccid_19 IS '未删除卡的19位ICCID普通查询索引'; +COMMENT ON INDEX idx_iot_card_iccid_20 IS '未删除卡的非空20位ICCID普通查询索引'; + +COMMIT; diff --git a/migrations/000178_make_iot_card_iccid_exact_unique.up.sql b/migrations/000178_make_iot_card_iccid_exact_unique.up.sql new file mode 100644 index 0000000..31f13bb --- /dev/null +++ b/migrations/000178_make_iot_card_iccid_exact_unique.up.sql @@ -0,0 +1,68 @@ +BEGIN; + +-- 锁住卡表,保证冲突扫描与索引切换之间不会插入新的重复 ICCID。 +LOCK TABLE tb_iot_card IN ACCESS EXCLUSIVE MODE; + +DO $$ +DECLARE + iccid_19_conflict_groups bigint; + iccid_20_conflict_groups bigint; + invalid_dual_column_rows bigint; +BEGIN + SELECT COUNT(*) + INTO invalid_dual_column_rows + 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)) + ); + + SELECT COUNT(*) + INTO iccid_19_conflict_groups + FROM ( + SELECT iccid_19 + FROM tb_iot_card + WHERE deleted_at IS NULL + AND iccid_19 IS NOT NULL + GROUP BY iccid_19 + HAVING COUNT(*) > 1 + ) conflicts; + + SELECT COUNT(*) + INTO iccid_20_conflict_groups + FROM ( + SELECT iccid_20 + FROM tb_iot_card + WHERE deleted_at IS NULL + AND iccid_20 IS NOT NULL + GROUP BY iccid_20 + HAVING COUNT(*) > 1 + ) conflicts; + + IF invalid_dual_column_rows > 0 OR iccid_19_conflict_groups > 0 OR iccid_20_conflict_groups > 0 THEN + RAISE EXCEPTION 'ICCID 唯一索引切换中止:双列异常卡数=%,19位冲突组数=%,20位冲突组数=%;请导出异常清单并人工处理后重试', + invalid_dual_column_rows, iccid_19_conflict_groups, iccid_20_conflict_groups; + END IF; +END +$$; + +-- 事务内先删除普通索引再以原名创建唯一索引;任何失败都会整体回滚。 +DROP INDEX IF EXISTS idx_iot_card_iccid_19; +DROP INDEX IF EXISTS idx_iot_card_iccid_20; + +CREATE UNIQUE INDEX idx_iot_card_iccid_19 + ON tb_iot_card (iccid_19) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX idx_iot_card_iccid_20 + ON tb_iot_card (iccid_20) + WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL; + +COMMENT ON INDEX idx_iot_card_iccid_19 IS '未删除卡的非空19位ICCID精确唯一索引'; +COMMENT ON INDEX idx_iot_card_iccid_20 IS '未删除卡的非空20位ICCID精确唯一索引'; + +COMMIT; diff --git a/migrations/000179_add_iot_card_realname_reversal_state.down.sql b/migrations/000179_add_iot_card_realname_reversal_state.down.sql new file mode 100644 index 0000000..6aaa510 --- /dev/null +++ b/migrations/000179_add_iot_card_realname_reversal_state.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +ALTER TABLE tb_iot_card + DROP CONSTRAINT IF EXISTS chk_iot_card_realname_reversal_count, + DROP COLUMN IF EXISTS realname_reversal_started_at, + DROP COLUMN IF EXISTS realname_reversal_count; + +COMMIT; diff --git a/migrations/000179_add_iot_card_realname_reversal_state.up.sql b/migrations/000179_add_iot_card_realname_reversal_state.up.sql new file mode 100644 index 0000000..67f0136 --- /dev/null +++ b/migrations/000179_add_iot_card_realname_reversal_state.up.sql @@ -0,0 +1,15 @@ +BEGIN; + +-- 将实名逆转确认状态与卡事实放入同一 PostgreSQL 事务,避免 Redis 计数与状态回滚不一致。 +ALTER TABLE tb_iot_card + ADD COLUMN realname_reversal_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN realname_reversal_started_at TIMESTAMPTZ; + +COMMENT ON COLUMN tb_iot_card.realname_reversal_count IS '实名逆转连续观测次数'; +COMMENT ON COLUMN tb_iot_card.realname_reversal_started_at IS '实名逆转当前确认窗口开始时间'; + +ALTER TABLE tb_iot_card + ADD CONSTRAINT chk_iot_card_realname_reversal_count + CHECK (realname_reversal_count >= 0 AND realname_reversal_count < 3); + +COMMIT; diff --git a/migrations/000180_create_card_observation_effect.down.sql b/migrations/000180_create_card_observation_effect.down.sql new file mode 100644 index 0000000..74b6204 --- /dev/null +++ b/migrations/000180_create_card_observation_effect.down.sql @@ -0,0 +1,5 @@ +BEGIN; + +DROP TABLE IF EXISTS tb_card_observation_effect; + +COMMIT; diff --git a/migrations/000180_create_card_observation_effect.up.sql b/migrations/000180_create_card_observation_effect.up.sql new file mode 100644 index 0000000..48f3b80 --- /dev/null +++ b/migrations/000180_create_card_observation_effect.up.sql @@ -0,0 +1,25 @@ +BEGIN; + +CREATE TABLE tb_card_observation_effect ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + event_id VARCHAR(180) NOT NULL, + effect_type VARCHAR(50) NOT NULL, + status INTEGER NOT NULL DEFAULT 0, + CONSTRAINT chk_card_observation_effect_status CHECK (status IN (0, 1, 2, 3, 4)) +); + +COMMENT ON TABLE tb_card_observation_effect IS '卡观测事件副作用幂等处理进度'; +COMMENT ON COLUMN tb_card_observation_effect.event_id IS 'Outbox事件ID'; +COMMENT ON COLUMN tb_card_observation_effect.effect_type IS '副作用类型'; +COMMENT ON COLUMN tb_card_observation_effect.status IS '处理状态 0-待处理 1-处理中或结果未知 2-日流量已记录 3-已扣减 4-已完成'; + +CREATE UNIQUE INDEX uk_card_observation_effect_event + ON tb_card_observation_effect (event_id) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/openspec/changes/complete-july-iteration-test-release/design.md b/openspec/changes/complete-july-iteration-test-release/design.md index 97284c7..545c0b8 100644 --- a/openspec/changes/complete-july-iteration-test-release/design.md +++ b/openspec/changes/complete-july-iteration-test-release/design.md @@ -31,7 +31,7 @@ - 真实表达后端、前端、验证、发布四个维度,不把代码存在等同验收完成。 - 保留冻结业务规则,将全部未完成工作组织为三批可执行纵向切片,并消除悬空和模糊依赖。 - 在不削弱资金、审批、异步与外部调用可靠性的前提下,形成测试环境快速交付顺序。 -- 冻结现有审计代码并解除 Audit Event 对测试环境的阻塞,同时明确生产前恢复清单。 +- 冻结现有审计代码并将 Audit Event 移出本 Change 及本次实际上线范围,不把它作为测试环境或生产上线阻塞项。 - 统一前后端契约、停机部署、回滚和延期验证边界。 **Non-Goals:** @@ -81,13 +81,25 @@ **否决方案**:强制所有新需求继续堆入旧 Service,或全仓一次性 DDD 重构;前者扩大旧 Service,后者超出当前完整用例边界。 -### 4. 三批实施与稳定依赖图 +### 4. 审批业务与外部渠道依赖倒置 + +退款、线下代充等业务的 Domain/Application 使用业务侧定义的 Approval Port,只依赖通用审批实例、标准决策和终态事件。WeCom Adapter 是首个渠道实现,负责 Token、账号绑定、模板/控件映射、附件副本、提交、回调、轮询和恢复,并将企微状态翻译为标准决策。未来接入钉钉或其他渠道时只新增 Adapter 和渠道配置,不修改退款、充值、钱包、佣金或套餐终结规则。 + +通用审批实例仅保存业务类型/业务 ID、真实提交人、`provider`、`external_ref`、标准状态、请求/决策快照及关联 ID。企微 `sp_no`、模板 ID、控件 ID、userid 绑定和加密回调参数只属于 WeCom Adapter 或渠道专属配置。本地业务附件仍是权威原件,外部审批附件只是副本。系统不建设 BPMN、节点编排、审批人路由或会签/或签等本地通用审批流引擎,这些仍由外部平台负责。 + +新提交在有效 Adapter 未装配、场景未发布或身份不可解析时,必须在写入业务单、通用审批实例和 Outbox 之前失败关闭。因此业务核心可先于企微实现与构建,但测试环境开放线下代充/退款提交入口前,必须完成 WeCom Adapter 与组合根装配。 + +**理由**:审批是业务决策边界,企微只是当前交付渠道;依赖倒置既允许业务核心先行,也避免未来替换渠道时重写资金和状态机。 + +**否决方案**:让退款/充值直接依赖企微 SDK、`sp_no` 或企微状态码;或为了渠道灵活性自建通用审批流引擎。前者锁定供应商,后者扩大了本期范围并重复外部平台能力。 + +### 5. 三批实施与稳定依赖图 ```mermaid flowchart TD PF[JULY-TECH-FOUNDATION 已完成] N[B1-01 公共通知] - W[B1-02 UR37 企微] + A[B1-02 渠道无关审批核心] C[B1-03 UR38 信用钱包] S[B1-04 UR94 卡状态] G[B1-05 UR43 系列授权] @@ -102,46 +114,51 @@ flowchart TD R97[B2-05 UR97 余额预警] R36[B2-06 UR36 批量订购] R49[B2-07 UR49 设备批量] - R34[B3-01 UR34 充值] - R35[B3-02 UR35 退款] - R57[B3-03 UR57 禁止换货] - R33[B3-04 UR33 临期] - R44[B3-05 UR44 摘要] - R42[B3-06 UR42 导出] - PF --> N & W & C & S & G & L & P & O & X + R34[B3-01 UR34 充值业务核心] + R35[B3-02 UR35 退款业务核心] + W[B3-03 UR37 WeCom Adapter] + R57[B3-04 UR57 禁止换货] + R33[B3-05 UR33 临期] + R44[B3-06 UR44 摘要] + R42[B3-07 UR42 导出] + PF --> N & A & C & S & G & L & P & O & X S --> R73 & R53 & R62 C --> R97 & R36 & R34 & R35 O --> R97 & R33 N --> R97 & R34 & R35 & R33 + A --> R34 & R35 R40 --> R36 & R33 R36 --> R49 - W --> R34 & R35 & R44 + R34 --> W + R35 --> W + A --> W + W --> R44 R35 --> R57 & R44 R34 --> R44 R33 --> R42 R44 --> R42 ``` -UR46 已完成后端并作为 B3-04/B3-06 的已满足后端依赖;UR55 已满足 UR46。UR45、UR46、UR60、UR86 只进入最终前端/人工/元数据收口,不重复实现后端。 +UR46 已完成后端并作为 B3-05/B3-07 的已满足后端依赖;UR55 已满足 UR46。UR45、UR46、UR60、UR86 只进入最终前端/人工/元数据收口,不重复实现后端。 **理由**:先交付公共写入、资金和外部系统边界,再交付依赖它们的业务,最后组合复杂业务,可消除原 `.scratch` 的悬空引用。 **否决方案**:按数据库、Service、Handler 水平排期,或让下游先各自复制公共能力;两者都会制造重复实现和不可验证中间态。 -### 5. 原 Tickets 保留业务粒度,但统一移除测试与 Audit Event 阻塞 +### 6. 原 Tickets 保留业务粒度,但统一移除测试与 Audit Event 阻塞 -已有 Tickets 的业务切片 SHALL 在对应总任务内逐项映射;原本纯自动化 Harness、验收测试、真实外部门禁或 Audit Event 写入子项不在本测试环境实施。它们移动到“延期验证与生产门禁”,而不是被标记完成。不存在路径和占位符改为本 Change 的稳定任务号: +已有 Tickets 的业务切片 SHALL 在对应总任务内逐项映射;原本纯自动化 Harness、验收测试和真实外部门禁移动到“延期验证与生产门禁”,Audit Event 写入、审计中心与治理子项则从本 Change 及本次实际上线范围排除。不存在路径和占位符改为本 Change 的稳定任务号: - 公共通知 04 改依赖 `B1-08.6`,不再引用不存在的 UR96 文件名。 -- UR37-01~13 形成可供 UR35 使用的公共能力;原 UR37-14“退款首个真实企微门禁”改映射到生产前 `6.2`,不再保留“UR35 拆票后替换”的占位符,也不反向阻塞 UR35 代码实现。 +- UR37-01~13 各自映射为后置 WeCom Adapter 纵向任务,不成为 UR34/UR35 业务核心的编译前置;原 UR37-14“退款首个真实企微门禁”分为测试环境 Adapter 装配检查和生产前 `6.2` 真实验收,不反向阻塞 UR34/UR35 核心规则实现。 - UR48/UR49 发布依赖使用本 Change 任务号,不依赖漂移标题。 -- 所有 `.scratch/tech-global-audit/issues/01`、`19`、`20`、`21` 等 Audit Event 阻塞边在测试环境路径删除,转入生产前 `6.*` 延期清单。 +- 所有 `.scratch/tech-global-audit/issues/01`、`19`、`20`、`21` 等 Audit Event 阻塞边从测试环境和生产上线依赖图删除;未来如需恢复审计能力,另开独立 Change,不在本 Change 的 `6.*` 清单中恢复。 **理由**:用户已明确授权测试和审计范围调整;保留旧 blocker 会让测试环境目标不可执行。 **否决方案**:修改原 Tickets。原 Tickets 需要保留评审历史,本 Change 只提供新的执行映射。 -### 6. 前后端契约以“冻结字段 + 状态矩阵 + 人工验收点”管理 +### 7. 前后端契约以“冻结字段 + 状态矩阵 + 人工验收点”管理 每条涉及 API 的纵向任务记录 Endpoint、请求字段、完整 `data`、用户类型、资源所有权、分页/排序/过滤、错误码、状态/枚举、前端页面与异常状态。响应包络一律为 `{code,msg,data,timestamp}`,参数错误使用 `CodeInvalidParam`,资源不存在/越权使用 `CodeForbidden` 统一语义。 @@ -149,19 +166,19 @@ UR46 已完成后端并作为 B3-04/B3-06 的已满足后端依赖;UR55 已满 **理由**:可以防止后端代码完成被误记为全链路完成,同时给前端明确实现输入。 -### 7. 资金、审批、异步与外部调用保留完整异常闭环 +### 8. 资金、审批、异步与外部调用保留完整异常闭环 - **资金**:钱包余额/版本/流水是 Domain Ledger;扣款、冻结、入账、退款按唯一业务键、乐观锁/条件更新和同事务事实保证;支付成功与钱包入账分阶段,已收款不得因后续失败丢失。 -- **审批**:企微负责节点/审批人/意见/附件;本地保存业务快照、实例和终态处理。提交结果未知不盲重试;回调与轮询进入同一同步用例;通过后撤销按是否已产生资金事实分流。 +- **审批**:业务侧先固化申请快照并只消费 `approved/rejected/cancelled/deleted/revoked_after_approved` 标准决策;Adapter 负责外部节点、审批人、意见和附件副本,并将回调/轮询翻译到同一同步用例。提交结果未知不盲重试;通过后撤销按是否已产生资金事实分流。 - **异步**:关键副作用同事务写 Outbox,Relay 投递 Asynq,消费者按至少一次与处理租约设计;任务五态和业务成功/失败计数分离。 -- **外部调用**:Integration Log 在请求/回调边界记录脱敏尝试、结果未知和恢复事实;支付、企微、Gateway、运营商状态不得由日志替代业务表。 +- **外部调用**:Integration Log 在请求/回调边界记录脱敏尝试、结果未知和恢复事实;支付、审批渠道、Gateway、运营商状态不得由日志替代业务表。 - **错误**:Application/Domain/Query 使用 `pkg/errors`,客户端不接收底层 SDK/GORM/Validator 错误;关键错误使用中文日志并携带稳定关联 ID。 -Audit Event 在本测试里程碑不接入新业务,以上 Domain Ledger、Integration Log、Outbox、事务和幂等均不得延期。 +Audit Event 不在本 Change 及本次实际上线范围内接入新业务,以上 Domain Ledger、Integration Log、Outbox、事务和幂等均不得因此延期。 -### 8. 审计冻结对运行时无新增影响 +### 9. 审计冻结对运行时无新增影响 -边界保持:Access Log 负责 HTTP 调试;Audit Event 负责操作者治理但本轮冻结;Domain Ledger 负责金额/状态事实;Integration Log 负责外部交互;Outbox 负责可靠投递。 +边界保持:Access Log 负责 HTTP 调试;Audit Event 属于本 Change 范围外的操作者治理能力;Domain Ledger 负责金额/状态事实;Integration Log 负责外部交互;Outbox 负责可靠投递。 `ff44305` 的运行时分析为: @@ -174,7 +191,7 @@ Audit Event 在本测试里程碑不接入新业务,以上 Domain Ledger、Int **否决方案**:回滚 `ff44305` 或删除审计迁移;没有必要且会丢失 Access Log 加固和未来恢复基线。 -### 9. 测试配置冲突由用户本次里程碑决策覆盖 +### 10. 测试配置冲突由用户本次里程碑决策覆盖 本测试环境里程碑不生成/运行自动化测试。每个批次只执行 `gofmt`、必要生成、`go build ./...`、迁移/路由/Worker/配置部署检查。状态使用“代码完成、验证延期”,不写“PASS”。 @@ -186,12 +203,13 @@ Audit Event 在本测试里程碑不接入新业务,以上 Domain Ledger、Int - **[无自动化回归导致状态机、资金与权限缺陷更晚暴露]** → 每批强制构建和部署检查;测试环境限制访问与资金;所有未执行验证进入生产阻塞清单,禁止沿用豁免。 - **[三批范围仍大,2026-07-27 前无法全部完成]** → 依赖 frontier 可并行,但下游只在具体上游完成后启动;状态台账实时反映未开始和代码完成/验证延期,不通过改状态掩盖延期。 -- **[原 Tickets 中审计与测试子项被误认为删除]** → 在任务映射中标注“测试环境豁免,转 D-*”,生产前必须恢复;原文件不修改。 -- **[Audit Event 未接入导致测试环境缺少操作者治理证据]** → 保留 Access Log、Domain Ledger、Integration Log 和关联 ID;限制为测试环境,生产前完成审计专项。 +- **[原 Tickets 中审计与测试子项被混为同一延期项]** → 在任务映射中区分处理:自动化与真实验收转 `6.*`,Audit Event 明确移出本 Change;原文件保留历史,不改写其评审记录。 +- **[Audit Event 未接入导致缺少统一操作者治理证据]** → 保留 Access Log、Domain Ledger、Integration Log 和关联 ID;本次实际上线接受这一治理缺口,未来是否建设由独立 Change 决定。 - **[Integration Log Repository 未生产装配但下游以为已可用]** → 每个真正需要外部交互的业务纵向任务显式完成 Adapter/组合根装配;不能把迁移存在等同运行时接入。 - **[前端仓库不在当前工作区导致后端状态被过度声明]** → 前端契约和人工验收独立复选框,后端无法代勾。 - **[停机迁移后存在不可逆审批/支付/资金事实]** → 开放访问前验证失败可回退;产生事实后暂停生产者、保留流水/Outbox/Integration Log并前向修复,不清表、不恢复旧 Writer。 - **[原 PRD 的依赖环阻塞执行]** → UR33 先交付临期 Query/通知,UR42 后接导出 Scene;UR34/35 先交付状态模型,UR44 再投影摘要,UR42 最后接 Scene。 +- **[渠道 Adapter 后置导致业务代码已完成但入口不可用]** → 业务核心以 Port 构建并保持新提交失败关闭;测试环境开放入口必须引用 WeCom Adapter 装配任务,不将“代码完成”记为“可发布”。 - **[旧配置与当前规范继续漂移]** → 本 Change 明确优先级;不在本次顺手修改 `openspec/config.yaml`,另行治理。 ## Migration Plan @@ -199,7 +217,7 @@ Audit Event 在本测试里程碑不接入新业务,以上 Domain Ledger、Int ### 规划到实施 1. 冻结本 Change 的 proposal/design/specs/tasks,后续不从分散 `.scratch` 目录直接启动七月新工作。 -2. 按 B1 → B2 → B3 的具体编号领取任务;同批无依赖项可并行,有依赖项顺序执行。 +2. 按 B1 → B2 → B3 的具体编号领取任务;B1 先固化渠道无关 Approval Port,B3 先完成 UR34/UR35 业务核心,再实现 WeCom Adapter 和组合根装配;同批无依赖项可并行,有依赖项顺序执行。 3. 每个需求先完成冻结 PRD 的业务纵向切片,再执行该批 `gofmt`、必要生成、`go build ./...` 和部署检查。 4. 完成 F1 已落地需求的元数据/前端/人工收口,再执行 F2 测试环境装配与停机部署。 5. 测试环境运行期间保留 D 组延期项未勾选;生产发布另开恢复窗口逐项完成。 @@ -224,13 +242,13 @@ flowchart LR - 支付成功未入账、审批终态未处理、Outbox 积压必须由恢复 Worker 幂等完成,不能要求用户重复提交或重复付款。 - 信用额度一旦产生负余额,不得回退到忽略信用边界的旧扣款逻辑。 -### 生产发布前恢复门禁 +### 生产发布前门禁 - 自动化测试与完整构建、真实 PostgreSQL/Redis/Asynq 验证。 - 真实企微模板/绑定/附件/回调/轮询,微信/支付宝预下单/查单/回调,Gateway 限速与运营商回调验收。 - 前后端全链路、历史数据抽样、存量迁移和停机演练。 - 权限、分页、N+1、性能、安全、Access Log 敏感矩阵与回滚演练。 -- 恢复 Audit Event Writer、业务覆盖、审计中心/历史投影和一次性发布门禁的评审与实施;不得沿用本测试豁免。 +- Audit Event Writer、审计中心和历史投影不属于本 Change 的生产门禁;未来如需建设,另开独立 Change,不反向阻塞本次上线。 ## Open Questions diff --git a/openspec/changes/complete-july-iteration-test-release/proposal.md b/openspec/changes/complete-july-iteration-test-release/proposal.md index 29699f9..fa6149d 100644 --- a/openspec/changes/complete-july-iteration-test-release/proposal.md +++ b/openspec/changes/complete-july-iteration-test-release/proposal.md @@ -10,20 +10,22 @@ - 按真实证据预置状态:TECH 七月公共开发基础和 UR55 标记已完成;UR45 标记“后端完成、原 Ticket 元数据待收口”;UR46、UR60、UR86 仅标记后端完成,保留前端与人工验收未完成;其余 22 条线保持未开始。 - 将已有 Tickets 直接纳入统一执行契约;将 UR33、UR34、UR35、UR42、UR44、UR53、UR57、UR62、UR73、UR97 的冻结 PRD 转换为可独立实施和验证的纵向任务,不再创建新的 `.scratch/issues`。 - 按三批实施:共享核心与独立 frontier、共享核心下游、复杂业务;最终收口已落地需求的前端契约、测试环境装配、迁移和人工验收准备。 +- 调整审批实施顺序:先定义由业务侧拥有的渠道无关 Approval Port 和标准决策,再完成退款、线下充值的金额、状态机与终态处理,最后接入企微作为首个 Adapter;未来接入钉钉或其他审批平台时不得修改退款、充值、钱包、佣金和套餐核心规则。 +- 审批公共层只统一业务引用、标准状态、提交/同步 Port、终态事件和渠道标识,不建设本地通用审批流引擎;审批节点、审批人、会签/或签、意见和渠道附件仍由外部平台负责。 - 将用户已确认的紧急交付决策写入契约:本里程碑目标是测试环境部署,不是生产验收;不新增 `_test.go`、不运行 `go test`、不执行真实 PostgreSQL/Redis/企微/支付/Gateway 自动验收,仅执行 `gofmt`、必要代码生成、按批次 `go build ./...` 与部署检查。 -- 暂缓全局 Audit Event 专项及其七月发布阻塞边:保留提交 `ff44305` 及既有审计代码、迁移和 Access Log 脱敏,不继续开发审计中心、Audit Event 写入、历史投影或一次性审计发布门禁;生产发布前必须恢复为延期治理项,不得永久标记 N/A。 -- 保留 Integration Log、Outbox、幂等、事务和外部调用恢复能力,它们继续作为业务可靠性设施,不随 Audit Event 暂缓而删除、回滚或绕过。 +- 将全局 Audit Event 专项移出本 Change 及本次实际上线范围:保留提交 `ff44305` 及既有代码、迁移和 Access Log 脱敏,不继续开发审计中心、Audit Event 写入、历史投影或一次性审计发布门禁,也不把它们作为测试环境或生产上线阻塞项;未来如需恢复,必须另开独立 Change 重新评审。 +- 保留 Integration Log、Outbox、Domain Ledger、幂等、事务和外部调用恢复能力,它们继续作为业务可靠性设施,不因 Audit Event 移出范围而删除、回滚或绕过。 - 永久排除已于 2026-07-14 移出本期的需求16;不得创建分销码、代理申请、分销关系、佣金提现相关表、API、页面、领域目录或迁移。 - 统一接口与工程约束:Fiber、GORM、PostgreSQL、Redis、Asynq、Viper、Zap、sonic;复杂写、简单写、Query 三通道触碰式迁移;统一响应 `{code,msg,data,timestamp}`;错误使用 `pkg/errors`;列表默认 20、最大 100;数据权限、幂等、无外键和文档生成器更新继续遵守项目标准。 -- **BREAKING**:按冻结 PRD 和标准评审稿实施时,退款与平台线下充值将停机切换到企微审批并下线旧本地审批/入账动作接口;系列授权等明确要求同批切换的旧混合命令语义不保留长期兼容层。 +- **BREAKING**:按冻结 PRD 和标准评审稿实施时,退款与平台线下充值将停机切换到渠道无关审批契约,并以企微作为本期首个交付 Adapter,下线旧本地审批/入账动作接口;系列授权等明确要求同批切换的旧混合命令语义不保留长期兼容层。 ## Capabilities ### New Capabilities - `july-iteration-master-ledger`: 定义 28 条线的唯一稳定编号、来源权威、真实完成状态、具体依赖和需求16排除规则。 -- `july-iteration-delivery-contract`: 定义冻结业务来源、DDD 三通道、API/权限/分页/错误契约、可靠性设施及前后端纵向交付边界。 -- `july-iteration-test-release-gate`: 定义测试环境发布豁免、审计冻结边界、部署与回滚顺序、状态真实性以及生产发布前必须恢复的延期门禁。 +- `july-iteration-delivery-contract`: 定义冻结业务来源、DDD 三通道、渠道无关审批 Port/Adapter、API/权限/分页/错误契约、可靠性设施及前后端纵向交付边界。 +- `july-iteration-test-release-gate`: 定义测试环境发布豁免、Audit Event 排除边界、部署与回滚顺序、状态真实性以及仍需完成的生产发布门禁。 ### Modified Capabilities @@ -32,8 +34,8 @@ ## Impact - **规划与权威关系**:后续七月迭代只从本 Change 的 `tasks.md` 领取剩余工作;原 `.scratch` PRD/Tickets 保留溯源和业务细节,不废弃、不另行扩展。冲突时依次以用户本次确认、七月标准评审稿、冻结 PRD、既有 Ticket 为准。 -- **后端**:后续实施将触及资产、套餐、钱包、订单、退款、充值、企微、通知、导出、Gateway、运营商回调、异步任务、系统配置及相关迁移、API/Worker 组合根;本次提案本身不修改这些业务代码。 +- **后端**:后续实施将触及资产、套餐、钱包、订单、退款、充值、渠道无关审批核心、企微首个 Adapter、通知、导出、Gateway、运营商回调、异步任务、系统配置及相关迁移、API/Worker 组合根;本次提案本身不修改这些业务代码。 - **前端**:涉及后台管理端、代理端和 C 端 H5/公众号的页面、状态、上传、轮询、审批、支付、导出和人工验收契约;当前仓库无前端源码,只交付框架无关契约与联调清单。 -- **外部系统**:企业微信、微信支付、支付宝、Gateway、运营商回调、对象存储、PostgreSQL、Redis/Asynq;本测试环境里程碑仅完成装配与人工验收准备,真实外部验收延期。 +- **外部系统**:企业微信是本期首个审批 Adapter,钉钉等未来渠道通过同一 Approval Port 扩展;另涉及微信支付、支付宝、Gateway、运营商回调、对象存储、PostgreSQL、Redis/Asynq。本测试环境里程碑仅完成装配与人工验收准备,真实外部验收延期。 - **性能与安全**:继续要求 API P95 < 200ms、P99 < 500ms、数据库查询 < 50ms,列表分页、批量查询避免 N+1;Access Log 脱敏继续生效,敏感配置和底层错误不得对外泄露。 - **文档**:后续每个纵向切片仍需同步中文功能文档、README 索引、OpenAPI 文档生成器及跨仓前端契约;本 Change 作为七月迭代唯一总台账和剩余实施入口。 diff --git a/openspec/changes/complete-july-iteration-test-release/specs/july-iteration-delivery-contract/spec.md b/openspec/changes/complete-july-iteration-test-release/specs/july-iteration-delivery-contract/spec.md index dca2daa..1e4fbc9 100644 --- a/openspec/changes/complete-july-iteration-test-release/specs/july-iteration-delivery-contract/spec.md +++ b/openspec/changes/complete-july-iteration-test-release/specs/july-iteration-delivery-contract/spec.md @@ -18,6 +18,28 @@ - **WHEN** 实施人员领取任一未完成纵向任务 - **THEN** 任务中的主/辅助通道 MUST 与其状态机、金额、并发、外部副作用或纯读取性质一致,并按任务声明限制迁移范围 +### Requirement: 审批业务必须与具体渠道解耦 +退款、线下充值及未来审批业务 MUST 只依赖渠道无关的 Approval Port、标准审批实例、标准决策和终态事件,不得在业务 Domain/Application 中引用企微、钉钉等渠道 SDK、DTO、回调状态码、模板控件 ID 或外部审批编号字段。审批实例 MUST 使用 `provider` 与通用 `external_ref` 标识渠道和外部实例;企微 `sp_no` 等渠道字段只能由对应 Adapter 转换或保存在渠道专属配置/安全快照中。 + +#### Scenario: 先实现退款业务核心 +- **WHEN** 企微 Adapter 尚未实现但退款金额、状态机和整单终结规则开始开发 +- **THEN** 退款业务 MUST 能围绕 Approval Port 和标准 `approved/rejected/cancelled/deleted/revoked_after_approved` 决策完成构建,不得依赖企微包或 `sp_no` + +#### Scenario: 接入企微首个 Adapter +- **WHEN** 企微提交、回调或轮询取得渠道状态 +- **THEN** WeCom Adapter MUST 将其翻译为标准审批引用与决策,再调用渠道无关 Application 用例;业务模块不得解析企微状态 + +#### Scenario: 未来接入其他审批渠道 +- **WHEN** 后续新增钉钉或其他审批平台 +- **THEN** 实施 MUST 新增对应 Adapter 和渠道配置,不得修改退款、充值、钱包、佣金、套餐或换货业务不变量 + +### Requirement: 审批抽象不得演变为本地流程引擎 +渠道无关审批层 MUST 仅管理业务引用、真实提交人快照、`provider/external_ref`、标准状态、提交与同步 Port、标准终态事件及幂等。系统不得在本期实现审批节点编排、审批人解析、组织模型、会签/或签、审批动作页面或 BPMN;这些能力继续由外部审批平台负责。 + +#### Scenario: 外部渠道未就绪 +- **WHEN** 业务代码已经完成但当前环境没有可用审批 Adapter 或场景配置 +- **THEN** 新审批提交入口 MUST 在写入业务单、审批实例和 Outbox 前失败关闭,已存在实例仍按其原 provider 继续同步和处理 + ### Requirement: API 必须使用项目统一契约 所有新增或修改 API MUST 使用 Fiber v2、Validator、`pkg/response` 和 `{code,msg,data,timestamp}` 响应包络;不得使用 `{message}` 替代 `msg`。错误 MUST 在 `pkg/errors` 中定义或复用,Handler 参数校验失败统一返回 `CodeInvalidParam`,资源不存在与越权使用不泄露存在性的 `CodeForbidden` 语义,禁止向客户端拼接底层错误。 @@ -51,7 +73,7 @@ 涉及跨事务副作用或外部系统的用例 MUST 保留事务、幂等、Outbox、Asynq 至少一次投递、Integration Log 和外部调用恢复能力。关键事件 MUST 与业务事实同事务写入 Outbox;消费者 MUST 使用状态条件、唯一业务键、处理租约或乐观锁保证幂等;`EnqueueTask` 载荷 MUST 传 struct 或 map,不得传预序列化 `[]byte`。 #### Scenario: 外部调用结果未知 -- **WHEN** 企微、支付、Gateway 或其他具有副作用的请求已经发出但结果未知 +- **WHEN** 任一审批渠道、支付、Gateway 或其他具有副作用的请求已经发出但结果未知 - **THEN** 系统 MUST 保存可恢复的 Integration Log 和业务状态,不得伪装为明确失败后盲目重试或重复资金动作 #### Scenario: 可靠事件投递失败 @@ -70,5 +92,4 @@ #### Scenario: 快速交付遇到复杂异常分支 - **WHEN** 某异常分支实现成本高但已被冻结 PRD 定义为资金或外部调用安全边界 -- **THEN** 该分支 MUST 留在本轮业务实现范围,不能与自动化测试和 Audit Event 一起延期 - +- **THEN** 该分支 MUST 留在本轮业务实现范围,不能因自动化测试延期或 Audit Event 已移出范围而被删除 diff --git a/openspec/changes/complete-july-iteration-test-release/specs/july-iteration-test-release-gate/spec.md b/openspec/changes/complete-july-iteration-test-release/specs/july-iteration-test-release-gate/spec.md index b095104..d3f784a 100644 --- a/openspec/changes/complete-july-iteration-test-release/specs/july-iteration-test-release-gate/spec.md +++ b/openspec/changes/complete-july-iteration-test-release/specs/july-iteration-test-release-gate/spec.md @@ -18,22 +18,22 @@ - **WHEN** 任务因本次豁免没有自动化或真实外部验证证据 - **THEN** 验证状态 MUST 标记“代码完成、验证延期”或“待前端/人工验收”,不得标记“验收完成” -### Requirement: Audit Event 专项不得阻塞测试环境里程碑 -`.scratch/tech-global-audit/issues/01`、`19`、`20`、`21` 及其他 Audit Event 写入、审计中心、历史投影、敏感读取、导出、保留清理和一次性审计切换任务 MUST 不进入本轮实现任务,也不得作为其他七月需求的测试环境阻塞项。提交 `ff44305` 已存在的审计代码 MUST 原样冻结,不删除、不回滚、不继续扩展。 +### Requirement: Audit Event 专项必须移出本 Change 与本次上线范围 +`.scratch/tech-global-audit/issues/01`、`19`、`20`、`21` 及其他 Audit Event 写入、审计中心、历史投影、敏感读取、导出、保留清理和一次性审计切换任务 MUST 不进入本轮实现任务,也不得作为其他七月需求的测试环境或生产上线阻塞项。提交 `ff44305` 已存在的相关代码 MUST 原样冻结,不删除、不回滚、不继续扩展;未来如需恢复审计治理,MUST 另开独立 Change 重新评审。 #### Scenario: 下游原 Ticket 依赖全局审计 - **WHEN** 某七月 PRD/Ticket 原先把 Audit Event 或一次性审计发布门禁列为 blocker -- **THEN** 本 Change MUST 移除该测试环境阻塞边,同时保留其生产发布前恢复义务 +- **THEN** 本 Change MUST 同时移除测试环境和生产上线阻塞边,不在本 Change 内保留恢复义务 #### Scenario: 审计冻结代码存在 - **WHEN** 构建包含 `integrationlog.Repository`、`cmd/audit-coverage`、`pkg/sanitizer` 或迁移 `000167_create_audit_integration_log` - **THEN** 不得为冻结目的删除这些代码;未装配到生产组合根的审计组件不得被本轮业务主动启用 -### Requirement: 可靠性日志与 Audit Event 必须保持边界清晰 -Access Log SHALL 继续负责 HTTP 调试并使用现有递归脱敏;Integration Log SHALL 记录外部交互及结果未知恢复事实;Domain Ledger SHALL 继续作为金额、订单、退款、充值、审批和状态的业务权威;Outbox SHALL 继续作为可靠副作用投递事实。Audit Event 仅作为“谁对什么做了什么”的治理能力暂缓,其他三类事实不得被停用或合并。 +### Requirement: 可靠性事实不得因 Audit Event 排除而削弱 +Access Log SHALL 继续负责 HTTP 调试并使用现有递归脱敏;Integration Log SHALL 记录外部交互及结果未知恢复事实;Domain Ledger SHALL 继续作为金额、订单、退款、充值、审批和状态的业务权威;Outbox SHALL 继续作为可靠副作用投递事实。Audit Event 作为“谁对什么做了什么”的统一治理能力不属于本 Change,其他事实不得被停用或合并。 #### Scenario: 新业务调用外部系统 -- **WHEN** 本轮新业务调用企微、支付、Gateway 或接收运营商回调 +- **WHEN** 本轮新业务通过企微等审批渠道 Adapter、支付、Gateway 或运营商 Adapter 发出请求或接收回调 - **THEN** 仍 MUST 写 Integration Log 并保留幂等、事务与恢复语义,即使本次不写 Audit Event #### Scenario: Access Log 处理敏感正文 @@ -47,6 +47,10 @@ Access Log SHALL 继续负责 HTTP 调试并使用现有递归脱敏;Integrati - **WHEN** 迁移、构建、配置、路由、Worker 注册或前后端版本一致性检查失败 - **THEN** 测试环境 MUST 保持维护或停止新入口,不得将该批标记部署完成 +#### Scenario: 审批业务核心先于渠道装配完成 +- **WHEN** 退款或线下代充业务代码已围绕 Approval Port 完成,但当前环境尚无有效 WeCom Adapter、场景或发起身份配置 +- **THEN** 该业务只能记录“后端代码完成”,新提交入口 MUST 保持关闭或失败关闭,不得标记“测试环境可用” + ### Requirement: 回滚必须保留不可逆业务事实 开放访问前失败 MAY 回滚应用和可逆迁移;一旦产生审批、支付、资金流水、订单、退款、充值、Outbox、Integration Log、通知或部分成功批量任务,回滚 MUST 保留这些事实,暂停异常生产者并采用前向修复。不得通过清表、恢复旧 Writer 或要求用户重复支付来回滚。 @@ -55,9 +59,8 @@ Access Log SHALL 继续负责 HTTP 调试并使用现有递归脱敏;Integrati - **THEN** 系统 MUST 保留支付与 Outbox/恢复事实,并在恢复后幂等完成入账,不得关闭原事实或要求重新支付 ### Requirement: 生产发布不得继承测试豁免 -生产发布前 MUST 恢复并完成自动化测试、真实 PostgreSQL/Redis/企微/支付/Gateway 验收、前后端联调、历史数据抽样与迁移演练、性能/权限/安全检查,以及 Audit Event 专项的重新评审、写入覆盖和发布门禁。延期项 MUST 保持未勾选,直至产生真实证据;不得把本测试环境豁免升级为永久 N/A。 +生产发布前 MUST 恢复并完成自动化测试、真实 PostgreSQL/Redis/企微/支付/Gateway 验收、前后端联调、历史数据抽样与迁移演练、性能/权限/安全检查。延期项 MUST 保持未勾选,直至产生真实证据;Audit Event 已明确移出本 Change,不属于需要恢复的测试豁免。 #### Scenario: 准备生产发布 - **WHEN** 团队从测试环境里程碑推进正式生产 - **THEN** 发布负责人 MUST 重新启用延期验证与治理清单,全部完成前不得声明生产验收通过 - diff --git a/openspec/changes/complete-july-iteration-test-release/tasks.md b/openspec/changes/complete-july-iteration-test-release/tasks.md index 0166ddd..a94e4a1 100644 --- a/openspec/changes/complete-july-iteration-test-release/tasks.md +++ b/openspec/changes/complete-july-iteration-test-release/tasks.md @@ -3,31 +3,31 @@ 状态口径:每条线同时记录“后端 / 前端 / 验证 / 发布”。`[x]` 只表示该行声明的范围已有仓库证据;`[ ]` 表示仍有实施、前端、人工、延期验证或发布工作。后续七月工作只从本文件领取,不再从分散 `.scratch` 目录直接启动。 - [x] 0.1 `JULY-TECH-FOUNDATION` TECH 七月公共开发基础|来源 `.scratch/tech-public-foundation/`|状态:已完成(PRD `completed`,12/12 Tickets 完成)|主通道 Infrastructure,辅助 Application/Query|发布:本仓公共基础已交付。 -- [ ] 0.2 `JULY-TECH-NOTIFICATION` TECH 公共站内通知|来源 `.scratch/tech-inapp-notifications/`(8 Tickets)|状态:未开始|主通道简单写 Application,辅助 Query/Infrastructure|依赖:0.1。 +- [x] 0.2 `JULY-TECH-NOTIFICATION` TECH 公共站内通知|来源 `.scratch/tech-inapp-notifications/`(8 Tickets)|状态:后端代码与跨仓契约完成、验证延期|主通道简单写 Application,辅助 Query/Infrastructure|依赖:0.1。 - [ ] 0.3 `JULY-UR33` 套餐临期查询、Dashboard 与提醒|来源 `.scratch/ur33-package-expiry-reminder/PRD.md`(由 PRD 直接转任务)|状态:未开始|主通道 Query,辅助 Application/Infrastructure|依赖:0.2、0.9、0.14、0.26。 -- [ ] 0.4 `JULY-UR34` 代理在线扫码充值与平台线下代充值|来源 `.scratch/ur34-agent-recharge/PRD.md`|状态:未开始|主通道复杂写,辅助 Query/Payment/WeCom Adapter|依赖:0.2、0.7、0.8。 -- [ ] 0.5 `JULY-UR35` 退款企微审批与整单终结|来源 `.scratch/ur35-refund-wecom-approval/PRD.md`|状态:未开始|主通道复杂写,辅助 Query/WeCom Adapter|依赖:0.2、0.7、0.8。 +- [ ] 0.4 `JULY-UR34` 代理在线扫码充值与平台线下代充值|来源 `.scratch/ur34-agent-recharge/PRD.md`|状态:未开始|主通道复杂写,辅助 Query/Payment/Approval Port|业务核心依赖:2.8、2.29、2.57、2.9~2.12;测试环境开放线下代充入口另依赖 4.W14。 +- [ ] 0.5 `JULY-UR35` 退款审批与整单终结|来源 `.scratch/ur35-refund-wecom-approval/PRD.md`|状态:未开始|主通道复杂写,辅助 Query/Approval Port|业务核心依赖:2.8、2.30、2.9~2.12;测试环境开放提交入口另依赖 4.W14。 - [ ] 0.6 `JULY-UR36` 批量订购套餐|来源 `.scratch/ur36-bulk-package-purchase/`(11 Tickets)|状态:未开始|主通道 Application/Domain,辅助 Query/Infrastructure|依赖:0.8、0.9。 -- [ ] 0.7 `JULY-UR37` 企业微信审批公共能力|来源 `.scratch/ur37-wecom-approval-foundation/`(14 Tickets)|状态:未开始|主通道复杂写,辅助 Query/Infrastructure|依赖:0.1;首个退款组合依赖 0.5。 -- [ ] 0.8 `JULY-UR38` 代理主钱包信用额度|来源 `.scratch/ur38-agent-main-wallet-credit/`(10 Tickets)|状态:未开始|主通道复杂写,辅助简单写/Query|依赖:0.1。 +- [ ] 0.7 `JULY-UR37` 企业微信审批公共能力|来源 `.scratch/ur37-wecom-approval-foundation/`(14 Tickets)|状态:未开始|主通道 Application + Port/Infrastructure Adapter,辅助 Query|顺序:先完成 2.9~2.12 渠道无关核心,UR34/UR35 业务核心完成后再执行 4.W1~4.W14 WeCom Adapter 与装配。 +- [x] 0.8 `JULY-UR38` 代理主钱包信用额度|来源 `.scratch/ur38-agent-main-wallet-credit/`(10 Tickets)|状态:后端代码与测试环境切换契约完成、自动化/真实资金验证延期|主通道复杂写,辅助简单写/Query|依赖:0.1。 - [ ] 0.9 `JULY-UR40` 下架套餐历史用户续费|来源 `.scratch/ur40-off-shelf-package-renewal/`(8 Tickets)|状态:未开始|主通道 Domain Policy,辅助 Query|依赖:0.1。 - [ ] 0.10 `JULY-UR42` 统一导出与字段权限|来源 `.scratch/ur42-unified-export-field-permissions/PRD.md`|状态:未开始|主通道 Query/DataSource,辅助简单写 Application|依赖:0.3、0.4、0.5、0.12、0.14。 - [ ] 0.11 `JULY-UR43` 系列套餐批量授权|来源 `.scratch/ur43-series-package-bulk-authorization/`(3 Tickets)|状态:未开始|主通道复杂写,辅助 Query|依赖:0.1。 -- [ ] 0.12 `JULY-UR44` 列表提交人与审批摘要|来源 `.scratch/ur44-list-submitter-approval-summary/PRD.md`|状态:未开始|主通道 Query,辅助简单写/迁移|依赖:0.4、0.5、0.7。 -- [ ] 0.13 `JULY-UR45` 换货快照与新旧独立搜索|来源 `.scratch/ur45-exchange-asset-search/`(2 Tickets)|状态:后端完成、Ticket 元数据待收口;前端/人工待确认|主通道复杂写 + Query。 -- [ ] 0.14 `JULY-UR46` 预计最终到期时间|来源 `.scratch/ur46-estimated-final-expiry/`(4 Tickets)|状态:后端完成、前端/人工未完成|主通道 Query|依赖 0.19 已满足。 +- [ ] 0.12 `JULY-UR44` 列表提交人与审批摘要|来源 `.scratch/ur44-list-submitter-approval-summary/PRD.md`|状态:未开始|主通道 Query,辅助简单写/迁移|依赖:4.6、4.11、2.10、4.W9;业务列表基础字段不依赖企微专属投影。 +- [x] 0.13 `JULY-UR45` 换货快照与新旧独立搜索|来源 `.scratch/ur45-exchange-asset-search/`(2 Tickets)|状态:后端完成、前端/人工待验收|主通道复杂写 + Query。 +- [x] 0.14 `JULY-UR46` 预计最终到期时间|来源 `.scratch/ur46-estimated-final-expiry/`(4 Tickets)|状态:后端完成、前端/人工未完成|主通道 Query|依赖 0.19 已满足。 - [ ] 0.15 `JULY-UR47` 卡片手动限速|来源 `.scratch/ur47-manual-card-speed-limit/`(4 Tickets)|状态:未开始|主通道 Application + Port/Adapter,辅助 Infrastructure|依赖:0.1。 - [ ] 0.16 `JULY-UR48` 按资产类型限制支付方式|来源 `.scratch/ur48-asset-payment-methods/`(5 Tickets)|状态:未开始|主通道简单写 + 复杂写,辅助 Query|依赖:0.1。 - [ ] 0.17 `JULY-UR49` 设备 CSV 批量分配|来源 `.scratch/ur49-device-batch-allocation-csv/`(5 Tickets)|状态:未开始|主通道 Application,辅助 Infrastructure/Query|依赖:0.6 的受控上传边界。 - [ ] 0.18 `JULY-UR53` 卡和设备实名状态筛选|来源 `.scratch/ur53-asset-realname-filter/PRD.md`|状态:未开始|主通道 Query,辅助投影 Application/Infrastructure|依赖:0.25。 - [x] 0.19 `JULY-UR55` 套餐生效条件覆盖与购买快照|来源 `.scratch/ur55-package-expiry-base/`|状态:5/5 Tickets 已完成;PRD 顶层状态滞后不影响代码完成证据|主通道简单写 + 复杂写。 - [ ] 0.20 `JULY-UR57` 活跃退款资产禁止换货|来源 `.scratch/ur57-block-exchange-active-refund/PRD.md`|状态:未开始|主通道复杂写,辅助批量 Query|依赖:0.5。 -- [ ] 0.21 `JULY-UR60` 店铺联系电话精确查询|来源 `.scratch/ur60-shop-phone-search/`(3 Tickets)|状态:后端完成、前端联调/人工验收未完成|主通道旧架构简单 Query。 +- [x] 0.21 `JULY-UR60` 店铺联系电话精确查询|来源 `.scratch/ur60-shop-phone-search/`(3 Tickets)|状态:后端完成、前端联调/人工验收未完成|主通道旧架构简单 Query。 - [ ] 0.22 `JULY-UR62` H5 购买与实名顺序配置|来源 `.scratch/ur62-h5-realname-order/PRD.md`|状态:未开始|主通道 Domain/Application,辅助简单写/Query|依赖:0.25。 - [ ] 0.23 `JULY-UR73` 按运营商实名能力控制复机|来源 `.scratch/ur73-resume-realname-policy/PRD.md`|状态:未开始|主通道复杂写,辅助 Gateway/Outbox|依赖:0.25。 -- [ ] 0.24 `JULY-UR86` 资产前代/后代换货链|来源 `.scratch/ur86-asset-exchange-trace/`(2 Tickets)|状态:后端完成、前端验收与历史抽样未完成|主通道 Query。 +- [x] 0.24 `JULY-UR86` 资产前代/后代换货链|来源 `.scratch/ur86-asset-exchange-trace/`(2 Tickets)|状态:后端完成、前端验收与历史抽样未完成|主通道 Query。 - [ ] 0.25 `JULY-UR94` 卡状态公共写入与运营商回调|来源 `.scratch/ur94-card-state-events-callbacks/`(13 Tickets)|状态:未开始|主通道复杂写,辅助 Adapter/Query|依赖:0.1。 -- [ ] 0.26 `JULY-UR96` 店铺业务员归属|来源 `.scratch/ur96-shop-business-owner/`(6 Tickets)|状态:未开始|主通道简单写,辅助 Query/Port|依赖:0.1。 +- [x] 0.26 `JULY-UR96` 店铺业务员归属|来源 `.scratch/ur96-shop-business-owner/`(6 Tickets)|状态:后端代码完成、验证延期|主通道简单写,辅助 Query/Port|依赖:0.1。 - [ ] 0.27 `JULY-UR97` 钱包低余额预警|来源 `.scratch/ur97-wallet-low-balance-alert/PRD.md`|状态:未开始|主通道复杂写,辅助 Query/Outbox/Notification|依赖:0.2、0.8、0.26。 - [ ] 0.28 `JULY-UR98` 换货新资产继承店铺|来源 `.scratch/ur98-exchange-inherit-shop/`(4 Tickets)|状态:未开始|主通道复杂写,辅助 Query/Infrastructure|依赖:0.1。 @@ -38,62 +38,52 @@ - [x] 1.1 固化 TECH 公共基础完成证据:12 张 Ticket、Outbox/Relay、幂等、五态任务、受控配置、Access Log 安全能力、迁移与交接文档;明确它不等于通知、审计或业务消费者已完成。 - [x] 1.2 固化 UR55 后端完成证据:创建/编辑覆盖值、同步购买快照、自动购包快照、激活与排队只消费快照共 5 张 Ticket;不重复实现后端。 - [x] 1.3 固化 UR45 后端完成证据:统一换货资产快照和新旧资产独立搜索均已有代码/文档/提交;不回填历史快照、不顺带迁移 UR86/UR98。 -- [ ] 1.4 收口 UR45 原 PRD/Ticket 元数据并向前端交付新旧资产筛选契约;保持“后端完成、前端/人工待验收”,不得把元数据修正记为重新实现。 +- [x] 1.4 收口 UR45 原 PRD/Ticket 元数据并向前端交付新旧资产筛选契约;保持“后端完成、前端/人工待验收”,不得把元数据修正记为重新实现。 - [x] 1.5 固化 UR46 四个后端 Query 切片完成证据:后台详情、卡列表、设备列表、C 端资产信息;不写汇总表、不重复计算第二套到期字段。 - [x] 1.6 固化 UR60 后端完成证据:分页校验、联系电话 11 位精确过滤/索引、企业账号路由限制;保留前端与人工任务未完成。 - [x] 1.7 固化 UR86 后端完成证据:前代和后代 Query、权限投影与索引;不新建换货关系表、不修改换货状态机。 -- [x] 1.8 冻结提交 `ff44305` 现有审计代码:保留 Integration Log 基础、离线 `cmd/audit-coverage`、Access Log 敏感字段判定复用和无外键迁移 `000167`;不装配 Audit Event、不继续开发审计中心,生产恢复见 6.5。 +- [x] 1.8 冻结提交 `ff44305` 现有相关代码:保留 Integration Log 基础、离线 `cmd/audit-coverage`、Access Log 敏感字段判定复用和无外键迁移 `000167`;Audit Event 与审计中心移出本 Change,不作为测试环境或生产上线门禁,未来如需恢复另开独立 Change。 ## 2. 第一批:共享核心与独立 frontier ### B1-01 公共站内通知(`JULY-TECH-NOTIFICATION`) -- [ ] 2.1 映射原通知 Ticket 01:交付后台明确接收账号的通知写入、`event_id + recipient` 幂等、未读查询与单条已读完整纵向切片。【主:简单写 Application;辅:Query/Infrastructure|边界:通知事实与当前账号读取|不迁移:业务触发规则、WebSocket|依赖:1.1】 -- [ ] 2.2 映射原 Ticket 02:交付后台通知筛选、分类汇总、稳定分页和全部/按分类已读。【主:Query;辅:简单写|边界:当前接收人数据|不迁移:业务列表|依赖:2.1】 -- [ ] 2.3 映射原 Ticket 03:交付个人客户通知投递、简化列表与已读 API。【主:简单写 + Query|边界:personal_customer 接收人|不迁移:后台权限模型|依赖:2.1】 -- [ ] 2.4 映射原 Ticket 04:交付店铺主账号、平台业务员及其他动态接收人的 Port/Adapter 解析。【主:Application + Port/Adapter|边界:只解析稳定接收人,不复制业务触发|不迁移:店铺层级|依赖:2.1、2.63】 -- [ ] 2.5 映射原 Ticket 05:交付受控 `ref_type/ref_id/ref_key` 目标解析、跳转白名单与跳转后二次权限校验。【主:Query|辅:Application|边界:通知目标解析|不迁移:任意 URL、业务权限实现|依赖:2.1】 -- [ ] 2.6 映射原 Ticket 06:交付通知保留、分批清理、失败可观测与安全日志。【主:Infrastructure|辅:Query|边界:通知记录生命周期|不迁移:Audit Event、外部消息渠道|依赖:2.1】 -- [ ] 2.7 映射原 Ticket 07:冻结后台与 C 端通知页面、30 秒轮询、铃铛/抽屉/中心、加载/空态/失败与受控跳转契约,并更新 OpenAPI/中文文档。【主:Query/API 契约|不迁移:前端框架选择|依赖:2.2、2.3、2.5】 -- [ ] 2.8 映射原 Ticket 08 为测试环境发布检查:完成通知 Handler/路由/文档生成器、Worker/模板注册与生产组合根装配;仅执行本批格式化、必要生成、构建和部署检查,自动化与正式发布门禁转 6.2/6.5。【主:Infrastructure|依赖:2.2-2.7】 +- [x] 2.1 映射原通知 Ticket 01:交付后台明确接收账号的通知写入、`event_id + recipient` 幂等、未读查询与单条已读完整纵向切片。【主:简单写 Application;辅:Query/Infrastructure|边界:通知事实与当前账号读取|不迁移:业务触发规则、WebSocket|依赖:1.1】 +- [x] 2.2 映射原 Ticket 02:交付后台通知筛选、分类汇总、稳定分页和全部/按分类已读。【主:Query;辅:简单写|边界:当前接收人数据|不迁移:业务列表|依赖:2.1】 +- [x] 2.3 映射原 Ticket 03:交付个人客户通知投递、简化列表与已读 API。【主:简单写 + Query|边界:personal_customer 接收人|不迁移:后台权限模型|依赖:2.1】 +- [x] 2.4 映射原 Ticket 04:交付店铺主账号、平台业务员及其他动态接收人的 Port/Adapter 解析。【主:Application + Port/Adapter|边界:只解析稳定接收人,不复制业务触发|不迁移:店铺层级|依赖:2.1、2.63】 +- [x] 2.5 映射原 Ticket 05:交付受控 `ref_type/ref_id/ref_key` 目标解析、跳转白名单与跳转后二次权限校验。【主:Query|辅:Application|边界:通知目标解析|不迁移:任意 URL、业务权限实现|依赖:2.1】 +- [x] 2.6 映射原 Ticket 06:交付通知保留、分批清理、失败可观测与安全日志。【主:Infrastructure|辅:Query|边界:通知记录生命周期|不迁移:Audit Event、外部消息渠道|依赖:2.1】 +- [x] 2.7 映射原 Ticket 07:冻结后台与 C 端通知页面、30 秒轮询、铃铛/抽屉/中心、加载/空态/失败与受控跳转契约,并更新 OpenAPI/中文文档。【主:Query/API 契约|不迁移:前端框架选择|依赖:2.2、2.3、2.5】 +- [x] 2.8 映射原 Ticket 08 为测试环境发布检查:完成通知 Handler/路由/文档生成器、Worker/模板注册与生产组合根装配;仅执行本批格式化、必要生成、构建和部署检查,真实外部验收转 6.2;Audit Event 不属于本 Change。【主:Infrastructure|依赖:2.2-2.7】 -### B1-02 企业微信审批公共能力(`JULY-UR37`) +### B1-02 渠道无关审批核心(`JULY-UR37` 通用部分) -- [ ] 2.9 映射原 UR37-01:交付企微连接状态、Token 缓存/锁、安全 Viper 配置与 WeCom Adapter;所有外部尝试接入 Integration Log。【主:Infrastructure Adapter|不迁移:业务退款/充值|依赖:1.1】 -- [ ] 2.10 映射原 UR37-02:交付平台/超管五分钟扫码绑定会话、本人查询/解绑与超管强制解绑;代理不绑定。【主:复杂写 Application/Domain|辅:Query|边界:账号与 userid 一对一|不迁移:组织模型|依赖:2.9】 -- [ ] 2.11 映射原 UR37-03:交付稳定场景、不可变模板版本、控件映射校验与首次发布。【主:复杂写|辅:Adapter/Query|边界:模板版本事实|不迁移:本地审批节点|依赖:2.9】 -- [ ] 2.12 映射原 UR37-04:交付业务创建前置校验、真实提交人/企微发起身份快照、审批实例与业务单/Outbox 同事务 Port。【主:Application + Port|边界:公共审批创建事务|不迁移:业务金额规则|依赖:2.10、2.11】 -- [ ] 2.13 映射原 UR37-05:交付本地业务附件权威存储、企微副本上传、异步 `applyevent`、结果未知状态和 Integration Log。【主:Application/Infrastructure|边界:一次审批技术提交|不迁移:业务附件权限|依赖:2.12】 -- [ ] 2.14 映射原 UR37-06:交付场景暂停、提交租约、模板换版/指纹验证、原子恢复与失效告警。【主:复杂写|辅:Scheduled Job|边界:新提交开关,不中断存量实例|不迁移:业务终态|依赖:2.11、2.13】 -- [ ] 2.15 映射原 UR37-07:交付加密回调验签解密、权威 `getapprovaldetail` 同步、状态条件更新与首次终态 Outbox。【主:复杂写|辅:Adapter|边界:审批镜像状态|不迁移:终态业务处理|依赖:2.13】 -- [ ] 2.16 映射原 UR37-08:交付两分钟轮询补偿、回调/轮询共用同步用例、处理租约与终态事件幂等。【主:Application|辅:Worker|不迁移:退款/充值 Worker|依赖:2.15】 -- [ ] 2.17 映射原 UR37-09:交付审批运行列表/详情、业务摘要、权限主体投影和稳定分页,代理不得看到审批人/内部意见/附件。【主:Query|边界:公共审批读取|不迁移:业务列表|依赖:2.10、2.11、2.15】 -- [ ] 2.18 映射原 UR37-10:交付提交结果未知的“绑定已有 sp_no”和“确认未创建后重发”受控恢复,保留每次技术尝试与 Integration Log。【主:复杂写|辅:Adapter|不迁移:新业务审批申请|依赖:2.13、2.15、2.17】 -- [ ] 2.19 映射原 UR37-11:消费首次终态事件并向真实业务提交人发送审批结果通知,按实例/接收人防重。【主:Application|辅:Notification Adapter|不迁移:业务到账通知|依赖:2.8、2.16】 -- [ ] 2.20 映射原 UR37-12:交付存量审批迁移契约、`legacy` 只读投影、平台绑定/代理固定账号前置检查与异常清单。【主:Application/Infrastructure|辅:Query|不迁移:伪造历史企微实例|依赖:2.10-2.15】 -- [ ] 2.21 映射原 UR37-13:冻结企微配置页、个人绑定、审批运行/详情/恢复与业务摘要的跨仓契约,补 OpenAPI、文档生成器和中文文档。【主:API/Query 契约|不迁移:前端技术栈|依赖:2.10、2.11、2.17、2.18】 -- [ ] 2.22 完成 UR37 公共能力的测试环境装配:配置模板/回调/轮询 Worker、路由与组合根并执行构建/部署检查;原 UR37-14“退款首个真实企微门禁”整体转 6.2,不阻塞 B3 退款代码实现。【主:Infrastructure|依赖:2.9-2.21】 +- [x] 2.9 定义业务侧 Approval Port、通用审批实例与标准决策 `approved/rejected/cancelled/deleted/revoked_after_approved`;实例只持有业务类型/ID、真实提交人、`provider`、`external_ref`、标准状态、请求/决策快照和关联 ID。【主:Application + Port/Domain|边界:渠道无关审批事实|不迁移:企微 Token、`sp_no`、模板/控件、节点、审批人、会签/或签|依赖:1.1】 +- [x] 2.10 交付通用审批 Query 和权限投影,输出稳定业务摘要、提交人、provider、标准状态与处理摘要;渠道专属节点、意见和附件仅由 Adapter 可选扩展,代理不得看到内部审批信息。【主:Query|边界:通用审批读模型|不迁移:业务列表、实时外部查询|依赖:2.9】 +- [x] 2.11 交付渠道回传决策的统一同步用例、状态条件更新、首次终态 Outbox、处理租约与幂等分发;业务消费者不得 import 企微/钉钉 SDK、DTO、状态码、模板控件 ID 或 `sp_no`。【主:Application + Outbox|边界:标准决策进站和终态分发|不迁移:退款/充值终态规则|依赖:2.9】 +- [x] 2.12 交付新审批申请的原子创建契约:业务用例在同一事务固化业务单、通用审批实例与提交 Outbox;有效 Adapter/场景/发起身份不可用时在上述事实写入前失败关闭。【主:Application + Port|边界:申请创建与可用性契约|不迁移:金额、钱包、佣金、套餐规则,不建本地审批流引擎|依赖:2.9、2.11】 ### B1-03 代理主钱包信用额度(`JULY-UR38`) -- [ ] 2.23 映射原 UR38-01:扩展主钱包信用字段、CHECK/索引/版本与 Wallet Domain 不变量;迁移前核对现有约束和异常数据。【主:复杂写 Domain|辅:Infrastructure|不迁移:员工/佣金/资产钱包|依赖:1.1】 -- [ ] 2.24 映射原 UR38-02:交付客户角色“仅新建店铺使用”的默认信用模板、权限与 API/前端契约。【主:简单写 Application|辅:Query|不迁移:已有钱包追溯更新|依赖:2.23】 -- [ ] 2.25 映射原 UR38-03:新建店铺事务内原子复制默认角色信用快照到代理主钱包。【主:复杂写 Application|边界:店铺 + 钱包创建事务|不迁移:其他店铺创建规则|依赖:2.24】 -- [ ] 2.26 映射原 UR38-04:交付平台独立权限调整店铺实际额度、版本冲突、降低/关闭边界和变更前后投影。【主:复杂写 Domain|辅:Query|不迁移:代理自助调额|依赖:2.23】 -- [ ] 2.27 映射原 UR38-05:统一代理订单、代购和钱包支付扣款用例,使用有效信用、版本/锁、唯一流水和同事务事实。【主:复杂写 Domain|不迁移:无关订单创建|依赖:2.23】 -- [ ] 2.28 映射原 UR38-06:统一代理主钱包冻结/解冻,维持 `balance-frozen+credit>=0` 与幂等流水。【主:复杂写 Domain|不迁移:佣金冻结|依赖:2.23、2.27】 -- [ ] 2.29 映射原 UR38-07:统一充值入账与人工调整用例,按唯一业务号、版本和流水幂等;只提供 UR34 可复用能力。【主:复杂写 Domain|不迁移:支付/企微/通知|依赖:2.23】 -- [ ] 2.30 映射原 UR38-08:统一代理订单退款回充,仅按原扣款流水定位原主钱包并幂等回溯。【主:复杂写 Domain|不迁移:渠道退款/资产钱包退款|依赖:2.27、2.29】 -- [ ] 2.31 映射原 UR38-09:资金概况 Query 返回信用开关、额度、现金可用、总可用、欠款和版本,前端不得自行计算。【主:Query|不迁移:钱包写入|依赖:2.23、2.26】 -- [ ] 2.32 映射原 UR38-10 为测试环境停机切换:完成所有主钱包写入口接入、迁移/API/前端契约/组合根装配与本批构建部署检查;并发/真实资金验收转 6.1/6.3。【主:Infrastructure/Application 收口|依赖:2.24-2.31】 +- [x] 2.23 映射原 UR38-01:扩展主钱包信用字段、CHECK/索引/版本与 Wallet Domain 不变量;迁移前核对现有约束和异常数据。【主:复杂写 Domain|辅:Infrastructure|不迁移:员工/佣金/资产钱包|依赖:1.1】 +- [x] 2.24 映射原 UR38-02:交付客户角色“仅新建店铺使用”的默认信用模板、权限与 API/前端契约。【主:简单写 Application|辅:Query|不迁移:已有钱包追溯更新|依赖:2.23】 +- [x] 2.25 映射原 UR38-03:新建店铺事务内原子复制默认角色信用快照到代理主钱包。【主:复杂写 Application|边界:店铺 + 钱包创建事务|不迁移:其他店铺创建规则|依赖:2.24】 +- [x] 2.26 映射原 UR38-04:交付既有店铺实际额度调整、版本冲突、降低/关闭边界和变更前后投影;`shop:credit-limit:manage` 仅控制前端按钮显示,后端本期不校验该权限或账号类型。【主:复杂写 Domain|辅:Query|依赖:2.23】 +- [x] 2.27 映射原 UR38-05:统一代理订单、代购和钱包支付扣款用例,使用有效信用、版本/锁、唯一流水和同事务事实。【主:复杂写 Domain|不迁移:无关订单创建|依赖:2.23】 +- [x] 2.28 映射原 UR38-06:统一代理主钱包冻结/解冻,维持 `balance-frozen+credit>=0` 与幂等流水。【主:复杂写 Domain|不迁移:佣金冻结|依赖:2.23、2.27】 +- [x] 2.29 映射原 UR38-07:统一充值入账与人工调整用例,按唯一业务号、版本和流水幂等;只提供 UR34 可复用能力。【主:复杂写 Domain|不迁移:支付/企微/通知|依赖:2.23】 +- [x] 2.30 映射原 UR38-08:统一代理订单退款回充,仅按原扣款流水定位原主钱包并幂等回溯。【主:复杂写 Domain|不迁移:渠道退款/资产钱包退款|依赖:2.27、2.29】 +- [x] 2.31 映射原 UR38-09:资金概况 Query 返回信用开关、额度、现金可用、总可用、欠款和版本,前端不得自行计算。【主:Query|不迁移:钱包写入|依赖:2.23、2.26】 +- [x] 2.32 映射原 UR38-10 为测试环境停机切换:完成所有主钱包写入口接入、迁移/API/前端契约/组合根装配与本批构建部署检查;并发/真实资金验收转 6.1/6.3。【主:Infrastructure/Application 收口|依赖:2.24-2.31】 ### B1-04 卡状态公共写入与运营商回调(`JULY-UR94`) -- [ ] 2.33 映射原 UR94-01:扫描 19 位 ICCID 冲突并交付未删除范围部分唯一索引与发布前异常清单。【主:Infrastructure|不迁移:ICCID 归一化|依赖:1.1】 -- [ ] 2.34 映射原 UR94-02:交付实名观测的唯一 `ApplyCardObservation` 写入闭环、锁/条件更新、领域事实与 Outbox。【主:复杂写 Domain/Application|不迁移:轮询调度|依赖:2.33】 -- [ ] 2.35 映射原 UR94-03:将流量观测收口到同一公共写入用例并保持已有周期/资格语义。【主:复杂写 Domain|不迁移:套餐轮询|依赖:2.34】 -- [ ] 2.36 映射原 UR94-04:将网络状态观测收口到同一用例并保持停复机业务边界。【主:复杂写 Domain|不迁移:复机资格(由 3.2 负责)|依赖:2.34】 -- [ ] 2.37 映射原 UR94-05:一次性切换三个现有轮询结果写入口到公共用例,保持 PostgreSQL 配置、Redis 分片、间隔、开关和失败重排不变。【主:Application/Infrastructure|不迁移:轮询调度|依赖:2.34-2.36】 +- [x] 2.33 映射原 UR94-01:扫描 19 位 ICCID 冲突并交付未删除范围部分唯一索引与发布前异常清单。【主:Infrastructure|不迁移:ICCID 归一化|依赖:1.1】 +- [x] 2.34 映射原 UR94-02:交付实名观测的唯一 `ApplyCardObservation` 写入闭环、锁/条件更新、领域事实与 Outbox。【主:复杂写 Domain/Application|不迁移:轮询调度|依赖:2.33】 +- [x] 2.35 映射原 UR94-03:将流量观测收口到同一公共写入用例并保持已有周期/资格语义。【主:复杂写 Domain|不迁移:套餐轮询|依赖:2.34】 +- [x] 2.36 映射原 UR94-04:将网络状态观测收口到同一用例并保持停复机业务边界。【主:复杂写 Domain|不迁移:复机资格(由 3.2 负责)|依赖:2.34】 +- [x] 2.37 映射原 UR94-05:一次性切换三个现有轮询结果写入口到公共用例,保持 PostgreSQL 配置、Redis 分片、间隔、开关和失败重排不变。【主:Application/Infrastructure|不迁移:轮询调度|依赖:2.34-2.36】 - [ ] 2.38 映射原 UR94-06:交付同场景可合并的立即/3/5 分钟观测序列、单次请求互斥、最小间隔与 Integration Log。【主:Application|辅:Asynq/Adapter|不迁移:自动退避状态|依赖:2.34-2.36】 - [ ] 2.39 映射原 UR94-07:在查询资产、获取实名链接等成功边界接入观测序列,不新增显式同步 API。【主:Application Adapter|不迁移:读取 Query 业务|依赖:2.38】 - [ ] 2.40 映射原 UR94-08:在停复机、购包和套餐激活成功边界接入观测序列,仅负责触发不复制业务资格。【主:Application Adapter|不迁移:UR73/套餐规则|依赖:2.38】 @@ -110,19 +100,19 @@ - [ ] 2.48 `JULY-UR43` 映射原 Ticket 03:交付首次/后续管理前端契约、三类价格语义、OpenAPI/文档生成器与同批切换部署检查。【主:API/Query 契约|依赖:2.46、2.47】 - [ ] 2.49 `JULY-UR47` 映射原 Ticket 01:修复同一设备多当前卡并建立部分唯一约束,异常数据先入清单。【主:Infrastructure|不迁移:绑定历史|依赖:1.1】 - [ ] 2.50 `JULY-UR47` 映射原 Ticket 02:交付语义化 `speed_level` 到账号/运营商渠道 `code` 的 Gateway Adapter、能力校验与 Integration Log。【主:Port/Adapter|不迁移:自动限速|依赖:2.49】 -- [ ] 2.51 `JULY-UR47` 映射原 Ticket 03:交付统一资产手动限速 API,设备解析当前卡,区分不限速与 0kbps,外部结果未知可恢复;Audit Event 延期到 6.5。【主:Application|辅:Adapter|不迁移:套餐限速|依赖:2.49、2.50】 +- [ ] 2.51 `JULY-UR47` 映射原 Ticket 03:交付统一资产手动限速 API,设备解析当前卡,区分不限速与 0kbps,外部结果未知可恢复;Audit Event 不属于本 Change 且不阻塞上线。【主:Application|辅:Adapter|不迁移:套餐限速|依赖:2.49、2.50】 - [ ] 2.52 `JULY-UR47` 映射原 Ticket 04:交付卡/设备详情限速能力、固定档位、失败状态、OpenAPI/文档生成器与测试环境配置/构建检查;真实 Gateway 验收转 6.4。【主:Query/API 契约|依赖:2.49-2.51】 - [ ] 2.53 `JULY-UR48` 映射原 Ticket 01:复用受控系统配置读取/更新,向资产展示允许支付方式并提供安全默认值。【主:简单写 + Query|不迁移:通用配置中心|依赖:1.1】 - [ ] 2.54 `JULY-UR48` 映射原 Ticket 02:限制普通资产钱包充值为资产允许集合与微信/支付宝交集,钱包不得作为充值渠道。【主:复杂写 Policy|不迁移:C 端全部充值|依赖:2.53】 - [ ] 2.55 `JULY-UR48` 映射原 Ticket 03:订单创建时必填并固化支付方式快照,强充按同一方式拉起且钱包不能给自身强充。【主:复杂写 Domain|不迁移:其他订单状态|依赖:2.53】 - [ ] 2.56 `JULY-UR48` 映射原 Ticket 04:支付时只执行订单快照方式并按最新资产配置二次校验,需要换方式时取消旧单重建。【主:复杂写 Domain|不迁移:支付方式替换接口|依赖:2.53、2.55】 - [ ] 2.57 `JULY-UR48` 映射原 Ticket 05:交付后台配置、C 端支付页/强充/取消重建契约、OpenAPI/文档生成器与测试环境构建部署检查;真实支付验证转 6.3。【主:API/前端契约|依赖:2.54-2.56】 -- [ ] 2.58 `JULY-UR96` 映射原 Ticket 01:为店铺增加 nullable 平台业务员字段、索引与启用平台账号约束,不回填历史。【主:Infrastructure|不迁移:店铺层级/分销|依赖:1.1】 -- [ ] 2.59 `JULY-UR96` 映射原 Ticket 02:平台创建店铺时显式设置或按规则继承业务员,并在同一用例保存快照。【主:简单写 Application|不迁移:信用模板(由 UR38)|依赖:2.58】 -- [ ] 2.60 `JULY-UR96` 映射原 Ticket 03:代理创建直属下级店铺时安全继承当前业务员,不允许代理指定其他平台账号。【主:简单写|不迁移:店铺权限|依赖:2.59】 -- [ ] 2.61 `JULY-UR96` 映射原 Ticket 04:交付业务员编辑权限、停用/删除后的历史保留和重绑;Audit Event 延期 6.5。【主:简单写|不迁移:账号生命周期|依赖:2.58】 -- [ ] 2.62 `JULY-UR96` 映射原 Ticket 05:店铺创建/编辑/列表/详情/筛选返回业务员投影、候选账号与手机号摘要。【主:Query|不迁移:数据范围|依赖:2.58】 -- [ ] 2.63 `JULY-UR96` 映射原 Ticket 06:交付主账号与可用业务员接收人解析 Port、OpenAPI/前端契约与测试环境构建部署检查。【主:Port/Adapter + Query|不迁移:通知触发规则|依赖:2.59-2.62】 +- [x] 2.58 `JULY-UR96` 映射原 Ticket 01:为店铺增加 nullable 平台业务员字段、索引与启用平台账号约束,不回填历史。【主:Infrastructure|不迁移:店铺层级/分销|依赖:1.1】 +- [x] 2.59 `JULY-UR96` 映射原 Ticket 02:平台创建店铺时显式设置或按规则继承业务员,并在同一用例保存快照。【主:简单写 Application|不迁移:信用模板(由 UR38)|依赖:2.58】 +- [x] 2.60 `JULY-UR96` 映射原 Ticket 03:代理创建直属下级店铺时安全继承当前业务员,不允许代理指定其他平台账号。【主:简单写|不迁移:店铺权限|依赖:2.59】 +- [x] 2.61 `JULY-UR96` 映射原 Ticket 04:交付业务员编辑权限、停用/删除后的历史保留和重绑;Audit Event 不属于本 Change 且不阻塞上线。【主:简单写|不迁移:账号生命周期|依赖:2.58】 +- [x] 2.62 `JULY-UR96` 映射原 Ticket 05:店铺创建/编辑/列表/详情/筛选返回业务员投影、候选账号与手机号摘要。【主:Query|不迁移:数据范围|依赖:2.58】 +- [x] 2.63 `JULY-UR96` 映射原 Ticket 06:交付主账号与可用业务员接收人解析 Port、OpenAPI/前端契约与测试环境构建部署检查。【主:Port/Adapter + Query|不迁移:通知触发规则|依赖:2.59-2.62】 - [ ] 2.64 `JULY-UR98` 映射原 Ticket 01:建立换货分配记录与目标资产归属校正基础,目标已属其他店铺时拒绝。【主:Infrastructure/Domain|不迁移:历史回填|依赖:1.1】 - [ ] 2.65 `JULY-UR98` 映射原 Ticket 02:换货完成事务内让新资产继承旧店铺并保留旧资产归属,钱包/套餐迁移仍只受 `migrate_data` 控制。【主:复杂写 Domain/Application|不迁移:整个换货模块|依赖:2.64】 - [ ] 2.66 `JULY-UR98` 映射原 Ticket 03:创建/发货/完成接口投影继承店铺和只读展示信息,禁止前端提交目标店铺。【主:Query/API 契约|依赖:2.65】 @@ -139,7 +129,7 @@ - [ ] 3.4 映射原 UR40-04:历史套餐投影按真实使用资格提供续费入口。【主:Query|依赖:3.1】 - [ ] 3.5 映射原 UR40-05:C 端订单创建重新校验续费资格、资产所有权和套餐状态。【主:复杂写 Domain/Application|依赖:3.1】 - [ ] 3.6 映射原 UR40-06:后台代购、自动购包等非个人入口统一禁止下架套餐绕过。【主:复杂写 Policy 接入|不迁移:入口其他规则|依赖:3.1】 -- [ ] 3.7 映射原 UR40-08:交付跨入口 API/前端契约、OpenAPI/文档生成器与构建部署检查;原 Ticket 07 Audit Event 迁入 6.5,不在本轮实现。【主:API/发布契约|依赖:3.2-3.6】 +- [ ] 3.7 映射原 UR40-08:交付跨入口 API/前端契约、OpenAPI/文档生成器与构建部署检查;原 Ticket 07 Audit Event 移出本 Change,不阻塞上线。【主:API/发布契约|依赖:3.2-3.6】 ### B2-02~B2-05 卡状态与钱包下游 @@ -179,7 +169,7 @@ - [ ] 3.35 映射原 UR49-05 为测试环境装配:完成迁移、Handler、Worker、路由、对象存储用途与构建部署检查;真实链路/自动化门禁转 6.1/6.3。【主:Infrastructure|依赖:3.31-3.34】 - [ ] 3.36 第二批批次检查:执行 `gofmt`、必要代码生成、`go build ./...`,核对迁移、Handler 文档生成器、Worker/Outbox/Integration Log、配置和跨批依赖;不新增/运行测试。 -## 4. 第三批:复杂业务与组合 Query +## 4. 第三批:审批业务先行、渠道后置与组合 Query ### B3-01 UR34 代理充值 @@ -187,32 +177,49 @@ - [ ] 4.2 交付代理当前店铺可用支付方式与在线充值创建:100 元~100 万元、新 request_id 新业务单/支付单、微信 Native/支付宝 PreCreate、结果未知恢复和 qr_content 原样返回。【主:复杂写 + Payment Adapter|不迁移:二维码图片/多通道池|依赖:2.29、2.57】 - [ ] 4.3 交付支付回调与受控查单共用确认用例:渠道/配置/金额/交易号/业务关联校验,迟到成功优先,支付事实与入账 Outbox 同事务。【主:复杂写|辅:Integration Log/Outbox|不迁移:前端轮询触发查单|依赖:4.2】 - [ ] 4.4 交付钱包入账 Worker:唯一流水、版本、处理租约、状态恢复和目标主账号/真实提交人到账通知,支付已成功时失败不得回滚收款事实。【主:复杂写 Wallet Application|不迁移:人工重复入账|依赖:2.29、2.8、4.3】 -- [ ] 4.5 交付平台/超管线下代充值创建、1~5 付款凭证、固定金额与企微实例;审批通过入账、驳回终结、撤销分流,旧 offline-pay/reject 停机下线。【主:复杂写|辅:WeCom/Worker|不迁移:本地审批/操作密码|依赖:2.22、4.1、4.4】 -- [ ] 4.6 交付充值列表/详情/payment-status、代理店铺层级权限、前端二维码/轮询/审批/处理状态、OpenAPI/文档生成器和测试环境构建部署检查;真实支付/企微验收转 6.2/6.3。【主:Query/API 契约|依赖:4.2-4.5】 +- [ ] 4.5 交付平台/超管线下代充值业务核心:后端计算固定金额、保存 1~5 付款凭证原件和请求快照,通过 Approval Port 创建通用审批实例;消费标准决策完成通过入账、拒绝/删除/撤销终结和通过后撤销分流,旧 offline-pay/reject 仅在渠道装配可用后停机下线。【主:复杂写 Application/Domain|辅:Approval Port/Worker|边界:线下代充规则和终态处理|不迁移:企微 SDK/DTO/`sp_no`/状态码,不建本地审批流引擎或操作密码|依赖:2.9~2.12、2.29、4.1、4.4】 +- [ ] 4.6 交付充值列表/详情/payment-status、代理店铺层级权限、前端二维码/轮询/标准审批/处理状态、OpenAPI/文档生成器和业务核心构建检查;渠道专属字段仅作可选扩展,线下代充入口在 4.W14 前保持关闭,真实支付/审批验收转 6.2/6.3。【主:Query/API 契约|依赖:4.2~4.5】 ### B3-02 UR35 退款 -- [ ] 4.7 交付退款固定申请金额、业务凭证、真实提交人、唯一企微实例与三套状态迁移;创建前校验无其他活跃退款。【主:复杂写|不迁移:本地审批/旧原单重提|依赖:2.22】 -- [ ] 4.8 交付退款创建纵向切片:后端读取实收金额、保存 1~5 本地凭证和审批快照、写提交 Outbox;删除 approve/reject/return/resubmit/manual-complete 路由。【主:复杂写 Application|不迁移:渠道自动退款|依赖:4.7】 -- [ ] 4.9 交付企微首次终态消费与退款处理租约:拒绝/撤销终结;通过后按支付类型处理资金,代理主钱包只按原扣款流水回溯,非代理资金由人工退款事实确认。【主:复杂写|不迁移:资产钱包自动回充|依赖:2.30、4.8】 +- [ ] 4.7 交付退款固定申请金额、业务凭证、真实提交人、唯一通用审批实例与申请/审批/处理三套状态迁移;创建前校验无其他活跃退款。【主:复杂写|辅:Approval Port|边界:退款申请和审批关联|不迁移:企微 SDK/DTO/`sp_no`/状态码,不建本地审批流引擎或旧原单重提|依赖:2.9~2.12】 +- [ ] 4.8 交付退款创建纵向切片:后端读取实收金额、保存 1~5 本地权威凭证和申请快照,通过 Approval Port 原子写通用实例与提交 Outbox;删除 approve/reject/return/resubmit/manual-complete 路由。【主:复杂写 Application|不迁移:渠道自动退款、外部审批附件权威化|依赖:4.7】 +- [ ] 4.9 交付标准审批终态消费与退款处理租约:`rejected/cancelled/deleted` 终结;`approved` 后按支付类型处理资金;`revoked_after_approved` 按资金事实分流。代理主钱包只按原扣款流水回溯,非代理资金由人工退款事实确认。【主:复杂写|边界:退款决策和资金处理|不迁移:渠道状态翻译、资产钱包自动回充|依赖:2.11、2.30、4.8】 - [ ] 4.10 交付整单终结的订单、已发放/未发放佣金、全部有效套餐、下一套餐激活或停止资产的持久化步骤幂等与失败恢复。【主:复杂写 Domain/Application|不迁移:未触碰订单/佣金模块|依赖:4.9、2.40】 - [ ] 4.11 交付退款列表/详情、店铺层级权限、代理最小审批投影、处理失败摘要和前端只读状态;更新 OpenAPI/文档生成器。【主:Query/API 契约|依赖:4.7-4.10】 -- [ ] 4.12 完成退款/企微/Worker/迁移测试环境装配和构建部署检查;真实企微、资金/佣金/套餐人工验收及自动化转 6.1/6.2/6.3。【主:Infrastructure|依赖:4.8-4.11】 +- [ ] 4.12 完成退款业务核心、标准决策消费者、Worker/迁移和 API 的构建检查;未装配有效 Adapter 时新提交失败关闭且不写业务单/实例/Outbox,测试环境开放退款入口另依赖 4.W14;资金/佣金/套餐人工验收及自动化转 6.1/6.2/6.3。【主:Infrastructure/Application 收口|依赖:4.8~4.11】 -### B3-03~B3-06 下游业务 +### B3-03 UR37 WeCom Adapter 与首个渠道装配 + +- [ ] 4.W1 映射原 UR37-01:在 UR34/UR35 业务核心已可独立构建后,交付企微连接状态、Token 缓存/锁、安全 Viper 配置与 WeCom Adapter;所有外部尝试接入 Integration Log。【主:Infrastructure Adapter|不迁移:Approval Port 与退款/充值规则|依赖:1.1、2.9、4.6、4.12】 +- [ ] 4.W2 映射原 UR37-02:交付平台/超管五分钟扫码绑定会话、本人查询/解绑与超管强制解绑;代理不绑定。【主:复杂写 Application/Domain|辅:Query|边界:WeCom userid 绑定|不迁移:组织模型、通用审批实例|依赖:4.W1】 +- [ ] 4.W3 映射原 UR37-03:交付稳定场景、不可变企微模板版本、控件映射校验与首次发布。【主:复杂写|辅:Adapter/Query|边界:WeCom 模板配置事实|不迁移:本地审批节点/审批人/会签或签|依赖:4.W1】 +- [ ] 4.W4 映射原 UR37-04:实现 WeCom Adapter 对 Approval Port 的可用性检查和发起身份解析,将企微身份作为渠道快照,不泄漏到业务 Domain/Application。【主:Infrastructure Adapter|边界:WeCom 发起前置|不迁移:业务单创建事务|依赖:2.12、4.W2、4.W3】 +- [ ] 4.W5 映射原 UR37-05:保持本地业务附件为权威原件,交付企微副本上传、异步 `applyevent`、`external_ref=sp_no` 绑定、结果未知和 Integration Log。【主:Application/Infrastructure Adapter|边界:一次 WeCom 技术提交|不迁移:业务附件权限与内容|依赖:2.12、4.W4】 +- [ ] 4.W6 映射原 UR37-06:交付场景暂停、提交租约、企微模板换版/指纹验证、原子恢复与失效告警;暂停只阻断新提交,不中断存量实例。【主:复杂写|辅:Scheduled Job|不迁移:业务终态|依赖:4.W3、4.W5】 +- [ ] 4.W7 映射原 UR37-07:交付企微加密回调验签解密、权威 `getapprovaldetail` 同步和 Translator,将企微状态翻译为 2.9 标准决策后调用 2.11,不直接执行退款/充值规则。【主:Infrastructure Adapter|辅:Application|边界:WeCom 入站防腐层|不迁移:终态业务处理|依赖:2.11、4.W5】 +- [ ] 4.W8 映射原 UR37-08:交付两分钟轮询补偿,与回调共用 4.W7 Translator 和 2.11 同步用例,按至少一次语义恢复。【主:Application/Infrastructure|辅:Worker|不迁移:退款/充值 Worker|依赖:4.W7】 +- [ ] 4.W9 映射原 UR37-09:在 2.10 通用 Query 之上交付企微节点、审批人、内部意见和附件的受权扩展投影;代理仅获取通用最小摘要。【主:Query|边界:WeCom 详情扩展|不迁移:业务列表、实时调企微|依赖:2.10、4.W2、4.W3、4.W7】 +- [ ] 4.W10 映射原 UR37-10:交付提交结果未知的“绑定已有 `sp_no`”和“确认未创建后重发”受控恢复,保留每次技术尝试与 Integration Log。【主:复杂写|辅:Adapter/Query|不迁移:新业务申请和通用决策|依赖:4.W5、4.W7、4.W9】 +- [ ] 4.W11 映射原 UR37-11:消费 2.11 首次标准终态事件,向真实业务提交人发送审批结果通知,按实例/接收人防重。【主:Application|辅:Notification Adapter|不迁移:业务到账通知|依赖:2.8、2.11】 +- [ ] 4.W12 映射原 UR37-12:交付存量审批迁移契约、`provider=legacy` 只读投影、平台绑定/代理固定账号前置检查与异常清单,不伪造历史企微实例。【主:Application/Infrastructure|辅:Query|不迁移:历史业务规则|依赖:2.9、4.W2~4.W7】 +- [ ] 4.W13 映射原 UR37-13:冻结企微配置页、个人绑定、审批运行/详情/恢复与业务摘要的跨仓契约,明确通用字段和 WeCom 扩展字段,补 OpenAPI、文档生成器和中文文档。【主:API/Query 契约|不迁移:前端技术栈与本地流程编排|依赖:4.W2、4.W3、4.W9、4.W10】 +- [ ] 4.W14 映射原 UR37-14 的测试环境部分:在 4.6/4.12 业务核心已完成后,装配 WeCom Adapter、模板/回调/轮询 Worker、路由和组合根,使线下代充/退款可用且无效 Adapter 仍失败关闭;执行本批构建/部署检查,真实企微门禁整体转 6.2。【主:Infrastructure 组合根|边界:首个渠道接入和入口开放|不迁移:业务规则或其他审批渠道|依赖:4.6、4.12、4.W1~4.W13】 + +### B3-04~B3-07 下游业务 - [ ] 4.13 `JULY-UR57` 在换货创建完整用例的任何副作用前,批量判定活跃退款(状态 1/历史 4,或状态 2 且处理未成功)并以统一禁止错误拒绝;不新增预检查 API、不改退款/换货状态机。【主:复杂写;辅:Query|依赖:4.7】 -- [ ] 4.14 `JULY-UR57` 更新换货 API/前端错误契约、中文文档和构建部署检查;拒绝路径 Audit Event 延期 6.5。【主:API 契约|依赖:4.13】 +- [ ] 4.14 `JULY-UR57` 更新换货 API/前端错误契约、中文文档和构建部署检查;拒绝路径 Audit Event 不属于本 Change 且不阻塞上线。【主:API 契约|依赖:4.13】 - [ ] 4.15 `JULY-UR33` 交付复用 UR46 的 0~15 天临期 Query、3 天内置顶、列表/Dashboard/C 端/续费投影;只接受可准确推算事实。【主:Query|不迁移:到期算法第二套实现|依赖:1.5、3.7、2.63】 - [ ] 4.16 `JULY-UR33` 交付每日扫描、15/7/3 最近节点、防重记录与 Outbox/通知消费;漏跑不补多条,站内通知失败不修改套餐事实。【主:Application/Infrastructure|不迁移:短信/企微提醒|依赖:2.8、4.15】 - [ ] 4.17 `JULY-UR33` 接入 `expiring_asset` 导出输入契约、前端临期页/Dashboard/高亮/续费与 OpenAPI/文档生成器;实际 DataSource 实现在 4.23。【主:Query/API 契约|依赖:4.15】 - [ ] 4.18 `JULY-UR33` 完成迁移、定时任务、Worker/模板、路由与测试环境构建部署检查;时间边界/通知真实验证转 6.1/6.6。【主:Infrastructure|依赖:4.15-4.17】 - [ ] 4.19 `JULY-UR44` 为退款、充值、换货新增提交人不可变快照和历史回填(含软删除账号,缺失显示未知),新建时同事务冻结。【主:简单写/迁移|不迁移:业务状态机|依赖:4.1、4.7】 -- [ ] 4.20 `JULY-UR44` 扩展三类列表,以本页实例批量投影 approval_source/status/current approver/processing;代理当前审批人固定为空,换货固定 none。【主:Query|不迁移:实时调用企微|依赖:2.17、4.19】 +- [ ] 4.20 `JULY-UR44` 扩展三类列表,以本页通用审批实例批量投影 approval_source/provider/status/current_approver/processing;业务列表基础字段只依赖 2.10,WeCom 节点摘要是 4.W9 的可选扩展,代理当前审批人固定为空,换货固定 none。【主:Query|不迁移:实时调用企微或将业务列表绑定单一渠道|依赖:2.10、4.W9、4.19】 - [ ] 4.21 `JULY-UR44` 交付前端列/状态/权限、OpenAPI/文档生成器和构建部署检查。【主:API 契约|依赖:4.20】 - [ ] 4.22 `JULY-UR42` 交付代码字段 Registry、角色 Scene 字段授权全量替换、超管全目录、普通账号并集交集和零字段拒绝。【主:简单写 Application|辅:Query|不迁移:菜单/行权限|依赖:1.1】 - [ ] 4.23 `JULY-UR42` 创建导出任务时快照字段/表头/过滤/数据范围/脱敏级别,扩展各 Scene DataSource,Count/Fetch 条件一致且无 N+1。【主:Query/DataSource|辅:Application|不迁移:第二套导出框架|依赖:4.6、4.11、4.17、4.20、1.5】 -- [ ] 4.24 `JULY-UR42` 交付稳定不可枚举 attachment_ref、前端登录恢复落地页和按当前业务权限生成短期下载 URL;不得输出对象 Key/media_id/预签名 URL。【主:Query/Infrastructure|不迁移:公开 Bucket|依赖:2.13、4.23】 +- [ ] 4.24 `JULY-UR42` 交付稳定不可枚举 attachment_ref、前端登录恢复落地页和按当前业务权限生成短期下载 URL;不得输出对象 Key/media_id/预签名 URL,导出只使用本地权威附件,不依赖企微副本。【主:Query/Infrastructure|不迁移:公开 Bucket 或渠道附件权威化|依赖:4.5、4.8、4.23】 - [ ] 4.25 `JULY-UR42` 交付角色字段配置、导出入口/任务/失败/附件页面契约,更新 OpenAPI/文档生成器和中文文档。【主:API/前端契约|依赖:4.22-4.24】 - [ ] 4.26 `JULY-UR42` 完成权限表/附件引用迁移、Export Worker/对象存储/配置装配和测试环境构建部署检查;文件内容/性能/权限自动化转 6.1/6.3。【主:Infrastructure|依赖:4.22-4.25】 - [ ] 4.27 第三批批次检查:执行 `gofmt`、必要代码生成、`go build ./...`,核对资金/审批/支付/导出迁移、Handler 文档生成器、Worker/Outbox/Integration Log、旧路由下线与配置;不新增/运行测试。 @@ -226,18 +233,18 @@ - [ ] 5.5 联合收口 UR98/UR45/UR86:确认换货新资产继承店铺、旧资产保留归属、新旧搜索和双向链路契约在同一前后端版本一致。 - [ ] 5.6 冻结测试环境版本、迁移清单、API/Worker/Relay/前端版本、配置样例和回滚负责人;确认需求16无代码/迁移/入口。 - [ ] 5.7 进入维护模式,停止相关写入与旧 Worker;执行增量迁移和前置数据清单,失败时保持维护且不开放访问。 -- [ ] 5.8 发布 API、Relay/Worker、匹配前端版本,装配企微、支付、Gateway、运营商、对象存储与 Redis/Asynq 测试环境配置。 +- [ ] 5.8 发布 API、Relay/Worker、匹配前端版本,装配企微、支付、Gateway、运营商、对象存储与 Redis/Asynq 测试环境配置;线下代充/退款入口仅在 4.W14 完成且 WeCom Adapter/场景/身份配置有效后开放。 - [ ] 5.9 执行最终允许门禁:`gofmt` 检查、必要代码生成、`go build ./...`、迁移版本、路由/OpenAPI、Worker 注册、配置就绪和前后端版本一致性检查;不运行 `go test` 或真实依赖自动验收。 -- [ ] 5.10 恢复 Worker 与测试环境访问,记录每条线实际状态;只能标记“测试环境已部署/待人工验收”,不得标记生产验收完成。 +- [ ] 5.10 恢复 Worker 与测试环境访问,记录每条线实际状态;未完成 4.W14 时必须继续关闭线下代充/退款新提交入口,且只能标记“后端代码完成”或“测试环境已部署/待人工验收”,不得标记生产验收完成。 - [ ] 5.11 发布后若产生审批、支付、资金、Outbox、Integration Log、通知或部分成功任务事实,回滚仅暂停异常入口并前向修复,不清表、不恢复旧 Writer、不要求重复付款。 -## 6. 延期验证与生产发布门禁(本次不得预勾选) +## 6. 延期验证与生产发布门禁 - [ ] 6.1 恢复并完成所有单元、集成、HTTP、业务流程、并发、迁移、权限、性能与回归自动化;补齐本轮未新增的 `_test.go`,运行完整 `go test ./...` 并保留最终代码证据。 - [ ] 6.2 完成真实企业微信模板、平台本人绑定、代理固定成员、附件、提交、加密回调、轮询、同意/拒绝/撤销/删除/结果未知恢复,以及退款和线下充值终态验收。 - [ ] 6.3 完成真实 PostgreSQL、Redis/Asynq、对象存储、微信 Native、支付宝 PreCreate/TradeQuery/回调、支付成功未入账恢复与导出文件验收。 - [ ] 6.4 完成真实 Gateway 限速账号/运营商档位、设备当前卡、失败/结果未知,以及移动/联通/电信运营商回调和 19/20 位 ICCID 验收。 -- [ ] 6.5 恢复全局 Audit Event 治理:重新评审 `.scratch/tech-global-audit/` 22 张票,完成 Writer、业务覆盖、历史投影、审计中心、系统配置/Outbox 正式 Adapter、敏感读取/导出/保留与一次性发布门禁;不得把测试环境冻结改为永久 N/A。 +- [x] 6.5 明确全局 Audit Event 治理移出本 Change:不实现 Writer、业务覆盖、历史投影、审计中心、敏感读取/导出/保留或一次性切换门禁,不阻塞本次实际上线;未来如需建设另开独立 Change。 - [ ] 6.6 完成后台、代理端、C 端全部页面与真实人工验收矩阵,包括异步部分成功、权限空态、通知、临期、换货历史抽样、余额预警和状态展示。 - [ ] 6.7 完成生产存量数据核对、进行中退款/充值迁移、企微模板与账号准备、支付/Gateway配置、停机发布与回滚演练、性能/安全/Access Log 脱敏门禁。 - [ ] 6.8 只有 6.1~6.7 全部具备真实证据后,才可把生产发布和七月迭代验收标记完成;测试环境豁免不得继承到生产。 diff --git a/pkg/constants/approval.go b/pkg/constants/approval.go new file mode 100644 index 0000000..ff73809 --- /dev/null +++ b/pkg/constants/approval.go @@ -0,0 +1,107 @@ +package constants + +import "time" + +const ( + // ApprovalStatusSubmitting 表示审批申请等待渠道提交。 + ApprovalStatusSubmitting = 0 + // ApprovalStatusPending 表示审批申请已进入渠道审批。 + ApprovalStatusPending = 1 + // ApprovalStatusApproved 表示审批已通过。 + ApprovalStatusApproved = 2 + // ApprovalStatusRejected 表示审批已拒绝。 + ApprovalStatusRejected = 3 + // ApprovalStatusCancelled 表示审批已撤销。 + ApprovalStatusCancelled = 4 + // ApprovalStatusRevokedAfterApproved 表示审批通过后被撤销。 + ApprovalStatusRevokedAfterApproved = 5 + // ApprovalStatusDeleted 表示审批已删除。 + ApprovalStatusDeleted = 6 + // ApprovalStatusSubmissionFailed 表示渠道明确确认审批提交失败。 + ApprovalStatusSubmissionFailed = 7 + // ApprovalStatusSubmissionUnknown 表示审批提交请求已发出但结果未知。 + ApprovalStatusSubmissionUnknown = 8 +) + +const ( + // ApprovalDecisionApproved 表示标准审批通过决策。 + ApprovalDecisionApproved = "approved" + // ApprovalDecisionRejected 表示标准审批拒绝决策。 + ApprovalDecisionRejected = "rejected" + // ApprovalDecisionCancelled 表示标准审批撤销决策。 + ApprovalDecisionCancelled = "cancelled" + // ApprovalDecisionDeleted 表示标准审批删除决策。 + ApprovalDecisionDeleted = "deleted" + // ApprovalDecisionRevokedAfterApproved 表示标准审批通过后撤销决策。 + ApprovalDecisionRevokedAfterApproved = "revoked_after_approved" +) + +const ( + // ApprovalInitialVersion 表示通用审批实例的初始乐观锁版本。 + ApprovalInitialVersion = 1 + // ApprovalQueryMaxBatchSize 表示通用审批摘要单次批量投影上限。 + ApprovalQueryMaxBatchSize = 100 + // ApprovalUnknownSubmitterName 表示提交人名称快照无法解析。 + ApprovalUnknownSubmitterName = "未知账号" + // ApprovalDecisionDeliveryLeaseDuration 表示标准决策消费者的单次处理租约时长。 + ApprovalDecisionDeliveryLeaseDuration = 5 * time.Minute + // ApprovalPreparationTTL 表示事务前审批可用性结果允许复用的最长期限。 + ApprovalPreparationTTL = 30 * time.Second +) + +const ( + // ApprovalDecisionDeliveryPending 表示标准决策等待业务消费。 + ApprovalDecisionDeliveryPending = 0 + // ApprovalDecisionDeliveryProcessing 表示标准决策已被消费者租约领取。 + ApprovalDecisionDeliveryProcessing = 1 + // ApprovalDecisionDeliverySucceeded 表示标准决策已被业务幂等处理。 + ApprovalDecisionDeliverySucceeded = 2 + // ApprovalDecisionDeliveryFailed 表示标准决策消费失败并等待重试。 + ApprovalDecisionDeliveryFailed = 3 +) + +const ( + // OutboxEventTypeApprovalTerminalDecision 表示通用审批标准终态已记录。 + OutboxEventTypeApprovalTerminalDecision = "approval.terminal_decision.recorded" + // OutboxEventTypeApprovalSubmissionRequested 表示通用审批实例等待渠道提交。 + OutboxEventTypeApprovalSubmissionRequested = "approval.submission.requested" + // ApprovalTerminalDecisionPayloadVersionV1 表示标准终态事件载荷第一版。 + ApprovalTerminalDecisionPayloadVersionV1 = 1 + // ApprovalSubmissionPayloadVersionV1 表示审批申请提交事件载荷第一版。 + ApprovalSubmissionPayloadVersionV1 = 1 +) + +const ( + // ApprovalSyncSourceCallback 表示由第三方审批回调触发权威同步。 + ApprovalSyncSourceCallback = "callback" + // ApprovalSyncSourcePolling 表示由定时兜底轮询触发权威同步。 + ApprovalSyncSourcePolling = "polling" + // ApprovalSyncSourceManual 表示由受控人工动作触发权威同步。 + ApprovalSyncSourceManual = "manual" +) + +// GetApprovalStatusName 返回通用审批状态的中文名称。 +func GetApprovalStatusName(status int) string { + switch status { + case ApprovalStatusSubmitting: + return "提交中" + case ApprovalStatusPending: + return "审批中" + case ApprovalStatusApproved: + return "已通过" + case ApprovalStatusRejected: + return "已拒绝" + case ApprovalStatusCancelled: + return "已撤销" + case ApprovalStatusRevokedAfterApproved: + return "通过后撤销" + case ApprovalStatusDeleted: + return "已删除" + case ApprovalStatusSubmissionFailed: + return "提交失败" + case ApprovalStatusSubmissionUnknown: + return "提交结果未知" + default: + return "未知" + } +} diff --git a/pkg/constants/card_observation.go b/pkg/constants/card_observation.go new file mode 100644 index 0000000..ae16acc --- /dev/null +++ b/pkg/constants/card_observation.go @@ -0,0 +1,90 @@ +package constants + +import "time" + +// 卡实名观测来源 +const ( + CardObservationSourcePolling = "polling" // 周期轮询 + CardObservationSourceManualSync = "manual_sync" // 手动同步 + CardObservationSourceManualOverride = "manual_override" // 人工纠偏 + CardObservationSourceCarrierCallback = "carrier_callback" // 运营商回调 + CardObservationSourceBusinessEvent = "business_event" // 业务成功后的观测 +) + +// 卡观测事件序列同步类型。 +const ( + CardObservationSyncTypeRealname = "realname" // 实名状态 + CardObservationSyncTypeTraffic = "traffic" // 流量读数 + CardObservationSyncTypeNetwork = "network" // 网络状态 + CardObservationSyncTypeDeviceInfo = "device_info" // 设备信息 +) + +// 卡观测事件序列资源类型。 +const ( + CardObservationResourceTypeCard = "iot_card" // IoT 卡 + CardObservationResourceTypeDevice = "device" // 设备 +) + +// 卡观测事件序列执行参数。 +const ( + CardObservationSeriesAttemptCount = 3 // 每个序列固定尝试次数 + CardObservationSeriesTTL = 7 * time.Minute // 活跃序列覆盖最后一次任务及缓冲 + CardObservationSeriesResultTTL = 24 * time.Hour // 尝试幂等与提前完成结果保留时间 + CardObservationGatewayLockTTL = 70 * time.Second // Gateway 请求互斥超时加安全余量 + CardObservationGatewayMinInterval = 10 * time.Second // 未配置时的运营商最小请求间隔 +) + +// CardObservationAttemptDelay 返回固定的立即、3 分钟、5 分钟阶梯延迟。 +func CardObservationAttemptDelay(attempt int) time.Duration { + switch attempt { + case 1: + return 0 + case 2: + return 3 * time.Minute + case 3: + return 5 * time.Minute + default: + return 0 + } +} + +// 卡观测副作用消费状态 +const ( + CardObservationEffectStatusPending = 0 // 等待处理 + CardObservationEffectStatusProcessing = 1 // 正在处理或结果未知 + CardObservationEffectStatusDailySaved = 2 // 日流量缓冲已记录 + CardObservationEffectStatusDeducted = 3 // 套餐流量已扣减 + CardObservationEffectStatusCompleted = 4 // 全部副作用已完成 +) + +// 卡观测副作用类型 +const ( + CardObservationEffectTypeTrafficIncrement = "traffic_increment" // 流量正增量副作用 +) + +// Gateway 轮询 Integration Log 操作编码。 +const ( + IntegrationProviderGateway = "gateway" // Gateway 外部系统 + IntegrationOperationGatewayRealname = "query_realname_status" // 查询实名状态 + IntegrationOperationGatewayTraffic = "query_flow" // 查询流量 + IntegrationOperationGatewayNetwork = "query_card_status" // 查询网络状态 +) + +// 卡实名观测场景 +const ( + CardObservationSceneRealnamePolling = "realname_polling" // 实名轮询 + CardObservationSceneTrafficPolling = "traffic_polling" // 流量轮询 + CardObservationSceneNetworkPolling = "network_polling" // 网络状态轮询 + CardObservationSceneManualRefresh = "manual_refresh" // 手动刷新 + CardObservationSceneCarrierCallback = "carrier_realname_callback" // 运营商实名回调 +) + +// 卡实名状态变更 Outbox 事件 +const ( + OutboxEventTypeCardRealnameChanged = "card.realname.changed" // 卡实名状态变化 + CardRealnameChangedPayloadVersionV1 = 1 // 卡实名状态变化载荷版本 + OutboxEventTypeCardTrafficIncremented = "card.traffic.incremented" // 卡流量正增量 + CardTrafficIncrementedPayloadVersionV1 = 1 // 卡流量正增量载荷版本 + OutboxEventTypeCardNetworkChanged = "card.network.changed" // 卡网络状态变化 + CardNetworkChangedPayloadVersionV1 = 1 // 卡网络状态变化载荷版本 +) diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index f42f154..b17f67c 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -80,10 +80,12 @@ const ( TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包 // 定时任务类型(由 Asynq Scheduler 调度) - TaskTypeAlertCheck = "alert:check" // 告警检查 - TaskTypeDataCleanup = "data:cleanup" // 数据清理 - TaskTypeDailyTrafficFlush = "traffic:daily:flush" // 每日流量落盘 - TaskTypeOutboxDeliver = "outbox:deliver" // 公共 Outbox 事件投递 + TaskTypeAlertCheck = "alert:check" // 告警检查 + TaskTypeDataCleanup = "data:cleanup" // 数据清理 + TaskTypeNotificationCleanup = "notification:cleanup" // 站内通知保留清理 + TaskTypeDailyTrafficFlush = "traffic:daily:flush" // 每日流量落盘 + TaskTypeOutboxDeliver = "outbox:deliver" // 公共 Outbox 事件投递 + TaskTypeCardObservationSeries = "card_observation:series" // 卡观测事件序列尝试 ) // 用户状态常量 @@ -225,6 +227,7 @@ const ( QueueDataCleanup = TaskTypeDataCleanup // 数据清理任务队列 QueueDailyTrafficFlush = TaskTypeDailyTrafficFlush // 每日流量落盘任务队列 QueueOutboxDeliver = TaskTypeOutboxDeliver // 公共 Outbox 投递队列 + QueueCardObservationSeries = TaskTypeCardObservationSeries // 卡观测事件序列队列 DefaultRetryMax = 5 // 默认任务最大重试次数 DefaultTimeout = 10 * time.Minute // 默认任务超时时间 @@ -280,10 +283,14 @@ func QueueForTaskType(taskType string) string { return QueueAlertCheck case TaskTypeDataCleanup: return QueueDataCleanup + case TaskTypeNotificationCleanup: + return QueueDataCleanup case TaskTypeDailyTrafficFlush: return QueueDailyTrafficFlush case TaskTypeOutboxDeliver: return QueueOutboxDeliver + case TaskTypeCardObservationSeries: + return QueueCardObservationSeries default: return QueueDefault } @@ -318,6 +325,7 @@ func DefaultTaskQueueWeights() map[string]int { QueueDataCleanup: 1, QueueDailyTrafficFlush: 1, QueueOutboxDeliver: 4, + QueueCardObservationSeries: 2, QueueDefault: 1, QueueLow: 1, } diff --git a/pkg/constants/iot.go b/pkg/constants/iot.go index 69e7f69..63f1889 100644 --- a/pkg/constants/iot.go +++ b/pkg/constants/iot.go @@ -233,13 +233,6 @@ const ( ApprovalTypeManual = "manual" // 人工([预留]) ) -// 审批状态([预留] 用于审批流程功能,待产品规划) -const ( - ApprovalStatusPending = 1 // 待审批([预留]) - ApprovalStatusApproved = 2 // 已通过([预留]) - ApprovalStatusRejected = 3 // 已拒绝([预留]) -) - // ======================================== // 4. 财务管理常量 // ======================================== diff --git a/pkg/constants/notification.go b/pkg/constants/notification.go new file mode 100644 index 0000000..d65fb0a --- /dev/null +++ b/pkg/constants/notification.go @@ -0,0 +1,119 @@ +package constants + +const ( + // NotificationRecipientKindAccount 表示后台账号接收人。 + NotificationRecipientKindAccount = "account" + // NotificationRecipientKindPersonalCustomer 表示个人客户接收人。 + NotificationRecipientKindPersonalCustomer = "personal_customer" + // NotificationTargetKindAccount 表示由稳定后台账号 ID 解析接收人。 + NotificationTargetKindAccount = "account" + // NotificationTargetKindPlatformRole 表示由当前平台角色解析接收人。 + NotificationTargetKindPlatformRole = "platform_role" + // NotificationTargetKindShop 表示由目标店铺解析主账号和当前业务员。 + NotificationTargetKindShop = "shop" + + // NotificationCategoryApproval 表示审批类通知。 + NotificationCategoryApproval = "approval" + // NotificationCategoryExpiry 表示临期类通知。 + NotificationCategoryExpiry = "expiry" + // NotificationCategorySync 表示同步类通知。 + NotificationCategorySync = "sync" + // NotificationCategorySystem 表示系统类通知。 + NotificationCategorySystem = "system" + + // NotificationSeverityInfo 表示普通提示。 + NotificationSeverityInfo = "info" + // NotificationSeverityWarning 表示需要关注的警告。 + NotificationSeverityWarning = "warning" + // NotificationSeverityError 表示处理失败。 + NotificationSeverityError = "error" + // NotificationSeverityCritical 表示严重系统异常。 + NotificationSeverityCritical = "critical" + + // NotificationTypeSystemNotice 表示受控通用系统通知。 + NotificationTypeSystemNotice = "system.notice" + // NotificationTypePackageExpiring 表示个人客户套餐临期提醒。 + NotificationTypePackageExpiring = "package.expiring" + + // NotificationRefTypeSystemConfig 表示系统配置资源引用。 + NotificationRefTypeSystemConfig = "system_config" + // NotificationRefTypeIntegrationLog 表示外部集成日志资源引用。 + NotificationRefTypeIntegrationLog = "integration_log" + // NotificationRefTypePackage 表示套餐资源引用。 + NotificationRefTypePackage = "package" + // NotificationRefTypeAsset 表示个人客户资产资源引用。 + NotificationRefTypeAsset = "asset" + // NotificationRefTypeRefund 表示退款详情资源引用。 + NotificationRefTypeRefund = "refund" + // NotificationRefTypeAgentRecharge 表示代理充值详情资源引用。 + NotificationRefTypeAgentRecharge = "agent_recharge" + // NotificationRefTypeWeComApproval 表示企微审批详情资源引用。 + NotificationRefTypeWeComApproval = "wecom_approval" + // NotificationRefTypeIotCard 表示物联网卡详情资源引用。 + NotificationRefTypeIotCard = "iot_card" + // NotificationRefTypeDevice 表示设备详情资源引用。 + NotificationRefTypeDevice = "device" + // NotificationRefTypeExpiringAsset 表示临期资产列表资源引用。 + NotificationRefTypeExpiringAsset = "expiring_asset" + // NotificationRefTypeShopFund 表示店铺资金概况资源引用。 + NotificationRefTypeShopFund = "shop_fund" + // NotificationRefTypeCardSync 表示卡同步外部集成资源引用。 + NotificationRefTypeCardSync = "card_sync" + + // NotificationTargetTypeRefundDetail 表示退款详情前端目标。 + NotificationTargetTypeRefundDetail = "refund_detail" + // NotificationTargetTypeAgentRechargeDetail 表示代理充值详情前端目标。 + NotificationTargetTypeAgentRechargeDetail = "agent_recharge_detail" + // NotificationTargetTypeWeComApprovalDetail 表示企微审批详情前端目标。 + NotificationTargetTypeWeComApprovalDetail = "wecom_approval_detail" + // NotificationTargetTypeIotCardDetail 表示物联网卡详情前端目标。 + NotificationTargetTypeIotCardDetail = "iot_card_detail" + // NotificationTargetTypeDeviceDetail 表示设备详情前端目标。 + NotificationTargetTypeDeviceDetail = "device_detail" + // NotificationTargetTypeExpiringAssetList 表示临期资产列表前端目标。 + NotificationTargetTypeExpiringAssetList = "expiring_asset_list" + // NotificationTargetTypeShopFundSummary 表示店铺资金概况前端目标。 + NotificationTargetTypeShopFundSummary = "shop_fund_summary" + // NotificationTargetTypeIntegrationLog 表示统一外部集成前端目标。 + NotificationTargetTypeIntegrationLog = "integration_log" + // NotificationTargetTypeSystemConfig 表示受控系统配置前端目标。 + NotificationTargetTypeSystemConfig = "system_config" + + // OutboxEventTypeAdminDirectNotification 表示向明确后台账号投递通知的稳定事件类型。 + OutboxEventTypeAdminDirectNotification = "notification.admin.direct.requested" + // OutboxEventTypePersonalCustomerDirectNotification 表示向明确个人客户投递通知的稳定事件类型。 + OutboxEventTypePersonalCustomerDirectNotification = "notification.personal_customer.direct.requested" + // OutboxEventTypeAdminDynamicNotification 表示按账号、平台角色或店铺动态解析后台接收人的事件类型。 + OutboxEventTypeAdminDynamicNotification = "notification.admin.dynamic.requested" + // NotificationPayloadVersionV1 表示明确后台账号通知载荷第一版。 + NotificationPayloadVersionV1 = 1 + + // NotificationDefaultPageSize 表示后台通知默认每页数量。 + NotificationDefaultPageSize = 20 + // NotificationMaxPageSize 表示后台通知每页最大数量。 + NotificationMaxPageSize = 50 + // NotificationMaxPage 表示通知列表允许查询的最大页码,避免无界偏移。 + NotificationMaxPage = 10000 + // NotificationMaxTitleLength 表示通知纯文本标题最大字符数。 + NotificationMaxTitleLength = 200 + // NotificationMaxBodyLength 表示通知纯文本正文最大字符数。 + NotificationMaxBodyLength = 2000 + // NotificationExpiryRetentionDays 表示临期通知数据保留天数。 + NotificationExpiryRetentionDays = 180 + // NotificationApprovalRetentionDays 表示审批结果通知数据保留天数。 + NotificationApprovalRetentionDays = 365 + // NotificationSyncDisplayDays 表示同步异常默认展示天数。 + NotificationSyncDisplayDays = 30 + // NotificationSyncRetentionDays 表示同步异常通知数据保留天数。 + NotificationSyncRetentionDays = 180 + // NotificationSystemDefaultDisplayDays 表示系统告警默认展示天数。 + NotificationSystemDefaultDisplayDays = 30 + // NotificationSystemMaxDisplayDays 表示系统告警最长展示天数。 + NotificationSystemMaxDisplayDays = 365 + // NotificationSystemRetentionDays 表示系统告警数据保留天数。 + NotificationSystemRetentionDays = 365 + // NotificationCleanupBatchSize 表示单批清理通知数量。 + NotificationCleanupBatchSize = 500 + // NotificationCleanupMaxBatches 表示每类通知单次任务最多清理批次数。 + NotificationCleanupMaxBatches = 20 +) diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index c745e58..52accf2 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -474,6 +474,36 @@ func RedisPollingRealnameReversalCountKey(cardID uint) string { return fmt.Sprintf("polling:card:realname:reversal:%d", cardID) } +// RedisCardObservationSeriesKey 返回同场景观测序列合并键。 +func RedisCardObservationSeriesKey(scene, resourceType, resourceID, syncType string) string { + return fmt.Sprintf("cardsync:series:%s:%s:%s:%s", scene, resourceType, resourceID, syncType) +} + +// RedisCardObservationSeriesIndexKey 返回同资源、同步类型的活跃序列索引键。 +func RedisCardObservationSeriesIndexKey(resourceType, resourceID, syncType string) string { + return fmt.Sprintf("cardsync:series:index:%s:%s:%s", resourceType, resourceID, syncType) +} + +// RedisCardObservationSeriesCompletedKey 返回序列提前完成标记键。 +func RedisCardObservationSeriesCompletedKey(seriesID string) string { + return fmt.Sprintf("cardsync:series:completed:%s", seriesID) +} + +// RedisCardObservationAttemptKey 返回 series_id + attempt 幂等键。 +func RedisCardObservationAttemptKey(seriesID string, attempt int) string { + return fmt.Sprintf("cardsync:attempt:%s:%d", seriesID, attempt) +} + +// RedisCardObservationInflightKey 返回一次实际 Gateway 请求的互斥键。 +func RedisCardObservationInflightKey(provider, syncType, resourceID string) string { + return fmt.Sprintf("cardsync:inflight:%s:%s:%s", provider, syncType, resourceID) +} + +// RedisCardObservationLastRequestKey 返回运营商接入与同步类型的最近请求时间键。 +func RedisCardObservationLastRequestKey(provider, syncType, resourceID string) string { + return fmt.Sprintf("cardsync:last_request:%s:%s:%s", provider, syncType, resourceID) +} + // RedisPollingDeviceOpLockKey 设备维度停复机操作锁 Key // 防止设备下多张卡并发触发 EvaluateAndAct 导致重复 Gateway 调用 // TTL 建议 30 秒(覆盖 stopDeviceCards/resumeDeviceCards 最长执行时间) diff --git a/pkg/constants/wallet.go b/pkg/constants/wallet.go index 7cf5340..92d9c28 100644 --- a/pkg/constants/wallet.go +++ b/pkg/constants/wallet.go @@ -14,6 +14,15 @@ const ( AgentWalletTypeCommission = "commission" // 分佣钱包 ) +// 代理主钱包信用管理权限 +const ( + PermissionRoleDefaultCreditManage = "role:default-credit:manage" // 配置客户角色的新建代理默认信用模板 + PermissionShopCreditLimitManage = "shop:credit-limit:manage" // 前端控制店铺实际信用额度按钮显示 +) + +// AgentFundManagementForbiddenMessage 是企业账号访问代理资金功能时的统一拒绝提示。 +const AgentFundManagementForbiddenMessage = "企业账号无权访问代理资金功能" + // 代理钱包状态 const ( AgentWalletStatusNormal = 1 // 正常 @@ -29,12 +38,37 @@ const ( AgentTransactionTypeCommission = "commission" // 分佣 AgentTransactionTypeWithdrawal = "withdrawal" // 提现 AgentTransactionTypeCommissionDeduct = "commission_deduct" // 退款佣金回扣 + AgentTransactionTypeAdjustment = "adjustment" // 人工余额调整 ) // 代理钱包交易子类型(当 transaction_type = "deduct" 用于订单支付时) const ( WalletTransactionSubtypeSelfPurchase = "self_purchase" // 自购 WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate" // 给下级代理购买 + + // OutboxEventTypeAgentMainWalletDebited 表示代理主钱包订单扣款已提交。 + OutboxEventTypeAgentMainWalletDebited = "wallet.agent_main.debited" + // AgentMainWalletDebitedPayloadVersionV1 是代理主钱包扣款事件载荷版本。 + AgentMainWalletDebitedPayloadVersionV1 = 1 + // OutboxEventTypeAgentMainWalletReservationChanged 表示代理主钱包预占状态已变化。 + OutboxEventTypeAgentMainWalletReservationChanged = "wallet.agent_main.reservation.changed" + // AgentMainWalletReservationPayloadVersionV1 是代理主钱包预占事件载荷版本。 + AgentMainWalletReservationPayloadVersionV1 = 1 + // OutboxEventTypeAgentMainWalletCredited 表示代理主钱包正向入账已提交。 + OutboxEventTypeAgentMainWalletCredited = "wallet.agent_main.credited" + // AgentMainWalletCreditedPayloadVersionV1 是代理主钱包入账事件载荷版本。 + AgentMainWalletCreditedPayloadVersionV1 = 1 + // OutboxEventTypeAgentMainWalletRefunded 表示代理主钱包订单退款回充已提交。 + OutboxEventTypeAgentMainWalletRefunded = "wallet.agent_main.refunded" + // AgentMainWalletRefundedPayloadVersionV1 是代理主钱包退款回充事件载荷版本。 + AgentMainWalletRefundedPayloadVersionV1 = 1 +) + +// 代理主钱包预占状态 +const ( + AgentWalletReservationStatusFrozen = 1 // 已冻结 + AgentWalletReservationStatusReleased = 2 // 已释放 + AgentWalletReservationStatusCompleted = 3 // 已完成扣除 ) // 代理充值订单号前缀 @@ -111,12 +145,13 @@ const ( // 关联业务类型 const ( - ReferenceTypeOrder = "order" // 订单 - ReferenceTypeCommission = "commission" // 分佣 - ReferenceTypeWithdrawal = "withdrawal" // 提现 - ReferenceTypeTopup = "topup" // 充值 - ReferenceTypeRefund = "refund" // 退款 - ReferenceTypeExchange = "exchange" // 换货 + ReferenceTypeOrder = "order" // 订单 + ReferenceTypeCommission = "commission" // 分佣 + ReferenceTypeWithdrawal = "withdrawal" // 提现 + ReferenceTypeTopup = "topup" // 充值 + ReferenceTypeRefund = "refund" // 退款 + ReferenceTypeExchange = "exchange" // 换货 + ReferenceTypeManualAdjustment = "manual_adjustment" // 人工余额调整 ) // ========== Redis Key 生成函数 ========== diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 937384e..e8536ae 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -25,6 +25,7 @@ func BuildDocHandlers() *bootstrap.Handlers { ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil, nil), ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil), ClientRechargeOrder: app.NewClientRechargeOrderHandler(nil, nil, nil), + ClientNotification: app.NewClientNotificationHandler(nil, nil, nil), Shop: admin.NewShopHandler(nil, nil), ShopRole: admin.NewShopRoleHandler(nil), AdminAuth: admin.NewAuthHandler(nil, nil), @@ -38,6 +39,7 @@ func BuildDocHandlers() *bootstrap.Handlers { IotCard: admin.NewIotCardHandler(nil), IotCardImport: admin.NewIotCardImportHandler(nil), ExportTask: admin.NewExportTaskHandler(nil), + Notification: admin.NewNotificationHandler(nil, nil, nil), Device: admin.NewDeviceHandler(nil), DeviceImport: admin.NewDeviceImportHandler(nil), AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), diff --git a/pkg/queue/card_observation_series.go b/pkg/queue/card_observation_series.go new file mode 100644 index 0000000..6513410 --- /dev/null +++ b/pkg/queue/card_observation_series.go @@ -0,0 +1,41 @@ +package queue + +import ( + "context" + "errors" + "strconv" + + "github.com/hibiken/asynq" + + cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// CardObservationSeriesScheduler 将固定阶梯任务提交到独立队列。 +type CardObservationSeriesScheduler struct { + client *Client +} + +// NewCardObservationSeriesScheduler 创建卡观测序列任务调度器。 +func NewCardObservationSeriesScheduler(client *Client) *CardObservationSeriesScheduler { + return &CardObservationSeriesScheduler{client: client} +} + +// Enqueue 以稳定 series_id + attempt 任务 ID 零重试入队。 +func (s *CardObservationSeriesScheduler) Enqueue(ctx context.Context, payload cardapp.SeriesTaskPayload) error { + taskID := "card-series-" + payload.SeriesID + "-" + strconv.Itoa(payload.Attempt) + err := s.client.EnqueueTask( + ctx, + constants.TaskTypeCardObservationSeries, + payload, + asynq.TaskID(taskID), + asynq.ProcessAt(payload.ScheduledAt), + asynq.MaxRetry(0), + ) + if errors.Is(err, asynq.ErrTaskIDConflict) { + return nil + } + return err +} + +var _ cardapp.SeriesScheduler = (*CardObservationSeriesScheduler)(nil) diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 1091c9f..6a7d122 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -8,6 +8,8 @@ import ( "github.com/break/junhong_cmp_fiber/internal/exporter" "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + notification "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" "github.com/break/junhong_cmp_fiber/internal/polling" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -70,10 +72,12 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux { h.registerCommissionStatsHandlers() h.registerCommissionCalculationHandler() h.registerPollingHandlers() + h.registerCardObservationSeriesHandler() h.registerPackageActivationHandlers() h.registerOrderExpireHandler() h.registerAlertCheckHandler() h.registerDataCleanupHandler() + h.registerNotificationCleanupHandler() h.registerAutoPurchaseHandler() h.registerDailyTrafficFlushHandler() @@ -81,6 +85,12 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux { return h.mux } +func (h *Handler) registerCardObservationSeriesHandler() { + handler := task.NewCardObservationSeriesHandler(h.workerResult.Services.CardObservationSeries, h.logger) + h.mux.HandleFunc(constants.TaskTypeCardObservationSeries, handler.Handle) + h.logger.Info("注册卡观测事件序列任务处理器", zap.String("task_type", constants.TaskTypeCardObservationSeries)) +} + func (h *Handler) registerIotCardImportHandler() { iotCardImportHandler := task.NewIotCardImportHandler( h.db, @@ -208,13 +218,12 @@ func (h *Handler) registerCommissionCalculationHandler() { } func (h *Handler) registerPollingHandlers() { + integrationRepository := integrationlog.NewRepository(h.db) realnameHandler := task.NewPollingRealnameHandler( - h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard, - h.asynqClient, h.pollingStopResumeSvc, h.workerResult.Stores.DeviceSimBinding) + h.pollingBase, h.gatewayClient, h.workerResult.Services.CardObservation, integrationRepository) carrierStore := postgres.NewCarrierStore(h.db) carddataHandler := task.NewPollingCarddataHandler( - h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard, - carrierStore, h.workerResult.Services.UsageService, h.pollingStopResumeSvc) + h.pollingBase, h.gatewayClient, carrierStore, h.workerResult.Services.CardObservation, integrationRepository) packageHandler := task.NewPollingPackageHandler( h.pollingBase, h.workerResult.Stores.IotCard, h.pollingStopResumeSvc) @@ -222,7 +231,7 @@ func (h *Handler) registerPollingHandlers() { h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard, h.workerResult.Stores.DeviceSimBinding, h.pollingStopResumeSvc) cardStatusHandler := task.NewPollingCardStatusHandler( - h.pollingBase, h.gatewayClient, h.workerResult.Stores.IotCard, h.pollingStopResumeSvc) + h.pollingBase, h.gatewayClient, h.workerResult.Services.CardObservation, integrationRepository) h.mux.HandleFunc(constants.TaskTypePollingRealname, realnameHandler.Handle) h.mux.HandleFunc(constants.TaskTypePollingCarddata, carddataHandler.Handle) @@ -267,6 +276,13 @@ func (h *Handler) registerDataCleanupHandler() { h.logger.Info("注册数据清理任务处理器", zap.String("task_type", constants.TaskTypeDataCleanup)) } +func (h *Handler) registerNotificationCleanupHandler() { + cleanupService := notification.NewCleanupService(h.db, h.logger) + cleanupHandler := task.NewNotificationCleanupHandler(cleanupService, h.logger) + h.mux.HandleFunc(constants.TaskTypeNotificationCleanup, cleanupHandler.Handle) + h.logger.Info("注册站内通知保留清理任务处理器", zap.String("task_type", constants.TaskTypeNotificationCleanup)) +} + func (h *Handler) registerAutoPurchaseHandler() { autoPurchaseHandler := task.NewAutoPurchaseHandler( h.db, diff --git a/pkg/queue/types.go b/pkg/queue/types.go index d34c0d2..9b6e243 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -3,6 +3,7 @@ package queue import ( "context" + cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" @@ -19,33 +20,33 @@ type OrderExpirer interface { // WorkerStores Worker 侧所有 Store 的集合 type WorkerStores struct { - AssetOperationLog *postgres.AssetOperationLogStore - IotCardImportTask *postgres.IotCardImportTaskStore - IotCard *postgres.IotCardStore - DeviceImportTask *postgres.DeviceImportTaskStore - ExportTask *postgres.ExportTaskStore - ExportShardTask *postgres.ExportShardTaskStore - Device *postgres.DeviceStore - DeviceSimBinding *postgres.DeviceSimBindingStore - ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore - ShopPackageAllocation *postgres.ShopPackageAllocationStore - CommissionRecord *postgres.CommissionRecordStore - Shop *postgres.ShopStore - ShopSeriesAllocation *postgres.ShopSeriesAllocationStore - PackageSeries *postgres.PackageSeriesStore - Order *postgres.OrderStore - OrderItem *postgres.OrderItemStore - Package *postgres.PackageStore - PackageUsage *postgres.PackageUsageStore - PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore - PollingAlertRule *postgres.PollingAlertRuleStore - PollingAlertHistory *postgres.PollingAlertHistoryStore - DataCleanupConfig *postgres.DataCleanupConfigStore - DataCleanupLog *postgres.DataCleanupLogStore - AgentWallet *postgres.AgentWalletStore - AgentWalletTransaction *postgres.AgentWalletTransactionStore - AssetWallet *postgres.AssetWalletStore - AssetIdentifier *postgres.AssetIdentifierStore + AssetOperationLog *postgres.AssetOperationLogStore + IotCardImportTask *postgres.IotCardImportTaskStore + IotCard *postgres.IotCardStore + DeviceImportTask *postgres.DeviceImportTaskStore + ExportTask *postgres.ExportTaskStore + ExportShardTask *postgres.ExportShardTaskStore + Device *postgres.DeviceStore + DeviceSimBinding *postgres.DeviceSimBindingStore + ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore + ShopPackageAllocation *postgres.ShopPackageAllocationStore + CommissionRecord *postgres.CommissionRecordStore + Shop *postgres.ShopStore + ShopSeriesAllocation *postgres.ShopSeriesAllocationStore + PackageSeries *postgres.PackageSeriesStore + Order *postgres.OrderStore + OrderItem *postgres.OrderItemStore + Package *postgres.PackageStore + PackageUsage *postgres.PackageUsageStore + PackageUsageDailyRecord *postgres.PackageUsageDailyRecordStore + PollingAlertRule *postgres.PollingAlertRuleStore + PollingAlertHistory *postgres.PollingAlertHistoryStore + DataCleanupConfig *postgres.DataCleanupConfigStore + DataCleanupLog *postgres.DataCleanupLogStore + AgentWallet *postgres.AgentWalletStore + AgentWalletTransaction *postgres.AgentWalletTransactionStore + AssetWallet *postgres.AssetWalletStore + AssetIdentifier *postgres.AssetIdentifierStore PersonalCustomer *postgres.PersonalCustomerStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore @@ -53,6 +54,8 @@ type WorkerStores struct { // WorkerServices Worker 侧所有 Service 的集合 type WorkerServices struct { + CardObservation *cardObservationApp.Service + CardObservationSeries *cardObservationApp.SeriesAttemptService CommissionCalculation *commission_calculation.Service CommissionStats *commission_stats.Service UsageService *packagepkg.UsageService diff --git a/scripts/batch_package_purchase/assets.example.csv b/scripts/batch_package_purchase/assets.example.csv index 3587686..4160639 100644 --- a/scripts/batch_package_purchase/assets.example.csv +++ b/scripts/batch_package_purchase/assets.example.csv @@ -1,23 +1,551 @@ identifier -8986032445201075412, -8986032445201075413, -8986032445201075414, -8986032445201075416, -8986032445201075417, -8986032445201075418, -8986032445201075419, -8986032445201075420, -8986032445201075421, -8986032445201075422, -8986032445201075423, -8986032445201075425, -8986032445201075426, -8986032445201075428, -8986032445201075430, -8986032445201075433, -8986032445201075434, -8986032445201075441, -8986032445201075444, -8986032445201075447, -8986032445201075448, -8986032445201075451 +898604A8012270312000, +898604A8012270312001, +898604A8012270312002, +898604A8012270312003, +898604A8012270312004, +898604A8012270312005, +898604A8012270312006, +898604A8012270312007, +898604A8012270312008, +898604A8012270312009, +898604A8012270312010, +898604A8012270312011, +898604A8012270312012, +898604A8012270312013, +898604A8012270312014, +898604A8012270312015, +898604A8012270312016, +898604A8012270312017, +898604A8012270312018, +898604A8012270312019, +898604A8012270312020, +898604A8012270312021, +898604A8012270312022, +898604A8012270312023, +898604A8012270312024, +898604A8012270312025, +898604A8012270312026, +898604A8012270312027, +898604A8012270312028, +898604A8012270312029, +898604A8012270312030, +898604A8012270312031, +898604A8012270312032, +898604A8012270312033, +898604A8012270312034, +898604A8012270312035, +898604A8012270312036, +898604A8012270312037, +898604A8012270312038, +898604A8012270312039, +898604A8012270312040, +898604A8012270312041, +898604A8012270312042, +898604A8012270312043, +898604A8012270312044, +898604A8012270312045, +898604A8012270312046, +898604A8012270312047, +898604A8012270312048, +898604A8012270312049, +898604A8012270312050, +898604A8012270312051, +898604A8012270312052, +898604A8012270312053, +898604A8012270312054, +898604A8012270312055, +898604A8012270312056, +898604A8012270312057, +898604A8012270312058, +898604A8012270312059, +898604A8012270312060, +898604A8012270312061, +898604A8012270312062, +898604A8012270312063, +898604A8012270312064, +898604A8012270312065, +898604A8012270312066, +898604A8012270312067, +898604A8012270312068, +898604A8012270312069, +898604A8012270312070, +898604A8012270312071, +898604A8012270312072, +898604A8012270312073, +898604A8012270312074, +898604A8012270312075, +898604A8012270312076, +898604A8012270312077, +898604A8012270312078, +898604A8012270312079, +898604A8012270312080, +898604A8012270312081, +898604A8012270312082, +898604A8012270312083, +898604A8012270312084, +898604A8012270312085, +898604A8012270312086, +898604A8012270312087, +898604A8012270312088, +898604A8012270312089, +898604A8012270312090, +898604A8012270312091, +898604A8012270312092, +898604A8012270312093, +898604A8012270312094, +898604A8012270312095, +898604A8012270312096, +898604A8012270312097, +898604A8012270312098, +898604A8012270312099, +898604A8012270312100, +898604A8012270312101, +898604A8012270312102, +898604A8012270312103, +898604A8012270312104, +898604A8012270312105, +898604A8012270312106, +898604A8012270312107, +898604A8012270312108, +898604A8012270312109, +898604A8012270312110, +898604A8012270312111, +898604A8012270312112, +898604A8012270312113, +898604A8012270312114, +898604A8012270312115, +898604A8012270312116, +898604A8012270312117, +898604A8012270312118, +898604A8012270312119, +898604A8012270312120, +898604A8012270312121, +898604A8012270312122, +898604A8012270312123, +898604A8012270312124, +898604A8012270312125, +898604A8012270312126, +898604A8012270312127, +898604A8012270312128, +898604A8012270312129, +898604A8012270312130, +898604A8012270312131, +898604A8012270312132, +898604A8012270312133, +898604A8012270312134, +898604A8012270312135, +898604A8012270312136, +898604A8012270312137, +898604A8012270312138, +898604A8012270312139, +898604A8012270312140, +898604A8012270312141, +898604A8012270312142, +898604A8012270312143, +898604A8012270312144, +898604A8012270312145, +898604A8012270312146, +898604A8012270312147, +898604A8012270312148, +898604A8012270312149, +898604A8012270312150, +898604A8012270312151, +898604A8012270312152, +898604A8012270312153, +898604A8012270312154, +898604A8012270312155, +898604A8012270312156, +898604A8012270312157, +898604A8012270312158, +898604A8012270312159, +898604A8012270312160, +898604A8012270312161, +898604A8012270312162, +898604A8012270312163, +898604A8012270312164, +898604A8012270312165, +898604A8012270312166, +898604A8012270312167, +898604A8012270312168, +898604A8012270312169, +898604A8012270312170, +898604A8012270312171, +898604A8012270312172, +898604A8012270312173, +898604A8012270312174, +898604A8012270312175, +898604A8012270312176, +898604A8012270312177, +898604A8012270312178, +898604A8012270312179, +898604A8012270312180, +898604A8012270312181, +898604A8012270312182, +898604A8012270312183, +898604A8012270312184, +898604A8012270312185, +898604A8012270312186, +898604A8012270312187, +898604A8012270312188, +898604A8012270312189, +898604A8012270312190, +898604A8012270312191, +898604A8012270312192, +898604A8012270312193, +898604A8012270312194, +898604A8012270312195, +898604A8012270312196, +898604A8012270312197, +898604A8012270312198, +898604A8012270312199, +898604A8012270312200, +898604A8012270312201, +898604A8012270312202, +898604A8012270312203, +898604A8012270312204, +898604A8012270312205, +898604A8012270312206, +898604A8012270312207, +898604A8012270312208, +898604A8012270312209, +898604A8012270312210, +898604A8012270312211, +898604A8012270312212, +898604A8012270312213, +898604A8012270312214, +898604A8012270312215, +898604A8012270312216, +898604A8012270312217, +898604A8012270312218, +898604A8012270312219, +898604A8012270312220, +898604A8012270312221, +898604A8012270312222, +898604A8012270312223, +898604A8012270312224, +898604A8012270312225, +898604A8012270312226, +898604A8012270312227, +898604A8012270312228, +898604A8012270312229, +898604A8012270312230, +898604A8012270312231, +898604A8012270312232, +898604A8012270312233, +898604A8012270312234, +898604A8012270312235, +898604A8012270312236, +898604A8012270312237, +898604A8012270312238, +898604A8012270312239, +898604A8012270312240, +898604A8012270312241, +898604A8012270312242, +898604A8012270312243, +898604A8012270312244, +898604A8012270312245, +898604A8012270312246, +898604A8012270312247, +898604A8012270312248, +898604A8012270312249, +898604A8012270312250, +898604A8012270312251, +898604A8012270312252, +898604A8012270312253, +898604A8012270312254, +898604A8012270312255, +898604A8012270312256, +898604A8012270312257, +898604A8012270312258, +898604A8012270312259, +898604A8012270312260, +898604A8012270312261, +898604A8012270312262, +898604A8012270312263, +898604A8012270312264, +898604A8012270312265, +898604A8012270312266, +898604A8012270312267, +898604A8012270312268, +898604A8012270312269, +898604A8012270312270, +898604A8012270312271, +898604A8012270312272, +898604A8012270312273, +898604A8012270312274, +898604A8012270312275, +898604A8012270312276, +898604A8012270312277, +898604A8012270312278, +898604A8012270312279, +898604A8012270312280, +898604A8012270312281, +898604A8012270312282, +898604A8012270312283, +898604A8012270312284, +898604A8012270312285, +898604A8012270312286, +898604A8012270312287, +898604A8012270312288, +898604A8012270312289, +898604A8012270312290, +898604A8012270312291, +898604A8012270312292, +898604A8012270312293, +898604A8012270312294, +898604A8012270312295, +898604A8012270312296, +898604A8012270312297, +898604A8012270312298, +898604A8012270312299, +898604A8012270312300, +898604A8012270312301, +898604A8012270312302, +898604A8012270312303, +898604A8012270312304, +898604A8012270312305, +898604A8012270312306, +898604A8012270312307, +898604A8012270312308, +898604A8012270312309, +898604A8012270312310, +898604A8012270312311, +898604A8012270312312, +898604A8012270312313, +898604A8012270312314, +898604A8012270312315, +898604A8012270312316, +898604A8012270312317, +898604A8012270312318, +898604A8012270312319, +898604A8012270312320, +898604A8012270312321, +898604A8012270312322, +898604A8012270312323, +898604A8012270312324, +898604A8012270312325, +898604A8012270312326, +898604A8012270312327, +898604A8012270312328, +898604A8012270312329, +898604A8012270312330, +898604A8012270312331, +898604A8012270312332, +898604A8012270312333, +898604A8012270312334, +898604A8012270312335, +898604A8012270312336, +898604A8012270312337, +898604A8012270312338, +898604A8012270312339, +898604A8012270312340, +898604A8012270312341, +898604A8012270312342, +898604A8012270312343, +898604A8012270312344, +898604A8012270312345, +898604A8012270312346, +898604A8012270312347, +898604A8012270312348, +898604A8012270312349, +898604A8012270312350, +898604A8012270312351, +898604A8012270312352, +898604A8012270312353, +898604A8012270312354, +898604A8012270312355, +898604A8012270312356, +898604A8012270312357, +898604A8012270312358, +898604A8012270312359, +898604A8012270312360, +898604A8012270312361, +898604A8012270312362, +898604A8012270312363, +898604A8012270312364, +898604A8012270312365, +898604A8012270312366, +898604A8012270312367, +898604A8012270312368, +898604A8012270312369, +898604A8012270312370, +898604A8012270312371, +898604A8012270312372, +898604A8012270312373, +898604A8012270312374, +898604A8012270312375, +898604A8012270312376, +898604A8012270312377, +898604A8012270312378, +898604A8012270312379, +898604A8012270312380, +898604A8012270312381, +898604A8012270312382, +898604A8012270312383, +898604A8012270312384, +898604A8012270312385, +898604A8012270312386, +898604A8012270312387, +898604A8012270312388, +898604A8012270312389, +898604A8012270312390, +898604A8012270312391, +898604A8012270312392, +898604A8012270312393, +898604A8012270312394, +898604A8012270312395, +898604A8012270312396, +898604A8012270312397, +898604A8012270312398, +898604A8012270312399, +898604A8012270312400, +898604A8012270312401, +898604A8012270312402, +898604A8012270312403, +898604A8012270312404, +898604A8012270312405, +898604A8012270312406, +898604A8012270312407, +898604A8012270312408, +898604A8012270312409, +898604A8012270312410, +898604A8012270312411, +898604A8012270312412, +898604A8012270312413, +898604A8012270312414, +898604A8012270312415, +898604A8012270312416, +898604A8012270312417, +898604A8012270312418, +898604A8012270312419, +898604A8012270312420, +898604A8012270312421, +898604A8012270312422, +898604A8012270312423, +898604A8012270312424, +898604A8012270312425, +898604A8012270312426, +898604A8012270312427, +898604A8012270312428, +898604A8012270312429, +898604A8012270312430, +898604A8012270312431, +898604A8012270312432, +898604A8012270312433, +898604A8012270312434, +898604A8012270312435, +898604A8012270312436, +898604A8012270312437, +898604A8012270312438, +898604A8012270312439, +898604A8012270312440, +898604A8012270312441, +898604A8012270312442, +898604A8012270312443, +898604A8012270312444, +898604A8012270312445, +898604A8012270312446, +898604A8012270312447, +898604A8012270312448, +898604A8012270312449, +898604A8012270312450, +898604A8012270312451, +898604A8012270312452, +898604A8012270312453, +898604A8012270312454, +898604A8012270312455, +898604A8012270312456, +898604A8012270312457, +898604A8012270312458, +898604A8012270312459, +898604A8012270312460, +898604A8012270312461, +898604A8012270312462, +898604A8012270312463, +898604A8012270312464, +898604A8012270312465, +898604A8012270312466, +898604A8012270312467, +898604A8012270312468, +898604A8012270312469, +898604A8012270312470, +898604A8012270312471, +898604A8012270312472, +898604A8012270312473, +898604A8012270312474, +898604A8012270312475, +898604A8012270312476, +898604A8012270312477, +898604A8012270312478, +898604A8012270312479, +898604A8012270312480, +898604A8012270312481, +898604A8012270312482, +898604A8012270312483, +898604A8012270312484, +898604A8012270312485, +898604A8012270312486, +898604A8012270312487, +898604A8012270312488, +898604A8012270312489, +898604A8012270312490, +898604A8012270312491, +898604A8012270312492, +898604A8012270312493, +898604A8012270312494, +898604A8012270312495, +898604A8012270312496, +898604A8012270312497, +898604A8012270312498, +898604A8012270312499, +898604A8012270312500, +898604A8012270312501, +898604A8012270312502, +898604A8012270312503, +898604A8012270312504, +898604A8012270312505, +898604A8012270312506, +898604A8012270312507, +898604A8012270312508, +898604A8012270312509, +898604A8012270312510, +898604A8012270312511, +898604A8012270312512, +898604A8012270312513, +898604A8012270312514, +898604A8012270312515, +898604A8012270312516, +898604A8012270312517, +898604A8012270312518, +898604A8012270312519, +898604A8012270312520, +898604A8012270312521, +898604A8012270312522, +898604A8012270312523, +898604A8012270312524, +898604A8012270312525, +898604A8012270312526, +898604A8012270312527, +898604A8012270312528, +898604A8012270312529, +898604A8012270312530, +898604A8012270312531, +898604A8012270312532, +898604A8012270312533, +898604A8012270312534, +898604A8012270312535, +898604A8012270312536, +898604A8012270312537, +898604A8012270312538, +898604A8012270312539, +898604A8012270312540, +898604A8012270312541, +898604A8012270312542, +898604A8012270312543, +898604A8012270312544, +898604A8012270312545, +898604A8012270312546, +898604A8012270312547, +898604A8012270312548, +898604A8012270312549 diff --git a/scripts/batch_package_purchase/assets.example_购买结果_20260724_130306.csv b/scripts/batch_package_purchase/assets.example_购买结果_20260724_130306.csv new file mode 100644 index 0000000..7021133 --- /dev/null +++ b/scripts/batch_package_purchase/assets.example_购买结果_20260724_130306.csv @@ -0,0 +1,551 @@ +line_no,identifier,status,http_status,code,msg,order_id,order_no,total_amount +2,898604A8012270312000,成功,200,0,success,30068,ORD20260724130306370347,600 +3,898604A8012270312001,成功,200,0,success,30069,ORD20260724130306069885,600 +4,898604A8012270312002,成功,200,0,success,30070,ORD20260724130307308044,600 +5,898604A8012270312003,成功,200,0,success,30071,ORD20260724130307416407,600 +6,898604A8012270312004,成功,200,0,success,30072,ORD20260724130308762086,600 +7,898604A8012270312005,成功,200,0,success,30073,ORD20260724130308872425,600 +8,898604A8012270312006,成功,200,0,success,30074,ORD20260724130309471278,600 +9,898604A8012270312007,成功,200,0,success,30075,ORD20260724130309129677,600 +10,898604A8012270312008,成功,200,0,success,30076,ORD20260724130309721027,600 +11,898604A8012270312009,成功,200,0,success,30077,ORD20260724130310596954,600 +12,898604A8012270312010,成功,200,0,success,30078,ORD20260724130310712467,600 +13,898604A8012270312011,成功,200,0,success,30079,ORD20260724130311942350,600 +14,898604A8012270312012,成功,200,0,success,30080,ORD20260724130311435126,600 +15,898604A8012270312013,成功,200,0,success,30081,ORD20260724130311014596,600 +16,898604A8012270312014,成功,200,0,success,30082,ORD20260724130312348939,600 +17,898604A8012270312015,成功,200,0,success,30083,ORD20260724130312828641,600 +18,898604A8012270312016,成功,200,0,success,30084,ORD20260724130313060613,600 +19,898604A8012270312017,成功,200,0,success,30085,ORD20260724130313751714,600 +20,898604A8012270312018,成功,200,0,success,30086,ORD20260724130314248259,600 +21,898604A8012270312019,成功,200,0,success,30087,ORD20260724130314740972,600 +22,898604A8012270312020,成功,200,0,success,30088,ORD20260724130314436214,600 +23,898604A8012270312021,成功,200,0,success,30089,ORD20260724130315490988,600 +24,898604A8012270312022,成功,200,0,success,30090,ORD20260724130315977211,600 +25,898604A8012270312023,成功,200,0,success,30091,ORD20260724130315166762,600 +26,898604A8012270312024,成功,200,0,success,30092,ORD20260724130316111130,600 +27,898604A8012270312025,成功,200,0,success,30093,ORD20260724130317260516,600 +28,898604A8012270312026,成功,200,0,success,30094,ORD20260724130317509823,600 +29,898604A8012270312027,成功,200,0,success,30095,ORD20260724130318464955,600 +30,898604A8012270312028,成功,200,0,success,30096,ORD20260724130319607166,600 +31,898604A8012270312029,成功,200,0,success,30097,ORD20260724130319407518,600 +32,898604A8012270312030,成功,200,0,success,30098,ORD20260724130319808152,600 +33,898604A8012270312031,成功,200,0,success,30099,ORD20260724130320396116,600 +34,898604A8012270312032,成功,200,0,success,30100,ORD20260724130320419778,600 +35,898604A8012270312033,成功,200,0,success,30101,ORD20260724130321650144,600 +36,898604A8012270312034,成功,200,0,success,30102,ORD20260724130321966171,600 +37,898604A8012270312035,成功,200,0,success,30103,ORD20260724130322093259,600 +38,898604A8012270312036,成功,200,0,success,30104,ORD20260724130322481254,600 +39,898604A8012270312037,成功,200,0,success,30105,ORD20260724130322823190,600 +40,898604A8012270312038,成功,200,0,success,30106,ORD20260724130323861066,600 +41,898604A8012270312039,成功,200,0,success,30107,ORD20260724130323104567,600 +42,898604A8012270312040,成功,200,0,success,30108,ORD20260724130324776288,600 +43,898604A8012270312041,成功,200,0,success,30109,ORD20260724130324127246,600 +44,898604A8012270312042,成功,200,0,success,30110,ORD20260724130325645601,600 +45,898604A8012270312043,成功,200,0,success,30111,ORD20260724130326297990,600 +46,898604A8012270312044,成功,200,0,success,30112,ORD20260724130327785398,600 +47,898604A8012270312045,成功,200,0,success,30113,ORD20260724130328514674,600 +48,898604A8012270312046,成功,200,0,success,30114,ORD20260724130329677063,600 +49,898604A8012270312047,成功,200,0,success,30115,ORD20260724130329445412,600 +50,898604A8012270312048,成功,200,0,success,30116,ORD20260724130330288241,600 +51,898604A8012270312049,成功,200,0,success,30117,ORD20260724130331809635,600 +52,898604A8012270312050,成功,200,0,success,30118,ORD20260724130331245655,600 +53,898604A8012270312051,成功,200,0,success,30119,ORD20260724130332040988,600 +54,898604A8012270312052,成功,200,0,success,30120,ORD20260724130332666410,600 +55,898604A8012270312053,成功,200,0,success,30121,ORD20260724130333807490,600 +56,898604A8012270312054,成功,200,0,success,30122,ORD20260724130333743808,600 +57,898604A8012270312055,成功,200,0,success,30123,ORD20260724130334479629,600 +58,898604A8012270312056,成功,200,0,success,30124,ORD20260724130335072534,600 +59,898604A8012270312057,成功,200,0,success,30125,ORD20260724130336078006,600 +60,898604A8012270312058,成功,200,0,success,30126,ORD20260724130337424695,600 +61,898604A8012270312059,成功,200,0,success,30127,ORD20260724130338884577,600 +62,898604A8012270312060,成功,200,0,success,30128,ORD20260724130339182426,600 +63,898604A8012270312061,成功,200,0,success,30129,ORD20260724130339667408,600 +64,898604A8012270312062,成功,200,0,success,30130,ORD20260724130340179246,600 +65,898604A8012270312063,成功,200,0,success,30131,ORD20260724130341148553,600 +66,898604A8012270312064,成功,200,0,success,30132,ORD20260724130342181756,600 +67,898604A8012270312065,成功,200,0,success,30133,ORD20260724130343036273,600 +68,898604A8012270312066,成功,200,0,success,30134,ORD20260724130343124871,600 +69,898604A8012270312067,成功,200,0,success,30135,ORD20260724130345585234,600 +70,898604A8012270312068,成功,200,0,success,30136,ORD20260724130346834357,600 +71,898604A8012270312069,成功,200,0,success,30137,ORD20260724130347809735,600 +72,898604A8012270312070,成功,200,0,success,30138,ORD20260724130348333269,600 +73,898604A8012270312071,成功,200,0,success,30139,ORD20260724130348682274,600 +74,898604A8012270312072,成功,200,0,success,30140,ORD20260724130349398594,600 +75,898604A8012270312073,成功,200,0,success,30141,ORD20260724130349035241,600 +76,898604A8012270312074,成功,200,0,success,30142,ORD20260724130350615124,600 +77,898604A8012270312075,成功,200,0,success,30143,ORD20260724130350415301,600 +78,898604A8012270312076,成功,200,0,success,30144,ORD20260724130350955649,600 +79,898604A8012270312077,成功,200,0,success,30145,ORD20260724130351745875,600 +80,898604A8012270312078,成功,200,0,success,30146,ORD20260724130351288521,600 +81,898604A8012270312079,成功,200,0,success,30147,ORD20260724130352501341,600 +82,898604A8012270312080,成功,200,0,success,30148,ORD20260724130353449526,600 +83,898604A8012270312081,成功,200,0,success,30149,ORD20260724130353346262,600 +84,898604A8012270312082,成功,200,0,success,30150,ORD20260724130353196926,600 +85,898604A8012270312083,成功,200,0,success,30151,ORD20260724130354702420,600 +86,898604A8012270312084,成功,200,0,success,30152,ORD20260724130354319427,600 +87,898604A8012270312085,成功,200,0,success,30153,ORD20260724130355713869,600 +88,898604A8012270312086,成功,200,0,success,30154,ORD20260724130355874872,600 +89,898604A8012270312087,成功,200,0,success,30155,ORD20260724130356948473,600 +90,898604A8012270312088,成功,200,0,success,30156,ORD20260724130356864268,600 +91,898604A8012270312089,成功,200,0,success,30157,ORD20260724130356882889,600 +92,898604A8012270312090,成功,200,0,success,30158,ORD20260724130357354724,600 +93,898604A8012270312091,成功,200,0,success,30159,ORD20260724130357206653,600 +94,898604A8012270312092,成功,200,0,success,30160,ORD20260724130358418770,600 +95,898604A8012270312093,成功,200,0,success,30161,ORD20260724130359260531,600 +96,898604A8012270312094,成功,200,0,success,30162,ORD20260724130359103577,600 +97,898604A8012270312095,成功,200,0,success,30163,ORD20260724130400367096,600 +98,898604A8012270312096,成功,200,0,success,30164,ORD20260724130400084976,600 +99,898604A8012270312097,成功,200,0,success,30165,ORD20260724130401560965,600 +100,898604A8012270312098,成功,200,0,success,30166,ORD20260724130401443819,600 +101,898604A8012270312099,成功,200,0,success,30167,ORD20260724130402248233,600 +102,898604A8012270312100,成功,200,0,success,30168,ORD20260724130402329779,600 +103,898604A8012270312101,成功,200,0,success,30169,ORD20260724130402400899,600 +104,898604A8012270312102,成功,200,0,success,30170,ORD20260724130403100138,600 +105,898604A8012270312103,成功,200,0,success,30171,ORD20260724130403559476,600 +106,898604A8012270312104,成功,200,0,success,30172,ORD20260724130404322961,600 +107,898604A8012270312105,成功,200,0,success,30173,ORD20260724130404148967,600 +108,898604A8012270312106,成功,200,0,success,30174,ORD20260724130404177642,600 +109,898604A8012270312107,成功,200,0,success,30175,ORD20260724130405785999,600 +110,898604A8012270312108,成功,200,0,success,30176,ORD20260724130405519466,600 +111,898604A8012270312109,成功,200,0,success,30177,ORD20260724130406220074,600 +112,898604A8012270312110,成功,200,0,success,30178,ORD20260724130406161990,600 +113,898604A8012270312111,成功,200,0,success,30179,ORD20260724130407037068,600 +114,898604A8012270312112,成功,200,0,success,30180,ORD20260724130407167640,600 +115,898604A8012270312113,成功,200,0,success,30181,ORD20260724130408535733,600 +116,898604A8012270312114,成功,200,0,success,30182,ORD20260724130409893323,600 +117,898604A8012270312115,成功,200,0,success,30183,ORD20260724130409447467,600 +118,898604A8012270312116,成功,200,0,success,30184,ORD20260724130410736837,600 +119,898604A8012270312117,成功,200,0,success,30185,ORD20260724130411616978,600 +120,898604A8012270312118,成功,200,0,success,30186,ORD20260724130411284278,600 +121,898604A8012270312119,成功,200,0,success,30187,ORD20260724130411415968,600 +122,898604A8012270312120,成功,200,0,success,30188,ORD20260724130412175626,600 +123,898604A8012270312121,成功,200,0,success,30189,ORD20260724130412192720,600 +124,898604A8012270312122,成功,200,0,success,30190,ORD20260724130413846448,600 +125,898604A8012270312123,成功,200,0,success,30191,ORD20260724130414578916,600 +126,898604A8012270312124,成功,200,0,success,30192,ORD20260724130415656628,600 +127,898604A8012270312125,成功,200,0,success,30193,ORD20260724130416885746,600 +128,898604A8012270312126,成功,200,0,success,30194,ORD20260724130417207392,600 +129,898604A8012270312127,成功,200,0,success,30195,ORD20260724130418355086,600 +130,898604A8012270312128,成功,200,0,success,30196,ORD20260724130418060629,600 +131,898604A8012270312129,成功,200,0,success,30197,ORD20260724130419954497,600 +132,898604A8012270312130,成功,200,0,success,30198,ORD20260724130420537747,600 +133,898604A8012270312131,成功,200,0,success,30199,ORD20260724130420635143,600 +134,898604A8012270312132,成功,200,0,success,30200,ORD20260724130421556473,600 +135,898604A8012270312133,成功,200,0,success,30201,ORD20260724130421284734,600 +136,898604A8012270312134,成功,200,0,success,30202,ORD20260724130422479524,600 +137,898604A8012270312135,成功,200,0,success,30203,ORD20260724130423808615,600 +138,898604A8012270312136,成功,200,0,success,30204,ORD20260724130423047294,600 +139,898604A8012270312137,成功,200,0,success,30205,ORD20260724130424936847,600 +140,898604A8012270312138,成功,200,0,success,30206,ORD20260724130424390578,600 +141,898604A8012270312139,成功,200,0,success,30207,ORD20260724130425906876,600 +142,898604A8012270312140,成功,200,0,success,30208,ORD20260724130426148243,600 +143,898604A8012270312141,成功,200,0,success,30209,ORD20260724130427941389,600 +144,898604A8012270312142,成功,200,0,success,30210,ORD20260724130427017407,600 +145,898604A8012270312143,成功,200,0,success,30211,ORD20260724130428757829,600 +146,898604A8012270312144,成功,200,0,success,30212,ORD20260724130428925402,600 +147,898604A8012270312145,成功,200,0,success,30213,ORD20260724130429440223,600 +148,898604A8012270312146,成功,200,0,success,30214,ORD20260724130430239285,600 +149,898604A8012270312147,成功,200,0,success,30215,ORD20260724130431122277,600 +150,898604A8012270312148,成功,200,0,success,30216,ORD20260724130431607142,600 +151,898604A8012270312149,成功,200,0,success,30217,ORD20260724130432892401,600 +152,898604A8012270312150,成功,200,0,success,30218,ORD20260724130433826340,600 +153,898604A8012270312151,成功,200,0,success,30219,ORD20260724130433681886,600 +154,898604A8012270312152,成功,200,0,success,30220,ORD20260724130434992111,600 +155,898604A8012270312153,成功,200,0,success,30221,ORD20260724130435931762,600 +156,898604A8012270312154,成功,200,0,success,30222,ORD20260724130436427731,600 +157,898604A8012270312155,成功,200,0,success,30223,ORD20260724130436355440,600 +158,898604A8012270312156,成功,200,0,success,30224,ORD20260724130437910346,600 +159,898604A8012270312157,成功,200,0,success,30225,ORD20260724130438291165,600 +160,898604A8012270312158,成功,200,0,success,30226,ORD20260724130438950252,600 +161,898604A8012270312159,成功,200,0,success,30227,ORD20260724130439120253,600 +162,898604A8012270312160,成功,200,0,success,30228,ORD20260724130440953528,600 +163,898604A8012270312161,成功,200,0,success,30229,ORD20260724130440243862,600 +164,898604A8012270312162,成功,200,0,success,30230,ORD20260724130441532543,600 +165,898604A8012270312163,成功,200,0,success,30231,ORD20260724130442272476,600 +166,898604A8012270312164,成功,200,0,success,30232,ORD20260724130443877361,600 +167,898604A8012270312165,成功,200,0,success,30233,ORD20260724130443746499,600 +168,898604A8012270312166,成功,200,0,success,30234,ORD20260724130444629737,600 +169,898604A8012270312167,成功,200,0,success,30235,ORD20260724130444235707,600 +170,898604A8012270312168,成功,200,0,success,30236,ORD20260724130445578895,600 +171,898604A8012270312169,成功,200,0,success,30237,ORD20260724130445524490,600 +172,898604A8012270312170,成功,200,0,success,30238,ORD20260724130445734010,600 +173,898604A8012270312171,成功,200,0,success,30239,ORD20260724130446853282,600 +174,898604A8012270312172,成功,200,0,success,30240,ORD20260724130446103637,600 +175,898604A8012270312173,成功,200,0,success,30241,ORD20260724130447502869,600 +176,898604A8012270312174,成功,200,0,success,30242,ORD20260724130447799715,600 +177,898604A8012270312175,成功,200,0,success,30243,ORD20260724130447272920,600 +178,898604A8012270312176,成功,200,0,success,30244,ORD20260724130448434729,600 +179,898604A8012270312177,成功,200,0,success,30245,ORD20260724130449320016,600 +180,898604A8012270312178,成功,200,0,success,30246,ORD20260724130449594988,600 +181,898604A8012270312179,成功,200,0,success,30247,ORD20260724130450758373,600 +182,898604A8012270312180,成功,200,0,success,30248,ORD20260724130451002052,600 +183,898604A8012270312181,成功,200,0,success,30249,ORD20260724130451827734,600 +184,898604A8012270312182,成功,200,0,success,30250,ORD20260724130452333807,600 +185,898604A8012270312183,成功,200,0,success,30251,ORD20260724130452225945,600 +186,898604A8012270312184,成功,200,0,success,30252,ORD20260724130452299122,600 +187,898604A8012270312185,成功,200,0,success,30253,ORD20260724130453518396,600 +188,898604A8012270312186,成功,200,0,success,30254,ORD20260724130453188542,600 +189,898604A8012270312187,成功,200,0,success,30255,ORD20260724130454243862,600 +190,898604A8012270312188,成功,200,0,success,30256,ORD20260724130454069408,600 +191,898604A8012270312189,成功,200,0,success,30257,ORD20260724130455664554,600 +192,898604A8012270312190,成功,200,0,success,30258,ORD20260724130455159678,600 +193,898604A8012270312191,成功,200,0,success,30259,ORD20260724130455822278,600 +194,898604A8012270312192,成功,200,0,success,30260,ORD20260724130456542869,600 +195,898604A8012270312193,成功,200,0,success,30261,ORD20260724130456903951,600 +196,898604A8012270312194,成功,200,0,success,30262,ORD20260724130457779367,600 +197,898604A8012270312195,成功,200,0,success,30263,ORD20260724130457396046,600 +198,898604A8012270312196,成功,200,0,success,30264,ORD20260724130458952991,600 +199,898604A8012270312197,成功,200,0,success,30265,ORD20260724130458704239,600 +200,898604A8012270312198,成功,200,0,success,30266,ORD20260724130458597159,600 +201,898604A8012270312199,成功,200,0,success,30267,ORD20260724130459072126,600 +202,898604A8012270312200,成功,200,0,success,30268,ORD20260724130459792564,600 +203,898604A8012270312201,成功,200,0,success,30269,ORD20260724130500364949,600 +204,898604A8012270312202,成功,200,0,success,30270,ORD20260724130500722450,600 +205,898604A8012270312203,成功,200,0,success,30271,ORD20260724130500240261,600 +206,898604A8012270312204,成功,200,0,success,30272,ORD20260724130501845611,600 +207,898604A8012270312205,成功,200,0,success,30273,ORD20260724130501602839,600 +208,898604A8012270312206,成功,200,0,success,30274,ORD20260724130502686176,600 +209,898604A8012270312207,成功,200,0,success,30275,ORD20260724130502156668,600 +210,898604A8012270312208,成功,200,0,success,30276,ORD20260724130502871652,600 +211,898604A8012270312209,成功,200,0,success,30277,ORD20260724130503422088,600 +212,898604A8012270312210,成功,200,0,success,30278,ORD20260724130503882605,600 +213,898604A8012270312211,成功,200,0,success,30279,ORD20260724130504053657,600 +214,898604A8012270312212,成功,200,0,success,30280,ORD20260724130504909258,600 +215,898604A8012270312213,成功,200,0,success,30281,ORD20260724130505599448,600 +216,898604A8012270312214,成功,200,0,success,30282,ORD20260724130505677013,600 +217,898604A8012270312215,成功,200,0,success,30283,ORD20260724130505926358,600 +218,898604A8012270312216,成功,200,0,success,30284,ORD20260724130506656596,600 +219,898604A8012270312217,成功,200,0,success,30285,ORD20260724130506041094,600 +220,898604A8012270312218,成功,200,0,success,30286,ORD20260724130507557360,600 +221,898604A8012270312219,成功,200,0,success,30287,ORD20260724130507068376,600 +222,898604A8012270312220,成功,200,0,success,30288,ORD20260724130507319633,600 +223,898604A8012270312221,成功,200,0,success,30289,ORD20260724130508818149,600 +224,898604A8012270312222,成功,200,0,success,30290,ORD20260724130508037772,600 +225,898604A8012270312223,成功,200,0,success,30291,ORD20260724130509231592,600 +226,898604A8012270312224,成功,200,0,success,30292,ORD20260724130509637399,600 +227,898604A8012270312225,成功,200,0,success,30293,ORD20260724130509192432,600 +228,898604A8012270312226,成功,200,0,success,30294,ORD20260724130510165715,600 +229,898604A8012270312227,成功,200,0,success,30295,ORD20260724130510942353,600 +230,898604A8012270312228,成功,200,0,success,30296,ORD20260724130510658564,600 +231,898604A8012270312229,成功,200,0,success,30297,ORD20260724130511226502,600 +232,898604A8012270312230,成功,200,0,success,30298,ORD20260724130511659729,600 +233,898604A8012270312231,成功,200,0,success,30299,ORD20260724130512740485,600 +234,898604A8012270312232,成功,200,0,success,30300,ORD20260724130512937784,600 +235,898604A8012270312233,成功,200,0,success,30301,ORD20260724130513571843,600 +236,898604A8012270312234,成功,200,0,success,30302,ORD20260724130513954859,600 +237,898604A8012270312235,成功,200,0,success,30303,ORD20260724130513444782,600 +238,898604A8012270312236,成功,200,0,success,30304,ORD20260724130514239954,600 +239,898604A8012270312237,成功,200,0,success,30305,ORD20260724130514812808,600 +240,898604A8012270312238,成功,200,0,success,30306,ORD20260724130514322740,600 +241,898604A8012270312239,成功,200,0,success,30307,ORD20260724130515318301,600 +242,898604A8012270312240,成功,200,0,success,30308,ORD20260724130515115516,600 +243,898604A8012270312241,成功,200,0,success,30309,ORD20260724130516023553,600 +244,898604A8012270312242,成功,200,0,success,30310,ORD20260724130516857702,600 +245,898604A8012270312243,成功,200,0,success,30311,ORD20260724130516326906,600 +246,898604A8012270312244,成功,200,0,success,30312,ORD20260724130517860664,600 +247,898604A8012270312245,成功,200,0,success,30313,ORD20260724130517630459,600 +248,898604A8012270312246,成功,200,0,success,30314,ORD20260724130518772889,600 +249,898604A8012270312247,成功,200,0,success,30315,ORD20260724130518404662,600 +250,898604A8012270312248,成功,200,0,success,30316,ORD20260724130519398984,600 +251,898604A8012270312249,成功,200,0,success,30317,ORD20260724130519602164,600 +252,898604A8012270312250,成功,200,0,success,30318,ORD20260724130519679582,600 +253,898604A8012270312251,成功,200,0,success,30319,ORD20260724130520636441,600 +254,898604A8012270312252,成功,200,0,success,30320,ORD20260724130520503621,600 +255,898604A8012270312253,成功,200,0,success,30321,ORD20260724130520067978,600 +256,898604A8012270312254,成功,200,0,success,30322,ORD20260724130521284515,600 +257,898604A8012270312255,成功,200,0,success,30323,ORD20260724130521573976,600 +258,898604A8012270312256,成功,200,0,success,30324,ORD20260724130522649929,600 +259,898604A8012270312257,成功,200,0,success,30325,ORD20260724130522548933,600 +260,898604A8012270312258,成功,200,0,success,30326,ORD20260724130522047969,600 +261,898604A8012270312259,成功,200,0,success,30327,ORD20260724130523057061,600 +262,898604A8012270312260,成功,200,0,success,30328,ORD20260724130523224779,600 +263,898604A8012270312261,成功,200,0,success,30329,ORD20260724130524437102,600 +264,898604A8012270312262,成功,200,0,success,30330,ORD20260724130524748786,600 +265,898604A8012270312263,成功,200,0,success,30331,ORD20260724130524270498,600 +266,898604A8012270312264,成功,200,0,success,30332,ORD20260724130525003527,600 +267,898604A8012270312265,成功,200,0,success,30333,ORD20260724130525209785,600 +268,898604A8012270312266,成功,200,0,success,30334,ORD20260724130526819846,600 +269,898604A8012270312267,成功,200,0,success,30335,ORD20260724130526296210,600 +270,898604A8012270312268,成功,200,0,success,30336,ORD20260724130527821261,600 +271,898604A8012270312269,成功,200,0,success,30337,ORD20260724130527862263,600 +272,898604A8012270312270,成功,200,0,success,30338,ORD20260724130528908834,600 +273,898604A8012270312271,成功,200,0,success,30339,ORD20260724130528425508,600 +274,898604A8012270312272,成功,200,0,success,30340,ORD20260724130529223597,600 +275,898604A8012270312273,成功,200,0,success,30341,ORD20260724130529475260,600 +276,898604A8012270312274,成功,200,0,success,30342,ORD20260724130530016171,600 +277,898604A8012270312275,成功,200,0,success,30343,ORD20260724130530259478,600 +278,898604A8012270312276,成功,200,0,success,30344,ORD20260724130530354407,600 +279,898604A8012270312277,成功,200,0,success,30345,ORD20260724130531023321,600 +280,898604A8012270312278,成功,200,0,success,30346,ORD20260724130531979694,600 +281,898604A8012270312279,成功,200,0,success,30347,ORD20260724130531744827,600 +282,898604A8012270312280,成功,200,0,success,30348,ORD20260724130532383244,600 +283,898604A8012270312281,成功,200,0,success,30349,ORD20260724130532692448,600 +284,898604A8012270312282,成功,200,0,success,30350,ORD20260724130533023091,600 +285,898604A8012270312283,成功,200,0,success,30351,ORD20260724130533032218,600 +286,898604A8012270312284,成功,200,0,success,30352,ORD20260724130533073716,600 +287,898604A8012270312285,成功,200,0,success,30353,ORD20260724130534653491,600 +288,898604A8012270312286,成功,200,0,success,30354,ORD20260724130535576563,600 +289,898604A8012270312287,成功,200,0,success,30355,ORD20260724130536735721,600 +290,898604A8012270312288,成功,200,0,success,30356,ORD20260724130537434412,600 +291,898604A8012270312289,成功,200,0,success,30357,ORD20260724130538354682,600 +292,898604A8012270312290,成功,200,0,success,30358,ORD20260724130539875601,600 +293,898604A8012270312291,成功,200,0,success,30359,ORD20260724130539087961,600 +294,898604A8012270312292,成功,200,0,success,30360,ORD20260724130542599452,600 +295,898604A8012270312293,成功,200,0,success,30361,ORD20260724130542011398,600 +296,898604A8012270312294,成功,200,0,success,30362,ORD20260724130543307468,600 +297,898604A8012270312295,成功,200,0,success,30363,ORD20260724130543015310,600 +298,898604A8012270312296,成功,200,0,success,30364,ORD20260724130544849884,600 +299,898604A8012270312297,成功,200,0,success,30365,ORD20260724130544549373,600 +300,898604A8012270312298,成功,200,0,success,30366,ORD20260724130544599241,600 +301,898604A8012270312299,成功,200,0,success,30367,ORD20260724130545252361,600 +302,898604A8012270312300,成功,200,0,success,30368,ORD20260724130545114222,600 +303,898604A8012270312301,成功,200,0,success,30369,ORD20260724130545126839,600 +304,898604A8012270312302,成功,200,0,success,30370,ORD20260724130546186875,600 +305,898604A8012270312303,成功,200,0,success,30371,ORD20260724130546847566,600 +306,898604A8012270312304,成功,200,0,success,30372,ORD20260724130547543999,600 +307,898604A8012270312305,成功,200,0,success,30373,ORD20260724130547181884,600 +308,898604A8012270312306,成功,200,0,success,30374,ORD20260724130547147638,600 +309,898604A8012270312307,成功,200,0,success,30375,ORD20260724130548684347,600 +310,898604A8012270312308,成功,200,0,success,30376,ORD20260724130548798124,600 +311,898604A8012270312309,成功,200,0,success,30377,ORD20260724130549884681,600 +312,898604A8012270312310,成功,200,0,success,30378,ORD20260724130549692743,600 +313,898604A8012270312311,成功,200,0,success,30379,ORD20260724130549860560,600 +314,898604A8012270312312,成功,200,0,success,30380,ORD20260724130550380381,600 +315,898604A8012270312313,成功,200,0,success,30381,ORD20260724130550304616,600 +316,898604A8012270312314,成功,200,0,success,30382,ORD20260724130550504182,600 +317,898604A8012270312315,成功,200,0,success,30383,ORD20260724130551604949,600 +318,898604A8012270312316,成功,200,0,success,30384,ORD20260724130552747964,600 +319,898604A8012270312317,成功,200,0,success,30385,ORD20260724130552165692,600 +320,898604A8012270312318,成功,200,0,success,30386,ORD20260724130552514971,600 +321,898604A8012270312319,成功,200,0,success,30387,ORD20260724130553965542,600 +322,898604A8012270312320,成功,200,0,success,30388,ORD20260724130553552414,600 +323,898604A8012270312321,成功,200,0,success,30389,ORD20260724130553547339,600 +324,898604A8012270312322,成功,200,0,success,30390,ORD20260724130554508256,600 +325,898604A8012270312323,成功,200,0,success,30391,ORD20260724130554188530,600 +326,898604A8012270312324,成功,200,0,success,30392,ORD20260724130555560940,600 +327,898604A8012270312325,成功,200,0,success,30393,ORD20260724130555908872,600 +328,898604A8012270312326,成功,200,0,success,30394,ORD20260724130555622724,600 +329,898604A8012270312327,成功,200,0,success,30395,ORD20260724130556588663,600 +330,898604A8012270312328,成功,200,0,success,30396,ORD20260724130556268196,600 +331,898604A8012270312329,成功,200,0,success,30397,ORD20260724130556136449,600 +332,898604A8012270312330,成功,200,0,success,30398,ORD20260724130557328229,600 +333,898604A8012270312331,成功,200,0,success,30399,ORD20260724130557092644,600 +334,898604A8012270312332,成功,200,0,success,30400,ORD20260724130558118599,600 +335,898604A8012270312333,成功,200,0,success,30401,ORD20260724130559776369,600 +336,898604A8012270312334,成功,200,0,success,30402,ORD20260724130559235003,600 +337,898604A8012270312335,成功,200,0,success,30404,ORD20260724130600449072,600 +338,898604A8012270312336,成功,200,0,success,30405,ORD20260724130600189073,600 +339,898604A8012270312337,成功,200,0,success,30406,ORD20260724130601687215,600 +340,898604A8012270312338,成功,200,0,success,30407,ORD20260724130601284801,600 +341,898604A8012270312339,成功,200,0,success,30408,ORD20260724130601284546,600 +342,898604A8012270312340,成功,200,0,success,30409,ORD20260724130602702212,600 +343,898604A8012270312341,成功,200,0,success,30412,ORD20260724130603954573,600 +344,898604A8012270312342,成功,200,0,success,30413,ORD20260724130604274949,600 +345,898604A8012270312343,成功,200,0,success,30414,ORD20260724130604193982,600 +346,898604A8012270312344,成功,200,0,success,30415,ORD20260724130605196962,600 +347,898604A8012270312345,成功,200,0,success,30416,ORD20260724130605021598,600 +348,898604A8012270312346,成功,200,0,success,30419,ORD20260724130606466709,600 +349,898604A8012270312347,成功,200,0,success,30420,ORD20260724130606968654,600 +350,898604A8012270312348,成功,200,0,success,30421,ORD20260724130606354884,600 +351,898604A8012270312349,成功,200,0,success,30422,ORD20260724130607477923,600 +352,898604A8012270312350,成功,200,0,success,30423,ORD20260724130607502632,600 +353,898604A8012270312351,成功,200,0,success,30424,ORD20260724130607917453,600 +354,898604A8012270312352,成功,200,0,success,30425,ORD20260724130608838129,600 +355,898604A8012270312353,成功,200,0,success,30427,ORD20260724130609126378,600 +356,898604A8012270312354,成功,200,0,success,30428,ORD20260724130609878588,600 +357,898604A8012270312355,成功,200,0,success,30429,ORD20260724130610617592,600 +358,898604A8012270312356,成功,200,0,success,30430,ORD20260724130610027427,600 +359,898604A8012270312357,成功,200,0,success,30431,ORD20260724130610132288,600 +360,898604A8012270312358,成功,200,0,success,30432,ORD20260724130611860940,600 +361,898604A8012270312359,成功,200,0,success,30433,ORD20260724130611176929,600 +362,898604A8012270312360,成功,200,0,success,30434,ORD20260724130612700143,600 +363,898604A8012270312361,成功,200,0,success,30435,ORD20260724130612297773,600 +364,898604A8012270312362,成功,200,0,success,30436,ORD20260724130613642272,600 +365,898604A8012270312363,成功,200,0,success,30437,ORD20260724130613475739,600 +366,898604A8012270312364,成功,200,0,success,30438,ORD20260724130614283321,600 +367,898604A8012270312365,成功,200,0,success,30439,ORD20260724130615138577,600 +368,898604A8012270312366,成功,200,0,success,30440,ORD20260724130616791398,600 +369,898604A8012270312367,成功,200,0,success,30441,ORD20260724130617039796,600 +370,898604A8012270312368,成功,200,0,success,30442,ORD20260724130617286872,600 +371,898604A8012270312369,成功,200,0,success,30443,ORD20260724130618460465,600 +372,898604A8012270312370,成功,200,0,success,30444,ORD20260724130618076180,600 +373,898604A8012270312371,成功,200,0,success,30445,ORD20260724130618849697,600 +374,898604A8012270312372,成功,200,0,success,30446,ORD20260724130619146913,600 +375,898604A8012270312373,成功,200,0,success,30447,ORD20260724130619044088,600 +376,898604A8012270312374,成功,200,0,success,30448,ORD20260724130620167521,600 +377,898604A8012270312375,成功,200,0,success,30449,ORD20260724130620015045,600 +378,898604A8012270312376,成功,200,0,success,30450,ORD20260724130620102025,600 +379,898604A8012270312377,成功,200,0,success,30451,ORD20260724130621970762,600 +380,898604A8012270312378,成功,200,0,success,30452,ORD20260724130621700113,600 +381,898604A8012270312379,成功,200,0,success,30453,ORD20260724130622338568,600 +382,898604A8012270312380,成功,200,0,success,30454,ORD20260724130622552110,600 +383,898604A8012270312381,成功,200,0,success,30455,ORD20260724130623407717,600 +384,898604A8012270312382,成功,200,0,success,30456,ORD20260724130623014561,600 +385,898604A8012270312383,成功,200,0,success,30457,ORD20260724130624249647,600 +386,898604A8012270312384,成功,200,0,success,30458,ORD20260724130624173547,600 +387,898604A8012270312385,成功,200,0,success,30459,ORD20260724130624962189,600 +388,898604A8012270312386,成功,200,0,success,30460,ORD20260724130625915135,600 +389,898604A8012270312387,成功,200,0,success,30461,ORD20260724130625716892,600 +390,898604A8012270312388,成功,200,0,success,30462,ORD20260724130626149376,600 +391,898604A8012270312389,成功,200,0,success,30463,ORD20260724130626458887,600 +392,898604A8012270312390,成功,200,0,success,30464,ORD20260724130626355145,600 +393,898604A8012270312391,成功,200,0,success,30465,ORD20260724130627545760,600 +394,898604A8012270312392,成功,200,0,success,30466,ORD20260724130627134629,600 +395,898604A8012270312393,成功,200,0,success,30467,ORD20260724130628219843,600 +396,898604A8012270312394,成功,200,0,success,30468,ORD20260724130628007661,600 +397,898604A8012270312395,成功,200,0,success,30469,ORD20260724130629026415,600 +398,898604A8012270312396,成功,200,0,success,30470,ORD20260724130629006524,600 +399,898604A8012270312397,成功,200,0,success,30471,ORD20260724130629937436,600 +400,898604A8012270312398,成功,200,0,success,30472,ORD20260724130630123235,600 +401,898604A8012270312399,成功,200,0,success,30473,ORD20260724130630572317,600 +402,898604A8012270312400,成功,200,0,success,30474,ORD20260724130630910602,600 +403,898604A8012270312401,成功,200,0,success,30475,ORD20260724130631288217,600 +404,898604A8012270312402,成功,200,0,success,30476,ORD20260724130631064556,600 +405,898604A8012270312403,成功,200,0,success,30477,ORD20260724130632236245,600 +406,898604A8012270312404,成功,200,0,success,30478,ORD20260724130632702828,600 +407,898604A8012270312405,成功,200,0,success,30479,ORD20260724130633126769,600 +408,898604A8012270312406,成功,200,0,success,30480,ORD20260724130633448473,600 +409,898604A8012270312407,成功,200,0,success,30481,ORD20260724130633287084,600 +410,898604A8012270312408,成功,200,0,success,30482,ORD20260724130634138774,600 +411,898604A8012270312409,成功,200,0,success,30483,ORD20260724130634763722,600 +412,898604A8012270312410,成功,200,0,success,30484,ORD20260724130635337945,600 +413,898604A8012270312411,成功,200,0,success,30485,ORD20260724130635739734,600 +414,898604A8012270312412,成功,200,0,success,30486,ORD20260724130635944553,600 +415,898604A8012270312413,成功,200,0,success,30487,ORD20260724130636890849,600 +416,898604A8012270312414,成功,200,0,success,30488,ORD20260724130636412153,600 +417,898604A8012270312415,成功,200,0,success,30489,ORD20260724130637424282,600 +418,898604A8012270312416,成功,200,0,success,30490,ORD20260724130637646977,600 +419,898604A8012270312417,成功,200,0,success,30491,ORD20260724130637180940,600 +420,898604A8012270312418,成功,200,0,success,30492,ORD20260724130638003864,600 +421,898604A8012270312419,成功,200,0,success,30493,ORD20260724130638352638,600 +422,898604A8012270312420,成功,200,0,success,30494,ORD20260724130639612733,600 +423,898604A8012270312421,成功,200,0,success,30495,ORD20260724130639817975,600 +424,898604A8012270312422,成功,200,0,success,30496,ORD20260724130640114122,600 +425,898604A8012270312423,成功,200,0,success,30497,ORD20260724130640062038,600 +426,898604A8012270312424,成功,200,0,success,30498,ORD20260724130640538816,600 +427,898604A8012270312425,成功,200,0,success,30499,ORD20260724130641392459,600 +428,898604A8012270312426,成功,200,0,success,30500,ORD20260724130641993192,600 +429,898604A8012270312427,成功,200,0,success,30501,ORD20260724130642628107,600 +430,898604A8012270312428,成功,200,0,success,30502,ORD20260724130642145898,600 +431,898604A8012270312429,成功,200,0,success,30503,ORD20260724130642678657,600 +432,898604A8012270312430,成功,200,0,success,30504,ORD20260724130643223183,600 +433,898604A8012270312431,成功,200,0,success,30505,ORD20260724130644675384,600 +434,898604A8012270312432,成功,200,0,success,30506,ORD20260724130645994278,600 +435,898604A8012270312433,成功,200,0,success,30507,ORD20260724130645627360,600 +436,898604A8012270312434,成功,200,0,success,30508,ORD20260724130646667388,600 +437,898604A8012270312435,成功,200,0,success,30509,ORD20260724130646296910,600 +438,898604A8012270312436,成功,200,0,success,30510,ORD20260724130647660429,600 +439,898604A8012270312437,成功,200,0,success,30511,ORD20260724130647380697,600 +440,898604A8012270312438,成功,200,0,success,30512,ORD20260724130647401458,600 +441,898604A8012270312439,成功,200,0,success,30513,ORD20260724130648243263,600 +442,898604A8012270312440,成功,200,0,success,30514,ORD20260724130648741168,600 +443,898604A8012270312441,成功,200,0,success,30515,ORD20260724130649528545,600 +444,898604A8012270312442,成功,200,0,success,30516,ORD20260724130649180520,600 +445,898604A8012270312443,成功,200,0,success,30517,ORD20260724130649711183,600 +446,898604A8012270312444,成功,200,0,success,30518,ORD20260724130650930396,600 +447,898604A8012270312445,成功,200,0,success,30519,ORD20260724130650078491,600 +448,898604A8012270312446,成功,200,0,success,30520,ORD20260724130651560835,600 +449,898604A8012270312447,成功,200,0,success,30521,ORD20260724130652416251,600 +450,898604A8012270312448,成功,200,0,success,30522,ORD20260724130653836455,600 +451,898604A8012270312449,成功,200,0,success,30523,ORD20260724130654484205,600 +452,898604A8012270312450,成功,200,0,success,30524,ORD20260724130654193355,600 +453,898604A8012270312451,成功,200,0,success,30525,ORD20260724130655672983,600 +454,898604A8012270312452,成功,200,0,success,30526,ORD20260724130655297092,600 +455,898604A8012270312453,成功,200,0,success,30527,ORD20260724130655657688,600 +456,898604A8012270312454,成功,200,0,success,30528,ORD20260724130656979277,600 +457,898604A8012270312455,成功,200,0,success,30529,ORD20260724130656600214,600 +458,898604A8012270312456,成功,200,0,success,30530,ORD20260724130657430401,600 +459,898604A8012270312457,成功,200,0,success,30531,ORD20260724130657960549,600 +460,898604A8012270312458,成功,200,0,success,30532,ORD20260724130658071113,600 +461,898604A8012270312459,成功,200,0,success,30533,ORD20260724130658645070,600 +462,898604A8012270312460,成功,200,0,success,30534,ORD20260724130659267835,600 +463,898604A8012270312461,成功,200,0,success,30535,ORD20260724130659875786,600 +464,898604A8012270312462,成功,200,0,success,30536,ORD20260724130659048365,600 +465,898604A8012270312463,成功,200,0,success,30537,ORD20260724130700086056,600 +466,898604A8012270312464,成功,200,0,success,30538,ORD20260724130700807298,600 +467,898604A8012270312465,成功,200,0,success,30539,ORD20260724130701879900,600 +468,898604A8012270312466,成功,200,0,success,30540,ORD20260724130701399152,600 +469,898604A8012270312467,成功,200,0,success,30541,ORD20260724130702024347,600 +470,898604A8012270312468,成功,200,0,success,30542,ORD20260724130702850184,600 +471,898604A8012270312469,成功,200,0,success,30543,ORD20260724130703793357,600 +472,898604A8012270312470,成功,200,0,success,30544,ORD20260724130703235524,600 +473,898604A8012270312471,成功,200,0,success,30545,ORD20260724130704670599,600 +474,898604A8012270312472,成功,200,0,success,30546,ORD20260724130704801676,600 +475,898604A8012270312473,成功,200,0,success,30547,ORD20260724130705744051,600 +476,898604A8012270312474,成功,200,0,success,30548,ORD20260724130705506053,600 +477,898604A8012270312475,成功,200,0,success,30549,ORD20260724130705312832,600 +478,898604A8012270312476,成功,200,0,success,30550,ORD20260724130706318085,600 +479,898604A8012270312477,成功,200,0,success,30551,ORD20260724130706277432,600 +480,898604A8012270312478,成功,200,0,success,30552,ORD20260724130707168926,600 +481,898604A8012270312479,成功,200,0,success,30553,ORD20260724130707855317,600 +482,898604A8012270312480,成功,200,0,success,30554,ORD20260724130707539891,600 +483,898604A8012270312481,成功,200,0,success,30555,ORD20260724130708369898,600 +484,898604A8012270312482,成功,200,0,success,30556,ORD20260724130708728689,600 +485,898604A8012270312483,成功,200,0,success,30557,ORD20260724130709172733,600 +486,898604A8012270312484,成功,200,0,success,30558,ORD20260724130709567152,600 +487,898604A8012270312485,成功,200,0,success,30559,ORD20260724130710737281,600 +488,898604A8012270312486,成功,200,0,success,30560,ORD20260724130710624162,600 +489,898604A8012270312487,成功,200,0,success,30561,ORD20260724130710294661,600 +490,898604A8012270312488,成功,200,0,success,30562,ORD20260724130711030822,600 +491,898604A8012270312489,成功,200,0,success,30563,ORD20260724130711516359,600 +492,898604A8012270312490,成功,200,0,success,30564,ORD20260724130712018158,600 +493,898604A8012270312491,成功,200,0,success,30565,ORD20260724130712603443,600 +494,898604A8012270312492,成功,200,0,success,30566,ORD20260724130712899537,600 +495,898604A8012270312493,成功,200,0,success,30567,ORD20260724130713738502,600 +496,898604A8012270312494,成功,200,0,success,30568,ORD20260724130714816423,600 +497,898604A8012270312495,成功,200,0,success,30569,ORD20260724130714784785,600 +498,898604A8012270312496,成功,200,0,success,30570,ORD20260724130714174852,600 +499,898604A8012270312497,成功,200,0,success,30571,ORD20260724130715324337,600 +500,898604A8012270312498,成功,200,0,success,30572,ORD20260724130715949072,600 +501,898604A8012270312499,成功,200,0,success,30573,ORD20260724130716940749,600 +502,898604A8012270312500,成功,200,0,success,30574,ORD20260724130716330921,600 +503,898604A8012270312501,成功,200,0,success,30575,ORD20260724130717900299,600 +504,898604A8012270312502,成功,200,0,success,30576,ORD20260724130717062046,600 +505,898604A8012270312503,成功,200,0,success,30577,ORD20260724130718305769,600 +506,898604A8012270312504,成功,200,0,success,30578,ORD20260724130718609334,600 +507,898604A8012270312505,成功,200,0,success,30579,ORD20260724130719949613,600 +508,898604A8012270312506,成功,200,0,success,30580,ORD20260724130719464163,600 +509,898604A8012270312507,成功,200,0,success,30581,ORD20260724130719531979,600 +510,898604A8012270312508,成功,200,0,success,30582,ORD20260724130720197377,600 +511,898604A8012270312509,成功,200,0,success,30583,ORD20260724130721845273,600 +512,898604A8012270312510,成功,200,0,success,30584,ORD20260724130722001071,600 +513,898604A8012270312511,成功,200,0,success,30585,ORD20260724130722753143,600 +514,898604A8012270312512,成功,200,0,success,30586,ORD20260724130723428672,600 +515,898604A8012270312513,成功,200,0,success,30587,ORD20260724130724070338,600 +516,898604A8012270312514,成功,200,0,success,30588,ORD20260724130724247068,600 +517,898604A8012270312515,成功,200,0,success,30589,ORD20260724130725069597,600 +518,898604A8012270312516,成功,200,0,success,30590,ORD20260724130725091525,600 +519,898604A8012270312517,成功,200,0,success,30591,ORD20260724130726982833,600 +520,898604A8012270312518,成功,200,0,success,30592,ORD20260724130727932536,600 +521,898604A8012270312519,成功,200,0,success,30593,ORD20260724130728137514,600 +522,898604A8012270312520,成功,200,0,success,30594,ORD20260724130728488863,600 +523,898604A8012270312521,成功,200,0,success,30595,ORD20260724130730940939,600 +524,898604A8012270312522,成功,200,0,success,30596,ORD20260724130730157237,600 +525,898604A8012270312523,成功,200,0,success,30597,ORD20260724130731175875,600 +526,898604A8012270312524,成功,200,0,success,30598,ORD20260724130731639030,600 +527,898604A8012270312525,成功,200,0,success,30599,ORD20260724130732992255,600 +528,898604A8012270312526,成功,200,0,success,30600,ORD20260724130732448584,600 +529,898604A8012270312527,成功,200,0,success,30601,ORD20260724130733384984,600 +530,898604A8012270312528,成功,200,0,success,30602,ORD20260724130733071669,600 +531,898604A8012270312529,成功,200,0,success,30603,ORD20260724130734501184,600 +532,898604A8012270312530,成功,200,0,success,30604,ORD20260724130734432170,600 +533,898604A8012270312531,成功,200,0,success,30605,ORD20260724130734659813,600 +534,898604A8012270312532,成功,200,0,success,30606,ORD20260724130735838523,600 +535,898604A8012270312533,成功,200,0,success,30607,ORD20260724130735858896,600 +536,898604A8012270312534,成功,200,0,success,30608,ORD20260724130736773575,600 +537,898604A8012270312535,成功,200,0,success,30609,ORD20260724130736739812,600 +538,898604A8012270312536,成功,200,0,success,30610,ORD20260724130736064418,600 +539,898604A8012270312537,成功,200,0,success,30611,ORD20260724130737824291,600 +540,898604A8012270312538,成功,200,0,success,30612,ORD20260724130738453981,600 +541,898604A8012270312539,成功,200,0,success,30613,ORD20260724130738598106,600 +542,898604A8012270312540,成功,200,0,success,30614,ORD20260724130739641512,600 +543,898604A8012270312541,成功,200,0,success,30615,ORD20260724130739954853,600 +544,898604A8012270312542,成功,200,0,success,30616,ORD20260724130740407349,600 +545,898604A8012270312543,成功,200,0,success,30617,ORD20260724130740304994,600 +546,898604A8012270312544,成功,200,0,success,30618,ORD20260724130741800175,600 +547,898604A8012270312545,成功,200,0,success,30619,ORD20260724130741506967,600 +548,898604A8012270312546,成功,200,0,success,30620,ORD20260724130742368330,600 +549,898604A8012270312547,成功,200,0,success,30621,ORD20260724130742913230,600 +550,898604A8012270312548,成功,200,0,success,30622,ORD20260724130743093125,600 +551,898604A8012270312549,成功,200,0,success,30623,ORD20260724130744387843,600