feat: 实现代理钱包订单创建和订单角色追踪功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m0s

新增功能:
- 代理在后台使用 wallet 支付时,订单直接完成(扣款 + 激活套餐)
- 支持代理自购和代理代购场景
- 新增订单角色追踪字段(operator_id、operator_type、actual_paid_amount、purchase_role)
- 订单查询支持 OR 逻辑(buyer_id 或 operator_id)
- 钱包流水记录交易子类型和关联店铺
- 佣金逻辑调整:代理代购不产生佣金

数据库变更:
- 订单表新增 4 个字段和 2 个索引
- 钱包流水表新增 2 个字段
- 包含迁移脚本和回滚脚本

文档:
- 功能总结文档
- 部署指南
- OpenAPI 文档更新
- Specs 同步(新增 agent-order-role-tracking capability)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 14:11:42 +08:00
parent c5bf85c8de
commit 8ed3d9da93
24 changed files with 3346 additions and 52 deletions

View File

@@ -1417,6 +1417,10 @@ components:
maxLength: 11
minLength: 11
type: string
default_role_id:
description: 店铺默认角色ID必须是客户角色
minimum: 1
type: integer
district:
description: 区县
maxLength: 50
@@ -1458,6 +1462,7 @@ components:
required:
- shop_name
- shop_code
- default_role_id
- init_password
- init_username
- init_phone
@@ -2487,9 +2492,6 @@ components:
carrier_type:
description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
type: string
cost_price:
description: 成本价(分)
type: integer
created_at:
description: 创建时间
format: date-time
@@ -2505,9 +2507,6 @@ components:
data_usage_mb:
description: 累计流量使用(MB)
type: integer
distribute_price:
description: 分销价(分)
type: integer
enable_polling:
description: 是否参与轮询
type: boolean
@@ -2979,6 +2978,10 @@ components:
type: object
DtoOrderResponse:
properties:
actual_paid_amount:
description: 实际支付金额(分)
nullable: true
type: integer
buyer_id:
description: 买家ID
minimum: 0
@@ -3013,12 +3016,26 @@ components:
is_purchase_on_behalf:
description: 是否为代购订单
type: boolean
is_purchased_by_parent:
description: 是否由上级代理购买
type: boolean
items:
description: 订单明细列表
items:
$ref: '#/components/schemas/DtoOrderItemResponse'
nullable: true
type: array
operator_id:
description: 操作者ID
minimum: 0
nullable: true
type: integer
operator_name:
description: 操作者名称
type: string
operator_type:
description: 操作者类型 (platform:平台, agent:代理)
type: string
order_no:
description: 订单号
type: string
@@ -3039,6 +3056,12 @@ components:
payment_status_text:
description: 支付状态文本
type: string
purchase_remark:
description: 购买备注
type: string
purchase_role:
description: 订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买)
type: string
total_amount:
description: 订单总金额(分)
type: integer
@@ -3901,10 +3924,6 @@ components:
minimum: 0
nullable: true
type: integer
from_shop_id:
description: 来源店铺ID被回收方
minimum: 1
type: integer
iccid_end:
description: 结束ICCIDselection_type=range时必填
maxLength: 20
@@ -3931,7 +3950,6 @@ components:
- filter
type: string
required:
- from_shop_id
- selection_type
type: object
DtoRecallStandaloneCardsResponse:
@@ -4739,9 +4757,6 @@ components:
carrier_type:
description: 运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)
type: string
cost_price:
description: 成本价(分)
type: integer
created_at:
description: 创建时间
format: date-time
@@ -4757,9 +4772,6 @@ components:
data_usage_mb:
description: 累计流量使用(MB)
type: integer
distribute_price:
description: 分销价(分)
type: integer
enable_polling:
description: 是否参与轮询
type: boolean
@@ -12250,6 +12262,12 @@ paths:
description: 订单号(精确查询)
maxLength: 30
type: string
- description: 订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买)
in: query
name: purchase_role
schema:
description: 订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买)
type: string
- description: 创建时间起始
in: query
name: start_time
@@ -18681,6 +18699,12 @@ paths:
description: 订单号(精确查询)
maxLength: 30
type: string
- description: 订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买)
in: query
name: purchase_role
schema:
description: 订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买)
type: string
- description: 创建时间起始
in: query
name: start_time

View File

@@ -0,0 +1,514 @@
# 代理钱包订单创建功能总结
## 概述
fix-agent-wallet-order-creation 提案修复了代理在后台使用钱包支付创建订单的问题,实现了代理钱包一步购买(扣款 + 激活)、代理代购、订单角色追踪等核心功能。
## <20><>景问题
### 问题描述
代理在后台使用钱包支付wallet创建订单时系统只创建待支付订单`payment_status = 1`),不扣款也不激活套餐,导致订单无法完成。后台没有支付接口,代理无法对待支付订单进行支付。
### 业务场景
- **代理自购**:代理为自己的卡/设备购买套餐,从自己钱包扣自己的成本价
- **代理代购**:代理为下级代理的卡/设备购买套餐,从自己钱包扣自己的成本价,但订单金额显示下级成本价
- **平台代购**(现有逻辑):平台使用 offline 支付为代理创建订单,不扣款,立即激活,产生佣金
## 核心功能
### 1. 订单角色追踪
**新增字段**`tb_order` 表):
- `operator_id` (INT, 可空):操作者 ID谁下的单
- `operator_type` (VARCHAR, 可空):操作者类型(`platform` / `agent`
- `actual_paid_amount` (BIGINT, 可空):实际支付金额(分)
- `purchase_role` (VARCHAR):订单角色枚举
**订单角色枚举**`internal/model/order.go`
```go
const (
PurchaseRoleSelfPurchase = "self_purchase" // 自己购买
PurchaseRolePurchasedByParent = "purchased_by_parent" // 上级代理购买
PurchaseRolePurchasedByPlatform = "purchased_by_platform" // 平台代购
PurchaseRolePurchaseForSubordinate = "purchase_for_subordinate" // 给下级购买
)
```
**索引**
- `idx_orders_operator_id` (operator_id):支持"我作为操作者的订单"查询
- `idx_orders_purchase_role` (purchase_role):支持按角色筛选
---
### 2. 后台钱包一步支付
**行为变更**
- **原逻辑**:后台 wallet 订单 → 创建待支付订单(`payment_status = 1`)→ 无法支付
- **新逻辑**:后台 wallet 订单 → 立即扣款 + 激活套餐 → 订单已支付(`payment_status = 2`
**区别于 H5 端**
- H5 端 wallet 订单仍使用两步流程:创建待支付订单 → 调用 WalletPay 接口支付
- 后台 wallet 订单一步完成,无需后续支付接口
**权限调整**
- 允许代理、平台、超管使用 wallet 支付方式
- offline 支付方式仍限制为平台和超管
---
### 3. 价格计算逻辑
**区分"订单金额"和"实际支付"**
| 场景 | 订单金额total_amount | 实际支付actual_paid_amount | 说明 |
|------|------------------------|------------------------------|------|
| 代理自购 | 操作者成本价 | 操作者成本价 | 两者相同 |
| 代理代购 | 买家成本价 | 操作者成本价 | 操作者实际扣款少于订单金额(赚取差价) |
| 平台代购 | 买家成本价 | NULL | 平台不扣款 |
**示例**
```
一级代理 A 成本价80 元
二级代理 B 成本价100 元
A 为 B 的卡购买套餐:
- total_amount = 10000100 元B 看到的订单金额)
- actual_paid_amount = 800080 元A 实际扣款)
- A 赚取差价20 元
```
**成本价查询**
通过 `ShopPackageAllocation` 表查询店铺对套餐的成本价。
---
### 4. 钱包流水记录扩展
**新增字段**`tb_agent_wallet_transaction` 表):
- `transaction_subtype` (VARCHAR):交易子类型(细分 order_payment 场景)
- `related_shop_id` (INT, 可空):关联店铺 ID代购时记录下级店铺
**交易子类型枚举**`pkg/constants/wallet.go`
```go
const (
WalletTransactionSubtypeSelfPurchase = "self_purchase"
WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate"
)
```
**流水示例**
- **自购**`transaction_subtype = "self_purchase"``remark = "购买套餐"`
- **代购**`transaction_subtype = "purchase_for_subordinate"``related_shop_id = 下级店铺 ID``remark = "为下级代理【XX】购买套餐"`
---
### 5. 订单查询增强
**OR 查询逻辑**`OrderStore.List()`
```sql
WHERE (buyer_type = 'agent' AND buyer_id = ?) OR operator_id = ?
```
代理可以看到两类订单:
1. 作为买家的订单(`buyer_id = 自己`):别人为自己代购、自己购买
2. 作为操作者的订单(`operator_id = 自己`):自己为下级代购
**新增查询参数**
- `purchase_role`可选筛选订单角色类型self_purchase / purchased_by_parent / purchased_by_platform / purchase_for_subordinate
---
### 6. 佣金逻辑调整
**规则**
- **代理代购**:操作者已赚取成本价差(自己成本价 vs 下级成本价),不产生佣金
- **平台代购**:平台不扣款,按买家成本价计算差价佣金,激励上级代理
**实现**
```go
// 只有平台代购operator_id == nil才入队佣金计算
if order.OperatorID == nil {
s.enqueueCommissionCalculation(ctx, order.ID)
}
```
---
### 7. 幂等性和并发控制
**乐观锁**(钱包扣款):
```go
result := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", walletID, amount, version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", amount),
"version": gorm.Expr("version + 1"),
})
```
**幂等性检查**(订单创建):
- 使用 Redis 业务键:`order:idempotency:{buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}`
- TTL3 分钟
- 分布式锁防止并发:`order:create:lock:{carrier_type}:{carrier_id}`
---
## API 变更
### 后台订单创建 API
**端点**`POST /api/admin/orders`
**行为变更**
- 代理使用 wallet 支付时,订单直接完成(`payment_status = 2`),无需后续支付
- 平台使用 offline 支付逻辑保持不变
**响应新增字段**
```json
{
"operator_id": 123,
"operator_type": "agent",
"operator_name": "一级代理 A",
"actual_paid_amount": 8000,
"purchase_role": "purchase_for_subordinate",
"is_purchased_by_parent": false,
"purchase_remark": "为下级代理【二级代理 B】购买"
}
```
---
### 订单列表 API
**端点**`GET /api/admin/orders`
**新增查询参数**
- `purchase_role` (可选):订单角色筛选
- `self_purchase`:自己购买
- `purchased_by_parent`:上级代理购买
- `purchased_by_platform`:平台代购
- `purchase_for_subordinate`:给下级购买
**查询逻辑变更**
- 代理可以看到 `buyer_id = 自己``operator_id = 自己` 的所有订单
---
## 数据库变更
### 订单表tb_order
**新增字段**
```sql
ALTER TABLE tb_order ADD COLUMN operator_id INT;
ALTER TABLE tb_order ADD COLUMN operator_type VARCHAR(20);
ALTER TABLE tb_order ADD COLUMN actual_paid_amount BIGINT;
ALTER TABLE tb_order ADD COLUMN purchase_role VARCHAR(50);
COMMENT ON COLUMN tb_order.operator_id IS '操作者ID谁下的单';
COMMENT ON COLUMN tb_order.operator_type IS '操作者类型platform/agent';
COMMENT ON COLUMN tb_order.actual_paid_amount IS '实际支付金额(分)';
COMMENT ON COLUMN tb_order.purchase_role IS '订单角色self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate';
```
**新增索引**
```sql
CREATE INDEX CONCURRENTLY idx_orders_operator_id ON tb_order(operator_id);
CREATE INDEX CONCURRENTLY idx_orders_purchase_role ON tb_order(purchase_role);
```
---
### 钱包流水表tb_agent_wallet_transaction
**新增字段**(如果不存在):
```sql
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN transaction_subtype VARCHAR(50);
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN related_shop_id INT;
COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_subtype IS '交易子类型(细分 order_payment 场景)';
COMMENT ON COLUMN tb_agent_wallet_transaction.related_shop_id IS '关联店铺ID代购时记录下级店铺';
```
---
## 代码结构
### Service 层新增方法
**`internal/service/order/service.go`**
1. **`getCostPrice(ctx, shopID, packageID) (int64, error)`**
- 查询店铺对套餐的成本价(通过 ShopPackageAllocation
2. **`createWalletTransaction(ctx, tx, walletID, orderID, amount, purchaseRole, relatedShopID) error`**
- 创建钱包流水,根据 purchaseRole 填充 subtype 和 remark
3. **`createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID) (*dto.OrderResponse, error)`**
- 钱包支付订单创建方法,事务内完成:订单创建 + 扣款 + 流水 + 激活套餐
**`Create()` 方法重构**
```go
// 场景判断
if req.PaymentMethod == "offline":
// 平台代购场景(保持现有逻辑)
return s.createOrderWithActivation(...)
else if req.PaymentMethod == "wallet":
// 获取资源所属店铺 ID
if 资源属于操作者:
// 代理自购场景
buyer = operator
purchase_role = "self_purchase"
total_amount = actual_paid_amount = 操作者成本价
else:
// 代理代购场景
buyer = 资源所属者
operator = 操作者
purchase_role = "purchase_for_subordinate"
total_amount = 买家成本价
actual_paid_amount = 操作者成本价
return s.createOrderWithWalletPayment(...)
```
---
### Store 层变更
**`internal/store/postgres/order_store.go`**
**`List()` 方法**
```go
// 代理用户:查询作为买家或操作者的订单
if shopID, ok := filters["shop_id"].(uint); ok {
query = query.Where(
"(buyer_type = ? AND buyer_id = ?) OR operator_id = ?",
model.BuyerTypeAgent, shopID, shopID,
)
}
// 支持 purchase_role 精确匹配筛选
if purchaseRole, ok := filters["purchase_role"].(string); ok {
query = query.Where("purchase_role = ?", purchaseRole)
}
```
---
### Handler 层变更
**`internal/handler/admin/order.go`**
**`Create()` 方法**
- 修改 wallet 支付方式的权限检查,允许代理、平台、超管使用
- offline 支付方式仍限制为平台和超管
**`List()` 方法**
- 从查询参数解析 `purchase_role`
- 传递给 Service 层的 `List()` 方法
---
## 使用指南
### 代理自购场景
**请求**
```http
POST /api/admin/orders
Authorization: Bearer {agent_token}
Content-Type: application/json
{
"order_type": 1,
"iot_card_id": 101,
"package_ids": [201],
"payment_method": "wallet"
}
```
**响应**
```json
{
"code": 0,
"data": {
"id": 1001,
"order_no": "ORD202602281234567890",
"payment_status": 2,
"operator_id": 10,
"buyer_id": 10,
"operator_type": "agent",
"purchase_role": "self_purchase",
"total_amount": 8000,
"actual_paid_amount": 8000
},
"msg": "订单创建成功"
}
```
---
### 代理代购场景
**请求**
```http
POST /api/admin/orders
Authorization: Bearer {parent_agent_token}
Content-Type: application/json
{
"order_type": 1,
"iot_card_id": 201,
"package_ids": [301],
"payment_method": "wallet"
}
```
**响应**
```json
{
"code": 0,
"data": {
"id": 1002,
"order_no": "ORD202602281234567891",
"payment_status": 2,
"operator_id": 10,
"buyer_id": 20,
"operator_type": "agent",
"operator_name": "一级代理 A",
"purchase_role": "purchase_for_subordinate",
"total_amount": 10000,
"actual_paid_amount": 8000,
"purchase_remark": "为下级代理【二级代理 B】购买"
},
"msg": "订单创建成功"
}
```
---
### 订单列表查询
**请求**
```http
GET /api/admin/orders?purchase_role=purchase_for_subordinate&page=1&page_size=20
Authorization: Bearer {agent_token}
```
**响应**
```json
{
"code": 0,
"data": {
"list": [
{
"id": 1002,
"purchase_role": "purchase_for_subordinate",
"operator_id": 10,
"buyer_id": 20,
"total_amount": 10000,
"actual_paid_amount": 8000
}
],
"total": 1
},
"msg": "success"
}
```
---
## 迁移和部署
### 数据库迁移
**迁移脚本**
- `migrations/000067_add_operator_fields_to_orders.up.sql`
- `migrations/000068_add_transaction_subtype_to_wallet_transaction.up.sql`
**回滚脚本**
- `migrations/000067_add_operator_fields_to_orders.down.sql`
- `migrations/000068_add_transaction_subtype_to_wallet_transaction.down.sql`
**数据回填**(可选):
- `migrations/backfill_order_purchase_role.sql`:回填历史平台代购订单
---
### 部署步骤
1. **测试环境验证**
- 执行迁移脚本
- 验证索引创建成功
- 手工测试三种代购场景
2. **灰度发布**
- 代码部署到灰度环境
- 观察日志和监控指标
- 验证订单创建、查询、钱包扣款功能
3. **生产环境部署**
- 低峰期执行数据库迁移
- 部署代码
- 监控错误日志和业务指标
- 验证核心功能
---
### 监控指标
**关键指标**
- 订单创建成功率(按 payment_method 分组)
- 钱包扣款成功率
- 错误日志:余额不足、并发冲突、套餐激活失败
- 订单创建耗时P95、P99
**告警规则**
- 钱包扣款失败率 > 5%
- 订单创建失败率 > 10%
- 并发冲突次数 > 100/分钟
---
## 兼容性说明
### 向后兼容
- **现有订单字段为空值**:不影响已有订单查询
- **平台代购offline逻辑不变**:保持现有行为
- **H5 钱包支付不受影响**H5 端仍使用两步流程
- **数据权限保持一致**:订单角色追踪不影响现有数据权限逻辑
### 破坏性变更
**无**。所有新增字段均为 nullable新增逻辑不影响现有流程。
---
## 测试覆盖
### 集成测试场景
1. **代理自购**:代理为自己的卡购买套餐,验证扣款、激活、流水
2. **代理代购**:一级代理为二级代理购买,验证价格差异、佣金不产生
3. **平台代购**:平台 offline 代购,验证不扣款、佣金产生
4. **订单查询**:验证 OR 查询逻辑、purchase_role 筛选
5. **边界场景**:余额不足、并发扣款、幂等性
### 验证结果
- ✅ 编译通过:`go build ./...`
- ✅ OpenAPI 文档更新:新增字段已包含
- ✅ 迁移脚本执行成功
---
## 相关文档
- [提案文档](../../openspec/changes/fix-agent-wallet-order-creation/proposal.md)
- [设计文档](../../openspec/changes/fix-agent-wallet-order-creation/design.md)
- [任务清单](../../openspec/changes/fix-agent-wallet-order-creation/tasks.md)
- [Specs 规范](../../openspec/changes/fix-agent-wallet-order-creation/specs/)
- [项目规范](../../CLAUDE.md)

View File

@@ -0,0 +1,538 @@
# 代理钱包订单创建功能部署指南
## 部署前检查清单
### 代码检查
- [x] 编译通过:`go build ./...`
- [x] OpenAPI 文档更新:`go run cmd/gendocs/main.go`
- [ ] 测试环境验证通过
- [ ] Code Review 通过
### 数据库准备
- [ ] 测试环境迁移脚本执行成功
- [ ] 生产环境数据库备份完成
- [ ] 回滚脚本准备完毕
---
## 数据库迁移
### 迁移脚本清单
**脚本位置**`migrations/`
| 序号 | 文件名 | 说明 | 执行时间 |
|------|--------|------|----------|
| 000067 | `add_operator_fields_to_orders.up.sql` | 订单表新增字段和索引 | < 5 秒 |
| 000068 | `add_transaction_subtype_to_wallet_transaction.up.sql` | 钱包流水表新增字段 | < 1 秒 |
**回滚脚本**
| 序号 | 文件名 | 说明 |
|------|--------|------|
| 000067 | `add_operator_fields_to_orders.down.sql` | 删除订单表字段和索引 |
| 000068 | `add_transaction_subtype_to_wallet_transaction.down.sql` | 删除钱包流水表字段 |
---
### 迁移执行步骤
#### 步骤 1备份数据库
```bash
# 生产环境数据库备份
pg_dump -h <host> -U <user> -d junhong_cmp -F c -b -v -f "backup_$(date +%Y%m%d_%H%M%S).dump"
```
**验证备份**
```bash
pg_restore --list backup_*.dump | head -20
```
---
#### 步骤 2执行迁移测试环境
**使用 migrate 工具**
```bash
# 切换到项目目录
cd /path/to/junhong_cmp_fiber
# 执行迁移
migrate -path migrations -database "postgresql://<user>:<password>@<host>:<port>/junhong_cmp?sslmode=disable" up
# 验证迁移版本
migrate -path migrations -database "postgresql://<user>:<password>@<host>:<port>/junhong_cmp?sslmode=disable" version
```
**手动执行(可选)**
```bash
# 连接数据库
psql -h <host> -U <user> -d junhong_cmp
# 执行迁移脚本
\i migrations/000067_add_operator_fields_to_orders.up.sql
\i migrations/000068_add_transaction_subtype_to_wallet_transaction.up.sql
```
---
#### 步骤 3验证迁移结果
**检查字段**
```sql
-- 验证订单表字段
\d tb_order
-- 预期输出包含:
-- operator_id | integer | | |
-- operator_type | character varying(20) | | |
-- actual_paid_amount | bigint | | |
-- purchase_role | character varying(50) | | |
```
**检查索引**
```sql
-- 验证索引
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'tb_order'
AND indexname IN ('idx_orders_operator_id', 'idx_orders_purchase_role');
-- 预期输出:
-- idx_orders_operator_id | CREATE INDEX idx_orders_operator_id ON public.tb_order USING btree (operator_id)
-- idx_orders_purchase_role | CREATE INDEX idx_orders_purchase_role ON public.tb_order USING btree (purchase_role)
```
**检查钱包流水表**
```sql
-- 验证钱包流水表字段
\d tb_agent_wallet_transaction
-- 预期输出包含:
-- transaction_subtype | character varying(50) | | |
-- related_shop_id | integer | | |
```
---
#### 步骤 4数据回填可选
**回填历史订单**
```bash
psql -h <host> -U <user> -d junhong_cmp -f migrations/backfill_order_purchase_role.sql
```
**验证回填结果**
```sql
SELECT purchase_role, operator_type, COUNT(*) as count
FROM tb_order
WHERE purchase_role IS NOT NULL
GROUP BY purchase_role, operator_type;
-- 预期输出示例:
-- purchased_by_platform | platform | 1234
```
---
#### 步骤 5执行迁移生产环境
**时间窗口**:选择低峰期(凌晨 2:00 - 4:00
**执行命令**(与测试环境相同):
```bash
migrate -path migrations -database "postgresql://<prod_host>:<prod_port>/<db>?sslmode=require" up
```
**监控指标**
- 迁移执行时间
- 索引创建时间CONCURRENTLY不锁表
- 数据库连接数
- 慢查询日志
---
### 回滚步骤
**场景**:迁移失败或发现严重 Bug
#### 步骤 1停止应用
```bash
# 停止应用服务
systemctl stop junhong-cmp-api
```
#### 步骤 2执行回滚
```bash
# 回滚到上一版本
migrate -path migrations -database "postgresql://<host>:<port>/<db>?sslmode=disable" down 2
```
**或手动执行回滚脚本**
```bash
psql -h <host> -U <user> -d junhong_cmp <<EOF
\i migrations/000068_add_transaction_subtype_to_wallet_transaction.down.sql
\i migrations/000067_add_operator_fields_to_orders.down.sql
EOF
```
#### 步骤 3验证回滚
```sql
-- 验证字段已删除
\d tb_order
\d tb_agent_wallet_transaction
-- 验证索引已删除
SELECT indexname FROM pg_indexes WHERE tablename = 'tb_order';
```
#### 步骤 4恢复应用旧版本代码
```bash
# 回滚代码到上一版本
git checkout <previous_commit>
# 重新编译
go build -o api cmd/api/main.go
# 启动应用
systemctl start junhong-cmp-api
```
---
## 代码部署
### 灰度发布计划
**阶段 1灰度服务器10% 流量)**
**时间**:低峰期(周一至周五 02:00 - 04:00
**步骤**
1. 部署代码到灰度服务器
2. 切换 10% 流量到灰度服务器
3. 观察 2 小时,监控关键指标
4. 手工测试代理自购、代理代购场景
**验证项**
- [ ] 应用启动成功
- [ ] 健康检查通过:`curl http://localhost:8080/health`
- [ ] 订单创建成功率 > 95%
- [ ] 钱包扣款成功率 > 99%
- [ ] 无严重错误日志
---
**阶段 2全量发布100% 流量)**
**时间**:灰度验证通过后 24 小时
**步骤**
1. 部署代码到所有服务器
2. 逐步切换流量20% → 50% → 100%
3. 持续监控 24 小时
**验证项**
- [ ] 所有服务器应用启动成功
- [ ] 订单创建成功率 > 95%
- [ ] 钱包扣款成功率 > 99%
- [ ] 错误日志无异常峰值
- [ ] 用户反馈无异常
---
### 发布命令
**构建**
```bash
# 构建二进制文件
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o api cmd/api/main.go
# 验证版本
./api --version
```
**部署**
```bash
# 停止服务
systemctl stop junhong-cmp-api
# 备份旧版本
cp /opt/junhong-cmp/api /opt/junhong-cmp/api.backup
# 替换新版本
cp api /opt/junhong-cmp/api
# 启动服务
systemctl start junhong-cmp-api
# 检查状态
systemctl status junhong-cmp-api
```
**验证**
```bash
# 健康检查
curl http://localhost:8080/health
# 查看日志
journalctl -u junhong-cmp-api -f
```
---
## 监控指标
### 关键业务指标
**订单创建**
- 订单创建成功率(总体)
- 订单创建成功率(按 payment_method 分组)
- 订单创建耗时P50、P95、P99
- 订单创建 QPS
**钱包扣款**
- 钱包扣款成功率
- 钱包扣款失败原因分布(余额不足、并发冲突、其他)
- 钱包余额不足次数
**订单查询**
- 订单列表查询耗时P95
- OR 查询性能(慢查询日志)
---
### 错误日志监控
**关键错误**
```bash
# 余额不足
grep "余额不足" /var/log/junhong-cmp/app.log | wc -l
# 并发冲突
grep "并发冲突" /var/log/junhong-cmp/app.log | wc -l
# 套餐激活失败
grep "套餐激活失败" /var/log/junhong-cmp/app.log | wc -l
# 成本价查询失败
grep "店铺没有该套餐的分配配置" /var/log/junhong-cmp/app.log | wc -l
```
---
### 数据库性能监控
**慢查询**
```sql
-- 查看慢查询
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
WHERE query LIKE '%tb_order%'
AND mean_time > 100
ORDER BY mean_time DESC
LIMIT 10;
```
**索引使用率**
```sql
-- 检查新索引是否被使用
SELECT schemaname, tablename, indexname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE indexname IN ('idx_orders_operator_id', 'idx_orders_purchase_role');
```
**OR 查询性能**
```sql
-- EXPLAIN 分析
EXPLAIN ANALYZE
SELECT * FROM tb_order
WHERE (buyer_type = 'agent' AND buyer_id = 10) OR operator_id = 10
LIMIT 20;
```
---
### 告警规则
**业务告警**
| 指标 | 阈值 | 级别 |
|------|------|------|
| 订单创建成功率 | < 95% | P1 |
| 钱包扣款成功率 | < 99% | P1 |
| 订单创建耗时 P99 | > 1000ms | P2 |
| 并发冲突次数 | > 100/分钟 | P2 |
| 余额不足次数 | > 500/小时 | P3 |
**系统告警**
| 指标 | 阈值 | 级别 |
|------|------|------|
| 应用进程退出 | - | P0 |
| 数据库连接数 | > 80% | P1 |
| 慢查询(订单相关) | > 1000ms | P2 |
---
## 验证测试
### 功能验证清单
**代理自购**
- [ ] 创建订单成功
- [ ] 钱包余额正确扣减
- [ ] 订单状态为已支付
- [ ] 套餐已激活
- [ ] 钱包流水记录正确transaction_subtype = "self_purchase"
- [ ] 订单响应字段完整operator_id、purchase_role 等)
**代理代购**
- [ ] 创建订单成功
- [ ] 钱包余额按操作者成本价扣减
- [ ] 订单金额显示买家成本价
- [ ] actual_paid_amount 为操作者成本价
- [ ] 套餐已激活
- [ ] 钱包流水记录正确transaction_subtype = "purchase_for_subordinate"、related_shop_id、remark 包含店铺名称)
- [ ] 未产生佣金记录
**平台代购**
- [ ] 创建订单成功
- [ ] 钱包余额未扣减
- [ ] 订单状态为已支付
- [ ] 套餐已激活
- [ ] 产生佣金记录
- [ ] purchase_role = "purchased_by_platform"
**订单查询**
- [ ] 代理可查询作为买家或操作者的订单
- [ ] purchase_role 筛选生效
- [ ] 订单列表响应包含新字段
**边界场景**
- [ ] 余额不足时返回明确错误
- [ ] 并发扣款时乐观锁生效
- [ ] 幂等性检查防止重复创建
- [ ] H5 端 wallet 订单不受影响(仍为待支付)
---
### 性能验证
**压力测试**(可选):
```bash
# 订单创建并发测试
ab -n 1000 -c 50 -H "Authorization: Bearer <token>" \
-p order_request.json \
-T "application/json" \
http://localhost:8080/api/admin/orders
# 订单列表查询性能测试
ab -n 5000 -c 100 -H "Authorization: Bearer <token>" \
http://localhost:8080/api/admin/orders?page=1&page_size=20
```
**预期结果**
- 订单创建 QPS > 50
- 订单创建 P95 < 200ms
- 订单列表查询 P95 < 100ms
---
## 回滚预案
### 回滚触发条件
满足以下任一条件时立即回滚:
- 订单创建成功率 < 90%(持续 5 分钟)
- 钱包扣款成功率 < 95%(持续 5 分钟)
- 发现严重 Bug重复扣款、金额计算错误、数据丢失
- 用户投诉量激增
---
### 快速回滚步骤
**步骤 1立即回滚代码**< 5 分钟)
```bash
# 停止服务
systemctl stop junhong-cmp-api
# 恢复旧版本
cp /opt/junhong-cmp/api.backup /opt/junhong-cmp/api
# 启动服务
systemctl start junhong-cmp-api
```
**步骤 2回滚数据库**(可选,< 10 分钟)
仅当数据异常时执行:
```bash
# 执行回滚脚本
migrate -path migrations -database "..." down 2
```
**步骤 3验证回滚成功**
- [ ] 应用启动成功
- [ ] 健康检查通过
- [ ] 订单创建成功率恢复
- [ ] 用户反馈恢复正常
---
## 上线后观察
### 观察期7 天)
**每日检查**
- [ ] 订单创建成功率
- [ ] 钱包扣款成功率
- [ ] 错误日志无异常
- [ ] 用户反馈无异常
- [ ] 数据库慢查询无新增
**周报总结**
- 订单创建总量、成功率
- 钱包扣款总量、成功率
- 代理自购 vs 代理代购占比
- 错误类型分布
- 性能指标趋势
---
## 联系人
**技术负责人**[姓名]
**运维负责人**[姓名]
**产品负责人**[姓名]
**紧急联系方式**
- 技术值班电话:[电话]
- 运维值班电话:[电话]
---
## 附录
### 相关文档
- [功能总结](./功能总结.md)
- [提案文档](../../openspec/changes/fix-agent-wallet-order-creation/proposal.md)
- [设计文档](../../openspec/changes/fix-agent-wallet-order-creation/design.md)
- [任务清单](../../openspec/changes/fix-agent-wallet-order-creation/tasks.md)
### 迁移脚本内容
详见 `migrations/` 目录:
- `000067_add_operator_fields_to_orders.up.sql`
- `000067_add_operator_fields_to_orders.down.sql`
- `000068_add_transaction_subtype_to_wallet_transaction.up.sql`
- `000068_add_transaction_subtype_to_wallet_transaction.down.sql`
- `backfill_order_purchase_role.sql`

View File

@@ -41,13 +41,15 @@ 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"`
Amount int64 `gorm:"column:amount;type:bigint;not null;comment:变动金额(单位:分,正数为增加,负数为减少)" json:"amount"`
TransactionType string `gorm:"column:transaction_type;type:varchar(20);not null;comment:交易类型(recharge-充值 | 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"`
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"`
Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"`
Metadata *string `gorm:"column:metadata;type:jsonb;comment:扩展信息(如手续费、支付方式等)" json:"metadata,omitempty"`
Creator uint `gorm:"column:creator;not null;comment:创建人ID" json:"creator"`

View File

@@ -16,6 +16,7 @@ type OrderListRequest struct {
PaymentStatus *int `json:"payment_status" query:"payment_status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
OrderType string `json:"order_type" query:"order_type" validate:"omitempty,oneof=single_card device" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
OrderNo string `json:"order_no" query:"order_no" validate:"omitempty,max=30" maxLength:"30" description:"订单号(精确查询)"`
PurchaseRole string `json:"purchase_role" query:"purchase_role" validate:"omitempty,oneof=self_purchase purchased_by_parent purchased_by_platform purchase_for_subordinate" description:"订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买)"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"创建时间起始"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"创建时间结束"`
}
@@ -49,9 +50,21 @@ type OrderResponse struct {
IsPurchaseOnBehalf bool `json:"is_purchase_on_behalf" description:"是否为代购订单"`
CommissionStatus int `json:"commission_status" description:"佣金状态 (1:待计算, 2:已计算)"`
CommissionConfigVersion int `json:"commission_config_version" description:"佣金配置版本"`
Items []*OrderItemResponse `json:"items" description:"订单明细列表"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
// 操作者信息
OperatorID *uint `json:"operator_id,omitempty" description:"操作者ID"`
OperatorType string `json:"operator_type,omitempty" description:"操作者类型 (platform:平台, agent:代理)"`
OperatorName string `json:"operator_name,omitempty" description:"操作者名称"`
ActualPaidAmount *int64 `json:"actual_paid_amount,omitempty" description:"实际支付金额(分)"`
// 订单角色
PurchaseRole string `json:"purchase_role,omitempty" description:"订单角色 (self_purchase:自己购买, purchased_by_parent:上级代理购买, purchased_by_platform:平台代购, purchase_for_subordinate:给下级购买)"`
IsPurchasedByParent bool `json:"is_purchased_by_parent" description:"是否由上级代理购买"`
PurchaseRemark string `json:"purchase_remark,omitempty" description:"购买备注"`
Items []*OrderItemResponse `json:"items" description:"订单明细列表"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
type OrderListResponse struct {

View File

@@ -42,6 +42,16 @@ type Order struct {
// 代购信息
IsPurchaseOnBehalf bool `gorm:"column:is_purchase_on_behalf;type:boolean;default:false;comment:是否为代购订单" json:"is_purchase_on_behalf"`
// 操作者信息(谁下的单)
OperatorID *uint `gorm:"column:operator_id;index:idx_orders_operator_id;comment:操作者ID谁下的单" json:"operator_id,omitempty"`
OperatorType string `gorm:"column:operator_type;type:varchar(20);comment:操作者类型platform/agent" json:"operator_type,omitempty"`
// 实际支付金额(可能与订单金额不同,如代理代购场景)
ActualPaidAmount *int64 `gorm:"column:actual_paid_amount;type:bigint;comment:实际支付金额(分)" json:"actual_paid_amount,omitempty"`
// 订单角色(标识订单中的买卖关系)
PurchaseRole string `gorm:"column:purchase_role;type:varchar(50);index:idx_orders_purchase_role;comment:订单角色self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate" json:"purchase_role,omitempty"`
}
// TableName 指定表名
@@ -83,6 +93,14 @@ const (
CommissionStatusCalculated = 2 // 已计算
)
// 订单角色常量
const (
PurchaseRoleSelfPurchase = "self_purchase" // 自己购买
PurchaseRolePurchasedByParent = "purchased_by_parent" // 上级代理购买
PurchaseRolePurchasedByPlatform = "purchased_by_platform" // 平台代购
PurchaseRolePurchaseForSubordinate = "purchase_for_subordinate" // 给下级购买
)
// OrderItem 订单明细模型
// 记录订单中购买的套餐明细,支持一个订单购买多个套餐
type OrderItem struct {

View File

@@ -131,35 +131,37 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
}
userID := middleware.GetUserIDFromContext(ctx)
// 提取资源所属店铺ID
var resourceShopID *uint
var seriesID *uint
if validationResult.Card != nil {
resourceShopID = validationResult.Card.ShopID
seriesID = validationResult.Card.SeriesID
} else if validationResult.Device != nil {
resourceShopID = validationResult.Device.ShopID
seriesID = validationResult.Device.SeriesID
}
// 初始化订单字段
orderBuyerType := buyerType
orderBuyerID := buyerID
totalAmount := validationResult.TotalPrice
paymentMethod := req.PaymentMethod
paymentStatus := model.PaymentStatusPending
var paidAt *time.Time
now := time.Now()
isPurchaseOnBehalf := false
var seriesID *uint
var sellerShopID *uint
var operatorID *uint
operatorType := ""
var actualPaidAmount *int64
purchaseRole := ""
var sellerShopID *uint = resourceShopID
var sellerCostPrice int64
if validationResult.Card != nil {
seriesID = validationResult.Card.SeriesID
sellerShopID = validationResult.Card.ShopID
} else if validationResult.Device != nil {
seriesID = validationResult.Device.SeriesID
sellerShopID = validationResult.Device.ShopID
}
if sellerShopID != nil && len(validationResult.Packages) > 0 {
firstPackageID := validationResult.Packages[0].ID
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, *sellerShopID, firstPackageID)
if err == nil && allocation != nil {
sellerCostPrice = allocation.CostPrice
}
}
// 场景判断offline平台代购、wallet代理钱包支付、其他待支付
if req.PaymentMethod == model.PaymentMethodOffline {
// ==== 场景 1平台代购offline====
purchaseBuyerID, buyerCostPrice, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult)
if err != nil {
return nil, err
@@ -172,6 +174,82 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
paidAt = purchasePaidAt
isPurchaseOnBehalf = true
sellerCostPrice = buyerCostPrice
// 设置操作者信息(平台代购)
operatorID = nil
operatorType = constants.OwnerTypePlatform
purchaseRole = model.PurchaseRolePurchasedByPlatform
actualPaidAmount = nil
} else if req.PaymentMethod == model.PaymentMethodWallet {
// ==== 场景 2代理钱包支付wallet====
// 只有代理账号可以使用钱包支付
if buyerType != model.BuyerTypeAgent {
return nil, errors.New(errors.CodeInvalidParam, "只有代理账号可以使用钱包支付")
}
operatorShopID := buyerID
// 判断资源是否属于操作者
if resourceShopID == nil {
return nil, errors.New(errors.CodeInternalError, "资源店铺ID为空")
}
// 获取第一个套餐ID用于查询成本价
if len(validationResult.Packages) == 0 {
return nil, errors.New(errors.CodeInternalError, "套餐列表为空")
}
firstPackageID := validationResult.Packages[0].ID
if *resourceShopID == operatorShopID {
// ==== 子场景 2.1:代理自购 ====
costPrice, err := s.getCostPrice(ctx, operatorShopID, firstPackageID)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = operatorShopID
totalAmount = costPrice
paymentMethod = model.PaymentMethodWallet
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = false
operatorID = &operatorShopID
operatorType = "agent"
actualPaidAmountVal := costPrice
actualPaidAmount = &actualPaidAmountVal
purchaseRole = model.PurchaseRoleSelfPurchase
sellerCostPrice = costPrice
} else {
// ==== 子场景 2.2:代理代购(给下级购买)====
// 获取买家成本价
buyerCostPrice, err := s.getCostPrice(ctx, *resourceShopID, firstPackageID)
if err != nil {
return nil, err
}
// 获取操作者成本价
operatorCostPrice, err := s.getCostPrice(ctx, operatorShopID, firstPackageID)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = *resourceShopID
totalAmount = buyerCostPrice
paymentMethod = model.PaymentMethodWallet
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = true
operatorID = &operatorShopID
operatorType = "agent"
actualPaidAmount = &operatorCostPrice
purchaseRole = model.PurchaseRolePurchaseForSubordinate
sellerCostPrice = buyerCostPrice
}
}
order := &model.Order{
@@ -195,27 +273,48 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
SellerShopID: sellerShopID,
SellerCostPrice: sellerCostPrice,
IsPurchaseOnBehalf: isPurchaseOnBehalf,
OperatorID: operatorID,
OperatorType: operatorType,
ActualPaidAmount: actualPaidAmount,
PurchaseRole: purchaseRole,
}
items := s.buildOrderItems(userID, validationResult.Packages)
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
// 根据支付方式选择创建订单的方式
if req.PaymentMethod == model.PaymentMethodOffline {
// 平台代购:创建订单并立即激活套餐
if err := s.createOrderWithActivation(ctx, order, items); err != nil {
return nil, err
}
s.enqueueCommissionCalculation(ctx, order.ID)
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(order, items), nil
}
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, err
}
} else if req.PaymentMethod == model.PaymentMethodWallet {
// 钱包支付:创建订单、扣款、激活套餐(在事务中完成)
if operatorID == nil {
return nil, errors.New(errors.CodeInternalError, "钱包支付场景下 operatorID 不能为空")
}
operatorShopID := *operatorID
buyerShopID := orderBuyerID
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(order, items), nil
if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil {
return nil, err
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(order, items), nil
} else {
// 其他支付方式:创建待支付订单
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, err
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(order, items), nil
}
}
func (s *Service) resolvePurchaseOnBehalfInfo(ctx context.Context, result *purchase_validation.PurchaseValidationResult) (uint, int64, *time.Time, error) {
@@ -272,6 +371,184 @@ func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package) []
return items
}
// getCostPrice 查询店铺对套餐的成本价
// shopID: 店铺ID
// packageID: 套餐ID
// 返回成本价(分),如果查询失败返回错误
func (s *Service) getCostPrice(ctx context.Context, shopID uint, packageID uint) (int64, error) {
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeInvalidParam, "店铺没有该套餐的分配配置")
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐成本价失败")
}
return allocation.CostPrice, nil
}
// createWalletTransaction 创建钱包流水记录
// ctx: 上下文
// tx: 事务对象
// walletID: 钱包ID
// orderID: 订单ID
// amount: 扣款金额(正数)
// purchaseRole: 订单角色
// relatedShopID: 关联店铺ID代购场景填充下级店铺ID
func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, walletID uint, orderID uint, amount int64, purchaseRole string, relatedShopID *uint) error {
var subtype *string
remark := "购买套餐"
// 根据订单角色确定交易子类型和备注
switch purchaseRole {
case model.PurchaseRoleSelfPurchase:
subtypeVal := constants.WalletTransactionSubtypeSelfPurchase
subtype = &subtypeVal
case model.PurchaseRolePurchaseForSubordinate:
subtypeVal := constants.WalletTransactionSubtypePurchaseForSubordinate
subtype = &subtypeVal
// 查询下级店铺名称,填充到备注
if relatedShopID != nil {
var shop model.Shop
if err := tx.Where("id = ?", *relatedShopID).First(&shop).Error; err == nil {
remark = fmt.Sprintf("为下级代理【%s】购买套餐", shop.ShopName)
} else {
remark = "为下级代理购买套餐"
}
}
}
userID := middleware.GetUserIDFromContext(ctx)
// 创建钱包流水记录
transaction := &model.AgentWalletTransaction{
AgentWalletID: walletID,
ShopID: 0, // 将在下面从钱包记录获取
UserID: userID,
TransactionType: constants.AgentTransactionTypeDeduct,
TransactionSubtype: subtype,
Amount: -amount, // 扣款为负数
BalanceBefore: 0, // 将在下面填充
BalanceAfter: 0, // 将在下面填充
Status: constants.TransactionStatusSuccess,
ReferenceType: strPtr(constants.ReferenceTypeOrder),
ReferenceID: &orderID,
RelatedShopID: relatedShopID,
Remark: &remark,
Creator: userID,
ShopIDTag: 0, // 将在下面填充
EnterpriseIDTag: nil,
}
// 查询钱包记录,获取 shop_id 和余额信息
var wallet model.AgentWallet
if err := tx.Where("id = ?", walletID).First(&wallet).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包信息失败")
}
transaction.ShopID = wallet.ShopID
transaction.ShopIDTag = wallet.ShopIDTag
transaction.EnterpriseIDTag = wallet.EnterpriseIDTag
transaction.BalanceBefore = wallet.Balance
transaction.BalanceAfter = wallet.Balance - amount
if err := tx.Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包流水失败")
}
return nil
}
// strPtr 字符串指针辅助函数
func strPtr(s string) *string {
return &s
}
// createOrderWithWalletPayment 使用钱包支付创建订单并完成支付
// 包含余额检查、扣款、创建流水、激活套餐等操作,在事务中执行
// 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 {
if order.ActualPaidAmount == nil {
return errors.New(errors.CodeInternalError, "实际支付金额不能为空")
}
actualAmount := *order.ActualPaidAmount
// 1. 事务外:检查钱包余额(快速失败)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, operatorShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
if wallet.Balance < actualAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
// 2. 事务内:创建订单 + 扣款 + 创建流水 + 激活套餐
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 2.1 创建订单
if err := tx.Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
// 2.2 创建订单明细
for _, item := range items {
item.OrderID = order.ID
}
if err := tx.CreateInBatches(items, 100).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
}
// 2.3 扣减钱包余额(乐观锁)
result := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, actualAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", actualAmount),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减钱包余额失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
// 2.4 创建钱包流水
var relatedShopID *uint
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
relatedShopID = &buyerShopID
}
if err := s.createWalletTransaction(ctx, tx, wallet.ID, order.ID, actualAmount, order.PurchaseRole, relatedShopID); err != nil {
return err
}
// 2.5 激活套餐
if err := s.activatePackage(ctx, tx, order); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
// 3. 事务外:佣金计算(异步)
// 只有平台代购才入队佣金计算operator_id == nil
if order.OperatorID == nil {
s.enqueueCommissionCalculation(ctx, order.ID)
}
return nil
}
func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
@@ -329,6 +606,9 @@ func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType
if req.OrderNo != "" {
filters["order_no"] = req.OrderNo
}
if req.PurchaseRole != "" {
filters["purchase_role"] = req.PurchaseRole
}
if req.StartTime != nil {
filters["start_time"] = req.StartTime
}
@@ -1024,6 +1304,33 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
statusText = "已退款"
}
// 查询操作者名称
operatorName := ""
if order.OperatorType == "agent" && order.OperatorID != nil {
var shop model.Shop
if err := s.db.Where("id = ?", *order.OperatorID).First(&shop).Error; err == nil {
operatorName = shop.ShopName
}
}
// 生成派生字段
isPurchasedByParent := order.PurchaseRole == model.PurchaseRolePurchasedByParent
purchaseRemark := ""
switch order.PurchaseRole {
case model.PurchaseRolePurchasedByParent:
if operatorName != "" {
purchaseRemark = fmt.Sprintf("由上级代理【%s】购买", operatorName)
} else {
purchaseRemark = "由上级代理购买"
}
case model.PurchaseRolePurchasedByPlatform:
purchaseRemark = "由平台代购"
case model.PurchaseRolePurchaseForSubordinate:
if operatorName != "" {
purchaseRemark = fmt.Sprintf("由【%s】为下级购买", operatorName)
}
}
return &dto.OrderResponse{
ID: order.ID,
OrderNo: order.OrderNo,
@@ -1040,9 +1347,21 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
IsPurchaseOnBehalf: order.IsPurchaseOnBehalf,
CommissionStatus: order.CommissionStatus,
CommissionConfigVersion: order.CommissionConfigVersion,
Items: itemResponses,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
// 操作者信息
OperatorID: order.OperatorID,
OperatorType: order.OperatorType,
OperatorName: operatorName,
ActualPaidAmount: order.ActualPaidAmount,
// 订单角色
PurchaseRole: order.PurchaseRole,
IsPurchasedByParent: isPurchasedByParent,
PurchaseRemark: purchaseRemark,
Items: itemResponses,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
}
}

View File

@@ -90,8 +90,18 @@ func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters
var total int64
query := s.db.WithContext(ctx).Model(&model.Order{})
// 应用数据权限过滤(使用 seller_shop_id 字段)
query = middleware.ApplySellerShopFilter(ctx, query)
// 应用数据权限过滤
// 代理用户:可以查看作为买家或操作者的订单
// 平台用户/超管:可以查看所有订单
subordinateShopIDs := middleware.GetSubordinateShopIDs(ctx)
if len(subordinateShopIDs) > 0 {
// 代理用户WHERE (buyer_type = 'agent' AND buyer_id IN ?) OR operator_id IN ?
query = query.Where(
s.db.Where("buyer_type = ? AND buyer_id IN ?", model.BuyerTypeAgent, subordinateShopIDs).
Or("operator_id IN ?", subordinateShopIDs),
)
}
if v, ok := filters["payment_status"]; ok {
query = query.Where("payment_status = ?", v)
@@ -108,6 +118,9 @@ func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters
if v, ok := filters["buyer_id"]; ok {
query = query.Where("buyer_id = ?", v)
}
if v, ok := filters["purchase_role"]; ok {
query = query.Where("purchase_role = ?", v)
}
if v, ok := filters["iot_card_id"]; ok {
query = query.Where("iot_card_id = ?", v)
}

View File

@@ -0,0 +1,8 @@
-- 回滚订单表字段变更
DROP INDEX IF EXISTS idx_orders_operator_id;
DROP INDEX IF EXISTS idx_orders_purchase_role;
ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_id;
ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_type;
ALTER TABLE tb_order DROP COLUMN IF EXISTS actual_paid_amount;
ALTER TABLE tb_order DROP COLUMN IF EXISTS purchase_role;

View File

@@ -0,0 +1,16 @@
-- 添加订单操作者和角色字段
ALTER TABLE tb_order ADD COLUMN operator_id INT;
ALTER TABLE tb_order ADD COLUMN operator_type VARCHAR(20);
ALTER TABLE tb_order ADD COLUMN actual_paid_amount BIGINT;
ALTER TABLE tb_order ADD COLUMN purchase_role VARCHAR(50);
-- 添加字段注释
COMMENT ON COLUMN tb_order.operator_id IS '操作者ID谁下的单';
COMMENT ON COLUMN tb_order.operator_type IS '操作者类型platform/agent';
COMMENT ON COLUMN tb_order.actual_paid_amount IS '实际支付金额(分)';
COMMENT ON COLUMN tb_order.purchase_role IS '订单角色self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate';
-- 添加索引
-- 注意:生产环境建议手动执行 CREATE INDEX CONCURRENTLY 避免锁表
CREATE INDEX IF NOT EXISTS idx_orders_operator_id ON tb_order(operator_id);
CREATE INDEX IF NOT EXISTS idx_orders_purchase_role ON tb_order(purchase_role);

View File

@@ -0,0 +1,3 @@
-- 回滚钱包流水表字段变更
ALTER TABLE tb_agent_wallet_transaction DROP COLUMN IF EXISTS transaction_subtype;
ALTER TABLE tb_agent_wallet_transaction DROP COLUMN IF EXISTS related_shop_id;

View File

@@ -0,0 +1,24 @@
-- 检查并添加钱包流水表字段(如果不存在)
DO $$
BEGIN
-- 检查并添加 transaction_subtype 字段
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='tb_agent_wallet_transaction'
AND column_name='transaction_subtype'
) THEN
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN transaction_subtype VARCHAR(50);
COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_subtype IS '交易子类型(细分 order_payment 场景)';
END IF;
-- 检查并添加 related_shop_id 字段
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='tb_agent_wallet_transaction'
AND column_name='related_shop_id'
) THEN
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN related_shop_id INT;
COMMENT ON COLUMN tb_agent_wallet_transaction.related_shop_id IS '关联店铺ID代购时记录下级店铺';
END IF;
END
$$;

View File

@@ -0,0 +1,43 @@
-- 数据回填脚本:为历史订单填充 purchase_role 和 operator_type
-- 用途:将现有平台代购订单标记为 purchased_by_platform
-- 执行时间:预计 < 1 秒(取决于历史订单数量)
-- 回滚方法:将 purchase_role 和 operator_type 设为 NULL
BEGIN;
-- 1. 回填平台代购订单offline + is_purchase_on_behalf = true
UPDATE tb_order
SET
purchase_role = 'purchased_by_platform',
operator_type = 'platform'
WHERE
payment_method = 'offline'
AND is_purchase_on_behalf = true
AND purchase_role IS NULL;
-- 2. 显示回填统计信息
DO $$
DECLARE
updated_count INTEGER;
BEGIN
SELECT COUNT(*) INTO updated_count
FROM tb_order
WHERE purchase_role = 'purchased_by_platform'
AND operator_type = 'platform';
RAISE NOTICE '已回填 % 条平台代购订单', updated_count;
END $$;
COMMIT;
-- 验证回填结果
SELECT
purchase_role,
operator_type,
payment_method,
is_purchase_on_behalf,
COUNT(*) as count
FROM tb_order
WHERE purchase_role IS NOT NULL
GROUP BY purchase_role, operator_type, payment_method, is_purchase_on_behalf
ORDER BY count DESC;

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-28

View File

@@ -0,0 +1,617 @@
## Context
当前订单创建逻辑存在两条路径:
1. **平台代购offline**:平台为代理创建订单,使用 `offline` 支付方式,订单创建后立即标记已支付并激活套餐,不扣钱包
2. **其他支付方式wallet/wechat/alipay**:创建待支付订单(`payment_status = pending`),后续调用支付接口完成支付
问题在于:代理在后台使用 `wallet` 创建订单时,走的是第 2 条路径(创建待支付订单),但后台没有支付接口,导致订单无法完成。
**实际业务场景**:代理帮客户购买套餐(代购),需要从自己钱包扣款并立即激活套餐,这是一步完成的操作,不应该创建待支付订单。
**现有代码分析**
- `Service.Create()` 方法中,只有 `payment_method == "offline"` 才会调用 `createOrderWithActivation()`(立即完成)
- `wallet` 支付会直接调用 `orderStore.Create()`,创建待支付订单,不扣款不激活
- 缺少"操作者"和"买家"的区分,无法追踪代购关系
## Goals / Non-Goals
**Goals:**
1. 支持代理在后台使用 wallet 一步完成订单(检查余额 → 扣款 → 激活套餐)
2. 区分订单中的"操作者"(谁下单)和"买家"(资源所属者),支持数据追溯和业务分析
3. 正确处理三种代购场景的价格逻辑:
- 代理自购:订单金额 = 实际支付 = 自己成本价
- 代理代购(给下级):订单金额 = 下级成本价,实际支付 = 自己成本价
- 平台代购:订单金额 = 下级成本价,实际支付 = 0不扣款
4. 支持按订单角色筛选查询(自购、上级购买、平台购买、给下级购买)
5. 钱包流水记录支持场景区分和关联店铺追踪
6. 佣金逻辑调整:代理代购不产生佣金(已赚差价)
**Non-Goals:**
- 不修改 H5 端的支付流程H5 端仍然是两步:创建待支付订单 → 调用 WalletPay
- 不修改平台代购offline的现有逻辑
- 不支持代理在后台使用微信/支付宝支付(后台只支持 wallet 和 offline
- 不涉及个人客户C 端)的订单流程
## Decisions
### 决策 1新增订单角色追踪字段
**决策**:在 `tb_order` 表新增 4 个字段:
- `operator_id` (INT, nullable):操作者 ID店铺 ID
- `operator_type` (VARCHAR, nullable):操作者类型(`platform` / `agent`
- `actual_paid_amount` (BIGINT, nullable):实际支付金额(分)
- `purchase_role` (VARCHAR):订单角色枚举
**理由**
- 现有 `buyer_id` 字段只记录买家(资源所属者),无法区分"谁下单"和"谁买单"
- `operator_id` 记录操作者,支持追溯代购关系(如:平台为代理 A 代购,代理 A 为代理 B 代购)
- `actual_paid_amount` 记录实际扣款金额,与 `total_amount`(订单金额)可能不同(代理代购场景)
- `purchase_role` 枚举字段支持高效筛选,避免依赖文本备注
**替代方案(已拒绝)**
- ❌ 使用 `creator` 字段代替 `operator_id``creator` 是审计字段,语义不同,不应混用
- ❌ 使用 `remark` 字段记录代购信息:文本字段无法高效索引和筛选
- ❌ 创建单独的代购订单表:增加系统复杂度,查询需要 JOIN 多表
**`purchase_role` 枚举值**
```go
const (
PurchaseRoleSelfPurchase = "self_purchase" // 自己购买
PurchaseRolePurchasedByParent = "purchased_by_parent" // 上级代理购买
PurchaseRolePurchasedByPlatform = "purchased_by_platform" // 平台代购
PurchaseRolePurchaseForSubordinate = "purchase_for_subordinate" // 给下级购买
)
```
**索引设计**
- `idx_orders_operator_id` (operator_id):支持"我作为操作者的订单"查询
- `idx_orders_purchase_role` (purchase_role):支持按角色筛选
---
### 决策 2订单创建流程重构
**决策**:在 `Service.Create()` 方法中,根据 `payment_method` 和资源归属判断场景:
```
IF payment_method == "offline":
→ 平台代购场景(保持现有逻辑)
→ buyer = 资源所属者, operator = nil, operator_type = "platform"
→ 价格 = 买家成本价, 实际支付 = nil不扣款
→ purchase_role = "purchased_by_platform"
→ 调用 createOrderWithActivation()
ELSE IF payment_method == "wallet":
→ 获取操作者店铺 ID
→ 获取资源所属店铺 ID
IF 资源所属 == 操作者:
→ 代理自购场景
→ buyer = 操作者, operator = 操作者
→ 价格 = 操作者成本价, 实际支付 = 操作者成本价
→ purchase_role = "self_purchase"
→ is_purchase_on_behalf = false
ELSE:
→ 代理代购场景
→ buyer = 资源所属者, operator = 操作者
→ 价格 = 买家成本价, 实际支付 = 操作者成本价
→ purchase_role = "purchase_for_subordinate"
→ is_purchase_on_behalf = true
→ 调用 createOrderWithWalletPayment()
```
**理由**
- 清晰区分三种场景,每种场景的价格逻辑和字段填充规则不同
- 复用现有的 `createOrderWithActivation()`(平台代购)
- 新增 `createOrderWithWalletPayment()`(代理钱包支付,含扣款逻辑)
**替代方案(已拒绝)**
- ❌ 使用策略模式分离三种场景:过度设计,增加代码复杂度
- ❌ 在 Handler 层判断场景:违反分层架构,业务逻辑应在 Service 层
---
### 决策 3价格计算逻辑
**决策**:区分"订单金额"`total_amount`)和"实际支付"`actual_paid_amount`
| 场景 | 订单金额total_amount | 实际支付actual_paid_amount | 说明 |
|------|------------------------|------------------------------|------|
| 代理自购 | 操作者成本价 | 操作者成本价 | 两者相同 |
| 代理代购 | 买家成本价 | 操作者成本价 | 操作者实际扣款少于订单金额(赚取差价) |
| 平台代购 | 买家成本价 | NULL | 平台不扣款 |
**理由**
- **订单金额**面向买家:买家看到的应该是"他的成本价",这样才符合业务逻辑
- **实际支付**面向操作者:操作者实际扣款金额,用于钱包流水和财务对账
- 代理代购时,操作者按自己的成本价扣款,但订单显示下级成本价,差价即为利润
**示例**
```
一级代理 A 成本价80 元
二级代理 B 成本价100 元
A 为 B 的卡购买套餐:
- total_amount = 100B 看到的订单金额)
- actual_paid_amount = 80A 实际扣款)
- A 赚取差价20 元
```
**成本价查询**
```go
// 通过 ShopPackageAllocation 表查询店铺对套餐的成本价
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
costPrice := allocation.CostPrice
```
---
### 决策 4事务处理设计
**决策**:新增 `createOrderWithWalletPayment()` 方法,使用 GORM 事务确保原子性:
```go
func (s *Service) createOrderWithWalletPayment(
ctx context.Context,
order *model.Order,
items []*model.OrderItem,
operatorShopID uint,
buyerShopID uint,
) (*dto.OrderResponse, error) {
actualAmount := *order.ActualPaidAmount
// 1. 事务外:检查钱包余额(快速失败)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, operatorShopID)
if wallet.Balance < actualAmount {
return nil, errors.New(errors.CodeInsufficientBalance, "余额不足")
}
// 2. 事务内:创建订单 + 扣款 + 创建流水 + 激活套餐
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 2.1 创建订单
if err := tx.Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
// 2.2 创建订单明细
for _, item := range items {
item.OrderID = order.ID
}
if err := tx.CreateInBatches(items, 100).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
}
// 2.3 扣减钱包余额(乐观锁)
walletResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, actualAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", actualAmount),
"version": gorm.Expr("version + 1"),
})
if walletResult.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
// 2.4 创建钱包流水
if err := s.createWalletTransaction(ctx, tx, wallet.ID, order.ID, actualAmount, order.PurchaseRole, buyerShopID); err != nil {
return err
}
// 2.5 激活套餐
return s.activatePackage(ctx, tx, order)
})
// 3. 事务外:佣金计算(异步)
if order.OperatorID == nil {
s.enqueueCommissionCalculation(ctx, order.ID)
}
return s.buildOrderResponse(order, items), nil
}
```
**事务步骤顺序理由**
1. **先创建订单**:确保订单记录存在,后续步骤失败时可以回滚
2. **后扣款**:避免扣款成功但订单创建失败的情况(钱扣了但没有订单)
3. **最后激活套餐**:套餐激活依赖订单 ID必须在订单创建后执行
**乐观锁设计**
- 使用 `version` 字段防止并发扣款导致余额不一致
- `WHERE balance >= ?` 确保余额充足
- `RowsAffected == 0` 表示余额不足或版本冲突,回滚事务
**替代方案(已拒绝)**
- ❌ 使用悲观锁SELECT FOR UPDATE会降低并发性能乐观锁已足够
- ❌ 先扣款后创建订单:扣款成功但订单失败时难以回滚(需要补偿事务)
---
### 决策 5订单查询 OR 逻辑
**决策**:修改 `OrderStore.List()` 方法,支持 `buyer_id = shopID OR operator_id = shopID` 查询:
```go
func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Order, int64, error) {
query := s.db.WithContext(ctx).Model(&model.Order{})
// 代理用户:查询作为买家或操作者的订单
if shopID, ok := filters["shop_id"].(uint); ok {
query = query.Where(
"(buyer_type = ? AND buyer_id = ?) OR operator_id = ?",
model.BuyerTypeAgent, shopID, shopID,
)
delete(filters, "shop_id")
}
// 其他筛选条件...
}
```
**理由**
- 代理需要看到两类订单:
1. 作为买家的订单(`buyer_id = 自己`):别人为自己代购、自己购买
2. 作为操作者的订单(`operator_id = 自己`):自己为下级代购
- OR 查询在 PostgreSQL 中性能可接受(已有索引)
**性能优化**
- `buyer_id` 已有索引 `idx_order_buyer`
- `operator_id` 新建索引 `idx_orders_operator_id`
- OR 查询会走两个索引的 UNION性能可接受
**替代方案(已拒绝)**
- ❌ 两次查询后合并结果:代码复杂,分页逻辑难以实现
- ❌ 冗余存储(同一订单插入两次):违反数据一致性原则
---
### 决策 6钱包流水记录设计
**决策**:钱包流水表新增字段(如果不存在):
- `transaction_subtype` (VARCHAR):交易子类型,细分 `order_payment` 场景
- `related_shop_id` (INT, nullable):关联店铺 ID代购时记录下级店铺
**子类型枚举**(在 `pkg/constants/wallet.go` 中定义):
```go
// 钱包交易子类型(当 transaction_type = "order_payment" 时)
const (
WalletTransactionSubtypeSelfPurchase = "self_purchase"
WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate"
)
```
**流水创建逻辑**
```go
func (s *Service) createWalletTransaction(
ctx context.Context,
tx *gorm.DB,
walletID uint,
orderID uint,
amount int64,
purchaseRole string,
relatedShopID *uint,
) error {
var subtype string
var remark string
switch purchaseRole {
case constants.PurchaseRoleSelfPurchase:
subtype = constants.WalletTransactionSubtypeSelfPurchase
remark = "购买套餐"
case constants.PurchaseRolePurchaseForSubordinate:
subtype = constants.WalletTransactionSubtypePurchaseForSubordinate
// 查询下级店铺名称
buyerShop, _ := s.shopStore.GetByID(ctx, *relatedShopID)
if buyerShop != nil {
remark = fmt.Sprintf("为下级代理【%s】购买套餐", buyerShop.ShopName)
} else {
remark = "为下级代理购买套餐"
}
}
transaction := &model.AgentWalletTransaction{
WalletID: walletID,
TransactionType: constants.AgentTransactionTypeDeduct,
TransactionSubtype: subtype,
Amount: -amount,
OrderID: &orderID,
RelatedShopID: relatedShopID,
Remark: remark,
}
return tx.Create(transaction).Error
}
```
**理由**
- `transaction_subtype` 支持高效筛选(如:查询所有自购流水)
- `related_shop_id` 支持追溯(如:查询为哪些下级代理购买过)
- `remark` 作为辅助展示字段,包含店铺名称便于人工查看
---
### 决策 7佣金逻辑调整
**决策**:在 `Service.Create()` 方法中,只有 `operator_id == nil`(平台代购)才入队佣金计算任务:
```go
// 佣金计算(异步)
if order.OperatorID == nil {
// 只有平台代购才产生佣金
s.enqueueCommissionCalculation(ctx, order.ID)
}
// 代理代购不入队佣金operator_id != nil
```
**理由**
- **代理代购**:操作者从自己钱包扣自己的成本价,已经赚取了成本价差(自己成本价 vs 下级成本价),不应再产生佣金
- **平台代购**:平台不扣款,按买家成本价计算差价佣金,激励上级代理
**示例**
```
一级代理 A 成本价80 元
二级代理 B 成本价100 元
场景 1A 为 B 的卡购买套餐(代理代购)
- A 扣款80 元
- A 利润100 - 80 = 20 元
- 佣金A 已赚取差价)
场景 2平台为 B 的卡购买套餐(平台代购)
- 平台扣款0 元
- 订单金额100 元
- 佣金:给 AB 的上级),金额 = 100 - 80 = 20 元
```
**替代方案(已拒绝)**
- ❌ 代理代购也计算佣金:会导致双重利润(差价 + 佣金),不符合业务逻辑
---
### 决策 8常量定义位置
**决策**
- **订单角色枚举**`PurchaseRole*`):定义在 `internal/model/order.go`,紧邻 Order 模型
- **钱包交易子类型**:扩展现有 `pkg/constants/wallet.go`,新增 `WalletTransactionSubtype*` 常量
- **操作者类型**:复用现有 `pkg/constants/iot.go` 中的 `OwnerTypePlatform` 和自定义 `"agent"` 字符串
**理由**
- 订单角色枚举只在订单模块使用,放在 `model/order.go` 避免常量文件膨胀
- 钱包交易子类型属于钱包系统,应在 `wallet.go` 中管理
- 避免重复定义已有常量(如 `OwnerTypePlatform`
---
### 决策 9DTO 响应字段设计
**决策**`OrderResponse` 新增字段:
```go
type OrderResponse struct {
// ... 现有字段
// 操作者信息
OperatorID *uint `json:"operator_id,omitempty"`
OperatorType string `json:"operator_type,omitempty"`
OperatorName string `json:"operator_name,omitempty"`
ActualPaidAmount *int64 `json:"actual_paid_amount,omitempty"`
// 订单角色
PurchaseRole string `json:"purchase_role"`
IsPurchasedByParent bool `json:"is_purchased_by_parent"`
PurchaseRemark string `json:"purchase_remark,omitempty"`
}
```
**字段说明**
- `operator_id``operator_type``actual_paid_amount`:直接映射数据库字段
- `operator_name`查询操作者名称Shop 表的 `shop_name`),便于前端展示
- `purchase_role`:订单角色枚举,支持前端筛选和标签展示
- `is_purchased_by_parent`:派生字段,`purchase_role == "purchased_by_parent"`
- `purchase_remark`:派生字段,如"由上级代理【XX】购买"或"由平台代购"
**理由**
- 前端需要友好的文本展示(如操作者名称、购买备注)
- `is_purchased_by_parent` 便于前端判断是否显示"上级代购"标签
- `purchase_remark` 避免前端拼接文本逻辑
---
## Risks / Trade-offs
### [风险] 数据库迁移失败 → 回滚策略
**问题**:新增字段的迁移脚本可能在生产环境执行失败(如表锁、超时)
**缓解措施**
1. 所有新增字段设为 `nullable`,不影响现有数据
2. 迁移脚本分步执行:
- Step 1: 添加字段(不加 NOT NULL 约束)
- Step 2: 数据回填(如有需要)
- Step 3: 添加索引CONCURRENTLY 方式,不锁表)
3. 回滚脚本:`DROP COLUMN IF EXISTS`
4. 测试环境充分验证后再上生产
---
### [风险] OR 查询性能下降 → 索引优化
**问题**`WHERE (buyer_id = X) OR (operator_id = X)` 可能无法有效使用索引
**缓解措施**
1. 使用 `EXPLAIN ANALYZE` 验证查询计划
2. 确保两个字段都有索引
3. 如果性能不佳,考虑使用 UNION
```sql
SELECT * FROM tb_order WHERE buyer_id = X
UNION
SELECT * FROM tb_order WHERE operator_id = X
```
4. 监控慢查询日志,按需优化
**预期性能**
- 单表查询,无 JOIN
- 订单表数据量级:百万级
- OR 查询在 PostgreSQL 中会走 BITMAP INDEX SCAN性能可接受
---
### [权衡] 两个金额字段增加存储成本 → 业务清晰度
**问题**`total_amount` 和 `actual_paid_amount` 在大部分场景下相同(代理自购),存在冗余
**权衡理由**
- **优势**:业务语义清晰,查询无需计算(如:统计实际收入用 `actual_paid_amount`
- **劣势**:存储成本增加(每订单 8 字节),数据冗余
- **结论**业务清晰度优先存储成本可接受8 字节在订单数据中占比很小)
---
### [权衡] 钱包流水查询店铺名称 → 性能 vs 便利性
**问题**:创建钱包流水时查询店铺名称(`shopStore.GetByID`)会增加一次数据库查询
**权衡理由**
- **优势**:备注字段包含店铺名称,便于人工查看,无需二次查询
- **劣势**:订单创建时增加一次查询(~5ms
- **结论**:可接受,因为:
1. 查询频率低(仅代购场景)
2. Shop 表有缓存机制
3. 事务内查询,不影响一致性
4. 如果性能敏感,可以异步更新备注
---
### [风险] 佣金逻辑调整导致收入计算错误 → 回归测试
**问题**:佣金计算逻辑变更可能影响现有代理的收入
**缓解措施**
1. 充分的单元测试和集成测试
2. 上线前在测试环境验证佣金计算结果
3. 灰度发布,监控佣金数据异常
4. 保留 `operator_id == nil` 的判断逻辑,避免误伤平台代购
---
## Migration Plan
### 数据库迁移步骤
**Step 1: 创建迁移脚本**
文件:`migrations/xxx_add_operator_fields_to_orders.up.sql`
```sql
-- 添加字段nullable
ALTER TABLE tb_order ADD COLUMN operator_id INT;
ALTER TABLE tb_order ADD COLUMN operator_type VARCHAR(20);
ALTER TABLE tb_order ADD COLUMN actual_paid_amount BIGINT;
ALTER TABLE tb_order ADD COLUMN purchase_role VARCHAR(50);
-- 添加注释
COMMENT ON COLUMN tb_order.operator_id IS '操作者ID谁下的单';
COMMENT ON COLUMN tb_order.operator_type IS '操作者类型platform/agent';
COMMENT ON COLUMN tb_order.actual_paid_amount IS '实际支付金额(分)';
COMMENT ON COLUMN tb_order.purchase_role IS '订单角色self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate';
-- 添加索引CONCURRENTLY 避免锁表)
CREATE INDEX CONCURRENTLY idx_orders_operator_id ON tb_order(operator_id);
CREATE INDEX CONCURRENTLY idx_orders_purchase_role ON tb_order(purchase_role);
```
**Step 2: 钱包流水表迁移**(如果字段不存在)
文件:`migrations/xxx_add_transaction_subtype_to_wallet_transaction.up.sql`
```sql
-- 检查字段是否存在,不存在则添加
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='tb_agent_wallet_transaction'
AND column_name='transaction_subtype') THEN
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN transaction_subtype VARCHAR(50);
COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_subtype IS '交易子类型(细分 order_payment 场景)';
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name='tb_agent_wallet_transaction'
AND column_name='related_shop_id') THEN
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN related_shop_id INT;
COMMENT ON COLUMN tb_agent_wallet_transaction.related_shop_id IS '关联店铺ID代购时记录下级店铺';
END IF;
END
$$;
```
**Step 3: 数据回填**(可选,视历史数据需求而定)
```sql
-- 回填现有订单的 purchase_role
-- 平台代购订单offline
UPDATE tb_order
SET purchase_role = 'purchased_by_platform',
operator_type = 'platform'
WHERE payment_method = 'offline'
AND is_purchase_on_behalf = true
AND purchase_role IS NULL;
-- 其他订单暂不回填(保持 NULL不影响业务
```
### 部署步骤
1. **测试环境验证**
- 执行迁移脚本
- 验证索引创建成功
- 运行集成测试
- 手工测试三种代购场景
2. **灰度发布**
- 代码部署到灰度环境
- 观察日志和监控指标
- 验证订单创建、查询、钱包扣款功能
3. **生产环境部署**
- 低峰期执行数据库迁移
- 部署代码
- 监控错误日志和业务指标
- 验证核心功能
### 回滚策略
**代码回滚**
- 回滚到上一版本代码即可,新增字段为 `nullable`,不影响老代码
**数据库回滚**
- 文件:`migrations/xxx_add_operator_fields_to_orders.down.sql`
```sql
DROP INDEX IF EXISTS idx_orders_operator_id;
DROP INDEX IF EXISTS idx_orders_purchase_role;
ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_id;
ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_type;
ALTER TABLE tb_order DROP COLUMN IF EXISTS actual_paid_amount;
ALTER TABLE tb_order DROP COLUMN IF EXISTS purchase_role;
```
---
## Open Questions
1. **是否需要支持代理在后台为个人客户C 端)代购?**
- 当前设计只支持代理为代理B2B不支持代理为个人客户B2C
- 如果未来需要,需要扩展 `buyer_type` 判断逻辑
2. **钱包流水的 `related_shop_id` 是否需要索引?**
- 当前设计未加索引,因为查询频率低
- 如果未来需要"查询我为哪些下级购买过"功能,需要添加索引
3. **是否需要支持订单角色的批量变更?**
- 当前设计 `purchase_role` 在订单创建时填充,后续不可修改
- 如果历史订单需要回填角色,需要单独的数据修复脚本
4. **代理代购时,下级代理是否能看到操作者信息?**
- 当前设计:下级可以看到 `operator_id` 和 `operator_name`
- 是否需要隐藏?(业务决策)

View File

@@ -0,0 +1,61 @@
## Why
代理在后台使用钱包支付wallet创建订单时系统只创建待支付订单payment_status = pending不扣款也不激活套餐导致订单无法完成。后台没有支付接口代理无法对待支付订单进行支付。这个问题阻塞了代理的核心业务场景代理帮客户购买套餐代购从自己钱包扣款并立即激活。
## What Changes
- **新增订单角色追踪字段**:在订单表中新增 `operator_id`操作者ID`operator_type`(操作者类型)、`actual_paid_amount`(实际支付金额)、`purchase_role`(订单角色)字段,用于区分"谁下单"和"谁买单"
- **支持代理钱包一步购买**:代理在后台使用 wallet 创建订单时,立即检查余额、扣款、激活套餐,订单状态直接为已支付(一步完成,无需后续支付接口)
- **区分代购场景**
- 代理自购(资源属于自己):从自己钱包扣自己的成本价,订单金额 = 实际支付
- 代理代购(资源属于下级):从自己钱包扣自己的成本价,但订单金额显示下级成本价(让下级看到他的成本)
- 平台代购offline保持现有逻辑不扣款立即激活
- **订单查询增强**:代理可以查询 `buyer_id = 自己``operator_id = 自己` 的订单(看到自己作为买家或操作者的所有订单)
- **钱包流水记录**:钱包扣款时记录交易子类型(自购 / 给下级购买和关联店铺ID支持按场景筛选
- **佣金逻辑调整**:代理代购订单不产生佣金(操作者已赚取成本价差),只有平台代购才触发佣金计算
## Capabilities
### New Capabilities
- `agent-order-role-tracking`: 订单角色追踪能力,记录并区分订单中的操作者、买家、支付者等角色关系,支持多种代购场景的数据查询和业务分析
### Modified Capabilities
- `purchase-on-behalf`: 扩展代购订单需求新增代理使用钱包wallet代购的场景区别于现有的平台线下offline代购
- `order-payment`: 新增后台订单钱包一步支付需求,代理创建订单时立即扣款并激活套餐,区别于 H5 端的两步支付流程(创建待支付订单 → 调用支付接口)
## Impact
### 数据库变更
- **订单表**`tb_order`)新增字段:
- `operator_id` (INT, 可空)操作者ID
- `operator_type` (VARCHAR, 可空)操作者类型platform/agent
- `actual_paid_amount` (BIGINT, 可空):实际支付金额(分)
- `purchase_role` (VARCHAR)订单角色枚举self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate
- 新增索引:`idx_orders_operator_id``idx_orders_purchase_role`
- **钱包流水表**`tb_agent_wallet_transaction`)新增/确认字段(如果不存在):
- `transaction_subtype` (VARCHAR):交易子类型(细分 order_payment 场景)
- `related_shop_id` (INT, 可空)关联店铺ID代购时记录下级店铺
### 受影响的模块
- `internal/model/order.go`:新增字段和枚举常量
- `internal/model/agent_wallet.go`:确认流水表字段
- `internal/model/dto/order_dto.go`OrderResponse 新增字段OrderListRequest 新增筛选参数
- `internal/service/order/service.go`:重构 `Create()` 方法,新增 `createOrderWithWalletPayment()` 方法
- `internal/store/postgres/order_store.go`:修改 `List()` 支持 OR 查询buyer_id 或 operator_id
- `internal/handler/admin/order.go`:调整权限检查和查询逻辑
### API 影响
- **后台订单创建 API**POST `/api/admin/orders`
- 行为变更:代理使用 wallet 支付时订单直接完成payment_status = paid无需后续支付
- 响应新增字段:`operator_id`, `operator_type`, `actual_paid_amount`, `purchase_role`, `is_purchased_by_parent`, `purchase_remark`
- **订单列表 API**GET `/api/admin/orders`
- 新增查询参数:`purchase_role`(可选,筛选订单角色类型)
- 查询逻辑变更:代理可以看到作为操作者或买家的所有订单
### 兼容性
- **向后兼容**:现有订单字段为空值,不影响已有订单查询
- **平台代购offline逻辑不变**:保持现有行为
- **H5 钱包支付不受影响**H5 端仍使用两步流程(创建待支付订单 → 调用 WalletPay 接口)

View File

@@ -0,0 +1,167 @@
# Capability: 订单角色追踪
## Purpose
本 capability 定义订单角色追踪能力,记录并区分订单中的操作者、买家、支付者等角色关系,支持多种代购场景的数据查询和业务分析。
## ADDED Requirements
### Requirement: 订单操作者记录
系统 SHALL 在订单创建时记录操作者信息(谁下的单),区别于买家信息(资源所属者)。
#### Scenario: 平台创建订单
- **WHEN** 平台账号创建订单
- **THEN** 订单的 `operator_id` 为 NULL`operator_type` 为 "platform"
#### Scenario: 代理创建订单
- **WHEN** 代理账号创建订单
- **THEN** 订单的 `operator_id` 为代理店铺 ID`operator_type` 为 "agent"
#### Scenario: 代理自购
- **WHEN** 代理为自己的资源创建订单
- **THEN** 订单的 `buyer_id` 等于 `operator_id`
#### Scenario: 代理代购
- **WHEN** 代理为下级代理的资源创建订单
- **THEN** 订单的 `buyer_id` 为资源所属店铺 ID`operator_id` 为操作者店铺 ID两者不同
---
### Requirement: 实际支付金额记录
系统 SHALL 记录订单的实际支付金额,区别于订单金额(买家视角的价格)。
#### Scenario: 代理自购订单
- **WHEN** 代理为自己的资源创建订单,成本价 80 元
- **THEN** 订单的 `total_amount` = 80 元,`actual_paid_amount` = 80 元
#### Scenario: 代理代购订单
- **WHEN** 一级代理(成本价 80 元)为二级代理(成本价 100 元)的资源创建订单
- **THEN** 订单的 `total_amount` = 100 元(买家成本价),`actual_paid_amount` = 80 元(操作者实际扣款)
#### Scenario: 平台代购订单
- **WHEN** 平台为代理创建订单
- **THEN** 订单的 `total_amount` = 代理成本价,`actual_paid_amount` 为 NULL平台不扣款
---
### Requirement: 订单角色枚举
系统 SHALL 使用 `purchase_role` 字段标识订单角色关系,支持高效筛选。
#### Scenario: 自己购买
- **WHEN** 代理为自己的资源创建订单
- **THEN** 订单的 `purchase_role` = "self_purchase"
#### Scenario: 上级代理购买
- **WHEN** 代理查询作为买家的订单,且 `operator_id` 不为 NULL 且不等于 `buyer_id`
- **THEN** 该订单的 `purchase_role` = "purchased_by_parent"(从买家视角)或 "purchase_for_subordinate"(从操作者视角)
#### Scenario: 平台代购
- **WHEN** 平台为代理创建订单
- **THEN** 订单的 `purchase_role` = "purchased_by_platform"
#### Scenario: 给下级购买
- **WHEN** 代理为下级代理的资源创建订单
- **THEN** 订单的 `purchase_role` = "purchase_for_subordinate"
---
### Requirement: 订单查询增强
系统 SHALL 支持代理查询作为买家或操作者的所有订单。
#### Scenario: 代理查询自己相关的订单
- **WHEN** 代理查询订单列表
- **THEN** 系统返回 `buyer_id = 代理店铺 ID``operator_id = 代理店铺 ID` 的所有订单
#### Scenario: 按订单角色筛选
- **WHEN** 代理查询订单列表,指定 `purchase_role = "self_purchase"`
- **THEN** 系统只返回自己购买的订单
#### Scenario: 按订单角色筛选给下级购买的订单
- **WHEN** 代理查询订单列表,指定 `purchase_role = "purchase_for_subordinate"`
- **THEN** 系统只返回为下级代理购买的订单
---
### Requirement: 订单响应包含角色信息
系统 SHALL 在订单响应中包含操作者和角色信息,支持前端展示。
#### Scenario: 订单响应包含操作者 ID
- **WHEN** 查询订单详情
- **THEN** 响应包含 `operator_id``operator_type` 字段
#### Scenario: 订单响应包含操作者名称
- **WHEN** 查询订单详情,且 `operator_type = "agent"`
- **THEN** 响应包含 `operator_name` 字段(从 Shop 表查询)
#### Scenario: 订单响应包含角色标识
- **WHEN** 查询订单详情
- **THEN** 响应包含 `purchase_role``is_purchased_by_parent``purchase_remark` 字段
#### Scenario: 上级代购订单的备注
- **WHEN** 查询上级代理购买的订单
- **THEN** `purchase_remark` 为"由上级代理【XX】购买"
#### Scenario: 平台代购订单的备注
- **WHEN** 查询平台代购的订单
- **THEN** `purchase_remark` 为"由平台代购"
---
### Requirement: 数据权限保持一致
系统 SHALL 确保订单角色追踪不影响现有数据权限逻辑。
#### Scenario: 代理只能查询有权限的订单
- **WHEN** 代理查询订单列表
- **THEN** 系统应用数据权限过滤,只返回 `buyer_id``operator_id` 在权限范围内的订单
#### Scenario: 平台可查询所有订单
- **WHEN** 平台账号查询订单列表
- **THEN** 系统不应用数据权限过滤,返回所有订单
---
### Requirement: 订单角色常量定义
系统 SHALL 在 `internal/model/order.go` 中定义订单角色枚举常量。
#### Scenario: 订单角色枚举值
- **WHEN** 代码中使用订单角色
- **THEN** 可用的枚举值包括:
- `PurchaseRoleSelfPurchase` = "self_purchase"
- `PurchaseRolePurchasedByParent` = "purchased_by_parent"
- `PurchaseRolePurchasedByPlatform` = "purchased_by_platform"
- `PurchaseRolePurchaseForSubordinate` = "purchase_for_subordinate"
---
### Requirement: 数据库索引支持
系统 SHALL 为订单角色追踪字段创建索引,支持高效查询。
#### Scenario: operator_id 索引
- **WHEN** 查询 `operator_id = X` 的订单
- **THEN** 数据库使用 `idx_orders_operator_id` 索引
#### Scenario: purchase_role 索引
- **WHEN** 查询 `purchase_role = 'self_purchase'` 的订单
- **THEN** 数据库使用 `idx_orders_purchase_role` 索引
---
### Requirement: 向后兼容性
系统 SHALL 确保新增字段不影响现有订单数据和查询。
#### Scenario: 现有订单字段为 NULL
- **WHEN** 查询历史订单
- **THEN** `operator_id``operator_type``actual_paid_amount``purchase_role` 字段为 NULL 或空值,不影响查询结果
#### Scenario: 订单列表查询兼容
- **WHEN** 代理查询订单列表,不指定 `purchase_role` 筛选
- **THEN** 系统返回所有订单包括历史订单role 为 NULL

View File

@@ -0,0 +1,159 @@
## ADDED Requirements
### Requirement: 后台钱包一步支付
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。
#### Scenario: 后台订单创建时钱包支付
- **WHEN** 代理在后台创建订单,支付方式为 wallet钱包余额充足
- **THEN** 系统创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2激活套餐
#### Scenario: 后台钱包支付余额不足
- **WHEN** 代理在后台创建订单,支付方式为 wallet钱包余额不足
- **THEN** 系统返回错误"余额不足",订单创建失败
#### Scenario: 后台钱包支付订单响应
- **WHEN** 后台钱包支付订单创建成功
- **THEN** API 响应包含已支付的订单信息,`payment_status` = 2`payment_method` = "wallet"`paid_at` 为当前时间
#### Scenario: 后台钱包支付不创建待支付订单
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** 系统不创建待支付订单(`payment_status` != 1直接完成支付
---
### Requirement: H5 钱包两步支付保持不变
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。
#### Scenario: H5 创建待支付订单
- **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet
- **THEN** 系统创建订单,`payment_status` = 1待支付不扣减钱包余额
#### Scenario: H5 调用 WalletPay 接口支付
- **WHEN** 个人客户调用 WalletPay 接口支付待支付订单
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐
#### Scenario: H5 和后台钱包支付流程独立
- **WHEN** H5 端创建 wallet 订单
- **THEN** 不影响后台 wallet 订单的一步支付逻辑
---
### Requirement: 钱包流水记录扩展
系统 SHALL 在钱包流水中记录交易子类型和关联店铺,支持按场景筛选。
#### Scenario: 自购钱包流水
- **WHEN** 代理为自己的资源购买套餐,使用 wallet
- **THEN** 钱包流水的 `transaction_subtype` = "self_purchase"`related_shop_id` 为 NULL`remark` = "购买套餐"
#### Scenario: 代购钱包流水
- **WHEN** 代理为下级代理购买套餐,使用 wallet
- **THEN** 钱包流水的 `transaction_subtype` = "purchase_for_subordinate"`related_shop_id` = 下级代理店铺 ID`remark` = "为下级代理【XX】购买套餐"
#### Scenario: 钱包流水查询店铺名称
- **WHEN** 创建代购钱包流水
- **THEN** 系统查询下级店铺名称,填充到 `remark` 字段
#### Scenario: 钱包流水筛选
- **WHEN** 代理查询钱包流水,筛选 `transaction_subtype` = "purchase_for_subordinate"
- **THEN** 系统返回所有为下级代理购买的流水记录
---
### Requirement: 钱包支付乐观锁
系统 SHALL 使用乐观锁防止钱包并发扣款导致余额不一致。
#### Scenario: 钱包扣款使用 version 字段
- **WHEN** 扣减钱包余额
- **THEN** SQL 语句包含 `WHERE balance >= ? AND version = ?`,更新时 `version + 1`
#### Scenario: 钱包并发扣款失败
- **WHEN** 两个请求同时扣减同一钱包
- **THEN** 只有一个请求成功,另一个返回"余额不足或并发冲突"
#### Scenario: 乐观锁重试逻辑
- **WHEN** 钱包扣款因 version 冲突失败
- **THEN** 系统不自动重试,返回错误(由客户端决定是否重试)
---
### Requirement: 钱包支付幂等性
系统 SHALL 防止同一订单重复创建和重复扣款。
#### Scenario: 订单创建幂等性检查
- **WHEN** 同一买家对同一载体的同一套餐组合在短时间内重复创建订单
- **THEN** 系统返回已创建的订单,不重复扣款
#### Scenario: 幂等性使用 Redis 业务键
- **WHEN** 检查订单幂等性
- **THEN** 系统使用 Redis key `order:idempotency:{buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}`
#### Scenario: 幂等性 TTL
- **WHEN** 订单创建成功后标记幂等性
- **THEN** Redis key 的 TTL 为 3 分钟
#### Scenario: 分布式锁防止并发
- **WHEN** 订单创建前检查幂等性
- **THEN** 系统使用分布式锁 `order:create:lock:{carrier_type}:{carrier_id}`TTL 10 秒
---
### Requirement: 后台订单 API 响应扩展
系统 SHALL 在后台订单创建和查询 API 响应中包含钱包支付相关字段。
#### Scenario: 订单响应包含实际支付金额
- **WHEN** 查询钱包支付的订单
- **THEN** 响应包含 `actual_paid_amount` 字段
#### Scenario: 订单响应包含操作者信息
- **WHEN** 查询代购订单
- **THEN** 响应包含 `operator_id``operator_type``operator_name` 字段
#### Scenario: 订单响应包含购买备注
- **WHEN** 查询上级代理购买的订单
- **THEN** 响应包含 `purchase_remark` 字段,如"由上级代理【XX】购买"
---
### Requirement: 钱包支付错误处理
系统 SHALL 在钱包支付失败时返回明确的错误信息。
#### Scenario: 钱包不存在
- **WHEN** 钱包支付时钱包不存在
- **THEN** 系统返回错误"钱包不存在"`CodeWalletNotFound`
#### Scenario: 余额不足
- **WHEN** 钱包支付时余额不足
- **THEN** 系统返回错误"余额不足"`CodeInsufficientBalance`
#### Scenario: 并发冲突
- **WHEN** 钱包扣款因 version 冲突失败
- **THEN** 系统返回错误"余额不足或并发冲突"`CodeInsufficientBalance`
#### Scenario: 套餐激活失败
- **WHEN** 钱包扣款成功但套餐激活失败
- **THEN** 事务回滚,钱包余额恢复,返回激活失败错误
---
### Requirement: 钱包支付与第三方支付的区别
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。
#### Scenario: 后台不支持第三方支付
- **WHEN** 代理在后台创建订单时选择 wechat 或 alipay
- **THEN** 系统返回错误"后台只支持 wallet 和 offline 支付方式"
#### Scenario: H5 支持第三方支付
- **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay
- **THEN** 系统创建待支付订单返回支付参数prepay_id 或 h5_url
#### Scenario: 钱包支付不需要支付参数
- **WHEN** 后台钱包支付订单创建成功
- **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数

View File

@@ -0,0 +1,131 @@
## ADDED Requirements
### Requirement: 代理钱包代购
系统 SHALL 允许代理使用钱包支付wallet为下级代理创建代购订单从自己钱包扣款并立即激活套餐。
#### Scenario: 代理为下级代理钱包代购
- **WHEN** 代理选择下级代理的资源创建订单,支付方式为 wallet
- **THEN** 系统创建订单,`buyer_id` = 下级代理店铺 ID`operator_id` = 操作者店铺 ID`is_purchase_on_behalf` = true`payment_method` = "wallet"`payment_status` = 2已支付
#### Scenario: 钱包代购扣款操作者钱包
- **WHEN** 代理使用 wallet 为下级代理购买套餐
- **THEN** 系统从操作者(上级代理)的钱包扣款
#### Scenario: 钱包代购使用操作者成本价扣款
- **WHEN** 一级代理(成本价 80 元)为二级代理(成本价 100 元)的资源创建 wallet 代购订单
- **THEN** 系统从一级代理钱包扣款 80 元(操作者成本价)
#### Scenario: 钱包代购订单金额显示买家成本价
- **WHEN** 一级代理为二级代理钱包代购
- **THEN** 订单的 `total_amount` = 100 元(买家成本价),`actual_paid_amount` = 80 元(操作者实际扣款)
#### Scenario: 钱包代购余额不足
- **WHEN** 代理使用 wallet 代购,但钱包余额不足
- **THEN** 系统返回错误"余额不足",订单创建失败
#### Scenario: 钱包代购自动激活套餐
- **WHEN** 钱包代购订单创建成功
- **THEN** 系统自动激活套餐(创建 PackageUsage 记录)
#### Scenario: 钱包代购不触发佣金
- **WHEN** 代理使用 wallet 代购订单完成
- **THEN** 系统不计算佣金,不发放佣金(操作者已赚取成本价差)
#### Scenario: 钱包代购创建钱包流水
- **WHEN** 代理使用 wallet 代购扣款成功
- **THEN** 系统创建钱包流水记录,`transaction_type` = "deduct"`transaction_subtype` = "purchase_for_subordinate"`related_shop_id` = 下级代理店铺 ID
---
### Requirement: 代理自购使用钱包
系统 SHALL 允许代理使用钱包支付为自己的资源购买套餐,立即扣款并激活。
#### Scenario: 代理为自己的资源购买套餐
- **WHEN** 代理选择自己的资源创建订单,支付方式为 wallet
- **THEN** 系统创建订单,`buyer_id` = 代理店铺 ID`operator_id` = 代理店铺 ID`is_purchase_on_behalf` = false`payment_method` = "wallet"`payment_status` = 2已支付
#### Scenario: 代理自购扣款自己成本价
- **WHEN** 代理为自己的资源购买套餐,成本价 80 元
- **THEN** 系统从代理钱包扣款 80 元,订单金额 = 80 元,实际支付 = 80 元
#### Scenario: 代理自购自动激活套餐
- **WHEN** 代理自购订单创建成功
- **THEN** 系统自动激活套餐
#### Scenario: 代理自购创建钱包流水
- **WHEN** 代理自购扣款成功
- **THEN** 系统创建钱包流水记录,`transaction_type` = "deduct"`transaction_subtype` = "self_purchase"
---
### Requirement: 钱包代购权限控制
系统 SHALL 在后台订单创建 API 中允许代理使用 wallet 支付方式。
#### Scenario: 代理可使用 wallet
- **WHEN** 代理账号创建订单时选择支付方式为 wallet
- **THEN** 系统允许创建订单(不返回权限错误)
#### Scenario: 平台可使用 wallet
- **WHEN** 平台账号创建订单时选择支付方式为 wallet
- **THEN** 系统允许创建订单
#### Scenario: 企业账号不可使用 wallet
- **WHEN** 企业账号尝试在后台创建订单
- **THEN** 系统返回错误"无权限创建订单"
---
### Requirement: 后台订单钱包支付与 H5 端区分
系统 SHALL 区分后台订单创建和 H5 端订单创建的钱包支付流程。
#### Scenario: 后台 wallet 订单一步完成
- **WHEN** 代理在后台使用 wallet 创建订单
- **THEN** 订单创建后立即标记为已支付(`payment_status` = 2无需调用后续支付接口
#### Scenario: H5 端 wallet 订单两步流程
- **WHEN** 个人客户在 H5 端使用 wallet 创建订单
- **THEN** 订单创建后标记为待支付(`payment_status` = 1需要调用 WalletPay 接口完成支付
---
### Requirement: 钱包代购与平台代购的区别
系统 SHALL 区分钱包代购wallet和平台代购offline的业务逻辑。
#### Scenario: 平台代购不扣款
- **WHEN** 平台使用 offline 创建代购订单
- **THEN** 系统不扣减任何钱包余额
#### Scenario: 钱包代购扣款
- **WHEN** 代理使用 wallet 创建代购订单
- **THEN** 系统扣减操作者钱包余额
#### Scenario: 平台代购产生佣金
- **WHEN** 平台使用 offline 创建代购订单
- **THEN** 系统计算并发放佣金
#### Scenario: 钱包代购不产生佣金
- **WHEN** 代理使用 wallet 创建代购订单
- **THEN** 系统不计算佣金
---
### Requirement: 钱包代购事务保证
系统 SHALL 在事务中完成钱包代购的订单创建、扣款、流水记录、套餐激活。
#### Scenario: 钱包代购事务成功
- **WHEN** 钱包代购的所有步骤成功
- **THEN** 事务提交,订单创建、钱包扣款、流水记录、套餐激活全部完成
#### Scenario: 钱包代购事务失败回滚
- **WHEN** 钱包代购过程中任一步骤失败(如余额不足、套餐激活失败)
- **THEN** 事务回滚,订单不创建,钱包余额不变
#### Scenario: 钱包代购并发控制
- **WHEN** 多个请求同时为同一载体创建订单
- **THEN** 系统使用乐观锁version 字段)和幂等性检查防止并发问题

View File

@@ -0,0 +1,159 @@
## 1. 数据库结构变更
- [x] 1.1 创建订单表字段迁移脚本(`migrations/xxx_add_operator_fields_to_orders.up.sql`),新增 `operator_id``operator_type``actual_paid_amount``purchase_role` 字段,添加字段注释
- [x] 1.2 在迁移脚本中创建索引(`idx_orders_operator_id``idx_orders_purchase_role`),使用 CONCURRENTLY 避免锁表
- [x] 1.3 创建钱包流水表字段迁移脚本(`migrations/xxx_add_transaction_subtype_to_wallet_transaction.up.sql`),检查并添加 `transaction_subtype``related_shop_id` 字段(如果不存在)
- [x] 1.4 创建数据回滚迁移脚本(`*.down.sql`),包含 DROP INDEX 和 DROP COLUMN 语句
- [x] 1.5 在测试环境执行迁移,验证字段创建成功,检查 `\d tb_order``\d tb_agent_wallet_transaction` 输出
## 2. Model 层:订单角色追踪
- [x] 2.1 在 `internal/model/order.go` 中的 `Order` 结构体添加新字段:`OperatorID``OperatorType``ActualPaidAmount``PurchaseRole`,添加 gorm 标签和中文注释
- [x] 2.2 在 `internal/model/order.go` 中定义订单角色枚举常量(`PurchaseRoleSelfPurchase``PurchaseRolePurchasedByParent``PurchaseRolePurchasedByPlatform``PurchaseRolePurchaseForSubordinate`),添加中文注释
- [x] 2.3 在 `internal/model/agent_wallet.go` 中确认 `AgentWalletTransaction` 结构体包含 `TransactionSubtype``RelatedShopID` 字段(如果不存在则添加)
- [x] 2.4 运行 `go build ./...` 验证编译通过
## 3. 常量定义:钱包流水子类型
- [x] 3.1 在 `pkg/constants/wallet.go` 中新增钱包交易子类型常量(`WalletTransactionSubtypeSelfPurchase``WalletTransactionSubtypePurchaseForSubordinate`),添加中文注释
- [x] 3.2 运行 `go build ./...` 验证编译通过
## 4. DTO 层:订单请求和响应
- [x] 4.1 在 `internal/model/dto/order_dto.go``OrderResponse` 中添加新字段:`OperatorID``OperatorType``OperatorName``ActualPaidAmount``PurchaseRole``IsPurchasedByParent``PurchaseRemark`,添加 JSON 标签和 description 注释
- [x] 4.2 在 `internal/model/dto/order_dto.go``OrderListRequest` 中添加 `PurchaseRole` 筛选字段,添加验证标签(`validate:"omitempty,oneof=self_purchase purchased_by_parent purchased_by_platform purchase_for_subordinate"`
- [x] 4.3 运行 `go build ./...` 验证编译通过
## 5. Store 层:订单查询 OR 逻辑
- [x] 5.1 修改 `internal/store/postgres/order_store.go``List()` 方法,支持 `shop_id` 筛选时使用 OR 查询:`WHERE (buyer_type = 'agent' AND buyer_id = ?) OR operator_id = ?`
- [x] 5.2 在 `List()` 方法中添加 `purchase_role` 精确匹配筛选支持
- [x] 5.3 运行 `go build ./...` 验证编译通过
- [x] 5.4 使用 PostgreSQL MCP 工具验证查询逻辑:创建测试订单,执行 `SELECT * FROM tb_order WHERE (buyer_id = X) OR (operator_id = X)` 并检查 EXPLAIN 输出
## 6. Service 层:成本价查询辅助方法
- [x] 6.1 在 `internal/service/order/service.go` 中新增 `getCostPrice(ctx, shopID, packageID)` 方法,通过 `ShopPackageAllocation` 查询店铺对套餐的成本价
- [x] 6.2 添加错误处理:如果查询失败,返回 `errors.New(errors.CodeInvalidParam, "店铺没有该套餐的分配配置")`
- [x] 6.3 运行 `go build ./...` 验证编译通过
## 7. Service 层:钱包流水创建方法
- [x] 7.1 在 `internal/service/order/service.go` 中新增 `createWalletTransaction(ctx, tx, walletID, orderID, amount, purchaseRole, relatedShopID)` 方法
- [x] 7.2 在方法中根据 `purchaseRole` 确定 `transaction_subtype``remark`:自购场景填充"购买套餐",代购场景查询下级店铺名称填充"为下级代理【XX】购买套餐"
- [x] 7.3 创建 `AgentWalletTransaction` 记录,设置 `TransactionType` = `AgentTransactionTypeDeduct``TransactionSubtype``Amount`(负数)、`RelatedShopID``Remark`
- [x] 7.4 运行 `go build ./...` 验证编译通过
## 8. Service 层:钱包支付订单创建方法
- [x] 8.1 在 `internal/service/order/service.go` 中新增 `createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID)` 方法
- [x] 8.2 在方法开头(事务外)检查钱包余额,如果余额不足返回错误
- [x] 8.3 开启 GORM 事务,在事务中依次执行:创建订单(`tx.Create(order)`)、创建订单明细(`tx.CreateInBatches(items, 100)`
- [x] 8.4 在事务中扣减钱包余额,使用乐观锁:`WHERE id = ? AND balance >= ? AND version = ?`,更新 `balance = balance - ?``version = version + 1`
- [x] 8.5 检查 `RowsAffected`,如果为 0 返回 `errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")`
- [x] 8.6 在事务中调用 `createWalletTransaction()` 创建钱包流水
- [x] 8.7 在事务中调用 `activatePackage()` 激活套餐
- [x] 8.8 事务外判断是否入队佣金计算:`if order.OperatorID == nil { s.enqueueCommissionCalculation() }`(平台代购才入队)
- [x] 8.9 运行 `go build ./...` 验证编译通过
## 9. Service 层:订单创建流程重构
- [x] 9.1 在 `internal/service/order/service.go``Create()` 方法中,在幂等性检查后添加场景判断逻辑
- [x] 9.2 提取资源所属店铺 ID`validationResult.Card.ShopID``validationResult.Device.ShopID`
- [x] 9.3 处理 `offline` 场景:设置 `operator_id = nil``operator_type = "platform"``purchase_role = "purchased_by_platform"`,调用 `resolvePurchaseOnBehalfInfo()` 获取买家成本价,保持现有逻辑调用 `createOrderWithActivation()`
- [x] 9.4 处理 `wallet` 场景:获取操作者店铺 ID判断资源是否属于操作者
- [x] 9.5 如果资源属于操作者(自购):设置 `buyer = operator``purchase_role = "self_purchase"``is_purchase_on_behalf = false`,调用 `getCostPrice()` 获取成本价,`total_amount = actual_paid_amount = 操作者成本价`
- [x] 9.6 如果资源不属于操作者(代购):设置 `buyer = 资源所属者``operator = 操作者``purchase_role = "purchase_for_subordinate"``is_purchase_on_behalf = true`,分别调用 `getCostPrice()` 获取买家和操作者成本价,`total_amount = 买家成本价``actual_paid_amount = 操作者成本价`
- [x] 9.7 `wallet` 场景调用 `createOrderWithWalletPayment()` 而不是 `orderStore.Create()`
- [x] 9.8 运行 `go build ./...` 验证编译通过
## 10. Service 层:订单响应构建方法
- [x] 10.1 在 `internal/service/order/service.go``buildOrderResponse()` 方法中添加新字段映射:`OperatorID``OperatorType``ActualPaidAmount``PurchaseRole`
- [x] 10.2 添加 `OperatorName` 字段逻辑:如果 `operator_type = "agent"``operator_id` 不为空,查询 `Shop` 表获取店铺名称
- [x] 10.3 添加 `IsPurchasedByParent` 派生字段:`purchase_role == "purchased_by_parent"`
- [x] 10.4 添加 `PurchaseRemark` 派生字段:根据 `purchase_role``operator_name` 生成备注文本(如"由上级代理【XX】购买"、"由平台代购"
- [x] 10.5 运行 `go build ./...` 验证编译通过
## 11. Handler 层:权限检查调整
- [x] 11.1 在 `internal/handler/admin/order.go``Create()` 方法中,修改 `wallet` 支付方式的权限检查,允许代理、平台、超管使用
- [x] 11.2 保持 `offline` 支付方式只允许平台和超管使用的限制
- [x] 11.3 运行 `go build ./...` 验证编译通过
## 12. Handler 层:订单查询参数传递
- [x] 12.1 在 `internal/handler/admin/order.go``List()` 方法中,从查询参数解析 `purchase_role`
- [x] 12.2 将 `purchase_role` 传递给 Service 层的 `List()` 方法
- [x] 12.3 运行 `go build ./...` 验证编译通过
## 13. 文档生成器更新OpenAPI
- [x] 13.1 确认 `cmd/api/docs.go``cmd/gendocs/main.go` 中的 `Handlers` 结构体已包含 `Order` Handler如果不存在则添加
- [x] 13.2 运行 `go run cmd/gendocs/main.go` 生成 OpenAPI 文档
- [x] 13.3 检查生成的文档中订单创建和列表接口的请求/响应字段是否包含新字段
## 14. 集成测试:代理自购场景
- [ ] 14.1 使用 PostgreSQL MCP 工具创建测试数据:创建代理账号、代理钱包(余额 10000 分、IoT 卡shop_id = 代理店铺 ID、套餐分配配置成本价 8000 分)
- [ ] 14.2 使用 Postman/curl 调用后台订单创建 API代理账号创建订单支付方式 wallet选择自己的卡和套餐
- [ ] 14.3 验证响应:`payment_status` = 2`operator_id` = 代理店铺 ID`buyer_id` = 代理店铺 ID`purchase_role` = "self_purchase"`total_amount` = 8000`actual_paid_amount` = 8000
- [ ] 14.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确
- [ ] 14.5 使用 PostgreSQL MCP 查询钱包表,验证余额扣减:`balance` = 200010000 - 8000
- [ ] 14.6 使用 PostgreSQL MCP 查询钱包流水表,验证流水记录:`transaction_subtype` = "self_purchase"`amount` = -8000`remark` = "购买套餐"
- [ ] 14.7 使用 PostgreSQL MCP 查询套餐使用表(`tb_package_usage`),验证套餐已激活:`status` = 1
## 15. 集成测试:代理代购场景
- [ ] 15.1 使用 PostgreSQL MCP 工具创建测试数据:一级代理(成本价 8000、二级代理成本价 10000parent_shop_id = 一级代理)、一级代理钱包(余额 10000、IoT 卡shop_id = 二级代理店铺 ID、套餐分配配置
- [ ] 15.2 使用 Postman/curl 调用后台订单创建 API一级代理账号创建订单支付方式 wallet选择二级代理的卡和套餐
- [ ] 15.3 验证响应:`payment_status` = 2`operator_id` = 一级代理店铺 ID`buyer_id` = 二级代理店铺 ID`purchase_role` = "purchase_for_subordinate"`total_amount` = 10000`actual_paid_amount` = 8000
- [ ] 15.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确
- [ ] 15.5 使用 PostgreSQL MCP 查询一级代理钱包,验证余额扣减:`balance` = 200010000 - 8000
- [ ] 15.6 使用 PostgreSQL MCP 查询钱包流水表,验证流水记录:`transaction_subtype` = "purchase_for_subordinate"`amount` = -8000`related_shop_id` = 二级代理店铺 ID`remark` 包含二级代理店铺名称
- [ ] 15.7 使用 PostgreSQL MCP 查询套餐使用表,验证套餐已激活
- [ ] 15.8 使用 PostgreSQL MCP 查询佣金表,验证未产生佣金记录(代理代购不产生佣金)
## 16. 集成测试:平台代购场景(回归测试)
- [ ] 16.1 使用 PostgreSQL MCP 工具创建测试数据代理、IoT 卡shop_id = 代理店铺 ID、套餐分配配置成本价 10000
- [ ] 16.2 使用 Postman/curl 调用后台订单创建 API平台账号创建订单支付方式 offline选择代理的卡和套餐
- [ ] 16.3 验证响应:`payment_status` = 2`operator_id` = NULL`operator_type` = "platform"`buyer_id` = 代理店铺 ID`purchase_role` = "purchased_by_platform"`total_amount` = 10000`actual_paid_amount` = NULL
- [ ] 16.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确
- [ ] 16.5 使用 PostgreSQL MCP 查询套餐使用表,验证套餐已激活
- [ ] 16.6 验证平台代购逻辑未被破坏(不扣款、立即激活、产生佣金)
## 17. 集成测试:订单查询场景
- [ ] 17.1 使用 PostgreSQL MCP 工具创建测试数据:一级代理、二级代理、多个订单(自购、代购、被代购)
- [ ] 17.2 使用 Postman/curl 调用后台订单列表 API一级代理账号查询订单列表不指定 purchase_role
- [ ] 17.3 验证响应包含buyer_id = 一级代理的订单 + operator_id = 一级代理的订单
- [ ] 17.4 使用 Postman/curl 调用后台订单列表 API一级代理账号查询订单列表指定 `purchase_role=self_purchase`
- [ ] 17.5 验证响应只包含自购订单
- [ ] 17.6 使用 Postman/curl 调用后台订单列表 API一级代理账号查询订单列表指定 `purchase_role=purchase_for_subordinate`
- [ ] 17.7 验证响应只包含为下级代理购买的订单
## 18. 集成测试:边界场景
- [ ] 18.1 测试钱包余额不足:代理钱包余额 5000创建订单金额 8000验证返回错误"余额不足"
- [ ] 18.2 测试并发扣款:模拟两个请求同时为同一钱包扣款,验证乐观锁生效,只有一个请求成功
- [ ] 18.3 测试幂等性:同一买家对同一载体的同一套餐组合短时间内重复创建订单,验证返回相同订单 ID不重复扣款
- [ ] 18.4 测试 H5 端 wallet 订单:使用 H5 端 API 创建 wallet 订单,验证订单状态为待支付(`payment_status` = 1不影响后台逻辑
## 19. 数据回填(可选)
- [x] 19.1 编写数据回填脚本,将现有 `payment_method = 'offline'``is_purchase_on_behalf = true` 的订单回填 `purchase_role = 'purchased_by_platform'``operator_type = 'platform'`
- [ ] 19.2 在测试环境执行回填脚本,验证历史订单可正常查询
## 20. 文档更新
- [x] 20.1 更新接口文档说明订单创建 API 的行为变更(后台 wallet 支付一步完成)
- [x] 20.2 更新接口文档说明订单响应新增字段的含义
- [x] 20.3 更新接口文档说明订单列表 API 新增 `purchase_role` 查询参数
## 21. 生产环境部署准备
- [ ] 21.1 在测试环境充分验证所有场景通过
- [x] 21.2 准备生产环境迁移脚本和回滚脚本
- [x] 21.3 准备灰度发布计划:代码部署 → 观察日志 → 验证核心功能 → 全量发布
- [x] 21.4 准备监控指标:订单创建成功率、钱包扣款成功率、错误日志(余额不足、并发冲突)

View File

@@ -0,0 +1,167 @@
# Capability: 订单角色追踪
## Purpose
本 capability 定义订单角色追踪能力,记录并区分订单中的操作者、买家、支付者等角色关系,支持多种代购场景的数据查询和业务分析。
## ADDED Requirements
### Requirement: 订单操作者记录
系统 SHALL 在订单创建时记录操作者信息(谁下的单),区别于买家信息(资源所属者)。
#### Scenario: 平台创建订单
- **WHEN** 平台账号创建订单
- **THEN** 订单的 `operator_id` 为 NULL`operator_type` 为 "platform"
#### Scenario: 代理创建订单
- **WHEN** 代理账号创建订单
- **THEN** 订单的 `operator_id` 为代理店铺 ID`operator_type` 为 "agent"
#### Scenario: 代理自购
- **WHEN** 代理为自己的资源创建订单
- **THEN** 订单的 `buyer_id` 等于 `operator_id`
#### Scenario: 代理代购
- **WHEN** 代理为下级代理的资源创建订单
- **THEN** 订单的 `buyer_id` 为资源所属店铺 ID`operator_id` 为操作者店铺 ID两者不同
---
### Requirement: 实际支付金额记录
系统 SHALL 记录订单的实际支付金额,区别于订单金额(买家视角的价格)。
#### Scenario: 代理自购订单
- **WHEN** 代理为自己的资源创建订单,成本价 80 元
- **THEN** 订单的 `total_amount` = 80 元,`actual_paid_amount` = 80 元
#### Scenario: 代理代购订单
- **WHEN** 一级代理(成本价 80 元)为二级代理(成本价 100 元)的资源创建订单
- **THEN** 订单的 `total_amount` = 100 元(买家成本价),`actual_paid_amount` = 80 元(操作者实际扣款)
#### Scenario: 平台代购订单
- **WHEN** 平台为代理创建订单
- **THEN** 订单的 `total_amount` = 代理成本价,`actual_paid_amount` 为 NULL平台不扣款
---
### Requirement: 订单角色枚举
系统 SHALL 使用 `purchase_role` 字段标识订单角色关系,支持高效筛选。
#### Scenario: 自己购买
- **WHEN** 代理为自己的资源创建订单
- **THEN** 订单的 `purchase_role` = "self_purchase"
#### Scenario: 上级代理购买
- **WHEN** 代理查询作为买家的订单,且 `operator_id` 不为 NULL 且不等于 `buyer_id`
- **THEN** 该订单的 `purchase_role` = "purchased_by_parent"(从买家视角)或 "purchase_for_subordinate"(从操作者视角)
#### Scenario: 平台代购
- **WHEN** 平台为代理创建订单
- **THEN** 订单的 `purchase_role` = "purchased_by_platform"
#### Scenario: 给下级购买
- **WHEN** 代理为下级代理的资源创建订单
- **THEN** 订单的 `purchase_role` = "purchase_for_subordinate"
---
### Requirement: 订单查询增强
系统 SHALL 支持代理查询作为买家或操作者的所有订单。
#### Scenario: 代理查询自己相关的订单
- **WHEN** 代理查询订单列表
- **THEN** 系统返回 `buyer_id = 代理店铺 ID``operator_id = 代理店铺 ID` 的所有订单
#### Scenario: 按订单角色筛选
- **WHEN** 代理查询订单列表,指定 `purchase_role = "self_purchase"`
- **THEN** 系统只返回自己购买的订单
#### Scenario: 按订单角色筛选给下级购买的订单
- **WHEN** 代理查询订单列表,指定 `purchase_role = "purchase_for_subordinate"`
- **THEN** 系统只返回为下级代理购买的订单
---
### Requirement: 订单响应包含角色信息
系统 SHALL 在订单响应中包含操作者和角色信息,支持前端展示。
#### Scenario: 订单响应包含操作者 ID
- **WHEN** 查询订单详情
- **THEN** 响应包含 `operator_id``operator_type` 字段
#### Scenario: 订单响应包含操作者名称
- **WHEN** 查询订单详情,且 `operator_type = "agent"`
- **THEN** 响应包含 `operator_name` 字段(从 Shop 表查询)
#### Scenario: 订单响应包含角色标识
- **WHEN** 查询订单详情
- **THEN** 响应包含 `purchase_role``is_purchased_by_parent``purchase_remark` 字段
#### Scenario: 上级代购订单的备注
- **WHEN** 查询上级代理购买的订单
- **THEN** `purchase_remark` 为"由上级代理【XX】购买"
#### Scenario: 平台代购订单的备注
- **WHEN** 查询平台代购的订单
- **THEN** `purchase_remark` 为"由平台代购"
---
### Requirement: 数据权限保持一致
系统 SHALL 确保订单角色追踪不影响现有数据权限逻辑。
#### Scenario: 代理只能查询有权限的订单
- **WHEN** 代理查询订单列表
- **THEN** 系统应用数据权限过滤,只返回 `buyer_id``operator_id` 在权限范围内的订单
#### Scenario: 平台可查询所有订单
- **WHEN** 平台账号查询订单列表
- **THEN** 系统不应用数据权限过滤,返回所有订单
---
### Requirement: 订单角色常量定义
系统 SHALL 在 `internal/model/order.go` 中定义订单角色枚举常量。
#### Scenario: 订单角色枚举值
- **WHEN** 代码中使用订单角色
- **THEN** 可用的枚举值包括:
- `PurchaseRoleSelfPurchase` = "self_purchase"
- `PurchaseRolePurchasedByParent` = "purchased_by_parent"
- `PurchaseRolePurchasedByPlatform` = "purchased_by_platform"
- `PurchaseRolePurchaseForSubordinate` = "purchase_for_subordinate"
---
### Requirement: 数据库索引支持
系统 SHALL 为订单角色追踪字段创建索引,支持高效查询。
#### Scenario: operator_id 索引
- **WHEN** 查询 `operator_id = X` 的订单
- **THEN** 数据库使用 `idx_orders_operator_id` 索引
#### Scenario: purchase_role 索引
- **WHEN** 查询 `purchase_role = 'self_purchase'` 的订单
- **THEN** 数据库使用 `idx_orders_purchase_role` 索引
---
### Requirement: 向后兼容性
系统 SHALL 确保新增字段不影响现有订单数据和查询。
#### Scenario: 现有订单字段为 NULL
- **WHEN** 查询历史订单
- **THEN** `operator_id``operator_type``actual_paid_amount``purchase_role` 字段为 NULL 或空值,不影响查询结果
#### Scenario: 订单列表查询兼容
- **WHEN** 代理查询订单列表,不指定 `purchase_role` 筛选
- **THEN** 系统返回所有订单包括历史订单role 为 NULL

View File

@@ -107,3 +107,162 @@
#### Scenario: 余额扣减后套餐激活失败
- **WHEN** 余额扣减成功但套餐激活失败
- **THEN** 事务回滚,余额恢复,订单状态不变
## ADDED Requirements
### Requirement: 后台钱包一步支付
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。
#### Scenario: 后台订单创建时钱包支付
- **WHEN** 代理在后台创建订单,支付方式为 wallet钱包余额充足
- **THEN** 系统创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2激活套餐
#### Scenario: 后台钱包支付余额不足
- **WHEN** 代理在后台创建订单,支付方式为 wallet钱包余额不足
- **THEN** 系统返回错误"余额不足",订单创建失败
#### Scenario: 后台钱包支付订单响应
- **WHEN** 后台钱包支付订单创建成功
- **THEN** API 响应包含已支付的订单信息,`payment_status` = 2`payment_method` = "wallet"`paid_at` 为当前时间
#### Scenario: 后台钱包支付不创建待支付订单
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** 系统不创建待支付订单(`payment_status` != 1直接完成支付
---
### Requirement: H5 钱包两步支付保持不变
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。
#### Scenario: H5 创建待支付订单
- **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet
- **THEN** 系统创建订单,`payment_status` = 1待支付不扣减钱包余额
#### Scenario: H5 调用 WalletPay 接口支付
- **WHEN** 个人客户调用 WalletPay 接口支付待支付订单
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐
#### Scenario: H5 和后台钱包支付流程独立
- **WHEN** H5 端创建 wallet 订单
- **THEN** 不影响后台 wallet 订单的一步支付逻辑
---
### Requirement: 钱包流水记录扩展
系统 SHALL 在钱包流水中记录交易子类型和关联店铺,支持按场景筛选。
#### Scenario: 自购钱包流水
- **WHEN** 代理为自己的资源购买套餐,使用 wallet
- **THEN** 钱包流水的 `transaction_subtype` = "self_purchase"`related_shop_id` 为 NULL`remark` = "购买套餐"
#### Scenario: 代购钱包流水
- **WHEN** 代理为下级代理购买套餐,使用 wallet
- **THEN** 钱包流水的 `transaction_subtype` = "purchase_for_subordinate"`related_shop_id` = 下级代理店铺 ID`remark` = "为下级代理【XX】购买套餐"
#### Scenario: 钱包流水查询店铺名称
- **WHEN** 创建代购钱包流水
- **THEN** 系统查询下级店铺名称,填充到 `remark` 字段
#### Scenario: 钱包流水筛选
- **WHEN** 代理查询钱包流水,筛选 `transaction_subtype` = "purchase_for_subordinate"
- **THEN** 系统返回所有为下级代理购买的流水记录
---
### Requirement: 钱包支付乐观锁
系统 SHALL 使用乐观锁防止钱包并发扣款导致余额不一致。
#### Scenario: 钱包扣款使用 version 字段
- **WHEN** 扣减钱包余额
- **THEN** SQL 语句包含 `WHERE balance >= ? AND version = ?`,更新时 `version + 1`
#### Scenario: 钱包并发扣款失败
- **WHEN** 两个请求同时扣减同一钱包
- **THEN** 只有一个请求成功,另一个返回"余额不足或并发冲突"
#### Scenario: 乐观锁重试逻辑
- **WHEN** 钱包扣款因 version 冲突失败
- **THEN** 系统不自动重试,返回错误(由客户端决定是否重试)
---
### Requirement: 钱包支付幂等性
系统 SHALL 防止同一订单重复创建和重复扣款。
#### Scenario: 订单创建幂等性检查
- **WHEN** 同一买家对同一载体的同一套餐组合在短时间内重复创建订单
- **THEN** 系统返回已创建的订单,不重复扣款
#### Scenario: 幂等性使用 Redis 业务键
- **WHEN** 检查订单幂等性
- **THEN** 系统使用 Redis key `order:idempotency:{buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}`
#### Scenario: 幂等性 TTL
- **WHEN** 订单创建成功后标记幂等性
- **THEN** Redis key 的 TTL 为 3 分钟
#### Scenario: 分布式锁防止并发
- **WHEN** 订单创建前检查幂等性
- **THEN** 系统使用分布式锁 `order:create:lock:{carrier_type}:{carrier_id}`TTL 10 秒
---
### Requirement: 后台订单 API 响应扩展
系统 SHALL 在后台订单创建和查询 API 响应中包含钱包支付相关字段。
#### Scenario: 订单响应包含实际支付金额
- **WHEN** 查询钱包支付的订单
- **THEN** 响应包含 `actual_paid_amount` 字段
#### Scenario: 订单响应包含操作者信息
- **WHEN** 查询代购订单
- **THEN** 响应包含 `operator_id``operator_type``operator_name` 字段
#### Scenario: 订单响应包含购买备注
- **WHEN** 查询上级代理购买的订单
- **THEN** 响应包含 `purchase_remark` 字段,如"由上级代理【XX】购买"
---
### Requirement: 钱包支付错误处理
系统 SHALL 在钱包支付失败时返回明确的错误信息。
#### Scenario: 钱包不存在
- **WHEN** 钱包支付时钱包不存在
- **THEN** 系统返回错误"钱包不存在"`CodeWalletNotFound`
#### Scenario: 余额不足
- **WHEN** 钱包支付时余额不足
- **THEN** 系统返回错误"余额不足"`CodeInsufficientBalance`
#### Scenario: 并发冲突
- **WHEN** 钱包扣款因 version 冲突失败
- **THEN** 系统返回错误"余额不足或并发冲突"`CodeInsufficientBalance`
#### Scenario: 套餐激活失败
- **WHEN** 钱包扣款成功但套餐激活失败
- **THEN** 事务回滚,钱包余额恢复,返回激活失败错误
---
### Requirement: 钱包支付与第三方支付的区别
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。
#### Scenario: 后台不支持第三方支付
- **WHEN** 代理在后台创建订单时选择 wechat 或 alipay
- **THEN** 系统返回错误"后台只支持 wallet 和 offline 支付方式"
#### Scenario: H5 支持第三方支付
- **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay
- **THEN** 系统创建待支付订单返回支付参数prepay_id 或 h5_url
#### Scenario: 钱包支付不需要支付参数
- **WHEN** 后台钱包支付订单创建成功
- **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数

View File

@@ -143,3 +143,134 @@
#### Scenario: 线下支付只用于代购
- **WHEN** payment_method = "offline"
- **THEN** 订单必须标记为 is_purchase_on_behalf = true
## ADDED Requirements
### Requirement: 代理钱包代购
系统 SHALL 允许代理使用钱包支付wallet为下级代理创建代购订单从自己钱包扣款并立即激活套餐。
#### Scenario: 代理为下级代理钱包代购
- **WHEN** 代理选择下级代理的资源创建订单,支付方式为 wallet
- **THEN** 系统创建订单,`buyer_id` = 下级代理店铺 ID`operator_id` = 操作者店铺 ID`is_purchase_on_behalf` = true`payment_method` = "wallet"`payment_status` = 2已支付
#### Scenario: 钱包代购扣款操作者钱包
- **WHEN** 代理使用 wallet 为下级代理购买套餐
- **THEN** 系统从操作者(上级代理)的钱包扣款
#### Scenario: 钱包代购使用操作者成本价扣款
- **WHEN** 一级代理(成本价 80 元)为二级代理(成本价 100 元)的资源创建 wallet 代购订单
- **THEN** 系统从一级代理钱包扣款 80 元(操作者成本价)
#### Scenario: 钱包代购订单金额显示买家成本价
- **WHEN** 一级代理为二级代理钱包代购
- **THEN** 订单的 `total_amount` = 100 元(买家成本价),`actual_paid_amount` = 80 元(操作者实际扣款)
#### Scenario: 钱包代购余额不足
- **WHEN** 代理使用 wallet 代购,但钱包余额不足
- **THEN** 系统返回错误"余额不足",订单创建失败
#### Scenario: 钱包代购自动激活套餐
- **WHEN** 钱包代购订单创建成功
- **THEN** 系统自动激活套餐(创建 PackageUsage 记录)
#### Scenario: 钱包代购不触发佣金
- **WHEN** 代理使用 wallet 代购订单完成
- **THEN** 系统不计算佣金,不发放佣金(操作者已赚取成本价差)
#### Scenario: 钱包代购创建钱包流水
- **WHEN** 代理使用 wallet 代购扣款成功
- **THEN** 系统创建钱包流水记录,`transaction_type` = "deduct"`transaction_subtype` = "purchase_for_subordinate"`related_shop_id` = 下级代理店铺 ID
---
### Requirement: 代理自购使用钱包
系统 SHALL 允许代理使用钱包支付为自己的资源购买套餐,立即扣款并激活。
#### Scenario: 代理为自己的资源购买套餐
- **WHEN** 代理选择自己的资源创建订单,支付方式为 wallet
- **THEN** 系统创建订单,`buyer_id` = 代理店铺 ID`operator_id` = 代理店铺 ID`is_purchase_on_behalf` = false`payment_method` = "wallet"`payment_status` = 2已支付
#### Scenario: 代理自购扣款自己成本价
- **WHEN** 代理为自己的资源购买套餐,成本价 80 元
- **THEN** 系统从代理钱包扣款 80 元,订单金额 = 80 元,实际支付 = 80 元
#### Scenario: 代理自购自动激活套餐
- **WHEN** 代理自购订单创建成功
- **THEN** 系统自动激活套餐
#### Scenario: 代理自购创建钱包流水
- **WHEN** 代理自购扣款成功
- **THEN** 系统创建钱包流水记录,`transaction_type` = "deduct"`transaction_subtype` = "self_purchase"
---
### Requirement: 钱包代购权限控制
系统 SHALL 在后台订单创建 API 中允许代理使用 wallet 支付方式。
#### Scenario: 代理可使用 wallet
- **WHEN** 代理账号创建订单时选择支付方式为 wallet
- **THEN** 系统允许创建订单(不返回权限错误)
#### Scenario: 平台可使用 wallet
- **WHEN** 平台账号创建订单时选择支付方式为 wallet
- **THEN** 系统允许创建订单
#### Scenario: 企业账号不可使用 wallet
- **WHEN** 企业账号尝试在后台创建订单
- **THEN** 系统返回错误"无权限创建订单"
---
### Requirement: 后台订单钱包支付与 H5 端区分
系统 SHALL 区分后台订单创建和 H5 端订单创建的钱包支付流程。
#### Scenario: 后台 wallet 订单一步完成
- **WHEN** 代理在后台使用 wallet 创建订单
- **THEN** 订单创建后立即标记为已支付(`payment_status` = 2无需调用后续支付接口
#### Scenario: H5 端 wallet 订单两步流程
- **WHEN** 个人客户在 H5 端使用 wallet 创建订单
- **THEN** 订单创建后标记为待支付(`payment_status` = 1需要调用 WalletPay 接口完成支付
---
### Requirement: 钱包代购与平台代购的区别
系统 SHALL 区分钱包代购wallet和平台代购offline的业务逻辑。
#### Scenario: 平台代购不扣款
- **WHEN** 平台使用 offline 创建代购订单
- **THEN** 系统不扣减任何钱包余额
#### Scenario: 钱包代购扣款
- **WHEN** 代理使用 wallet 创建代购订单
- **THEN** 系统扣减操作者钱包余额
#### Scenario: 平台代购产生佣金
- **WHEN** 平台使用 offline 创建代购订单
- **THEN** 系统计算并发放佣金
#### Scenario: 钱包代购不产生佣金
- **WHEN** 代理使用 wallet 创建代购订单
- **THEN** 系统不计算佣金
---
### Requirement: 钱包代购事务保证
系统 SHALL 在事务中完成钱包代购的订单创建、扣款、流水记录、套餐激活。
#### Scenario: 钱包代购事务成功
- **WHEN** 钱包代购的所有步骤成功
- **THEN** 事务提交,订单创建、钱包扣款、流水记录、套餐激活全部完成
#### Scenario: 钱包代购事务失败回滚
- **WHEN** 钱包代购过程中任一步骤失败(如余额不足、套餐激活失败)
- **THEN** 事务回滚,订单不创建,钱包余额不变
#### Scenario: 钱包代购并发控制
- **WHEN** 多个请求同时为同一载体创建订单
- **THEN** 系统使用乐观锁version 字段)和幂等性检查防止并发问题

View File

@@ -30,6 +30,12 @@ const (
AgentTransactionTypeWithdrawal = "withdrawal" // 提现
)
// 代理钱包交易子类型(当 transaction_type = "deduct" 用于订单支付时)
const (
WalletTransactionSubtypeSelfPurchase = "self_purchase" // 自购
WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate" // 给下级代理购买
)
// 代理充值订单号前缀
const (
AgentRechargeOrderPrefix = "ARCH" // 代理充值订单号前缀