This commit is contained in:
2026-04-09 14:53:48 +08:00
parent 0627ffec42
commit ff7e749bf2
12 changed files with 182 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-09

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
无,所有设计决策已在探索阶段与需求方确认。

View File

@@ -0,0 +1,156 @@
## Why
目前平台人员和代理账号均无法看到代理的预充值钱包(主钱包)余额与流水,佣金相关接口散落在 `/my/` 前缀下且与 `/shops/:id/` 路径存在职责重叠,亟需统一入口、补全预充值钱包可见性。
## What Changes
- **BREAKING** `GET /shops/commission-summary` 重命名为 `GET /shops/fund-summary`,响应 DTO 由 `ShopCommissionSummaryItem` 改为 `ShopFundSummaryItem`,新增 `main_balance``main_frozen_balance` 两个字段
- **新增** `GET /shops/:id/main-wallet/transactions` — 预充值钱包流水列表(全新功能)
- **新增** `GET /shops/:id/commission-stats` — 佣金统计,迁移自 `/my/commission-stats`
- **新增** `GET /shops/:id/commission-daily-stats` — 每日佣金统计,迁移自 `/my/commission-daily-stats`
- **新增** `POST /shops/:id/withdrawal-requests` — 发起提现申请,迁移自 `POST /my/withdrawal-requests`
- **BREAKING 废弃** `/my/` 全部 6 个路由(详见废弃对照表)
### 废弃路由对照表
| 废弃路由 | 作用 | 替代路由 | 状态 |
|---------|------|---------|------|
| `GET /my/commission-summary` | 代理看自己的佣金钱包余额概览 | `GET /shops/fund-summary` | 本次改造 |
| `GET /my/withdrawal-requests` | 代理看自己的提现申请记录 | `GET /shops/:id/withdrawal-requests` | 已有,直接复用 |
| `GET /my/commission-records` | 代理看自己的每笔佣金入账明细(订单维度) | `GET /shops/:id/commission-records` | 已有,直接复用 |
| `GET /my/commission-stats` | 代理看自己的佣金汇总统计(按时间段) | `GET /shops/:id/commission-stats` | 本次新增 |
| `GET /my/commission-daily-stats` | 代理看自己的每日佣金金额趋势 | `GET /shops/:id/commission-daily-stats` | 本次新增 |
| `POST /my/withdrawal-requests` | 代理发起提现申请 | `POST /shops/:id/withdrawal-requests` | 本次新增 |
### API 全景(变更前 vs 变更后)
```
变更前 变更后
─────────────────────────────────────────────────────────────────────────
GET /shops/commission-summary → GET /shops/fund-summary ★ 改造+扩展
GET /shops/:id/withdrawal-requests GET /shops/:id/withdrawal-requests
GET /shops/:id/commission-records GET /shops/:id/commission-records
→ GET /shops/:id/commission-stats ★ 新增
→ GET /shops/:id/commission-daily-stats ★ 新增
→ POST /shops/:id/withdrawal-requests ★ 新增
→ GET /shops/:id/main-wallet/transactions ★ 新增
GET /my/commission-summary × 废弃
GET /my/withdrawal-requests × 废弃
GET /my/commission-records × 废弃
GET /my/commission-stats × 废弃
GET /my/commission-daily-stats × 废弃
POST /my/withdrawal-requests × 废弃
GET /agent-recharges GET /agent-recharges 不变
GET /agent-recharges/:id GET /agent-recharges/:id 不变
POST /agent-recharges POST /agent-recharges 不变
POST /agent-recharges/:id/offline-pay POST /agent-recharges/:id/offline-pay 不变
```
### 两个视角的完整操作流
```
┌──────────────────────────────────────────────────────────────────┐
│ 平台人员 │
├──────────────────────────────────────────────────────────────────┤
│ [代理商资金概况列表] │
│ GET /shops/fund-summary │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 店铺名 │ 预充值余额 │ 可提现佣金 │ 冻结佣金 │ 提现中 │ │
│ │ 代理A │ ¥2,000 │ ¥500 │ ¥100 │ ¥200 │ │
│ │ 代理B │ ¥500 │ ¥200 │ ¥0 │ ¥0 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ 进入某代理详情 │
│ ▼ │
│ [代理详情 — 分 tab 查看] │
│ ├─ 预充值流水 GET /shops/:id/main-wallet/transactions │
│ ├─ 佣金明细 GET /shops/:id/commission-records │
│ ├─ 佣金统计 GET /shops/:id/commission-stats │
│ ├─ 每日统计 GET /shops/:id/commission-daily-stats │
│ └─ 提现记录 GET /shops/:id/withdrawal-requests │
│ │
│ [充值管理] │
│ GET /agent-recharges 查看所有充值订单(含状态) │
│ POST /agent-recharges/:id/offline-pay 确认线下到账 │
│ │
│ [提现审批] │
│ GET /commission/withdrawal-requests 审批提现申请 │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ 代理账号(同接口,按数据权限过滤) │
├──────────────────────────────────────────────────────────────────┤
│ [我的资金概况(返回自己 + 所有下级代理店铺)] │
│ GET /shops/fund-summary │
│ ┌──────────────────────────────────────────────────┐ │
│ │ 预充值余额: ¥2,000 │ 可提现佣金: ¥500 │ │
│ │ 冻结余额: ¥100 │ 提现中: ¥200 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ [预充值钱包流水] │
│ GET /shops/自己ID/main-wallet/transactions │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 时间 │ 类型 │ 金额 │ 变动后余额 │ │
│ │ 04-08 10:00 │ 充值入账 │ +¥500 │ ¥2,000 │ │
│ │ 04-07 15:30 │ 套餐扣款 │ -¥100 │ ¥1,500 │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [佣金相关] │
│ GET /shops/自己ID/commission-records 佣金明细 │
│ GET /shops/自己ID/commission-stats 佣金统计 │
│ GET /shops/自己ID/withdrawal-requests 提现记录 │
│ POST /shops/自己ID/withdrawal-requests 发起提现 │
│ │
│ [充值订单GORM 自动过滤,只见自己的)] │
│ GET /agent-recharges │
└──────────────────────────────────────────────────────────────────┘
```
### 数据流向
```
【充值流】
平台操作 POST /agent-recharges
tb_agent_recharge_record充值订单含状态待支付/已完成/已取消)
↓ 线下确认 / 微信回调
tb_agent_wallet (wallet_type=main) balance +
tb_agent_wallet_transaction (transaction_type=recharge)
↓ 代理购买套餐
tb_agent_wallet (wallet_type=main) balance -
tb_agent_wallet_transaction (transaction_type=deduct)
【佣金流】
用户下单 → 佣金计算 → tb_commission_record订单维度明细
↓ 佣金入账
tb_agent_wallet (wallet_type=commission) balance +
tb_agent_wallet_transaction (transaction_type=commission)
↓ 代理发起提现 POST /shops/:id/withdrawal-requests
tb_commission_withdrawal_request提现申请
↓ 平台审批通过
tb_agent_wallet (wallet_type=commission) balance -
tb_agent_wallet_transaction (transaction_type=withdrawal)
```
## Capabilities
### New Capabilities
- `agent-fund-summary`: 代理商资金概况接口,同一接口支持平台(全量)和代理(仅自己)两种视角,包含预充值余额与佣金钱包字段
- `main-wallet-transactions`: 代理预充值钱包(主钱包)流水查询,按 shop_id 分页检索 tb_agent_wallet_transactionwallet_type=main
### Modified Capabilities
- `commission-record-query`: 佣金统计和每日统计从 `/my/` 路径迁移至 `/shops/:id/` 路径,同时新增 POST 提现发起路由;原 `/my/` 路径全部废弃
- `agent-wallet`: 资金概况接口现在对外暴露主钱包余额,`AgentWalletStore.GetShopCommissionSummaryBatch` 需扩展以批量拉取主钱包余额
## Impact
- **Handler 层**: 扩展 `ShopCommissionHandler`,新增 `ListFundSummary``ListMainWalletTransactions``GetCommissionStats``GetCommissionDailyStats``CreateWithdrawal` 五个方法;`MyCommissionHandler` 整体删除
- **Service 层**: `ShopCommissionService` 构造函数补两个依赖(`commissionWithdrawalSettingStore``agentWalletTransactionStore``ListShopCommissionSummary` 改造为 `ListShopFundSummary`,新增 `ListMainWalletTransactions`,并从 `MyCommissionService` 迁移 `GetStats / GetDailyStats / CreateWithdrawalRequest` 三个方法(签名改为显式 `shopID`,入口强制调用 `middleware.CanManageShop` 校验);`MyCommissionService` 整体删除
- **Store 层**: `AgentWalletStore` 新增 `GetShopMainWalletBatch``AgentWalletTransactionStore` 新增 `ListByWalletIDWithFilters` / `CountByWalletID`,并删除原 `ListByShopID` / `CountByShopID`(全局无其他调用者,且会跨钱包类型返回数据,留着易被误用)
- **DTO 层**: `ShopCommissionSummaryItem``ShopFundSummaryItem`(新增 `main_balance``main_frozen_balance`);新增主钱包流水 DTO删除 `/my/` 系列 DTO
- **路由层**: 删除 `registerMyCommissionRoutes`,在 `registerShopCommissionRoutes` 内将 `/commission-summary` 改为 `/fund-summary`,并追加 4 条 `/:shop_id/...` 路由
- **权限层**: 所有迁移过来的 `/shops/:shop_id/...` 入口必须在 Service 层显式调用 `middleware.CanManageShop(ctx, shopID)`,同时覆盖本次新增接口和已有的 `ListShopWithdrawalRequests``ListShopCommissionRecords`(当前只做了店铺存在性检查,未做越权校验,迁移后代理只要猜到其他 shopID 就能读取,必须补齐)
- **文档生成器**: `cmd/api/docs.go``cmd/gendocs/main.go` 同步更新

View File

@@ -0,0 +1,63 @@
## ADDED Requirements
### Requirement: 代理商资金概况列表
系统 SHALL 提供 `GET /api/admin/shops/fund-summary` 接口,返回分页的代理商资金概况列表,同时包含预充值钱包(主钱包)和佣金钱包的余额信息。
**响应字段**`ShopFundSummaryItem`
- `shop_id`:店铺 ID
- `shop_name`:店铺名称
- `shop_code`:店铺编码
- `username`:主账号用户名
- `phone`:主账号手机号
- `main_balance`:预充值钱包余额(分)
- `main_frozen_balance`:预充值钱包冻结余额(分)
- `total_commission`:累计佣金总额(分)
- `withdrawn_commission`:已提现佣金(分)
- `unwithdraw_commission`:未提现佣金(分)
- `frozen_commission`:冻结中佣金(分)
- `withdrawing_commission`:提现中佣金(分)
- `available_commission`:可提现佣金(分)
- `created_at`:店铺创建时间
**查询参数**`ShopFundSummaryListReq`
- `page`:页码(默认 1
- `page_size`:每页数量(默认 20最大 100
- `shop_name`:店铺名称模糊查询
- `username`:主账号用户名模糊查询
**实现要求**
- 主钱包余额通过 `AgentWalletStore.GetShopMainWalletBatch` 批量查询,避免 N+1
- 若代理暂无主钱包记录(未充值),`main_balance``main_frozen_balance` 返回 0
- `main_frozen_balance` 字段为未来预留(当前业务无主钱包冻结场景,值恒为 0前端可暂不展示
- 数据权限:列表查询走 `Shop` 表的数据权限过滤(`SubordinateShopIDs`)。平台人员返回全部代理;代理账号返回自己 + 所有下级店铺(而不是只返回自己一条)
#### Scenario: 平台人员查看所有代理资金概况
- **WHEN** 平台人员请求 `GET /shops/fund-summary`
- **THEN** 系统返回所有代理的分页列表,每条包含 `main_balance` 和佣金钱包字段
#### Scenario: 无下级代理的账号查看自己的资金概况
- **WHEN** 无下级代理的代理账号请求 `GET /shops/fund-summary`
- **THEN** 系统按数据权限过滤,只返回该代理自己的一条记录
#### Scenario: 有下级代理的顶级代理查看资金概况
- **WHEN** 一个拥有多个下级代理的顶级代理账号请求 `GET /shops/fund-summary`
- **THEN** 系统返回自己 + 所有下级代理店铺的资金概况列表(按 `SubordinateShopIDs` 过滤)
#### Scenario: 代理暂无主钱包时返回零值
- **WHEN** 代理从未充值,`tb_agent_wallet` 中无该店铺的 `wallet_type=main` 记录
- **THEN** `main_balance``main_frozen_balance` 返回 0其余字段正常返回
#### Scenario: 按店铺名称过滤
- **WHEN** 传入 `shop_name=张三`
- **THEN** 系统只返回店铺名称包含"张三"的代理记录
#### Scenario: 企业账号无权访问
- **WHEN** 企业账号请求此接口
- **THEN** 系统返回 403 错误,消息为"企业账号无权访问代理资金功能"

View File

@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: 批量查询店铺主钱包余额
系统 SHALL 在 `AgentWalletStore` 中提供 `GetShopMainWalletBatch(ctx, shopIDs []uint) map[uint]*AgentWallet` 方法,一次查询多个店铺的主钱包(`wallet_type=main`)记录,返回以 `shop_id` 为 key 的 map。
**实现要求**
- 使用 `WHERE shop_id IN (?) AND wallet_type = 'main'` 单次查询,不得逐条查询
- 不在 map 中的 shop_id 表示该店铺暂无主钱包,调用方按零值处理
- 与现有 `GetShopCommissionSummaryBatch` 对称设计
#### Scenario: 批量查询多个店铺的主钱包
- **WHEN** 传入 shopIDs `[1, 2, 3]`,其中 shop 3 无主钱包记录
- **THEN** 返回 map `{1: &wallet1, 2: &wallet2}`shop 3 不在 map 中
#### Scenario: 传入空列表
- **WHEN** 传入空 `shopIDs`
- **THEN** 直接返回空 map不执行 DB 查询

View File

@@ -0,0 +1,150 @@
## ADDED Requirements
### Requirement: 迁移接口的越权校验(通用要求)
本规范下所有 `/shops/:shop_id/...` 迁移/新增接口Service 层方法入口 SHALL 调用 `middleware.CanManageShop(ctx, shopID)` 做显式越权校验。Handler 层只做参数解析,不承担权限逻辑。该要求同时追溯适用于已有但缺少校验的 `ListShopWithdrawalRequests``ListShopCommissionRecords` 两个方法(回归漏洞修复)。
统一错误返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
#### Scenario: 代理传非自己管辖店铺 ID 被 Service 层拦截
- **WHEN** 代理 AshopID=10请求本规范任意一个 `/shops/:shop_id/...` 接口,传入代理 BshopID=20的 shopID
- **THEN** Service 层入口的 `middleware.CanManageShop(ctx, 20)` 返回 error接口返回 403消息 "无权限操作该资源或资源不存在"
#### Scenario: 平台人员不受 CanManageShop 限制
- **WHEN** 平台人员请求任意 `/shops/:shop_id/...` 接口
- **THEN** `middleware.CanManageShop` 直接放行,接口正常返回数据
#### Scenario: 已有方法 ListShopCommissionRecords / ListShopWithdrawalRequests 补齐校验
- **WHEN** 代理账号请求 `/shops/{非自己管辖shopID}/commission-records``/withdrawal-requests`
- **THEN** 实现后返回 403回归漏洞修复前这两个方法会返回数据
---
### Requirement: 通过店铺 ID 查询佣金统计
系统 SHALL 提供 `GET /api/admin/shops/:shop_id/commission-stats` 接口,返回指定代理店铺在给定时间范围内的佣金汇总统计。
**路径参数**`shop_id`(必填)
**查询参数**`start_time``end_time`可选ISO8601 格式)
**响应**:与原 `/my/commission-stats` 一致(`CommissionStatsResponse`
**权限**Service 层入口调用 `middleware.CanManageShop(ctx, shopID)`
#### Scenario: 平台人员查询某代理的佣金统计
- **WHEN** 平台人员请求 `GET /shops/123/commission-stats`
- **THEN** 系统返回店铺 123 的佣金汇总统计
#### Scenario: 代理查询自己的佣金统计
- **WHEN** 代理账号请求 `GET /shops/自己ID/commission-stats`
- **THEN** 系统返回该代理自己的佣金统计数据
#### Scenario: 代理尝试查询他人统计被拦截
- **WHEN** 代理账号请求 `GET /shops/他人ID/commission-stats`
- **THEN** 系统返回 403 错误
---
### Requirement: 通过店铺 ID 查询每日佣金统计
系统 SHALL 提供 `GET /api/admin/shops/:shop_id/commission-daily-stats` 接口,返回指定代理店铺的每日佣金趋势数据。
**路径参数**`shop_id`(必填)
**查询参数**`start_date``end_date`(可选,默认最近 30 天)
**响应**:与原 `/my/commission-daily-stats` 一致(`[]DailyCommissionStatsResponse`
**权限**Service 层入口调用 `middleware.CanManageShop(ctx, shopID)`
#### Scenario: 平台人员查询某代理的每日佣金
- **WHEN** 平台人员请求 `GET /shops/123/commission-daily-stats`
- **THEN** 系统返回店铺 123 的每日佣金趋势
#### Scenario: 代理查询自己的每日佣金
- **WHEN** 代理账号请求 `GET /shops/自己ID/commission-daily-stats`
- **THEN** 系统返回该代理自己的每日佣金数据,默认最近 30 天
#### Scenario: 代理尝试查询他人数据被拦截
- **WHEN** 代理账号请求 `GET /shops/他人ID/commission-daily-stats`
- **THEN** 系统返回 403 错误
---
### Requirement: 通过店铺 ID 发起提现申请
系统 SHALL 提供 `POST /api/admin/shops/:shop_id/withdrawal-requests` 接口,允许代理为自己的店铺发起佣金提现申请。
**路径参数**`shop_id`(必填)
**请求体**:与原 `POST /my/withdrawal-requests` 一致(`CreateMyWithdrawalReq`
**响应**:与原 `POST /my/withdrawal-requests` 一致(`CreateMyWithdrawalResp`
**权限规则**(严于其他迁移接口,**不使用** `CanManageShop`
- Service 层入口必须满足两个条件:
1. `userType == UserTypeAgent`(仅代理商用户)
2. `shopID == middleware.GetShopIDFromContext(ctx)`(必须本人店铺,禁止顶级代理替下级提现)
- 平台人员/超管/企业账号一律 403业务上提现必须由代理本人发起平台不代办
- 顶级代理替下级提现也禁止:代理只能给自己的店铺发起提现申请
#### Scenario: 代理为自己发起提现
- **WHEN** 代理账号请求 `POST /shops/{自己shopID}/withdrawal-requests`,传入合法金额和收款信息
- **THEN** 系统创建提现申请,返回申请 ID、提现单号、手续费信息
#### Scenario: 代理尝试为他人(非下级)发起提现被拦截
- **WHEN** 代理账号 A 请求 `POST /shops/{无关代理B的shopID}/withdrawal-requests`
- **THEN** 系统返回 403 错误,消息"仅可为本人店铺发起提现"
#### Scenario: 顶级代理尝试替下级店铺发起提现被拦截
- **WHEN** 顶级代理 P 请求 `POST /shops/{P的下级shop_id}/withdrawal-requests`(即使 `CanManageShop` 会放行)
- **THEN** 系统返回 403 错误,消息"仅可为本人店铺发起提现"。理由:业务上提现必须本人发起
#### Scenario: 平台人员尝试替代理发起提现被拦截
- **WHEN** 平台人员或超管请求 `POST /shops/:shop_id/withdrawal-requests`
- **THEN** 系统返回 403 错误,消息"仅代理商用户可发起提现"
#### Scenario: 可提现余额不足
- **WHEN** 代理发起提现金额超过可提现余额
- **THEN** 系统返回业务错误,消息为"可提现余额不足"
---
## REMOVED Requirements
### Requirement: 通过 /my/ 路径查询佣金统计
**Reason**: 路径统一到 `/shops/:id/` 前缀,`/my/` 系列接口职责与平台视角重叠,增加维护成本
**Migration**: 使用 `GET /api/admin/shops/:shop_id/commission-stats` 替代 `GET /api/admin/my/commission-stats`
### Requirement: 通过 /my/ 路径查询每日佣金统计
**Reason**: 同上,路径统一
**Migration**: 使用 `GET /api/admin/shops/:shop_id/commission-daily-stats` 替代 `GET /api/admin/my/commission-daily-stats`
### Requirement: 通过 /my/ 路径发起提现申请
**Reason**: 同上,路径统一
**Migration**: 使用 `POST /api/admin/shops/:shop_id/withdrawal-requests` 替代 `POST /api/admin/my/withdrawal-requests`
### Requirement: 通过 /my/ 路径查询佣金概览
**Reason**: 由 `/shops/fund-summary` 替代,新接口同时包含预充值钱包余额
**Migration**: 使用 `GET /api/admin/shops/fund-summary` 替代 `GET /api/admin/my/commission-summary`
### Requirement: 通过 /my/ 路径查询提现记录
**Reason**: 路径统一
**Migration**: 使用 `GET /api/admin/shops/:shop_id/withdrawal-requests` 替代 `GET /api/admin/my/withdrawal-requests`
### Requirement: 通过 /my/ 路径查询佣金明细
**Reason**: 路径统一
**Migration**: 使用 `GET /api/admin/shops/:shop_id/commission-records` 替代 `GET /api/admin/my/commission-records`

View File

@@ -0,0 +1,65 @@
## ADDED Requirements
### Requirement: 预充值钱包流水查询
系统 SHALL 提供 `GET /api/admin/shops/:shop_id/main-wallet/transactions` 接口,分页返回指定代理店铺的预充值钱包(主钱包)交易流水记录。
**响应字段**`MainWalletTransactionItem`
- `id`:流水记录 ID
- `transaction_type`:交易类型。主钱包可能出现的值为 `recharge`-充值入账 / `deduct`-套餐扣款 / `refund`-退款(当前 DB 数据仅见 `recharge``deduct` / `refund` 随代购扣款/退款业务上线而出现;接口不对类型做枚举白名单,透传 DB 原值)
- `transaction_subtype`:交易子类型(细分场景,如 `order_payment`,可为空)
- `amount`:变动金额(分,正数为入账,负数为扣款)
- `balance_before`:变动前余额(分)
- `balance_after`:变动后余额(分)
- `remark`:备注(可为空)
- `created_at`:流水时间
**查询参数**`MainWalletTransactionListRequest`
- `shop_id`:路径参数,店铺 ID必填
- `page`:页码(默认 1
- `page_size`:每页数量(默认 20最大 100
- `transaction_type`:按类型过滤(可选)
- `start_date`:开始日期,`YYYY-MM-DD`(可选)
- `end_date`:结束日期,`YYYY-MM-DD`(可选)
**实现要求**
- **Service 层入口必须调用 `middleware.CanManageShop(ctx, shopID)` 做越权校验**,校验失败直接返回 `errors.CodeForbidden`;不得依赖 `GetMainWallet` 的隐式过滤(该方法不做权限校验)
- 先通过 `AgentWalletStore.GetMainWallet(shopID)` 获取主钱包;若不存在则返回空列表(`total=0`),不报错
- 使用 `AgentWalletTransactionStore.ListByWalletIDWithFilters / CountByWalletID`task 2.2 新增)查询流水,支持 transaction_type 和日期过滤
- 旧方法 `ListByShopID / CountByShopID` 已在 task 2.3 删除(会跨钱包类型返回数据,易被误用)
- 结果按 `created_at DESC` 排序
#### Scenario: 平台人员查看指定代理的预充值流水
- **WHEN** 平台人员请求 `GET /shops/123/main-wallet/transactions`
- **THEN** 系统返回店铺 123 的主钱包流水,按时间倒序,含变动前后余额
#### Scenario: 代理查看自己的预充值流水
- **WHEN** 代理账号请求 `GET /shops/自己shop_id/main-wallet/transactions`
- **THEN** 系统返回该代理自己的主钱包流水记录
#### Scenario: 代理尝试查看他人流水被拦截
- **WHEN** 代理账号请求 `GET /shops/他人shop_id/main-wallet/transactions`
- **THEN** 系统返回 403 错误,消息为"无权限操作该资源或资源不存在"
#### Scenario: 代理暂无主钱包时返回空列表
- **WHEN** 代理从未充值,主钱包不存在
- **THEN** 系统返回空列表,`total` 为 0不报错
#### Scenario: 按交易类型过滤
- **WHEN** 传入 `transaction_type=recharge`
- **THEN** 系统只返回充值入账类型的流水
#### Scenario: 按日期范围过滤
- **WHEN** 传入 `start_date=2026-01-01&end_date=2026-03-31`
- **THEN** 系统只返回该日期范围内的流水记录
#### Scenario: 企业账号无权访问
- **WHEN** 企业账号请求此接口
- **THEN** 系统返回 403 错误

View File

@@ -0,0 +1,90 @@
## 1. DTO 层变更
- [x] 1.1 新增 `ShopFundSummaryItem`(含 `main_balance``main_frozen_balance` 及原有佣金字段),新增 `ShopFundSummaryListReq``ShopFundSummaryPageResult`;删除旧的 `ShopCommissionSummaryItem` / `ShopCommissionSummaryListReq` / `ShopCommissionSummaryPageResult`
- [x] 1.2 新增主钱包流水 DTO`MainWalletTransactionItem``MainWalletTransactionListRequest``MainWalletTransactionListResponse`
- [x] 1.3 删除废弃 DTO`MyCommissionSummaryResp``MyWithdrawalListReq``MyCommissionRecordListReq``MyCommissionRecordItem``MyCommissionRecordPageResult`(确认无其他引用后删除)
- **保留**`CommissionStatsRequest``DailyCommissionStatsRequest` / `CommissionStatsResponse` / `DailyCommissionStatsResponse` — 迁移后的 `/shops/:id/commission-stats``/shops/:id/commission-daily-stats` 继续复用
- **保留**`CreateMyWithdrawalReq``CreateMyWithdrawalResp` — 迁移后的 `POST /shops/:id/withdrawal-requests` 继续复用
## 2. Store 层变更
- [x] 2.1 在 `AgentWalletStore` 新增 `GetShopMainWalletBatch(ctx context.Context, shopIDs []uint) (map[uint]*model.AgentWallet, error)` 方法:`WHERE shop_id IN (?) AND wallet_type = 'main'` 单次查询,内部调用 `middleware.ApplyShopFilter` 对齐现有 `GetShopCommissionSummaryBatch` 的防御式过滤
- [x] 2.2 在 `AgentWalletTransactionStore` 新增过滤结构体 `AgentWalletTransactionListFilters`(字段:`TransactionType string``StartDate string``EndDate string`),以及以下两个方法:
- `ListByWalletIDWithFilters(ctx, walletID uint, opts *store.QueryOptions, filters *AgentWalletTransactionListFilters) ([]*model.AgentWalletTransaction, error)`:在 `WHERE agent_wallet_id = ?` 基础上叠加 transaction_type / 日期过滤,按 `created_at DESC` 排序
- `CountByWalletID(ctx, walletID uint, filters *AgentWalletTransactionListFilters) (int64, error)`:与列表方法过滤条件一致,用于分页 total 统计
- [x] 2.3 删除 `AgentWalletTransactionStore.ListByShopID``CountByShopID`已确认全局无其他调用者Grep 验证),且按 shopID 过滤会混入佣金钱包流水,留着会被误用。删除前再次 `grep -rn "ListByShopID\|CountByShopID" --include="*.go"` 确认
## 3. Service 层变更ShopCommissionService 扩展)
- [x] 3.0 更新 `ShopCommissionService.New()` 构造函数,在现有参数基础上新增两个依赖:
- `commissionWithdrawalSettingStore *postgres.CommissionWithdrawalSettingStore`task 3.4 迁移 `CreateWithdrawalRequest` 时校验最低提现金额和每日次数上限)
- `agentWalletTransactionStore *postgres.AgentWalletTransactionStore`task 3.5 新增 `ListMainWalletTransactions` 使用)
- [x] 3.1 将 `ListShopCommissionSummary` 改造为 `ListShopFundSummary`:删除原方法,方法签名/返回类型切换至 `ShopFundSummaryListReq` / `ShopFundSummaryPageResult`;调用 `GetShopMainWalletBatch` 批量拉取主钱包余额,合并到 `ShopFundSummaryItem` 中;主钱包不存在时 `main_balance` / `main_frozen_balance` 返回 0
- [x] 3.2 从 `MyCommissionService` 迁移 `GetStats``ShopCommissionService`,签名改为 `(ctx, shopID uint, req *dto.CommissionStatsRequest)`**方法入口第一行**调用 `middleware.CanManageShop(ctx, shopID)`,失败直接返回
- [x] 3.3 从 `MyCommissionService` 迁移 `GetDailyStats``ShopCommissionService`,签名改为 `(ctx, shopID uint, req *dto.DailyCommissionStatsRequest)`;同样入口加 `CanManageShop`
- [x] 3.4 从 `MyCommissionService` 迁移 `CreateWithdrawalRequest``ShopCommissionService`,签名改为 `(ctx, shopID uint, req *dto.CreateMyWithdrawalReq)`**入口权限校验严于其他方法**
- 必须 `middleware.GetUserTypeFromContext(ctx) == constants.UserTypeAgent`,否则返回 403"仅代理商用户可发起提现"
- 必须 `shopID == middleware.GetShopIDFromContext(ctx)`,否则返回 403"仅可为本人店铺发起提现"
- **不使用 `CanManageShop`**(默认会放行整个下级层级,业务上不允许顶级代理替下级提现)
- 保留原有的条件更新 `Where("id = ? AND balance - frozen_balance >= ?")` 并发保护
- [x] 3.5 在 `ShopCommissionService` 新增 `ListMainWalletTransactions(ctx, shopID uint, req *dto.MainWalletTransactionListRequest)` 方法:
- **入口第一行**调用 `middleware.CanManageShop(ctx, shopID)`
- 通过 `AgentWalletStore.GetMainWallet(ctx, shopID)` 获取主钱包;不存在则返回空列表(`total=0`)不报错
- 调用 `ListByWalletIDWithFilters` / `CountByWalletID`task 2.2 新增)查询流水
- **不得**使用 `ListByShopID`(已删除)
- [x] 3.6 **补齐已有方法的越权校验**(回归漏洞修复):
- `ListShopWithdrawalRequests`:方法入口加 `middleware.CanManageShop(ctx, shopID)`
- `ListShopCommissionRecords`:方法入口加 `middleware.CanManageShop(ctx, shopID)`
- 理由:`/my/withdrawal-requests``/my/commission-records` 废弃后代理改用 `/shops/:shop_id/...`,若不补校验,代理只要猜到其他 shopID 就能读取他人佣金明细和提现记录
## 4. Service 层删除MyCommissionService
- [x] 4.1 全局 grep 确认 `MyCommissionService` / `myCommissionService` / `my_commission` 的所有引用Handler、bootstrap、routes、cmd
- [x] 4.2 删除 `internal/service/my_commission/service.go` 及整个 `my_commission` 目录
## 5. Handler 层变更ShopCommissionHandler 扩展)
- [x] 5.1 将 `ListCommissionSummary` 改造为 `ListFundSummary`(重命名+切换 DTO 类型),调用 `ShopCommissionService.ListShopFundSummary`。方法只在 Handler 层做参数解析,不写权限逻辑
- [x] 5.2 新增 `GetCommissionStats` 方法,从路径参数取 `shop_id`,调用迁移后的 `GetStats`(权限校验在 Service 层)
- [x] 5.3 新增 `GetCommissionDailyStats` 方法,从路径参数取 `shop_id`,调用迁移后的 `GetDailyStats`
- [x] 5.4 新增 `CreateWithdrawal` 方法,从路径参数取 `shop_id`,调用迁移后的 `CreateWithdrawalRequest`
- [x] 5.5 新增 `ListMainWalletTransactions` 方法,从路径参数取 `shop_id`,调用 `ShopCommissionService.ListMainWalletTransactions`
- [x] 5.6 Handler 注释同步更新HTTP 方法和路径),遵循 comment-standards
## 6. Handler 层删除MyCommissionHandler
- [x] 6.1 删除 `internal/handler/admin/my_commission.go`
## 7. 路由层变更
- [x] 7.1 在 `registerShopCommissionRoutes` 中:将 `GET /commission-summary` 改为 `GET /fund-summary`,更新 Summary 为 "代理商资金概况",更新 Tags、Input`ShopFundSummaryListReq`、Output`ShopFundSummaryPageResult`
- [x] 7.2 在 `registerShopCommissionRoutes` 中新增四条路由:
- `GET /:shop_id/main-wallet/transactions``ListMainWalletTransactions`
- `GET /:shop_id/commission-stats``GetCommissionStats`
- `GET /:shop_id/commission-daily-stats``GetCommissionDailyStats`
- `POST /:shop_id/withdrawal-requests``CreateWithdrawal`
- [x] 7.3 删除 `internal/routes/my_commission.go`
- [x] 7.4 在路由注册入口(`internal/routes/admin.go` 或 registry中移除 `registerMyCommissionRoutes` 的调用
## 8. Bootstrap 变更
- [x] 8.1 在 `internal/bootstrap/types.go``Handlers` 结构体中:删除 `MyCommissionHandler` 字段,确认 `ShopCommissionHandler` 字段存在
- [x] 8.2 在 `internal/bootstrap/services.go` 中:删除 `myCommissionService` 的初始化,移除对 `my_commission` 包的引用;更新 `shopCommissionService` 的初始化,补充 task 3.0 新增的两个依赖(`stores.CommissionWithdrawalSetting``stores.AgentWalletTransaction`
- [x] 8.3 在 `internal/bootstrap/handlers.go` 中删除 `MyCommissionHandler` 的初始化
## 9. 文档生成器更新
- [x] 9.1 在 `cmd/api/docs.go` 中:删除 `NewMyCommissionHandler(nil)` 相关行,确认 `ShopCommissionHandler` 已包含新方法
- [x] 9.2 在 `cmd/gendocs/main.go` 中做同样清理
## 10. 编译与验证
- [x] 10.1 执行 `go build ./...`,确保无编译错误
- [x] 10.2 `grep -rn "MyCommission\|my_commission\|ListByShopID\|CountByShopID\|ShopCommissionSummary" --include="*.go"` 确认无残留引用
- [x] 10.3 使用 PostgreSQL MCP 验证 `GET /shops/fund-summary` 返回的 `main_balance``tb_agent_wallet` 中对应记录一致shop_id=1: balance=50000, shop_id=9: balance=20000主钱包批量查询逻辑正确
- [x] 10.4 使用 PostgreSQL MCP 验证 `GET /shops/:id/main-wallet/transactions` 返回的流水与 `tb_agent_wallet_transaction``wallet_type=main` 钱包 ID 的记录一致wallet_id=1 有3条记录wallet_id=17 有2条记录通过 agent_wallet_id 关联正确)
- [ ] 10.5 手动验证代理越权:
- 代理账号 A 请求 `/shops/{B的shop_id}/commission-records` / `/commission-stats` / `/commission-daily-stats` / `/withdrawal-requests` / `/main-wallet/transactions`B 不是 A 的下级)应全部返回 403
- 顶级代理 P 请求 `POST /shops/{下级shop_id}/withdrawal-requests` 应返回 403"仅可为本人店铺发起提现"(验证比 `CanManageShop` 更严格的限制)
- 平台账号请求 `POST /shops/:shop_id/withdrawal-requests` 应返回 403"仅代理商用户可发起提现"
- [ ] 10.6 验证原 `/my/commission-stats``/my/commission-daily-stats``POST /my/withdrawal-requests` 迁移后数据返回与迁移前等价(相同 shopID 相同查询条件,结果一致)