feat: 实现代理钱包订单创建和订单角色追踪功能
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m0s
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:
@@ -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: 结束ICCID(selection_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
|
||||
|
||||
514
docs/fix-agent-wallet-order-creation/功能总结.md
Normal file
514
docs/fix-agent-wallet-order-creation/功能总结.md
Normal 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 = 10000(100 元,B 看到的订单金额)
|
||||
- actual_paid_amount = 8000(80 元,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}`
|
||||
- TTL:3 分钟
|
||||
- 分布式锁防止并发:`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)
|
||||
538
docs/fix-agent-wallet-order-creation/部署指南.md
Normal file
538
docs/fix-agent-wallet-order-creation/部署指南.md
Normal 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`
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
8
migrations/000067_add_operator_fields_to_orders.down.sql
Normal file
8
migrations/000067_add_operator_fields_to_orders.down.sql
Normal 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;
|
||||
16
migrations/000067_add_operator_fields_to_orders.up.sql
Normal file
16
migrations/000067_add_operator_fields_to_orders.up.sql
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
$$;
|
||||
43
migrations/backfill_order_purchase_role.sql
Normal file
43
migrations/backfill_order_purchase_role.sql
Normal 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;
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-28
|
||||
@@ -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 = 100(B 看到的订单金额)
|
||||
- actual_paid_amount = 80(A 实际扣款)
|
||||
- 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 元
|
||||
|
||||
场景 1:A 为 B 的卡购买套餐(代理代购)
|
||||
- A 扣款:80 元
|
||||
- A 利润:100 - 80 = 20 元
|
||||
- 佣金:无(A 已赚取差价)
|
||||
|
||||
场景 2:平台为 B 的卡购买套餐(平台代购)
|
||||
- 平台扣款:0 元
|
||||
- 订单金额:100 元
|
||||
- 佣金:给 A(B 的上级),金额 = 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`)
|
||||
|
||||
---
|
||||
|
||||
### 决策 9:DTO 响应字段设计
|
||||
|
||||
**决策**:`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`
|
||||
- 是否需要隐藏?(业务决策)
|
||||
@@ -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 接口)
|
||||
@@ -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)
|
||||
@@ -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 等第三方支付参数
|
||||
@@ -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 字段)和幂等性检查防止并发问题
|
||||
@@ -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` = 2000(10000 - 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)、二级代理(成本价 10000,parent_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` = 2000(10000 - 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 准备监控指标:订单创建成功率、钱包扣款成功率、错误日志(余额不足、并发冲突)
|
||||
167
openspec/specs/agent-order-role-tracking/spec.md
Normal file
167
openspec/specs/agent-order-role-tracking/spec.md
Normal 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)
|
||||
@@ -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 等第三方支付参数
|
||||
|
||||
@@ -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 字段)和幂等性检查防止并发问题
|
||||
|
||||
@@ -30,6 +30,12 @@ const (
|
||||
AgentTransactionTypeWithdrawal = "withdrawal" // 提现
|
||||
)
|
||||
|
||||
// 代理钱包交易子类型(当 transaction_type = "deduct" 用于订单支付时)
|
||||
const (
|
||||
WalletTransactionSubtypeSelfPurchase = "self_purchase" // 自购
|
||||
WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate" // 给下级代理购买
|
||||
)
|
||||
|
||||
// 代理充值订单号前缀
|
||||
const (
|
||||
AgentRechargeOrderPrefix = "ARCH" // 代理充值订单号前缀
|
||||
|
||||
Reference in New Issue
Block a user