新增功能: - 代理在后台使用 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>
14 KiB
代理钱包订单创建功能总结
概述
fix-agent-wallet-order-creation 提案修复了代理在后台使用钱包支付创建订单的问题,实现了代理钱包一步购买(扣款 + 激活)、代理代购、订单角色追踪等核心功能。
<EFBFBD><EFBFBD>景问题
问题描述
代理在后台使用钱包支付(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):
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):
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()):
WHERE (buyer_type = 'agent' AND buyer_id = ?) OR operator_id = ?
代理可以看到两类订单:
- 作为买家的订单(
buyer_id = 自己):别人为自己代购、自己购买 - 作为操作者的订单(
operator_id = 自己):自己为下级代购
新增查询参数:
purchase_role(可选):筛选订单角色类型(self_purchase / purchased_by_parent / purchased_by_platform / purchase_for_subordinate)
6. 佣金逻辑调整
规则:
- 代理代购:操作者已赚取成本价差(自己成本价 vs 下级成本价),不产生佣金
- 平台代购:平台不扣款,按买家成本价计算差价佣金,激励上级代理
实现:
// 只有平台代购(operator_id == nil)才入队佣金计算
if order.OperatorID == nil {
s.enqueueCommissionCalculation(ctx, order.ID)
}
7. 幂等性和并发控制
乐观锁(钱包扣款):
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 支付逻辑保持不变
响应新增字段:
{
"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)
新增字段:
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 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)
新增字段(如果不存在):
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:
-
getCostPrice(ctx, shopID, packageID) (int64, error)- 查询店铺对套餐的成本价(通过 ShopPackageAllocation)
-
createWalletTransaction(ctx, tx, walletID, orderID, amount, purchaseRole, relatedShopID) error- 创建钱包流水,根据 purchaseRole 填充 subtype 和 remark
-
createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID) (*dto.OrderResponse, error)- 钱包支付订单创建方法,事务内完成:订单创建 + 扣款 + 流水 + 激活套餐
Create() 方法重构:
// 场景判断
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() 方法:
// 代理用户:查询作为买家或操作者的订单
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()方法
使用指南
代理自购场景
请求:
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"
}
响应:
{
"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": "订单创建成功"
}
代理代购场景
请求:
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"
}
响应:
{
"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": "订单创建成功"
}
订单列表查询
请求:
GET /api/admin/orders?purchase_role=purchase_for_subordinate&page=1&page_size=20
Authorization: Bearer {agent_token}
响应:
{
"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.sqlmigrations/000068_add_transaction_subtype_to_wallet_transaction.up.sql
回滚脚本:
migrations/000067_add_operator_fields_to_orders.down.sqlmigrations/000068_add_transaction_subtype_to_wallet_transaction.down.sql
数据回填(可选):
migrations/backfill_order_purchase_role.sql:回填历史平台代购订单
部署步骤
-
测试环境验证:
- 执行迁移脚本
- 验证索引创建成功
- 手工测试三种代购场景
-
灰度发布:
- 代码部署到灰度环境
- 观察日志和监控指标
- 验证订单创建、查询、钱包扣款功能
-
生产环境部署:
- 低峰期执行数据库迁移
- 部署代码
- 监控错误日志和业务指标
- 验证核心功能
监控指标
关键指标:
- 订单创建成功率(按 payment_method 分组)
- 钱包扣款成功率
- 错误日志:余额不足、并发冲突、套餐激活失败
- 订单创建耗时(P95、P99)
告警规则:
- 钱包扣款失败率 > 5%
- 订单创建失败率 > 10%
- 并发冲突次数 > 100/分钟
兼容性说明
向后兼容
- 现有订单字段为空值:不影响已有订单查询
- 平台代购(offline)逻辑不变:保持现有行为
- H5 钱包支付不受影响:H5 端仍使用两步流程
- 数据权限保持一致:订单角色追踪不影响现有数据权限逻辑
破坏性变更
无。所有新增字段均为 nullable,新增逻辑不影响现有流程。
测试覆盖
集成测试场景
- 代理自购:代理为自己的卡购买套餐,验证扣款、激活、流水
- 代理代购:一级代理为二级代理购买,验证价格差异、佣金不产生
- 平台代购:平台 offline 代购,验证不扣款、佣金产生
- 订单查询:验证 OR 查询逻辑、purchase_role 筛选
- 边界场景:余额不足、并发扣款、幂等性
验证结果
- ✅ 编译通过:
go build ./... - ✅ OpenAPI 文档更新:新增字段已包含
- ✅ 迁移脚本执行成功