feat: 代理商资金可见性重构(agent-fund-visibility)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m10s

- 将 GET /shops/commission-summary 重命名为 GET /shops/fund-summary,
  响应新增 main_balance、main_frozen_balance 两个预充值钱包字段
- 新增 GET /shops/:id/main-wallet/transactions 预充值钱包流水接口
- 将佣金统计、每日统计、发起提现从 /my/ 路径迁移至 /shops/:id/ 路径:
  GET /shops/:id/commission-stats
  GET /shops/:id/commission-daily-stats
  POST /shops/:id/withdrawal-requests
- 删除 MyCommissionService、MyCommissionHandler 及全部 /my/ 路由
- 补齐 ListShopWithdrawalRequests、ListShopCommissionRecords 的
  CanManageShop 越权校验(安全修复)
- 提现接口增加严格权限:仅代理账号本人可为自己店铺发起提现

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 14:40:39 +08:00
parent 1d6535b81b
commit 0627ffec42
25 changed files with 1806 additions and 1426 deletions

View File

@@ -0,0 +1,188 @@
## Context
### 现状
系统中代理钱包有两种类型(`tb_agent_wallet`
- `wallet_type=main`:预充值钱包,代理购买套餐时扣款
- `wallet_type=commission`:佣金钱包,订单完成后自动入账、可提现
当前 `/shops/commission-summary` 仅聚合佣金钱包数据,主钱包完全不可见。代理自己的操作路径依赖 `/my/` 前缀系列接口,与平台视角的 `/shops/:id/` 系列存在职责重叠,产生维护成本。
### 已有可复用能力
| 组件 | 方法 | 状态 |
|------|------|------|
| `AgentWalletStore` | `GetMainWallet(shopID)` | 已有,单条查询 |
| `AgentWalletStore` | `GetShopCommissionSummaryBatch(shopIDs)` | 已有,批量查佣金钱包 |
| `AgentWalletTransactionStore` | `ListByShopID / CountByShopID` | 已有,直接复用 |
| `ShopCommissionService` | `ListShopCommissionSummary` | 已有,需扩展 |
| `ShopCommissionService` | `ListShopCommissionRecords` | 已有,不动 |
| `ShopCommissionService` | `ListShopWithdrawalRequests` | 已有,不动 |
| `MyCommissionService` | `GetStats / GetDailyStats / CreateWithdrawalRequest` | 业务逻辑迁移到 ShopCommissionService原 service 删除 |
---
## Goals / Non-Goals
**Goals:**
- 平台人员在一个列表(资金概况)中同时看到每个代理的预充值余额和佣金余额
- 代理账号通过相同接口看到自己的资金概况GORM 多租户过滤)
- 提供预充值钱包流水接口,支持平台和代理两个视角
-`/my/` 6 个接口的业务逻辑完整迁移至 `/shops/:id/`,删除冗余路径
**Non-Goals:**
- 不新增纯佣金钱包流水接口(`commission-records` 订单维度已满足需求)
- 不改动充值订单相关接口(`/agent-recharges` 系列不变)
- 不改动提现审批流程(`/commission/withdrawal-*` 不变)
- 不做向后兼容,直接删除 `/my/` 路由
---
## Decisions
### 决策 1资金概况使用批量查询主钱包余额
**问题**`/shops/fund-summary` 分页返回多条记录,每条需要主钱包余额。若逐条查询会产生 N+1。
**决策**:在 `AgentWalletStore` 新增 `GetShopMainWalletBatch(ctx, shopIDs) map[uint]*AgentWallet`,与现有 `GetShopCommissionSummaryBatch` 对称,一次 `WHERE shop_id IN (...)` 查完,在 Service 层合并。
**备选**:单次 SQL JOIN 查两个钱包类型 — 复杂度高,破坏 Store 层单一职责,放弃。
---
### 决策 2主钱包流水接口不做独立 Handler归入 ShopCommissionHandler
**问题**:主钱包流水是代理资金详情的一部分,是否需要独立 Handler。
**决策**:归入扩展后的 `ShopCommissionHandler`(或重命名为 `ShopFundHandler`),保持路由注册集中,避免 Handler 碎片化。
**注意**`AgentWalletTransactionStore.ListByShopID` 已有,只需 Service 层加一个 `ListMainWalletTransactions(ctx, shopID, req)` 方法。
---
### 决策 3`/my/` 路由的业务逻辑迁移方式
**问题**`MyCommissionService` 中的 `GetStats``GetDailyStats``CreateWithdrawalRequest` 当前从 ctx 自动读取 shopID隐式即"自己"),迁移后接口路径从 `:shop_id` 取,必须显式越权校验。
**决策**
- 将三个方法迁移到 `ShopCommissionService`,签名改为接受 `shopID uint` 参数
- **Service 层入口**强制权限校验(不是 Handler 层),对齐 CLAUDE.md 的 Code Review 规范(参考 `internal/service/account/service.go`
- Handler 只做参数解析,不写权限逻辑
- 删除 `MyCommissionService``MyCommissionHandler`
**两档校验策略**(根据业务严格度区分):
| 方法 | 校验策略 | 理由 |
|---|---|---|
| `GetStats` / `GetDailyStats` | `middleware.CanManageShop(ctx, shopID)` | 查询类,平台和顶级代理可看下级数据 |
| `ListMainWalletTransactions` | `middleware.CanManageShop(ctx, shopID)` | 查询类,同上 |
| `ListShopWithdrawalRequests` / `ListShopCommissionRecords`(已有方法补齐) | `middleware.CanManageShop(ctx, shopID)` | 查询类,同上 |
| `CreateWithdrawalRequest` | **更严**`userType==Agent``shopID==GetShopIDFromContext(ctx)` | 写操作,业务规定提现必须本人发起,平台和顶级代理均不允许代办 |
**越权校验覆盖范围**:不仅新增/迁移的方法要加,**已有的 `ListShopWithdrawalRequests` / `ListShopCommissionRecords` 也必须补齐** —— 现状它们只做了 `shopStore.GetByID` 的存在性检查,没有 `CanManageShop``/my/commission-records``/my/withdrawal-requests` 废弃后代理只要猜 shopID 就可读他人数据。是本变更必须修复的回归入口。
---
### 决策 4Handler 不重命名,路由集中在 registerShopCommissionRoutes
`ShopCommissionHandler` 职责扩大(涵盖资金概况、主钱包流水、提现发起、佣金统计),保持原名 `ShopCommissionHandler` 不重命名,避免大范围文件改动。路由全部集中在扩展后的 `registerShopCommissionRoutes` 中,不再新建 `registerShopFundSummaryRoutes`,避免一个模块两处注册入口。
---
### 决策 5`main_frozen_balance` 字段保留但标注预留
**现状**`tb_agent_wallet.frozen_balance` 是通用字段但主钱包当前业务无冻结场景DB 验证 10 条主钱包记录 `frozen_balance` 全为 0
**决策**`ShopFundSummaryItem` 保留 `main_frozen_balance`,作为未来扩展(如主钱包预授权/待扣款场景预留位。Handler 直接返回 `AgentWallet.FrozenBalance`,无额外逻辑。前端展示可暂时不渲染,等业务触发时再暴露。
**备选**:不返回此字段 —— 将来接入时要改 DTO + 前端 + 文档,破坏性更大,放弃。
---
## 分层变更总览
```
Handler 层
ShopCommissionHandler 新增方法: ListFundSummary, ListMainWalletTransactions,
GetCommissionStats, GetCommissionDailyStats,
CreateWithdrawal
删除方法: ListCommissionSummary被 ListFundSummary 替代)
MyCommissionHandler 整体删除
Service 层
ShopCommissionService 构造函数补依赖: commissionWithdrawalSettingStore,
agentWalletTransactionStore
改造方法: ListShopCommissionSummary → ListShopFundSummary
(加批量主钱包查询)
新增方法: ListMainWalletTransactions, GetStats,
GetDailyStats, CreateWithdrawalRequest
(均在入口调 middleware.CanManageShop
补权限校验: ListShopWithdrawalRequests,
ListShopCommissionRecords
(现状无越权校验,必须补齐)
MyCommissionService 整体删除
Store 层
AgentWalletStore 新增: GetShopMainWalletBatch(ctx, shopIDs)
AgentWalletTransactionStore 新增: ListByWalletIDWithFilters, CountByWalletID
删除: ListByShopID, CountByShopID
(全局无调用者,且会跨钱包类型返回
数据,留着易被误用)
DTO 层
ShopCommissionSummaryItem → ShopFundSummaryItem新增 main_balance, main_frozen_balance
ShopCommissionSummaryListReq / PageResult → ShopFundSummaryListReq / PageResult
新增: MainWalletTransactionItem, MainWalletTransactionListRequest/Response
删除: MyCommissionSummaryResp, MyWithdrawalListReq, MyCommissionRecordListReq 等
路由层
registerMyCommissionRoutes 删除
registerShopCommissionRoutes 扩展:
- GET /commission-summary → GET /fund-summary
- 新增 GET /:shop_id/main-wallet/transactions
- 新增 GET /:shop_id/commission-stats
- 新增 GET /:shop_id/commission-daily-stats
- 新增 POST /:shop_id/withdrawal-requests
```
---
## Risks / Trade-offs
| 风险 | 缓解措施 |
|------|---------|
| 批量查主钱包余额新增一次 DB 查询,影响 fund-summary 接口性能 | 数据量有限(代理数量通常 < 1000IN 查询 < 5ms如后期扩大可加 Redis 缓存 |
| 删除 `/my/` 路由是破坏性变更,前端需同步修改 | 开发阶段,不考虑向后兼容;前端在本次变更后同步切换 |
| `MyCommissionService` 删除后,若有其他地方引用会编译报错 | tasks 中包含全局 grep 检查,确保删干净 |
| commission-stats/daily-stats 迁移后shopID 改为显式参数,逻辑路径变化 | 单独验证两个统计接口的查询结果与原 /my/ 接口一致 |
| **越权回归漏洞**`/my/commission-records` / `/my/withdrawal-requests` 废弃后代理改走 `/shops/:shop_id/...`,而已有的 `ListShopCommissionRecords` / `ListShopWithdrawalRequests` 没做 `CanManageShop` 校验,代理只要猜到他人 shopID 就能读到敏感数据 | task 3.6 强制在这两个已有方法的入口补 `CanManageShop`task 10.5 手动验证 403 |
| `CreateWithdrawalRequest` 若用 `CanManageShop` 会允许顶级代理替下级店铺提现 | 已确认产品不允许:`CreateWithdrawalRequest` 不使用 `CanManageShop`,改为更严格的双重校验(`userType==Agent``shopID==自己`),平台人员也禁止调用,与原 `/my/withdrawal-requests` 行为一致 |
| 删除 `AgentWalletTransactionStore.ListByShopID/CountByShopID` 可能导致外部/历史代码编译失败 | task 2.3 在删除前用 grep 确认无其他调用者(已在审查阶段验证过) |
---
## Migration Plan
1. DTO 层:重命名 `ShopCommissionSummaryItem → ShopFundSummaryItem` 并新增字段、新增主钱包流水 DTO
2. Store 层:
- `AgentWalletStore` 新增 `GetShopMainWalletBatch`
- `AgentWalletTransactionStore` 新增 `ListByWalletIDWithFilters` / `CountByWalletID`,删除 `ListByShopID` / `CountByShopID`
3. Service 层:
- 补构造函数依赖
- 改造 `ListShopFundSummary`
- 迁移三个 `/my/` 方法(入口强制 `CanManageShop`
- 新增 `ListMainWalletTransactions`(入口强制 `CanManageShop`
- 给已有的 `ListShopWithdrawalRequests` / `ListShopCommissionRecords``CanManageShop`
4. Handler 层:`ShopCommissionHandler` 新增方法、删除 `ListCommissionSummary`
5. 路由层:扩展 `registerShopCommissionRoutes`、删除 `registerMyCommissionRoutes`
6. Bootstrap删除 `MyCommissionHandler`/`myCommissionService`,更新 `shopCommissionService` 初始化
7. 文档生成器:`cmd/api/docs.go``cmd/gendocs/main.go`
8. 删除 `internal/handler/admin/my_commission.go``internal/service/my_commission/``internal/routes/my_commission.go`
9. `go build ./...` 全量编译 + grep 确认无残留引用
10. PostgreSQL MCP 手动验证
无数据库迁移,无需 rollback 策略。
## Open Questions
无,所有设计决策已在探索阶段与需求方确认。