From 8ed3d9da93555c2afcf57d41d52bbcb7df6ffc24 Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 28 Feb 2026 14:11:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E8=AE=A2=E5=8D=95=E5=88=9B=E5=BB=BA=E5=92=8C?= =?UTF-8?q?=E8=AE=A2=E5=8D=95=E8=A7=92=E8=89=B2=E8=BF=BD=E8=B8=AA=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增功能: - 代理在后台使用 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 --- docs/admin-openapi.yaml | 58 +- .../功能总结.md | 514 +++++++++++++++ .../部署指南.md | 538 +++++++++++++++ internal/model/agent_wallet.go | 6 +- internal/model/dto/order_dto.go | 19 +- internal/model/order.go | 18 + internal/service/order/service.go | 375 ++++++++++- internal/store/postgres/order_store.go | 17 +- ...067_add_operator_fields_to_orders.down.sql | 8 + ...00067_add_operator_fields_to_orders.up.sql | 16 + ...ion_subtype_to_wallet_transaction.down.sql | 3 + ...ction_subtype_to_wallet_transaction.up.sql | 24 + migrations/backfill_order_purchase_role.sql | 43 ++ .../.openspec.yaml | 2 + .../design.md | 617 ++++++++++++++++++ .../proposal.md | 61 ++ .../specs/agent-order-role-tracking/spec.md | 167 +++++ .../specs/order-payment/spec.md | 159 +++++ .../specs/purchase-on-behalf/spec.md | 131 ++++ .../tasks.md | 159 +++++ .../specs/agent-order-role-tracking/spec.md | 167 +++++ openspec/specs/order-payment/spec.md | 159 +++++ openspec/specs/purchase-on-behalf/spec.md | 131 ++++ pkg/constants/wallet.go | 6 + 24 files changed, 3346 insertions(+), 52 deletions(-) create mode 100644 docs/fix-agent-wallet-order-creation/功能总结.md create mode 100644 docs/fix-agent-wallet-order-creation/部署指南.md create mode 100644 migrations/000067_add_operator_fields_to_orders.down.sql create mode 100644 migrations/000067_add_operator_fields_to_orders.up.sql create mode 100644 migrations/000068_add_transaction_subtype_to_wallet_transaction.down.sql create mode 100644 migrations/000068_add_transaction_subtype_to_wallet_transaction.up.sql create mode 100644 migrations/backfill_order_purchase_role.sql create mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/.openspec.yaml create mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md create mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/proposal.md create mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/agent-order-role-tracking/spec.md create mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/order-payment/spec.md create mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/purchase-on-behalf/spec.md create mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/tasks.md create mode 100644 openspec/specs/agent-order-role-tracking/spec.md diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 93cd689..9592c35 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -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 diff --git a/docs/fix-agent-wallet-order-creation/功能总结.md b/docs/fix-agent-wallet-order-creation/功能总结.md new file mode 100644 index 0000000..6669c31 --- /dev/null +++ b/docs/fix-agent-wallet-order-creation/功能总结.md @@ -0,0 +1,514 @@ +# 代理钱包订单创建功能总结 + +## 概述 + +fix-agent-wallet-order-creation 提案修复了代理在后台使用钱包支付创建订单的问题,实现了代理钱包一步购买(扣款 + 激活)、代理代购、订单角色追踪等核心功能。 + +## ��景问题 + +### 问题描述 + +代理在后台使用钱包支付(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) diff --git a/docs/fix-agent-wallet-order-creation/部署指南.md b/docs/fix-agent-wallet-order-creation/部署指南.md new file mode 100644 index 0000000..be73066 --- /dev/null +++ b/docs/fix-agent-wallet-order-creation/部署指南.md @@ -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 -U -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://:@:/junhong_cmp?sslmode=disable" up + +# 验证迁移版本 +migrate -path migrations -database "postgresql://:@:/junhong_cmp?sslmode=disable" version +``` + +**手动执行(可选)**: +```bash +# 连接数据库 +psql -h -U -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 -U -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://:/?sslmode=require" up +``` + +**监控指标**: +- 迁移执行时间 +- 索引创建时间(CONCURRENTLY,不锁表) +- 数据库连接数 +- 慢查询日志 + +--- + +### 回滚步骤 + +**场景**:迁移失败或发现严重 Bug + +#### 步骤 1:停止应用 + +```bash +# 停止应用服务 +systemctl stop junhong-cmp-api +``` + +#### 步骤 2:执行回滚 + +```bash +# 回滚到上一版本 +migrate -path migrations -database "postgresql://:/?sslmode=disable" down 2 +``` + +**或手动执行回滚脚本**: +```bash +psql -h -U -d junhong_cmp < + +# 重新编译 +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 " \ + -p order_request.json \ + -T "application/json" \ + http://localhost:8080/api/admin/orders + +# 订单列表查询性能测试 +ab -n 5000 -c 100 -H "Authorization: Bearer " \ + 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` diff --git a/internal/model/agent_wallet.go b/internal/model/agent_wallet.go index b9ec71f..cea6965 100644 --- a/internal/model/agent_wallet.go +++ b/internal/model/agent_wallet.go @@ -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"` diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index 4de917c..9ebe425 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -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 { diff --git a/internal/model/order.go b/internal/model/order.go index 8167ca9..3398e6a 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -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 { diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 892c533..b2af5ca 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -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, } } diff --git a/internal/store/postgres/order_store.go b/internal/store/postgres/order_store.go index d5c9fdd..792e9e7 100644 --- a/internal/store/postgres/order_store.go +++ b/internal/store/postgres/order_store.go @@ -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) } diff --git a/migrations/000067_add_operator_fields_to_orders.down.sql b/migrations/000067_add_operator_fields_to_orders.down.sql new file mode 100644 index 0000000..4eee82d --- /dev/null +++ b/migrations/000067_add_operator_fields_to_orders.down.sql @@ -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; diff --git a/migrations/000067_add_operator_fields_to_orders.up.sql b/migrations/000067_add_operator_fields_to_orders.up.sql new file mode 100644 index 0000000..8fcd431 --- /dev/null +++ b/migrations/000067_add_operator_fields_to_orders.up.sql @@ -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); diff --git a/migrations/000068_add_transaction_subtype_to_wallet_transaction.down.sql b/migrations/000068_add_transaction_subtype_to_wallet_transaction.down.sql new file mode 100644 index 0000000..c5a9cf4 --- /dev/null +++ b/migrations/000068_add_transaction_subtype_to_wallet_transaction.down.sql @@ -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; diff --git a/migrations/000068_add_transaction_subtype_to_wallet_transaction.up.sql b/migrations/000068_add_transaction_subtype_to_wallet_transaction.up.sql new file mode 100644 index 0000000..97aa4f1 --- /dev/null +++ b/migrations/000068_add_transaction_subtype_to_wallet_transaction.up.sql @@ -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 +$$; diff --git a/migrations/backfill_order_purchase_role.sql b/migrations/backfill_order_purchase_role.sql new file mode 100644 index 0000000..7063c29 --- /dev/null +++ b/migrations/backfill_order_purchase_role.sql @@ -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; diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/.openspec.yaml b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/.openspec.yaml new file mode 100644 index 0000000..34b5b23 --- /dev/null +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-28 diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md new file mode 100644 index 0000000..b39829d --- /dev/null +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md @@ -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` + - 是否需要隐藏?(业务决策) diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/proposal.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/proposal.md new file mode 100644 index 0000000..698f255 --- /dev/null +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/proposal.md @@ -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 接口) diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/agent-order-role-tracking/spec.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/agent-order-role-tracking/spec.md new file mode 100644 index 0000000..529f4d4 --- /dev/null +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/agent-order-role-tracking/spec.md @@ -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) diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/order-payment/spec.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/order-payment/spec.md new file mode 100644 index 0000000..b446dde --- /dev/null +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/order-payment/spec.md @@ -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 等第三方支付参数 diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/purchase-on-behalf/spec.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/purchase-on-behalf/spec.md new file mode 100644 index 0000000..8197787 --- /dev/null +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/purchase-on-behalf/spec.md @@ -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 字段)和幂等性检查防止并发问题 diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/tasks.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/tasks.md new file mode 100644 index 0000000..72a1722 --- /dev/null +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/tasks.md @@ -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 准备监控指标:订单创建成功率、钱包扣款成功率、错误日志(余额不足、并发冲突) diff --git a/openspec/specs/agent-order-role-tracking/spec.md b/openspec/specs/agent-order-role-tracking/spec.md new file mode 100644 index 0000000..529f4d4 --- /dev/null +++ b/openspec/specs/agent-order-role-tracking/spec.md @@ -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) diff --git a/openspec/specs/order-payment/spec.md b/openspec/specs/order-payment/spec.md index 00202a5..113ac26 100644 --- a/openspec/specs/order-payment/spec.md +++ b/openspec/specs/order-payment/spec.md @@ -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 等第三方支付参数 diff --git a/openspec/specs/purchase-on-behalf/spec.md b/openspec/specs/purchase-on-behalf/spec.md index a31e8cd..b77f549 100644 --- a/openspec/specs/purchase-on-behalf/spec.md +++ b/openspec/specs/purchase-on-behalf/spec.md @@ -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 字段)和幂等性检查防止并发问题 diff --git a/pkg/constants/wallet.go b/pkg/constants/wallet.go index 4c6f88b..51fe29d 100644 --- a/pkg/constants/wallet.go +++ b/pkg/constants/wallet.go @@ -30,6 +30,12 @@ const ( AgentTransactionTypeWithdrawal = "withdrawal" // 提现 ) +// 代理钱包交易子类型(当 transaction_type = "deduct" 用于订单支付时) +const ( + WalletTransactionSubtypeSelfPurchase = "self_purchase" // 自购 + WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate" // 给下级代理购买 +) + // 代理充值订单号前缀 const ( AgentRechargeOrderPrefix = "ARCH" // 代理充值订单号前缀