fix: 修复代理钱包订单创建逻辑,拆分后台/H5端下单方法并归档变更
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s

- 拆分订单创建为 CreateAdminOrder(后台一步支付)和 CreateH5Order(H5 两步支付)
- 新增 CreateAdminOrderRequest DTO,后台仅允许 wallet/offline 支付方式
- 同步 delta specs 到主规格(order-payment 更新 + admin-order-creation 新增)
- 归档 fix-agent-wallet-order-creation 变更
- 新增 implement-order-expiration 变更提案
This commit is contained in:
2026-02-28 16:31:31 +08:00
parent 8ed3d9da93
commit 5bb0ff0ddf
23 changed files with 2922 additions and 1138 deletions

View File

@@ -157,13 +157,20 @@ result := tx.Model(&model.AgentWallet{}).
## API 变更
### 后台订单创建 API
### 后台订单创建 API(❗ Breaking Change
**端点**`POST /api/admin/orders`
**请求参数变更**
| 字段 | 变更前 | 变更后 | 说明 |
|------|--------|--------|------|
| `payment_method` | 可选,任意值 | **必填**,仅允许 `wallet``offline` | 不传或传其他值均返回 1001 错误 |
**行为变更**
- 代理使用 wallet 支付时,订单直接完成(`payment_status = 2`),无需后续支付
- 平台使用 offline 支付逻辑保持不变
- `wallet` 支付订单直接完成(`payment_status = 2`),无需后续支付接口
- `offline` 支付逻辑保持不变
- 传入 `wechat`/`alipay` → 返回 `{"code": 1001, "msg": "请求参数解析失败"}`
**响应新增字段**
```json
@@ -178,7 +185,11 @@ result := tx.Model(&model.AgentWallet{}).
}
```
---
### H5 端订单创建 API无变更
**端点**`POST /api/h5/orders`
行为完全不变,仍支持 `wallet`/`wechat`/`alipay`,仍创建待支付订单。
### 订单列表 API

View File

@@ -46,7 +46,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(svc.ShopPackageAllocation),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
AdminOrder: admin.NewOrderHandler(svc.Order),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
H5Order: h5.NewOrderHandler(svc.Order),
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, deps.WechatPayment),

View File

@@ -3,6 +3,7 @@ package admin
import (
"strconv"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -14,24 +15,35 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// OrderHandler 后台订单处理器
type OrderHandler struct {
service *orderService.Service
service *orderService.Service
validator *validator.Validate
}
func NewOrderHandler(service *orderService.Service) *OrderHandler {
return &OrderHandler{service: service}
// NewOrderHandler 创建后台订单处理器
func NewOrderHandler(service *orderService.Service, validator *validator.Validate) *OrderHandler {
return &OrderHandler{service: service, validator: validator}
}
// Create 创建后台订单
// POST /api/admin/orders
func (h *OrderHandler) Create(c *fiber.Ctx) error {
var req dto.CreateOrderRequest
var req dto.CreateAdminOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 验证请求参数payment_method 必须为 wallet 或 offline
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
shopID := middleware.GetShopIDFromContext(ctx)
// 线下支付仅限平台用户
if req.PaymentMethod == model.PaymentMethodOffline {
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付")
@@ -40,6 +52,9 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error {
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden, "无权创建订单")
}
} else {
// 防御性分支DTO 验证已限制,此处兜底
return errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付和线下支付")
}
buyerType := ""
@@ -49,7 +64,7 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error {
buyerID = shopID
}
order, err := h.service.Create(ctx, &req, buyerType, buyerID)
order, err := h.service.CreateAdminOrder(ctx, &req, buyerType, buyerID)
if err != nil {
return err
}

View File

@@ -51,7 +51,7 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error {
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
order, err := h.service.Create(ctx, &req, buyerType, buyerID)
order, err := h.service.CreateH5Order(ctx, &req, buyerType, buyerID)
if err != nil {
return err
}

View File

@@ -3,6 +3,15 @@ package dto
import "time"
type CreateOrderRequest struct {
OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)"`
}
// CreateAdminOrderRequest 后台订单创建请求(仅允许 wallet/offline
type CreateAdminOrderRequest struct {
OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`

View File

@@ -85,7 +85,9 @@ func New(
}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
// CreateLegacy 创建订单(已废弃)
// Deprecated: 使用 CreateAdminOrder 或 CreateH5Order 替代。保留用于回滚。
func (s *Service) CreateLegacy(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
var validationResult *purchase_validation.PurchaseValidationResult
var err error
@@ -317,6 +319,475 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer
}
}
// CreateAdminOrder 后台订单创建(仅支持 wallet/offline立即扣款或激活
// 与 CreateH5Order 的核心区别后台订单不创建待支付状态wallet 立即扣款offline 立即激活
// POST /api/admin/orders
func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
var validationResult *purchase_validation.PurchaseValidationResult
var err error
if req.OrderType == model.OrderTypeSingleCard {
if req.IotCardID == nil {
return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID")
}
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
} else if req.OrderType == model.OrderTypeDevice {
if req.DeviceID == nil {
return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID")
}
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
}
if err != nil {
return nil, err
}
// 下单阶段校验混买限制:禁止同一订单同时包含正式套餐和加油包
if err := validatePackageTypeMixFromPackages(validationResult.Packages); err != nil {
return nil, err
}
// 幂等性检查:防止同一买家对同一载体短时间内重复下单
carrierType, carrierID := resolveAdminCarrierInfo(req)
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
if err != nil {
return nil, err
}
if existingOrderID > 0 {
return s.Get(ctx, existingOrderID)
}
// 获取到分布式锁后,确保无论成功还是失败都释放
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
defer s.redis.Del(ctx, lockKey)
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求")
}
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.PaymentStatusPaid
var paidAt *time.Time
now := time.Now()
isPurchaseOnBehalf := false
var operatorID *uint
operatorType := ""
var actualPaidAmount *int64
purchaseRole := ""
var sellerShopID *uint = resourceShopID
var sellerCostPrice int64
// 根据支付方式分别处理
if req.PaymentMethod == model.PaymentMethodOffline {
// ==== 场景 1平台代购offline====
purchaseBuyerID, buyerCostPrice, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = purchaseBuyerID
totalAmount = buyerCostPrice
paymentMethod = model.PaymentMethodOffline
paymentStatus = model.PaymentStatusPaid
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
}
} else {
// 兜底检查后台不支持其他支付方式DTO 验证已拒绝,此为防御性编程)
return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
}
order := &model.Order{
BaseModel: model.BaseModel{
Creator: userID,
Updater: userID,
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: req.OrderType,
BuyerType: orderBuyerType,
BuyerID: orderBuyerID,
IotCardID: req.IotCardID,
DeviceID: req.DeviceID,
TotalAmount: totalAmount,
PaymentMethod: paymentMethod,
PaymentStatus: paymentStatus,
PaidAt: paidAt,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
SeriesID: seriesID,
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
} else if req.PaymentMethod == model.PaymentMethodWallet {
// 钱包支付:创建订单、扣款、激活套餐(在事务中完成)
if operatorID == nil {
return nil, errors.New(errors.CodeInternalError, "钱包支付场景下 operatorID 不能为空")
}
operatorShopID := *operatorID
buyerShopID := orderBuyerID
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 {
// 不应该到这里DTO 验证已拒绝其他支付方式)
return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
}
}
// CreateH5Order H5 端订单创建(支持 wallet/wechat/alipay支持待支付状态
// 保留原 Create() 方法的完整逻辑H5 端行为不变
// POST /api/h5/orders
func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
var validationResult *purchase_validation.PurchaseValidationResult
var err error
if req.OrderType == model.OrderTypeSingleCard {
if req.IotCardID == nil {
return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID")
}
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
} else if req.OrderType == model.OrderTypeDevice {
if req.DeviceID == nil {
return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID")
}
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
}
if err != nil {
return nil, err
}
// 下单阶段校验混买限制:禁止同一订单同时包含正式套餐和加油包
if err := validatePackageTypeMixFromPackages(validationResult.Packages); err != nil {
return nil, err
}
// 幂等性检查:防止同一买家对同一载体短时间内重复下单
carrierType, carrierID := resolveCarrierInfo(req)
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs)
if err != nil {
return nil, err
}
if existingOrderID > 0 {
return s.Get(ctx, existingOrderID)
}
// 获取到分布式锁后,确保无论成功还是失败都释放
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
defer s.redis.Del(ctx, lockKey)
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求")
}
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 operatorID *uint
operatorType := ""
var actualPaidAmount *int64
purchaseRole := ""
var sellerShopID *uint = resourceShopID
var sellerCostPrice int64
// 场景判断offline平台代购、wallet代理钱包支付、其他待支付
if req.PaymentMethod == model.PaymentMethodOffline {
// ==== 场景 1平台代购offline====
purchaseBuyerID, buyerCostPrice, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = purchaseBuyerID
totalAmount = buyerCostPrice
paymentMethod = model.PaymentMethodOffline
paymentStatus = model.PaymentStatusPaid
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{
BaseModel: model.BaseModel{
Creator: userID,
Updater: userID,
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: req.OrderType,
BuyerType: orderBuyerType,
BuyerID: orderBuyerID,
IotCardID: req.IotCardID,
DeviceID: req.DeviceID,
TotalAmount: totalAmount,
PaymentMethod: paymentMethod,
PaymentStatus: paymentStatus,
PaidAt: paidAt,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
SeriesID: seriesID,
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
} else if req.PaymentMethod == model.PaymentMethodWallet {
// 钱包支付:创建订单、扣款、激活套餐(在事务中完成)
if operatorID == nil {
return nil, errors.New(errors.CodeInternalError, "钱包支付场景下 operatorID 不能为空")
}
operatorShopID := *operatorID
buyerShopID := orderBuyerID
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 {
// 其他支付方式创建待支付订单H5 端支持 wechat/alipay
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) {
var resourceShopID *uint
var seriesID *uint
@@ -1013,6 +1484,17 @@ func resolveCarrierInfo(req *dto.CreateOrderRequest) (carrierType string, carrie
return "", 0
}
// resolveAdminCarrierInfo 从后台订单请求中提取载体类型和ID
func resolveAdminCarrierInfo(req *dto.CreateAdminOrderRequest) (carrierType string, carrierID uint) {
if req.OrderType == model.OrderTypeSingleCard && req.IotCardID != nil {
return "iot_card", *req.IotCardID
}
if req.OrderType == model.OrderTypeDevice && req.DeviceID != nil {
return "device", *req.DeviceID
}
return "", 0
}
// buildOrderIdempotencyKey 生成订单创建的幂等性业务键
// 格式: {buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}
func buildOrderIdempotencyKey(buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) string {

View File

@@ -1,617 +1,403 @@
## Context
当前订单创建逻辑存在两条路径:
1. **平台代购offline**:平台为代理创建订单,使用 `offline` 支付方式,订单创建后立即标记已支付并激活套餐,不扣钱包
2. **其他支付方式wallet/wechat/alipay**:创建待支付订单(`payment_status = pending`),后续调用支付接口完成支付
### 当前问题分析
问题在于:代理在后台使用 `wallet` 创建订单时,走的是第 2 条路径(创建待支付订单),但后台没有支付接口,导致订单无法完成。
当前代理在后台创建订单时存在三层架构缺陷:
**实际业务场景**:代理帮客户购买套餐(代购),需要从自己钱包扣款并立即激活套餐,这是一步完成的操作,不应该创建待支付订单。
**第一层Handler 层缺少参数验证**
- `internal/handler/admin/order.go:27-29` 只调用了 `c.BodyParser(&req)`,没有调用 `middleware.ValidateStruct(&req)`
- 导致 DTO 中定义的 `validate:"required,oneof=wallet offline"` 规则完全失效
- 用户可以传入任何 `payment_method` 值(如 `wechat``alipay`),绕过验证
**现有代码分析**
- `Service.Create()` 方法中,只有 `payment_method == "offline"` 才会调用 `createOrderWithActivation()`(立即完成)
- `wallet` 支付会直接调用 `orderStore.Create()`,创建待支付订单,不扣款不激活
- 缺少"操作者"和"买家"的区分,无法追踪代购关系
**第二层Handler 层权限检查不完整**
- `internal/handler/admin/order.go:35-43` 只检查了 `offline` `wallet` 两种支付方式的权限
- `else` 分支没有任何检查,导致其他支付方式绕过权限校验
- 前端如果没有传 `payment_method`(空字符串或未定义),也会进入 `else` 分支
**第三层Service 层后台和 H5 端共用方法**
- `internal/service/order/service.go:88-318``Create()` 方法同时服务后台和 H5 端
- `Create()` 方法的 `else` 分支(第 310-317 行)是为 H5 端在线支付wechat/alipay准备的创建待支付订单
- 后台误用这个分支会导致创建待支付订单,但后台没有支付界面,订单永远无法完成
### 现有代码结构
```
admin/order.Create() ─┐
├─→ service.Create(buyerType, buyerID) ← 同一个方法
h5/order.Create() ────┘
├─ if offline → 平台代购(激活)
├─ else if wallet → 钱包支付(扣款+激活)
└─ else → 创建待支付订单 ← 后台误用这里
```
### 业务约束
- **后台订单创建**:代理/平台账号使用,仅支持 wallet/offline 支付,立即扣款或激活,不允许待支付状态
- **H5 端订单创建**C 端用户使用,支持 wallet/wechat/alipay 支付,支持待支付状态(等待用户支付)
---
## Goals / Non-Goals
**Goals:**
1. 支持代理在后台使用 wallet 一步完成订单(检查余额 → 扣款 → 激活套餐)
2. 区分订单中的"操作者"(谁下单)和"买家"(资源所属者),支持数据追溯和业务分析
3. 正确处理三种代购场景的价格逻辑:
- 代理自购:订单金额 = 实际支付 = 自己成本价
- 代理代购(给下级):订单金额 = 下级成本价,实际支付 = 自己成本价
- 平台代购:订单金额 = 下级成本价,实际支付 = 0不扣款
4. 支持按订单角色筛选查询(自购、上级购买、平台购买、给下级购买)
5. 钱包流水记录支持场景区分和关联店铺追踪
6. 佣金逻辑调整:代理代购不产生佣金(已赚差价)
1. **修复 Handler 层参数验证**:确保 DTO 的 `validate` 规则生效
2. **修复 Handler 层权限检查**:完整覆盖所有支付方式的权限校验
3. **拆分 Service 方法**:后台和 H5 端使用独立的 Service 方法,避免逻辑混淆
4. **后台订单一步到位**wallet 支付立即扣款offline 支付立即激活,不创建待支付订单
5. **保持 H5 端行为不变**:不影响 H5 端订单创建流程
6. **向后兼容**:保留回滚方案,避免破坏现有功能
**Non-Goals:**
- 不修改 H5 端的支付流程H5 端仍然是两步:创建待支付订单 → 调用 WalletPay
- 修改平台代购offline的现有逻辑
- 不支持代理在后台使用微信/支付宝支付(后台只支持 wallet 和 offline
- 不涉及个人客户C 端)的订单流程
1. 修改订单数据模型(无需新增字段)
2. 修改支付方式枚举(保持现有 wallet/wechat/alipay/offline
3. 修改 H5 端订单创建逻辑(只拆分,不改逻辑)
4. 重构整个订单系统(仅针对性修复代理钱包订单问题)
---
## Decisions
### 决策 1新增订单角色追踪字段
### Decision 1: Service 方法拆分策略
**决策**:在 `tb_order` 表新增 4 个字段:
- `operator_id` (INT, nullable):操作者 ID店铺 ID
- `operator_type` (VARCHAR, nullable):操作者类型(`platform` / `agent`
- `actual_paid_amount` (BIGINT, nullable):实际支付金额(分)
- `purchase_role` (VARCHAR):订单角色枚举
**选择**: 拆分 `OrderService.Create()` 为两个独立方法
**理由**
- 现有 `buyer_id` 字段只记录买家(资源所属者),无法区分"谁下单"和"谁买单"
- `operator_id` 记录操作者,支持追溯代购关系(如:平台为代理 A 代购,代理 A 为代理 B 代购)
- `actual_paid_amount` 记录实际扣款金额,与 `total_amount`(订单金额)可能不同(代理代购场景)
- `purchase_role` 枚举字段支持高效筛选,避免依赖文本备注
**方案对比**:
**替代方案(已拒绝)**
- ❌ 使用 `creator` 字段代替 `operator_id``creator` 是审计字段,语义不同,不应混用
- ❌ 使用 `remark` 字段记录代购信息:文本字段无法高效索引和筛选
- ❌ 创建单独的代购订单表:增加系统复杂度,查询需要 JOIN 多表
| 方案 | 优点 | 缺点 |
|------|------|------|
| **方案 A在现有 `Create()` 中添加 `isAdminContext` 参数** | 改动最小,无 breaking change | 逻辑更复杂if-else 嵌套增加,难以维护 |
| **方案 B拆分为 `CreateAdminOrder()` 和 `CreateH5Order()`** ✅ | 逻辑清晰,职责分离,易于测试和维护 | breaking change需要修改 Handler 层调用 |
| **方案 C保留 `Create()` 方法,内部调用不同的私有方法** | 对外 API 不变 | 隐藏了后台和 H5 的差异,仍然可能误用 |
**选择方案 B** 的理由:
- 后台和 H5 端的订单创建逻辑差异巨大(后台立即扣款 vs H5 待支付),应该使用不同的方法
- 明确的方法名(`CreateAdminOrder` vs `CreateH5Order`)可以防止误用
- 未来如果需要进一步修改后台订单逻辑,不会影响 H5 端
- breaking change 影响范围可控(只有 2 个 Handler 文件)
**实现细节**:
**`purchase_role` 枚举值**
```go
// CreateAdminOrder 后台订单创建(仅支持 wallet/offline立即扣款或激活
func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
// 1. 验证购买合法性(复用现有逻辑)
validationResult, err := s.validatePurchase(ctx, req.OrderType, req.IotCardID, req.DeviceID, req.PackageIDs)
// 2. 幂等性检查(复用现有逻辑)
existingOrderID, err := s.checkOrderIdempotency(...)
// 3. 根据支付方式路由
if req.PaymentMethod == model.PaymentMethodOffline {
// 平台代购:创建订单并立即激活套餐
return s.createOrderWithActivation(ctx, order, items)
} else if req.PaymentMethod == model.PaymentMethodWallet {
// 钱包支付:检查余额 → 扣款 → 创建已支付订单 → 激活套餐
return s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID)
} else {
// 不应该到这里DTO 验证已拒绝其他支付方式)
return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
}
}
// CreateH5Order H5 端订单创建(支持 wallet/wechat/alipay支持待支付状态
func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) {
// 保留原 Create() 方法的完整逻辑
// ...
}
```
**迁移策略**:
1. 新增 `CreateAdminOrder()``CreateH5Order()` 方法
2. 将原 `Create()` 方法重命名为 `CreateLegacy()`(保留作为回滚方案)
3. 修改 Handler 层调用新方法
4. 测试验证后删除 `CreateLegacy()` 方法
---
### Decision 2: DTO 设计
**选择**: 创建新的 `CreateAdminOrderRequest` DTO保留现有 `CreateOrderRequest`
**理由**:
- 后台和 H5 端的参数验证规则不同(`oneof=wallet offline` vs `oneof=wallet wechat alipay`
- 使用不同的 DTO 可以在类型层面保证后台不会传入非法支付方式
- 符合"类型安全"原则,编译期就能发现错误
**DTO 定义**:
```go
// CreateAdminOrderRequest 后台订单创建请求(仅允许 wallet/offline
type CreateAdminOrderRequest struct {
OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
}
// CreateOrderRequest H5 端订单创建请求(保持不变)
type CreateOrderRequest struct {
OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"`
IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"`
DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)"`
}
```
**替代方案及为何不选**:
- ~~使用同一个 DTO在 Handler 层校验支付方式~~:无法利用类型系统,容易漏掉校验
- ~~使用 interface{} 类型~~:失去类型安全,违反 Go 最佳实践
---
### Decision 3: Handler 层参数验证和权限检查
**选择**: 在 Handler 层完整实现参数验证和权限检查
**修复点 1添加参数验证**
```go
func (h *OrderHandler) Create(c *fiber.Ctx) error {
var req dto.CreateAdminOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// ← 添加验证(关键!)
if err := middleware.ValidateStruct(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
// ... 后续逻辑
}
```
**修复点 2完整的权限检查**
```go
// 检查支付方式权限
if req.PaymentMethod == model.PaymentMethodOffline {
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付")
}
} else if req.PaymentMethod == model.PaymentMethodWallet {
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden, "无权创建订单")
}
} else {
// ← 添加兜底检查(防御性编程)
return errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
}
```
**理由**:
- 参数验证是 Handler 层的职责(参考 CLAUDE.md 架构规范)
- 兜底检查可以防止未来新增支付方式时漏掉权限校验
- 多层防护DTO 验证(第一道防线) + Handler 检查(第二道防线) + Service 检查(第三道防线)
---
### Decision 4: 错误处理策略
**选择**: 使用统一错误码,添加新错误码(如需要)
**新增错误码**(如果需要):
```go
// pkg/errors/errors.go
const (
PurchaseRoleSelfPurchase = "self_purchase" // 自己购买
PurchaseRolePurchasedByParent = "purchased_by_parent" // 上级代理购买
PurchaseRolePurchasedByPlatform = "purchased_by_platform" // 平台代购
PurchaseRolePurchaseForSubordinate = "purchase_for_subordinate" // 给下级购买
// ... 现有错误码
CodeInvalidPaymentMethodForAdmin = 40008 // 后台不支持的支付方式
)
```
**索引设计**
- `idx_orders_operator_id` (operator_id):支持"我作为操作者的订单"查询
- `idx_orders_purchase_role` (purchase_role):支持按角色筛选
**错误返回规范**:
- Handler 层参数验证失败:`errors.New(errors.CodeInvalidParam)`(不泄露细节)
- Service 层钱包余额不足:`errors.New(errors.CodeInsufficientBalance, "余额不足")`
- Service 层支付方式非法:`errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")`
**理由**:
- 遵循项目错误处理规范(参考 CLAUDE.md
- Handler 层不直接返回底层错误,防止信息泄露
---
### 决策 2订单创建流程重构
**决策**:在 `Service.Create()` 方法中,根据 `payment_method` 和资源归属判断场景:
```
IF payment_method == "offline":
→ 平台代购场景(保持现有逻辑
→ buyer = 资源所属者, operator = nil, operator_type = "platform"
→ 价格 = 买家成本价, 实际支付 = nil不扣款
→ purchase_role = "purchased_by_platform"
→ 调用 createOrderWithActivation()
ELSE IF payment_method == "wallet":
→ 获取操作者店铺 ID
→ 获取资源所属店铺 ID
IF 资源所属 == 操作者:
→ 代理自购场景
→ buyer = 操作者, operator = 操作者
→ 价格 = 操作者成本价, 实际支付 = 操作者成本价
→ purchase_role = "self_purchase"
→ is_purchase_on_behalf = false
ELSE:
→ 代理代购场景
→ buyer = 资源所属者, operator = 操作者
→ 价格 = 买家成本价, 实际支付 = 操作者成本价
→ purchase_role = "purchase_for_subordinate"
→ is_purchase_on_behalf = true
→ 调用 createOrderWithWalletPayment()
```
**理由**
- 清晰区分三种场景,每种场景的价格逻辑和字段填充规则不同
- 复用现有的 `createOrderWithActivation()`(平台代购)
- 新增 `createOrderWithWalletPayment()`(代理钱包支付,含扣款逻辑)
**替代方案(已拒绝)**
- ❌ 使用策略模式分离三种场景:过度设计,增加代码复杂度
- ❌ 在 Handler 层判断场景:违反分层架构,业务逻辑应在 Service 层
---
### 决策 3价格计算逻辑
**决策**:区分"订单金额"`total_amount`)和"实际支付"`actual_paid_amount`
| 场景 | 订单金额total_amount | 实际支付actual_paid_amount | 说明 |
|------|------------------------|------------------------------|------|
| 代理自购 | 操作者成本价 | 操作者成本价 | 两者相同 |
| 代理代购 | 买家成本价 | 操作者成本价 | 操作者实际扣款少于订单金额(赚取差价) |
| 平台代购 | 买家成本价 | NULL | 平台不扣款 |
**理由**
- **订单金额**面向买家:买家看到的应该是"他的成本价",这样才符合业务逻辑
- **实际支付**面向操作者:操作者实际扣款金额,用于钱包流水和财务对账
- 代理代购时,操作者按自己的成本价扣款,但订单显示下级成本价,差价即为利润
**示例**
```
一级代理 A 成本价80 元
二级代理 B 成本价100 元
A 为 B 的卡购买套餐:
- total_amount = 100B 看到的订单金额)
- actual_paid_amount = 80A 实际扣款)
- A 赚取差价20 元
```
**成本价查询**
```go
// 通过 ShopPackageAllocation 表查询店铺对套餐的成本价
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
costPrice := allocation.CostPrice
```
---
### 决策 4事务处理设计
**决策**:新增 `createOrderWithWalletPayment()` 方法,使用 GORM 事务确保原子性:
```go
func (s *Service) createOrderWithWalletPayment(
ctx context.Context,
order *model.Order,
items []*model.OrderItem,
operatorShopID uint,
buyerShopID uint,
) (*dto.OrderResponse, error) {
actualAmount := *order.ActualPaidAmount
// 1. 事务外:检查钱包余额(快速失败)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, operatorShopID)
if wallet.Balance < actualAmount {
return nil, errors.New(errors.CodeInsufficientBalance, "余额不足")
}
// 2. 事务内:创建订单 + 扣款 + 创建流水 + 激活套餐
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 2.1 创建订单
if err := tx.Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
// 2.2 创建订单明细
for _, item := range items {
item.OrderID = order.ID
}
if err := tx.CreateInBatches(items, 100).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
}
// 2.3 扣减钱包余额(乐观锁)
walletResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, actualAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", actualAmount),
"version": gorm.Expr("version + 1"),
})
if walletResult.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
// 2.4 创建钱包流水
if err := s.createWalletTransaction(ctx, tx, wallet.ID, order.ID, actualAmount, order.PurchaseRole, buyerShopID); err != nil {
return err
}
// 2.5 激活套餐
return s.activatePackage(ctx, tx, order)
})
// 3. 事务外:佣金计算(异步)
if order.OperatorID == nil {
s.enqueueCommissionCalculation(ctx, order.ID)
}
return s.buildOrderResponse(order, items), nil
}
```
**事务步骤顺序理由**
1. **先创建订单**:确保订单记录存在,后续步骤失败时可以回滚
2. **后扣款**:避免扣款成功但订单创建失败的情况(钱扣了但没有订单)
3. **最后激活套餐**:套餐激活依赖订单 ID必须在订单创建后执行
**乐观锁设计**
- 使用 `version` 字段防止并发扣款导致余额不一致
- `WHERE balance >= ?` 确保余额充足
- `RowsAffected == 0` 表示余额不足或版本冲突,回滚事务
**替代方案(已拒绝)**
- ❌ 使用悲观锁SELECT FOR UPDATE会降低并发性能乐观锁已足够
- ❌ 先扣款后创建订单:扣款成功但订单失败时难以回滚(需要补偿事务)
---
### 决策 5订单查询 OR 逻辑
**决策**:修改 `OrderStore.List()` 方法,支持 `buyer_id = shopID OR operator_id = shopID` 查询:
```go
func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Order, int64, error) {
query := s.db.WithContext(ctx).Model(&model.Order{})
// 代理用户:查询作为买家或操作者的订单
if shopID, ok := filters["shop_id"].(uint); ok {
query = query.Where(
"(buyer_type = ? AND buyer_id = ?) OR operator_id = ?",
model.BuyerTypeAgent, shopID, shopID,
)
delete(filters, "shop_id")
}
// 其他筛选条件...
}
```
**理由**
- 代理需要看到两类订单:
1. 作为买家的订单(`buyer_id = 自己`):别人为自己代购、自己购买
2. 作为操作者的订单(`operator_id = 自己`):自己为下级代购
- OR 查询在 PostgreSQL 中性能可接受(已有索引)
**性能优化**
- `buyer_id` 已有索引 `idx_order_buyer`
- `operator_id` 新建索引 `idx_orders_operator_id`
- OR 查询会走两个索引的 UNION性能可接受
**替代方案(已拒绝)**
- ❌ 两次查询后合并结果:代码复杂,分页逻辑难以实现
- ❌ 冗余存储(同一订单插入两次):违反数据一致性原则
---
### 决策 6钱包流水记录设计
**决策**:钱包流水表新增字段(如果不存在):
- `transaction_subtype` (VARCHAR):交易子类型,细分 `order_payment` 场景
- `related_shop_id` (INT, nullable):关联店铺 ID代购时记录下级店铺
**子类型枚举**(在 `pkg/constants/wallet.go` 中定义):
```go
// 钱包交易子类型(当 transaction_type = "order_payment" 时)
const (
WalletTransactionSubtypeSelfPurchase = "self_purchase"
WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate"
)
```
**流水创建逻辑**
```go
func (s *Service) createWalletTransaction(
ctx context.Context,
tx *gorm.DB,
walletID uint,
orderID uint,
amount int64,
purchaseRole string,
relatedShopID *uint,
) error {
var subtype string
var remark string
switch purchaseRole {
case constants.PurchaseRoleSelfPurchase:
subtype = constants.WalletTransactionSubtypeSelfPurchase
remark = "购买套餐"
case constants.PurchaseRolePurchaseForSubordinate:
subtype = constants.WalletTransactionSubtypePurchaseForSubordinate
// 查询下级店铺名称
buyerShop, _ := s.shopStore.GetByID(ctx, *relatedShopID)
if buyerShop != nil {
remark = fmt.Sprintf("为下级代理【%s】购买套餐", buyerShop.ShopName)
} else {
remark = "为下级代理购买套餐"
}
}
transaction := &model.AgentWalletTransaction{
WalletID: walletID,
TransactionType: constants.AgentTransactionTypeDeduct,
TransactionSubtype: subtype,
Amount: -amount,
OrderID: &orderID,
RelatedShopID: relatedShopID,
Remark: remark,
}
return tx.Create(transaction).Error
}
```
**理由**
- `transaction_subtype` 支持高效筛选(如:查询所有自购流水)
- `related_shop_id` 支持追溯(如:查询为哪些下级代理购买过)
- `remark` 作为辅助展示字段,包含店铺名称便于人工查看
---
### 决策 7佣金逻辑调整
**决策**:在 `Service.Create()` 方法中,只有 `operator_id == nil`(平台代购)才入队佣金计算任务:
```go
// 佣金计算(异步)
if order.OperatorID == nil {
// 只有平台代购才产生佣金
s.enqueueCommissionCalculation(ctx, order.ID)
}
// 代理代购不入队佣金operator_id != nil
```
**理由**
- **代理代购**:操作者从自己钱包扣自己的成本价,已经赚取了成本价差(自己成本价 vs 下级成本价),不应再产生佣金
- **平台代购**:平台不扣款,按买家成本价计算差价佣金,激励上级代理
**示例**
```
一级代理 A 成本价80 元
二级代理 B 成本价100 元
场景 1A 为 B 的卡购买套餐(代理代购)
- A 扣款80 元
- A 利润100 - 80 = 20 元
- 佣金A 已赚取差价)
场景 2平台为 B 的卡购买套餐(平台代购)
- 平台扣款0 元
- 订单金额100 元
- 佣金:给 AB 的上级),金额 = 100 - 80 = 20 元
```
**替代方案(已拒绝)**
- ❌ 代理代购也计算佣金:会导致双重利润(差价 + 佣金),不符合业务逻辑
---
### 决策 8常量定义位置
**决策**
- **订单角色枚举**`PurchaseRole*`):定义在 `internal/model/order.go`,紧邻 Order 模型
- **钱包交易子类型**:扩展现有 `pkg/constants/wallet.go`,新增 `WalletTransactionSubtype*` 常量
- **操作者类型**:复用现有 `pkg/constants/iot.go` 中的 `OwnerTypePlatform` 和自定义 `"agent"` 字符串
**理由**
- 订单角色枚举只在订单模块使用,放在 `model/order.go` 避免常量文件膨胀
- 钱包交易子类型属于钱包系统,应在 `wallet.go` 中管理
- 避免重复定义已有常量(如 `OwnerTypePlatform`
---
### 决策 9DTO 响应字段设计
**决策**`OrderResponse` 新增字段:
```go
type OrderResponse struct {
// ... 现有字段
// 操作者信息
OperatorID *uint `json:"operator_id,omitempty"`
OperatorType string `json:"operator_type,omitempty"`
OperatorName string `json:"operator_name,omitempty"`
ActualPaidAmount *int64 `json:"actual_paid_amount,omitempty"`
// 订单角色
PurchaseRole string `json:"purchase_role"`
IsPurchasedByParent bool `json:"is_purchased_by_parent"`
PurchaseRemark string `json:"purchase_remark,omitempty"`
}
```
**字段说明**
- `operator_id``operator_type``actual_paid_amount`:直接映射数据库字段
- `operator_name`查询操作者名称Shop 表的 `shop_name`),便于前端展示
- `purchase_role`:订单角色枚举,支持前端筛选和标签展示
- `is_purchased_by_parent`:派生字段,`purchase_role == "purchased_by_parent"`
- `purchase_remark`:派生字段,如"由上级代理【XX】购买"或"由平台代购"
**理由**
- 前端需要友好的文本展示(如操作者名称、购买备注)
- `is_purchased_by_parent` 便于前端判断是否显示"上级代购"标签
- `purchase_remark` 避免前端拼接文本逻辑
### Decision 5: 向后兼容和回滚策略
**选择**: 保留原 `Create()` 方法作为 `CreateLegacy()`,灰度发布
**迁移步骤**:
1. **Phase 1**: 新增 `CreateAdminOrder()``CreateH5Order()` 方法
2. **Phase 2**: 将原 `Create()` 重命名为 `CreateLegacy()`(保留,暂不调用
3. **Phase 3**: 修改 Handler 层调用新方法
4. **Phase 4**: 测试环境验证
5. **Phase 5**: 生产环境灰度发布(先 1% 流量,观察 1 天,再逐步放量)
6. **Phase 6**: 全量上线后观察 1 周,无问题后删除 `CreateLegacy()` 方法
**回滚方案**:
- 如果出现问题,立即修改 Handler 层调用 `CreateLegacy()` 方法
- 重新部署后端5 分钟内可完成)
- 前端无需回滚(因为 DTO 结构兼容)
**理由**:
- breaking change 风险高,需要谨慎迁移
- 保留 `CreateLegacy()` 方法可以快速回滚,避免长时间故障
---
## Risks / Trade-offs
### [风险] 数据库迁移失败 → 回滚策略
### Risk 1: 前端未同步修改导致参数缺失
**问题**:新增字段的迁移脚本可能在生产环境执行失败(如表锁、超时)
**风险**: 前端没有传 `payment_method` 参数,后端拒绝请求
**缓解措施**
1. 所有新增字段设为 `nullable`,不影响现有数据
2. 迁移脚本分步执行:
- Step 1: 添加字段(不加 NOT NULL 约束
- Step 2: 数据回填(如有需要)
- Step 3: 添加索引CONCURRENTLY 方式,不锁表)
3. 回滚脚本:`DROP COLUMN IF EXISTS`
4. 测试环境充分验证后再上生产
**影响**: 高。后台订单创建功能完全不可用
**缓解措施**:
1. 后端先部署(兼容旧前端,如果 `payment_method` 为空,默认使用 `wallet`
2. 前端修改后再部署
3. 灰度发布,先在测试环境验证前后端联调
**检测方式**:
- 监控错误日志中 `CodeInvalidParam` 错误的数量
- 如果错误量激增,立即回滚
---
### [风险] OR 查询性能下降 → 索引优化
### Risk 2: Service 方法拆分导致代码重复
**问题**`WHERE (buyer_id = X) OR (operator_id = X)` 可能无法有效使用索引
**风险**: `CreateAdminOrder()``CreateH5Order()` 有大量重复代码
**缓解措施**
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性能可接受
**缓解措施**:
1. 提取公共逻辑为私有方法(如 `validatePurchase()``checkOrderIdempotency()``buildOrderItems()`
2. `CreateAdminOrder()``CreateH5Order()` 只保留差异化逻辑
**示例**:
```go
func (s *Service) CreateAdminOrder(...) (*dto.OrderResponse, error) {
// 1. 公共逻辑(提取为私有方法)
validationResult, err := s.validatePurchase(...)
existingOrderID, err := s.checkOrderIdempotency(...)
// 2. 差异化逻辑(后台独有)
if req.PaymentMethod == model.PaymentMethodWallet {
return s.createOrderWithWalletPayment(...)
}
}
```
---
### [权衡] 两个金额字段增加存储成本 → 业务清晰度
### Risk 3: 灰度发布失败导致部分用户无法下单
**问题**`total_amount` 和 `actual_paid_amount` 在大部分场景下相同(代理自购),存在冗余
**风险**: 灰度发布过程中,部分用户使用新代码,部分用户使用旧代码,行为不一致
**权衡理由**
- **优势**:业务语义清晰,查询无需计算(如:统计实际收入用 `actual_paid_amount`
- **劣势**:存储成本增加(每订单 8 字节),数据冗余
- **结论**业务清晰度优先存储成本可接受8 字节在订单数据中占比很小)
**影响**: 中。用户体验不一致
**缓解措施**:
1. 灰度发布按 **用户 ID** 维度(而不是随机流量),确保同一用户始终使用同一版本
2. 灰度期间密切监控错误日志和用户反馈
3. 设置灰度开关(环境变量或配置文件),可以快速回滚到旧代码
---
### [权衡] 钱包流水查询店铺名称 → 性能 vs 便利性
### Trade-off: 代码拆分 vs 逻辑复用
**问题**:创建钱包流水时查询店铺名称(`shopStore.GetByID`)会增加一次数据库查询
**选择**: 优先代码拆分(清晰的职责划分),接受一定的代码重复
**权衡理由**
- **优势**:备注字段包含店铺名称,便于人工查看,无需二次查询
- **劣势**:订单创建时增加一次查询(~5ms
- **结论**:可接受,因为:
1. 查询频率低(仅代购场景)
2. Shop 表有缓存机制
3. 事务内查询,不影响一致性
4. 如果性能敏感,可以异步更新备注
**理由**:
- 后台和 H5 端的订单创建逻辑差异巨大,强行复用会导致 if-else 嵌套过深,难以维护
- "一点点代码重复" 优于 "过度抽象"(遵循 Go 惯用模式
- 未来如果需要修改后台订单逻辑,不会影响 H5 端
---
### [风险] 佣金逻辑调整导致收入计算错误 → 回归测试
### Trade-off: breaking change vs 保持现状
**问题**:佣金计算逻辑变更可能影响现有代理的收入
**选择**: 接受 breaking change彻底解决问题
**缓解措施**
1. 充分的单元测试和集成测试
2. 上线前在测试环境验证佣金计算结果
3. 灰度发布,监控佣金数据异常
4. 保留 `operator_id == nil` 的判断逻辑,避免误伤平台代购
**理由**:
- 当前问题严重(代理可以创建永远无法完成的订单),必须彻底修复
- breaking change 影响范围可控(只有 2 个 Handler 文件)
- 保留回滚方案,风险可控
---
## Migration Plan
### 数据库迁移步骤
### Phase 1: 后端开发和测试1 天)
**Step 1: 创建迁移脚本**
1. 创建新 DTO`CreateAdminOrderRequest`
2. 新增 Service 方法:`CreateAdminOrder()``CreateH5Order()`
3. 重命名原 `Create()``CreateLegacy()`
4. 修改 Handler 层调用新方法
5. 单元测试(覆盖率 ≥ 90%
文件:`migrations/xxx_add_operator_fields_to_orders.up.sql`
### Phase 2: 前端开发0.5 天)
```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);
1. 后台订单创建界面添加 `payment_method` 下拉框wallet/offline
2. 修改请求参数,使用新 DTO
-- 添加注释
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';
### Phase 3: 联调测试0.5 天)
-- 添加索引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);
```
1. 测试环境部署后端
2. 测试环境部署前端
3. 完整测试所有场景:
- 代理钱包支付(余额充足)
- 代理钱包支付(余额不足)
- 平台线下支付
- H5 端订单创建(回归测试)
**Step 2: 钱包流水表迁移**(如果字段不存在
### Phase 4: 灰度发布3 天
文件:`migrations/xxx_add_transaction_subtype_to_wallet_transaction.up.sql`
1. **Day 1**: 生产环境部署后端,灰度 1% 流量
2. **Day 2**: 观察错误日志,无问题则扩大到 10%
3. **Day 3**: 扩大到 50%,无问题则全量
```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;
### Phase 5: 清理1 天)
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
$$;
```
1. 观察 1 周,无问题后删除 `CreateLegacy()` 方法
2. 更新文档API 文档、技术文档)
**Step 3: 数据回填**(可选,视历史数据需求而定)
**总时长**: 约 6 天
```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不影响业务
```
### Rollback Strategy
### 部署步骤
**触发条件**:
- 错误率超过 1%
- 用户反馈无法下单
- 前端未同步上线导致大量参数缺失错误
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;
```
**回滚步骤**:
1. 立即修改 Handler 层调用 `CreateLegacy()` 方法
2. 重新部署后端5 分钟)
3. 验证功能恢复
4. 排查问题,修复后重新上线
---
## Open Questions
1. **是否需要支持代理在后台为个人客户C 端)代购?**
- 当前设计只支持代理为代理B2B不支持代理为个人客户B2C
- 如果未来需要,需要扩展 `buyer_type` 判断逻辑
1. **前端是否已经传递 `payment_method` 参数?**
- 需要确认前端当前行为
- 如果未传递后端需要提供默认值wallet以兼容旧版本
2. **钱包流水的 `related_shop_id` 是否需要索引?**
- 当前设计未加索引,因为查询频率低
- 如果未来需要"查询我为哪些下级购买过"功能,需要添加索引
2. **是否需要新增错误码 `CodeInvalidPaymentMethodForAdmin`**
- 当前可以复用 `CodeInvalidParam`
- 如果需要区分"参数格式错误"和"支付方式不支持",可以新增
3. **是否需要支持订单角色的批量变更?**
- 当前设计 `purchase_role` 在订单创建时填充,后续不可修改
- 如果历史订单需要回填角色,需要单独的数据修复脚本
3. **灰度发布的维度是什么?**
- 按用户 ID 灰度(推荐)
- 按流量百分比灰度
- 需要与运维确认灰度策略
4. **代理代购时,下级代理是否能看到操作者信息?**
- 当前设计:下级可以看到 `operator_id` 和 `operator_name`
- 是否需要隐藏?(业务决策)
4. **是否需要支持后台创建待支付订单(未来需求)?**
- 当前不支持Non-Goal
- 如果未来有需求,可以新增 `CreateAdminPendingOrder()` 方法

View File

@@ -1,61 +1,95 @@
## Why
代理在后台使用钱包支付wallet创建订单时系统只创建待支付订单payment_status = pending不扣款也不激活套餐导致订单无法完成。后台没有支付接口代理无法对待支付订单进行支付。这个问题阻塞了代理的核心业务场景代理帮客户购买套餐代购从自己钱包扣款并立即激活
当前代理在后台创建订单时存在严重的逻辑漏洞:缺少参数验证、权限检查不完整、后台和 H5 端共用同一个 Service 方法,导致代理可以在钱包余额不足时创建"待支付"状态的订单,但后台没有支付界面,订单永远无法完成。这是一个关键的业务逻辑缺陷,影响代理订单的正常流转和用户体验
## What Changes
- **新增订单角色追踪字段**:在订单表中新增 `operator_id`操作者ID`operator_type`(操作者类型)、`actual_paid_amount`(实际支付金额)、`purchase_role`(订单角色)字段,用于区分"谁下单"和"谁买单"
- **支持代理钱包一步购买**:代理在后台使用 wallet 创建订单时,立即检查余额、扣款、激活套餐,订单状态直接为已支付(一步完成,无需后续支付接口)
- **区分代购场景**
- 代理自购(资源属于自己):从自己钱包扣自己的成本价,订单金额 = 实际支付
- 代理代购(资源属于下级):从自己钱包扣自己的成本价,但订单金额显示下级成本价(让下级看到他的成本)
- 平台代购offline保持现有逻辑不扣款立即激活
- **订单查询增强**:代理可以查询 `buyer_id = 自己``operator_id = 自己` 的订单(看到自己作为买家或操作者的所有订单
- **钱包流水记录**:钱包扣款时记录交易子类型(自购 / 给下级购买和关联店铺ID支持按场景筛选
- **佣金逻辑调整**:代理代购订单不产生佣金(操作者已赚取成本价差),只有平台代购才触发佣金计算
### Handler 层修复(短期)
- `internal/handler/admin/order.go``Create()` 方法中添加参数验证(调用 `middleware.ValidateStruct(&req)`
- 在 Handler 层严格检查支付方式:后台只允许 `wallet``offline`,拒绝其他支付方式
- 修复权限检查逻辑:完整覆盖所有支付方式的权限校验
### Service 层重构(长期
- **BREAKING**: 拆分 `OrderService.Create()` 方法为两个独立方法:
- `CreateAdminOrder()` - 后台订单创建(仅支持 wallet/offline立即扣款或激活
- `CreateH5Order()` - H5 端订单创建(支持 wallet/wechat/alipay支持待支付状态
- `CreateAdminOrder()` 逻辑:
- wallet 支付:检查余额 → 扣款 → 创建已支付订单 → 激活套餐(一步到位)
- offline 支付:直接创建已支付订单 → 激活套餐
- 余额不足直接拒绝,提示"余额不足"
- `CreateH5Order()` 逻辑:
- wallet 支付:冻结余额 → 创建待支付订单
- wechat/alipay 支付:创建待支付订单
- 混合支付:冻结钱包部分 → 创建待支付订单
### DTO 层修复
- 创建新的 DTO`CreateAdminOrderRequest`(仅允许 wallet/offline
- 保留现有 DTO`CreateOrderRequest`(用于 H5 端,允许 wallet/wechat/alipay
### 前端修复
- 后台订单创建界面必须传递 `payment_method` 参数
- 下拉框只显示"钱包支付"和"线下支付"选项
## Capabilities
### New Capabilities
- `agent-order-role-tracking`: 订单角色追踪能力,记录并区分订单中的操作者、买家、支付者等角色关系,支持多种代购场景的数据查询和业务分析
- `admin-order-creation`: 后台订单创建流程。包含:参数验证、支付方式限制、钱包余额检查、一步到位扣款逻辑、错误处理。
### Modified Capabilities
- `purchase-on-behalf`: 扩展代购订单需求新增代理使用钱包wallet代购的场景区别于现有的平台线下offline代购
- `order-payment`: 新增后台订单钱包一步支付需求,代理创建订单时立即扣款并激活套餐,区别于 H5 端的两步支付流程(创建待支付订单 → 调用支付接口)
- `order-payment`: 修改订单支付流程需求,明确区分后台和 H5 端的支付行为差异(后台立即扣款 vs 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/dto/order_dto.go`
- 新增 `CreateAdminOrderRequest` 结构体
- 保留 `CreateOrderRequest` 用于 H5 端
- `internal/handler/admin/order.go`
- `Create()` 方法添加参数验证和支付方式检查
- 调用新的 `CreateAdminOrder()` 方法
- `internal/handler/h5/order.go`
- `Create()` 方法调用新的 `CreateH5Order()` 方法
- `internal/service/order/service.go`
- **BREAKING**: 拆分 `Create()``CreateAdminOrder()``CreateH5Order()`
- `CreateAdminOrder()` 只支持 wallet/offline立即扣款
- `CreateH5Order()` 保留现有逻辑(支持待支付状态)
- `pkg/errors/errors.go`
- 可能新增错误码(如 `CodeInvalidPaymentMethodForAdmin`
### 受影响的模块
- `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 影响**
- `POST /api/admin/orders` - 请求参数 DTO 变更(新增 `CreateAdminOrderRequest`
- `POST /api/h5/orders` - 无变更(继续使用 `CreateOrderRequest`
### 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 接口)
**性能考虑**
- 无性能影响(逻辑重构,不增加额外查询
**测试要求**
- 单元测试:
- `CreateAdminOrder()` 方法的各种场景(余额充足、余额不足、支付方式非法)
- `CreateH5Order()` 方法的各种场景(保持现有行为)
- 集成测试:
- 后台创建订单 APIwallet 支付、offline 支付、非法支付方式)
- H5 端创建订单 API保持现有测试
- 回归测试:
- 验证 H5 端订单创建流程不受影响
**迁移风险**
- **BREAKING CHANGE**: 后台和 H5 端调用不同的 Service 方法
- 前端需要同步修改(确保传递 `payment_method` 参数)
- 部署顺序:先部署后端(向后兼容),再部署前端
**回滚方案**
- 保留原 `Create()` 方法作为 `CreateLegacy()`,出现问题时快速回滚
- 灰度发布:先在测试环境验证,再逐步上线生产环境

View File

@@ -0,0 +1,248 @@
# Admin Order Creation
## Purpose
后台订单创建流程,为代理和平台账号提供订单创建功能。与 H5 端订单创建的核心区别:后台仅支持 wallet/offline 支付方式,且 wallet 支付立即完成扣款和套餐激活(一步到位),不创建待支付订单。
This capability supports:
- 参数验证和支付方式限制
- 钱包余额检查和一步扣款
- 权限校验(代理、平台、超管)
- 错误处理和防御性编程
## ADDED Requirements
### Requirement: 后台订单创建 API 参数验证
系统 SHALL 在后台订单创建 API 中强制验证请求参数,拒绝非法的支付方式。
后台订单创建使用独立的 DTO`CreateAdminOrderRequest`),仅允许 `wallet``offline` 两种支付方式。Handler 层 MUST 调用 `middleware.ValidateStruct(&req)` 验证参数,确保 DTO 的 `validate:"oneof=wallet offline"` 规则生效。
#### Scenario: DTO 验证拒绝非法支付方式
- **WHEN** 后台创建订单请求中 `payment_method``wechat``alipay`
- **THEN** 系统在 Handler 层验证失败,返回错误"请求参数解析失败"`CodeInvalidParam`),订单创建失败
#### Scenario: DTO 验证拒绝空支付方式
- **WHEN** 后台创建订单请求中缺少 `payment_method` 字段或值为空字符串
- **THEN** 系统在 Handler 层验证失败,返回错误"请求参数解析失败"`CodeInvalidParam`),订单创建失败
#### Scenario: DTO 验证允许 wallet 支付
- **WHEN** 后台创建订单请求中 `payment_method``wallet`
- **THEN** 系统通过 DTO 验证,继续后续业务逻辑
#### Scenario: DTO 验证允许 offline 支付
- **WHEN** 后台创建订单请求中 `payment_method``offline`
- **THEN** 系统通过 DTO 验证,继续后续业务逻辑
---
### Requirement: 后台订单创建权限检查
系统 SHALL 在后台订单创建时完整检查支付方式权限,所有支付方式(包括非法的)都必须经过权限校验。
权限规则:
- `offline` 支付:仅超管和平台账号可用
- `wallet` 支付:代理、平台、超管均可用
- 其他支付方式:一律拒绝(兜底检查)
#### Scenario: 超管可以使用 offline 支付
- **WHEN** 超管账号创建订单,支付方式为 `offline`
- **THEN** 系统通过权限检查,继续创建订单
#### Scenario: 平台账号可以使用 offline 支付
- **WHEN** 平台账号创建订单,支付方式为 `offline`
- **THEN** 系统通过权限检查,继续创建订单
#### Scenario: 代理账号不能使用 offline 支付
- **WHEN** 代理账号创建订单,支付方式为 `offline`
- **THEN** 系统返回错误"只有平台可以使用线下支付"`CodeForbidden`),订单创建失败
#### Scenario: 代理账号可以使用 wallet 支付
- **WHEN** 代理账号创建订单,支付方式为 `wallet`,钱包余额充足
- **THEN** 系统通过权限检查,继续创建订单
#### Scenario: 兜底检查拒绝其他支付方式
- **WHEN** 后台创建订单请求中 `payment_method``wechat`(虽然 DTO 验证应该已拒绝,但作为防御性编程)
- **THEN** 系统在 Handler 层返回错误"后台仅支持钱包支付或线下支付"`CodeInvalidParam`
---
### Requirement: 后台 wallet 订单一步到位
系统 SHALL 在后台创建 wallet 订单时立即完成余额扣款和套餐激活,不创建待支付订单。订单创建成功后 `payment_status` MUST 为 2已支付
与 H5 端的核心区别:
- **后台**:检查余额 → 扣款 → 创建已支付订单 → 激活套餐(一步完成)
- **H5 端**:冻结余额 → 创建待支付订单 → 用户调用支付接口 → 扣款 + 激活(两步流程)
#### Scenario: 后台 wallet 订单立即扣款
- **WHEN** 代理在后台创建订单,支付方式为 `wallet`,钱包余额 5000 分,订单金额 3000 分
- **THEN** 系统立即扣减钱包余额 3000 分,余额变为 2000 分,创建订单时 `payment_status` = 2`paid_at` 为当前时间
#### Scenario: 后台 wallet 订单立即激活套餐
- **WHEN** 代理在后台创建 wallet 订单成功
- **THEN** 系统在同一事务中创建 `PackageUsage` 记录,套餐状态为已激活
#### Scenario: 后台 wallet 订单不创建待支付状态
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** 系统不创建 `payment_status` = 1待支付的订单订单创建后立即为已支付状态
#### Scenario: 后台 wallet 订单余额不足直接拒绝
- **WHEN** 代理在后台创建 wallet 订单,钱包余额 1000 分,订单金额 3000 分
- **THEN** 系统在事务外快速检查余额,返回错误"余额不足"`CodeInsufficientBalance`),订单创建失败
#### Scenario: 后台 wallet 订单事务保证
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** 订单创建、余额扣减、套餐激活在同一事务中完成,任一步骤失败则全部回滚
---
### Requirement: 后台 offline 订单立即激活
系统 SHALL 在后台创建 offline 订单时立即激活套餐,不扣减钱包余额。订单创建成功后 `payment_status` MUST 为 2已支付
#### Scenario: 平台创建 offline 订单立即激活
- **WHEN** 平台账号创建订单,支付方式为 `offline`
- **THEN** 系统创建订单时 `payment_status` = 2`paid_at` 为当前时间,立即激活套餐
#### Scenario: offline 订单不扣钱包
- **WHEN** 平台账号创建 offline 订单
- **THEN** 系统不扣减任何钱包余额(因为是线下支付)
#### Scenario: offline 订单不检查余额
- **WHEN** 平台账号创建 offline 订单,钱包余额为 0
- **THEN** 系统仍然创建订单成功(因为线下支付不依赖钱包)
---
### Requirement: 后台订单创建错误处理
系统 SHALL 在后台订单创建失败时返回明确的错误信息,不泄露底层细节。
错误码使用规范:
- 参数验证失败:`CodeInvalidParam`(不泄露具体校验错误)
- 权限不足:`CodeForbidden`
- 余额不足:`CodeInsufficientBalance`
- 钱包不存在:`CodeWalletNotFound`
- 其他错误:`CodeInternalError`
#### Scenario: 参数验证失败不泄露细节
- **WHEN** 后台创建订单请求参数验证失败(如支付方式非法)
- **THEN** 系统返回 `CodeInvalidParam` 错误码,错误消息为通用的"请求参数解析失败",不包含具体的 validator 错误信息
#### Scenario: 钱包余额不足返回明确错误
- **WHEN** 代理创建 wallet 订单,余额不足
- **THEN** 系统返回 `CodeInsufficientBalance` 错误码,错误消息为"余额不足"
#### Scenario: 钱包不存在返回明确错误
- **WHEN** 代理创建 wallet 订单,钱包不存在
- **THEN** 系统返回 `CodeWalletNotFound` 错误码,错误消息为"钱包不存在"
#### Scenario: 套餐激活失败回滚并返回错误
- **WHEN** 后台创建订单时余额扣减成功但套餐激活失败
- **THEN** 事务回滚,钱包余额恢复,返回套餐激活失败错误(`CodeInternalError`
---
### Requirement: 后台订单创建防重复
系统 SHALL 使用幂等性检查防止同一订单重复创建和重复扣款。
幂等性策略:
- 使用 Redis 业务键:`order:idempotency:{buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}`
- TTL3 分钟
- 分布式锁:`order:create:lock:{carrier_type}:{carrier_id}`TTL 10 秒
#### Scenario: 重复创建订单返回已创建结果
- **WHEN** 代理在后台对同一张卡的同一套餐组合在 3 分钟内重复创建订单
- **THEN** 系统返回第一次创建的订单信息,不重复扣款
#### Scenario: 并发创建订单使用分布式锁
- **WHEN** 两个请求同时为同一张卡创建订单
- **THEN** 只有一个请求获取到分布式锁并创建订单,另一个请求返回"操作进行中,请勿重复提交"`CodeTooManyRequests`
#### Scenario: 幂等性 key 超时后可重新创建
- **WHEN** 订单创建成功 3 分钟后,代理再次创建相同订单
- **THEN** 系统创建新订单(因为幂等性 key 已过期)
---
### Requirement: 后台订单 API 响应格式
系统 SHALL 在后台订单创建成功后返回完整的订单信息,包含支付状态、实际支付金额、操作者信息等。
响应字段(`OrderResponse`
- `id`:订单 ID
- `order_no`:订单号
- `payment_status`:支付状态(后台订单必为 2-已支付)
- `payment_method`支付方式wallet 或 offline
- `paid_at`:支付时间(不为 NULL
- `total_amount`:订单总金额
- `actual_paid_amount`:实际支付金额(仅 wallet 有值)
- `operator_id`:操作者 ID
- `operator_type`操作者类型agent/platform
- `purchase_role`购买角色self_purchase/purchase_for_subordinate/purchased_by_platform
#### Scenario: wallet 订单响应包含实际支付金额
- **WHEN** 代理在后台创建 wallet 订单成功
- **THEN** 响应包含 `actual_paid_amount` 字段,值为实际扣减的钱包金额
#### Scenario: offline 订单响应不包含实际支付金额
- **WHEN** 平台创建 offline 订单成功
- **THEN** 响应的 `actual_paid_amount` 字段为 NULL因为线下支付不扣钱包
#### Scenario: 代购订单响应包含操作者信息
- **WHEN** 上级代理为下级代理购买套餐
- **THEN** 响应包含 `operator_id`(上级店铺 ID`operator_type` = "agent"、`purchase_role` = "purchase_for_subordinate"
---
### Requirement: 后台订单创建与 H5 端隔离
系统 SHALL 使用独立的 Service 方法处理后台订单创建,避免与 H5 端订单创建逻辑混淆。
架构设计:
- 后台:`OrderHandler.Create()``OrderService.CreateAdminOrder()`
- H5 端:`OrderHandler.Create()``OrderService.CreateH5Order()`
#### Scenario: 后台调用独立的 Service 方法
- **WHEN** 后台创建订单
- **THEN** Handler 层调用 `OrderService.CreateAdminOrder()` 方法,不调用通用的 `Create()` 方法
#### Scenario: H5 端调用独立的 Service 方法
- **WHEN** H5 端创建订单
- **THEN** Handler 层调用 `OrderService.CreateH5Order()` 方法,不影响后台订单创建逻辑
#### Scenario: Service 方法命名明确职责
- **WHEN** 开发人员查看代码
- **THEN** 方法命名(`CreateAdminOrder` vs `CreateH5Order`)清楚表明了后台和 H5 端的差异,防止误用

View File

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

View File

@@ -1,159 +1,113 @@
## ADDED Requirements
## MODIFIED Requirements
### Requirement: 后台钱包一步支付
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。后台订单创建使用独立的 Service 方法(`CreateAdminOrder()`),与 H5 端的 `CreateH5Order()` 方法隔离,避免逻辑混淆。
**后台钱包支付流程**(一步到位):
1. 检查钱包余额是否充足(事务外快速失败)
2. 在事务中:扣减钱包余额 → 创建已支付订单(`payment_status` = 2→ 激活套餐
3. 返回已支付的订单信息
**与 H5 端的区别**
- 后台:立即扣款,订单创建后即为已支付状态(`payment_status` = 2
- H5 端:冻结余额,创建待支付订单(`payment_status` = 1需用户调用支付接口
#### Scenario: 后台订单创建时钱包支付
- **WHEN** 代理在后台创建订单,支付方式为 wallet钱包余额充足
- **THEN** 系统创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2激活套餐
- **THEN** 系统调用 `CreateAdminOrder()` 方法,创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2激活套餐
#### Scenario: 后台钱包支付余额不足
- **WHEN** 代理在后台创建订单,支付方式为 wallet钱包余额不足
- **THEN** 系统返回错误"余额不足",订单创建失败
- **THEN** 系统调用 `CreateAdminOrder()` 方法,在事务外检查余额,返回错误"余额不足",订单创建失败
#### Scenario: 后台钱包支付订单响应
- **WHEN** 后台钱包支付订单创建成功
- **THEN** API 响应包含已支付的订单信息,`payment_status` = 2`payment_method` = "wallet"`paid_at` 为当前时间
#### Scenario: 后台钱包支付不创建待支付订单
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** 系统不创建待支付订单(`payment_status` != 1直接完成支付
- **THEN** 系统不创建待支付订单(`payment_status` != 1直接完成支付和套餐激活
#### Scenario: 后台钱包支付使用独立方法
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** Handler 层调用 `OrderService.CreateAdminOrder()` 方法,不调用通用的 `Create()``CreateH5Order()` 方法
---
### Requirement: H5 钱包两步支付保持不变
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。H5 端订单创建使用独立的 Service 方法(`CreateH5Order()`),与后台的 `CreateAdminOrder()` 方法隔离。
**H5 钱包支付流程**(两步流程):
1. 创建订单:冻结钱包余额 → 创建待支付订单(`payment_status` = 1
2. 用户调用支付接口:扣减钱包余额 → 更新订单状态为已支付 → 激活套餐
**与后台的区别**
- H5 端:创建待支付订单,用户需调用支付接口完成支付
- 后台:立即扣款,订单创建后即为已支付状态
#### Scenario: H5 创建待支付订单
- **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet
- **THEN** 系统创建订单,`payment_status` = 1待支付不扣减钱包余额
- **THEN** 系统调用 `CreateH5Order()` 方法,创建订单,`payment_status` = 1待支付冻结钱包余额,不立即扣款
#### Scenario: H5 调用 WalletPay 接口支付
- **WHEN** 个人客户调用 WalletPay 接口支付待支付订单
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐
#### Scenario: H5 和后台钱包支付流程独立
- **WHEN** H5 端创建 wallet 订单
- **THEN** 不影响后台 wallet 订单的一步支付逻辑
- **THEN** 系统调用 `CreateH5Order()` 方法,不影响后台 wallet 订单的一步支付逻辑
---
#### Scenario: H5 钱包支付使用独立方法
### 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** 事务回滚,钱包余额恢复,返回激活失败错误
- **WHEN** 个人客户在 H5 端创建 wallet 订单
- **THEN** Handler 层调用 `OrderService.CreateH5Order()` 方法,不调用 `CreateAdminOrder()` 方法
---
### Requirement: 钱包支付与第三方支付的区别
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。后台订单创建 MUST 在 Handler 层强制验证支付方式,拒绝 `wechat``alipay` 支付方式。
#### Scenario: 后台不支持第三方支付
- **WHEN** 代理在后台创建订单时选择 wechat 或 alipay
- **THEN** 系统返回错误"后台只支持 wallet 和 offline 支付方式"
**后台支付方式限制**
- 允许:`wallet``offline`
- 拒绝:`wechat``alipay`、其他任何值
**实现层级**
1. **DTO 验证**(第一道防线):`CreateAdminOrderRequest``payment_method` 字段使用 `validate:"oneof=wallet offline"` 规则
2. **Handler 验证**(第二道防线):调用 `middleware.ValidateStruct(&req)` 验证 DTO
3. **Handler 兜底检查**(第三道防线):对所有支付方式进行权限检查,包括非法值
#### Scenario: 后台参数验证拒绝第三方支付
- **WHEN** 代理在后台创建订单时 `payment_method` 为 wechat 或 alipay
- **THEN** 系统在 Handler 层的 DTO 验证阶段拒绝请求,返回错误"请求参数解析失败"`CodeInvalidParam`),订单创建失败
#### Scenario: 后台兜底检查拒绝其他支付方式
- **WHEN** 代理在后台创建订单时 `payment_method` 为未知值(防御性编程)
- **THEN** 系统在 Handler 层的兜底检查阶段拒绝请求,返回错误"后台仅支持钱包支付或线下支付"`CodeInvalidParam`
#### Scenario: H5 支持第三方支付
- **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay
- **THEN** 系统创建待支付订单返回支付参数prepay_id 或 h5_url
- **THEN** 系统调用 `CreateH5Order()` 方法,创建待支付订单返回支付参数prepay_id 或 h5_url
#### Scenario: 钱包支付不需要支付参数
- **WHEN** 后台钱包支付订单创建成功
- **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数
#### Scenario: 后台使用独立的 DTO
- **WHEN** 后台创建订单
- **THEN** Handler 层使用 `CreateAdminOrderRequest` DTO仅允许 wallet/offlineH5 端使用 `CreateOrderRequest` DTO允许 wallet/wechat/alipay

View File

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

View File

@@ -1,159 +1,102 @@
## 1. 数据库结构变更
## 1. DTO 层新增 CreateAdminOrderRequest
- [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` 输出
- [x] 1.1 `internal/model/dto/order_dto.go` 创建 `CreateAdminOrderRequest` 结构体,仅允许 wallet/offline 支付方式
- [x] 1.2 添加字段验证规则:`payment_method` 使用 `validate:"required,oneof=wallet offline"`
- [x] 1.3 复制其他字段定义:`order_type``iot_card_id``device_id``package_ids`(与 `CreateOrderRequest` 保持一致
- [x] 1.4 验证编译:运行 `go build ./internal/model/...` 确认无编译错误
## 2. Model 层:订单角色追踪
## 2. Service 层新增 CreateAdminOrder 方法
- [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 ./...` 验证编译通过
- [x] 2.1 在 `internal/service/order/service.go` 新增 `CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error)` 方法签名
- [x] 2.2 实现步骤 1调用 `validatePurchase()` 验证购买合法性(单卡/设备购买、套餐有效性)
- [x] 2.3 实现步骤 2调用 `checkOrderIdempotency()` 检查幂等性,如果已创建则返回现有订单
- [x] 2.4 实现步骤 3调用 `checkForceRechargeRequirement()` 检查强充要求
- [x] 2.5 实现步骤 4提取资源所属店铺 ID 和系列 ID复用现有逻辑
- [x] 2.6 实现步骤 5根据 `payment_method` 路由到不同分支offline → `createOrderWithActivation`wallet → `createOrderWithWalletPayment`
- [x] 2.7 实现步骤 6添加 else 兜底检查,返回错误"后台仅支持钱包支付或线下支付"
- [x] 2.8 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误
## 3. 常量定义:钱包流水子类型
## 3. Service 层新增 CreateH5Order 方法
- [x] 3.1 在 `pkg/constants/wallet.go` 新增钱包交易子类型常量(`WalletTransactionSubtypeSelfPurchase``WalletTransactionSubtypePurchaseForSubordinate`),添加中文注释
- [x] 3.2 运行 `go build ./...` 验证编译通过
- [x] 3.1 在 `internal/service/order/service.go` 新增 `CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error)` 方法签名
- [x] 3.2 将原 `Create()` 方法的完整逻辑复制到 `CreateH5Order()` 方法中(保持 H5 端行为不变)
- [x] 3.3 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误
## 4. DTO 层:订单请求和响应
## 4. Service 层重命名原 Create 方法为 CreateLegacy
- [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 ./...` 验证编译通过
- [x] 4.1 `internal/service/order/service.go``Create()` 方法重命名为 `CreateLegacy()`(保留作为回滚方案)
- [x] 4.2 添加注释标记:`// Deprecated: 使用 CreateAdminOrder 或 CreateH5Order 替代。保留用于回滚。`
- [x] 4.3 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误
## 5. Store 层:订单查询 OR 逻辑
## 5. Handler 层修复后台订单创建admin
- [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 输出
- [x] 5.1 修改 `internal/handler/admin/order.go``Create()` 方法:将 DTO 类型从 `CreateOrderRequest` 改为 `CreateAdminOrderRequest`
- [x] 5.2 在 `c.BodyParser(&req)` 后添加参数验证:调用 `h.validator.Struct(&req)`,验证失败返回 `errors.New(errors.CodeInvalidParam)`
- [x] 5.3 修改权限检查逻辑:在 `else` 分支添加兜底检查,返回错误"后台仅支持钱包支付或线下支付"
- [x] 5.4 修改 Service 调用:将 `h.service.Create(...)` 改为 `h.service.CreateAdminOrder(...)`
- [x] 5.5 验证编译:运行 `go build ./internal/handler/admin/...` 确认无编译错误
## 6. Service 层:成本价查询辅助方法
## 6. Handler 层修复 H5 端订单创建
- [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 ./...` 验证编译通过
- [x] 6.1 修改 `internal/handler/h5/order.go``Create()` 方法:将 Service 调用从 `h.service.Create(...)` 改为 `h.service.CreateH5Order(...)`
- [x] 6.2 确认其他逻辑保持不变DTO 仍使用 `CreateOrderRequest`,支持 wallet/wechat/alipay
- [x] 6.3 验证编译:运行 `go build ./internal/handler/h5/...` 确认无编译错误
## 7. Service 层:钱包流水创建方法
## 7. 错误码检查(可选)
- [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 ./...` 验证编译通过
- [x] 7.1 检查 `pkg/errors/errors.go` 是否已定义以下错误码:`CodeInvalidParam``CodeForbidden``CodeInsufficientBalance``CodeWalletNotFound`
- [x] 7.2 如果缺少,添加错误码定义(如 `CodeInvalidPaymentMethodForAdmin = 40008`)— 不需要,所有错误码已存在
- [x] 7.3 验证编译:运行 `go build ./pkg/errors/...` 确认无编译错误
## 8. Service 层:钱包支付订单创建方法
## 8. 单元测试 - CreateAdminOrder 方法(跳过:项目禁止自动化测试)
- [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 ./...` 验证编译通过
- [x] ~~8.1-8.8~~ 跳过项目规范禁止编写自动化测试代码AGENTS.md
## 9. Service 层:订单创建流程重构
## 9. 单元测试 - CreateH5Order 方法(跳过:项目禁止自动化测试)
- [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 ./...` 验证编译通过
- [x] ~~9.1-9.4~~ 跳过项目规范禁止编写自动化测试代码AGENTS.md
## 10. Service 层:订单响应构建方法
## 10. 集成测试 - 后台订单创建 API跳过需人工手动验证
- [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 ./...` 验证编译通过
- [x] ~~10.1-10.7~~ 跳过:手动测试由用户自行完成
## 11. Handler 层:权限检查调整
## 11. 集成测试 - H5 端订单创建 API跳过需人工手动验证
- [x] 11.1`internal/handler/admin/order.go``Create()` 方法中,修改 `wallet` 支付方式的权限检查,允许代理、平台、超管使用
- [x] 11.2 保持 `offline` 支付方式只允许平台和超管使用的限制
- [x] 11.3 运行 `go build ./...` 验证编译通过
- [x] ~~11.1-11.5~~ 跳过:手动测试由用户自行完成
## 12. Handler 层:订单查询参数传递
## 12. 钱包余额和流水验证(跳过:需人工手动验证)
- [x] 12.1`internal/handler/admin/order.go``List()` 方法中,从查询参数解析 `purchase_role`
- [x] 12.2 将 `purchase_role` 传递给 Service 层的 `List()` 方法
- [x] 12.3 运行 `go build ./...` 验证编译通过
- [x] ~~12.1-12.5~~ 跳过:手动测试由用户自行完成
## 13. 文档生成器更新OpenAPI
## 13. 幂等性和并发测试(跳过:需人工手动验证
- [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 检查生成的文档中订单创建和列表接口的请求/响应字段是否包含新字段
- [x] ~~13.1-13.4~~ 跳过:手动测试由用户自行完成
## 14. 集成测试:代理自购场景
## 14. 错误日志和监控验证(跳过:需人工手动验证)
- [ ] 14.1 使用 PostgreSQL MCP 工具创建测试数据:创建代理账号、代理钱包(余额 10000 分、IoT 卡shop_id = 代理店铺 ID、套餐分配配置成本价 8000 分)
- [ ] 14.2 使用 Postman/curl 调用后台订单创建 API代理账号创建订单支付方式 wallet选择自己的卡和套餐
- [ ] 14.3 验证响应:`payment_status` = 2`operator_id` = 代理店铺 ID`buyer_id` = 代理店铺 ID`purchase_role` = "self_purchase"`total_amount` = 8000`actual_paid_amount` = 8000
- [ ] 14.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确
- [ ] 14.5 使用 PostgreSQL MCP 查询钱包表,验证余额扣减:`balance` = 200010000 - 8000
- [ ] 14.6 使用 PostgreSQL MCP 查询钱包流水表,验证流水记录:`transaction_subtype` = "self_purchase"`amount` = -8000`remark` = "购买套餐"
- [ ] 14.7 使用 PostgreSQL MCP 查询套餐使用表(`tb_package_usage`),验证套餐已激活:`status` = 1
- [x] ~~14.1-14.3~~ 跳过:手动测试由用户自行完成
## 15. 集成测试:代理代购场景
## 15. 代码质量检查
- [ ] 15.1 使用 PostgreSQL MCP 工具创建测试数据:一级代理(成本价 8000、二级代理成本价 10000parent_shop_id = 一级代理)、一级代理钱包(余额 10000、IoT 卡shop_id = 二级代理店铺 ID、套餐分配配置
- [ ] 15.2 使用 Postman/curl 调用后台订单创建 API一级代理账号创建订单支付方式 wallet选择二级代理的卡和套餐
- [ ] 15.3 验证响应:`payment_status` = 2`operator_id` = 一级代理店铺 ID`buyer_id` = 二级代理店铺 ID`purchase_role` = "purchase_for_subordinate"`total_amount` = 10000`actual_paid_amount` = 8000
- [ ] 15.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确
- [ ] 15.5 使用 PostgreSQL MCP 查询一级代理钱包,验证余额扣减:`balance` = 200010000 - 8000
- [ ] 15.6 使用 PostgreSQL MCP 查询钱包流水表,验证流水记录:`transaction_subtype` = "purchase_for_subordinate"`amount` = -8000`related_shop_id` = 二级代理店铺 ID`remark` 包含二级代理店铺名称
- [ ] 15.7 使用 PostgreSQL MCP 查询套餐使用表,验证套餐已激活
- [ ] 15.8 使用 PostgreSQL MCP 查询佣金表,验证未产生佣金记录(代理代购不产生佣金)
- [x] 15.1 运行 `gofmt -s -w .` 格式化代码
- [x] 15.2 运行 `go vet ./...` 检查代码问题
- [x] 15.3 运行 `go build ./...` 确认全部编译通过
- [x] 15.4 检查所有新增代码的中文注释:确认导出函数有文档注释,复杂逻辑有实现注释
## 16. 集成测试:平台代购场景(回归测试)
## 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 验证平台代购逻辑未被破坏(不扣款、立即激活、产生佣金)
- [x] 16.1 更新功能总结文档:补充后台订单 API 的 Breaking Change 说明payment_method 必填、仅允许 wallet/offline
## 17. 集成测试:订单查询场景
## 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 验证响应只包含为下级代理购买的订单
- [x] 17.1 检查是否有未使用的导入或变量(使用 IDE 或 `go vet`
- [x] 17.2 保留 `CreateLegacy()` 方法作为回滚方案,已有 Deprecated 标记
- [x] 17.3 确认所有 TODO 注释已处理或转为 issue
- [x] ~~17.4~~ 跳过:项目禁止自动化测试
## 18. 集成测试:边界场景
## 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 准备监控指标:订单创建成功率、钱包扣款成功率、错误日志(余额不足、并发冲突)
- [ ] 18.1 使用 `git add` 暂存所有修改文件
- [ ] 18.2 使用 `/commit` 创建 Git commit提交消息"修复代理钱包订单创建逻辑漏洞"
- [ ] 18.3 使用 `/opsx:verify` 验证实现与规格一致
- [ ] 18.4 使用 `/opsx:archive` 归档变更,同步 delta specs 到主规格文档

View File

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

View File

@@ -0,0 +1,677 @@
## Context
当前系统中待支付订单创建后不会自动失效,虽然 `iot-order``order-payment` 规格文档中提到了超时取消机制,但实际代码中完全未实现。这导致:
1. **数据库膨胀**:大量"僵尸订单"(待支付但永不支付)占用存储空间
2. **用户体验差**:无法明确订单是否有效,用户可能尝试支付已过期订单
3. **资源浪费**:钱包余额被冻结但订单永不完成(混合支付场景)
4. **数据质量低**:订单统计数据不准确(包含大量永不完成的订单)
**现有实现**
- `tb_order` 表缺少 `expires_at` 字段
- 无超时相关的 Asynq 定时任务
- `OrderService.Cancel()` 方法不支持钱包解冻
- 无超时相关常量定义
**技术栈**
- Asynq v0.24.x 任务队列(已用于佣金计算、轮询等异步任务)
- GORM v1.25.x ORM
- PostgreSQL 14+(已有索引优化经验)
- Redis 6.0+(已用于分布式锁、缓存)
## Goals / Non-Goals
**Goals:**
1. 实现订单 30 分钟超时自动取消机制
2. 支持钱包余额自动解冻(混合支付/H5 钱包支付场景)
3. 提供过期状态查询和筛选功能
4. 性能符合要求(定时任务查询 < 50ms单批处理 < 5s
5. 支持数据库迁移和回滚
6. 不影响现有订单业务逻辑
**Non-Goals:**
1. ❌ 不支持可配置的超时时间(固定 30 分钟)
2. ❌ 不支持订单续期(延长过期时间)
3. ❌ 不发送超时提醒通知(后续可扩展)
4. ❌ 不处理已支付订单的退款超时(不在本次范围)
5. ❌ 不修改第三方支付回调逻辑(已有幂等保证)
## Decisions
### Decision 1: 数据库字段设计
**选择**: 新增 `expires_at TIMESTAMP NULL` 字段到 `tb_order`
**理由**:
- `NULL` 语义:已支付/已取消/已退款订单无需过期时间,设为 NULL 节省存储
- `TIMESTAMP` 类型:支持时区,精度到秒(超时 30 分钟,秒级精度足够)
- 索引设计:复合索引 `idx_order_expires(expires_at, payment_status)` 优化定时任务查询
**替代方案**:
- ~~使用 `expired_at` 字段名~~不符合业务语义expires_at 表示"何时过期"expired_at 表示"何时已过期"
- ~~使用 INT 存储 Unix 时间戳~~:可读性差,不利于 SQL 调试
- ~~使用单列索引 `idx_expires_at`~~性能不如复合索引WHERE 条件包含 payment_status
**数据迁移策略**:
- 迁移时已存在的订单 `expires_at` 初始化为 NULL
- 不对历史待支付订单设置过期时间(避免批量取消历史订单)
- 新创建的待支付订单才设置过期时间
---
### Decision 2: 定时任务实现方式
**选择**: 使用 Asynq 的 Scheduler周期任务调度器每分钟执行一次
**理由**:
- **架构统一性**:项目已使用 Asynq 作为任务队列基础设施,定时任务也应统一使用 Asynq Scheduler而非 `time.Ticker`
- **分布式支持**:多 Worker 部署时,通过 Redis 分布式锁确保任务只执行一次,避免重复处理超时订单
- **任务持久化**:任务记录在 Redis支持查询执行历史、监控失败率
- **自动重试**:支持任务失败自动重试(可配置重试次数和延迟)
- **无额外依赖**:复用现有 Redis 基础设施
- **未来扩展性**:为项目中现有的 `time.Ticker` 定时任务(告警检查器、数据清理)迁移到 Asynq 提供范例
**替代方案**:
- ~~使用 `time.Ticker`/`time.Timer`~~:虽然简单,但多 Worker 部署时会重复执行,且无任务持久化和执行历史
- ~~使用 PostgreSQL pg_cron 扩展~~:增加数据库负载,不符合项目架构(业务逻辑在应用层)
- ~~使用独立的 Cron 服务~~:增加运维复杂度,技术栈碎片化
**实现步骤**:
1. **创建 Asynq Scheduler 实例**`cmd/worker/main.go`
```go
// 创建 Asynq Scheduler
asynqScheduler := asynq.NewScheduler(
asynq.RedisClientOpt{
Addr: redisAddr,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
},
&asynq.SchedulerOpts{
Location: time.Local, // 使用本地时区
},
)
```
2. **注册周期任务**
```go
// 注册订单超时检查任务(每分钟执行)
_, err := asynqScheduler.Register(
"@every 1m", // cron 表达式:每分钟
asynq.NewTask(constants.TaskTypeOrderExpire, nil),
asynq.Queue(constants.QueueDefault),
)
if err != nil {
appLogger.Fatal("注册订单超时任务失败", zap.Error(err))
}
```
3. **启动 Scheduler**
```go
if err := asynqScheduler.Start(); err != nil {
appLogger.Fatal("启动 Asynq Scheduler 失败", zap.Error(err))
}
defer asynqScheduler.Shutdown()
```
4. **创建 Task Handler**`internal/task/order_expire.go`
```go
type OrderExpireHandler struct {
orderService *order.Service
logger *zap.Logger
}
func (h *OrderExpireHandler) HandleOrderExpire(ctx context.Context, task *asynq.Task) error {
count, err := h.orderService.CancelExpiredOrders(ctx)
if err != nil {
h.logger.Error("取消超时订单失败", zap.Error(err))
return err // 返回错误Asynq 自动重试
}
if count > 0 {
h.logger.Info("成功取消超时订单", zap.Int("count", count))
}
return nil
}
```
5. **注册 Handler**`pkg/queue/handler.go`
```go
func (h *Handler) registerOrderExpireHandler() {
orderExpireHandler := task.NewOrderExpireHandler(
h.workerResult.Services.OrderService,
h.logger,
)
h.mux.HandleFunc(constants.TaskTypeOrderExpire, orderExpireHandler.HandleOrderExpire)
h.logger.Info("注册订单超时检查任务处理器", zap.String("task_type", constants.TaskTypeOrderExpire))
}
```
---
### Decision 3: 批量处理策略
**选择**: 单次最多处理 100 条订单,使用事务批量更新
**理由**:
- 避免单次处理时间过长(单批 < 5s
- 事务保证订单状态更新和钱包解冻的原子性
- 超过 100 条的订单在下次任务执行时处理(每分钟执行,延迟可接受)
**替代方案**:
- ~~使用 LIMIT 1000~~:单批处理时间可能超过 5s影响任务调度
- ~~使用分页循环处理~~:复杂度高,事务范围难控制
- ~~不使用事务~~:订单状态更新和钱包解冻可能不一致
**实现细节**:
```go
// 单批处理逻辑
func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) {
// 1. 查询超时订单(最多 100 条)
orders, err := s.orderStore.FindExpiredOrders(ctx, 100)
// 2. 开启事务
return len(orders), s.db.Transaction(func(tx *gorm.DB) error {
// 3. 批量更新订单状态
// 4. 批量解冻钱包余额(如需)
})
}
```
---
### Decision 4: 钱包余额解冻逻辑
**选择**: 在 `OrderService.Cancel()` 方法中统一处理解冻逻辑,支持手动取消和自动取消两种场景
**理由**:
- 代码复用:手动取消和自动取消共用同一解冻逻辑
- 事务保证:订单状态更新和钱包解冻在同一事务中
- 支持多种支付方式:钱包支付、混合支付
**解冻规则**:
| 支付方式 | 是否解冻 | 解冻金额 |
|---------|---------|---------|
| 钱包支付H5 端待支付) | ✅ | `total_amount` |
| 混合支付 | ✅ | `wallet_payment_amount` |
| 纯在线支付wechat/alipay | ❌ | - |
| 后台钱包一步支付 | ❌ | - (订单创建时已完成支付) |
**替代方案**:
- ~~在定时任务中直接解冻钱包~~:代码重复,手动取消时需重复实现
- ~~不在事务中解冻~~:可能导致订单已取消但钱包未解冻
**实现细节**:
```go
func (s *Service) Cancel(ctx context.Context, orderID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// 1. 查询订单
order, err := s.orderStore.GetByID(ctx, orderID)
// 2. 校验状态(只能取消待支付订单)
if order.PaymentStatus != model.PaymentStatusPending {
return errors.New(errors.CodeInvalidParam, "只能取消待支付订单")
}
// 3. 更新订单状态
order.PaymentStatus = model.PaymentStatusCancelled
order.ExpiresAt = nil
// 4. 解冻钱包余额(如需)
if needUnfreeze(order) {
amount := getUnfreezeAmount(order)
err := s.walletService.Unfreeze(ctx, tx, order.BuyerType, order.BuyerID, amount)
}
return s.orderStore.Update(ctx, tx, order)
})
}
```
---
### Decision 5: 订单创建流程修改
**选择**: 在 `OrderService.Create()` 方法中,仅对待支付订单设置 `expires_at`
**理由**:
- 后台钱包一步支付订单创建时立即完成支付(`payment_status = 2`),无需过期时间
- 线下支付订单offline创建时立即标记为已支付无需过期时间
- 只有 H5 端或后台创建的待支付订单需要设置过期时间
**设置规则**:
| 场景 | 订单状态 | 是否设置 `expires_at` |
|------|---------|---------------------|
| H5 端创建钱包支付订单 | `payment_status = 1` | ✅ `now + 30min` |
| H5 端创建在线支付订单wechat/alipay | `payment_status = 1` | ✅ `now + 30min` |
| H5 端创建混合支付订单 | `payment_status = 1` | ✅ `now + 30min` |
| 后台创建钱包支付订单 | `payment_status = 2` | ❌ NULL |
| 后台创建线下支付订单 | `payment_status = 2` | ❌ NULL |
**实现细节**:
```go
func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest) (*model.Order, error) {
order := &model.Order{
// ... 其他字段
PaymentStatus: model.PaymentStatusPending,
}
// 仅待支付订单设置过期时间
if order.PaymentStatus == model.PaymentStatusPending {
expiresAt := time.Now().Add(constants.OrderExpireTimeout)
order.ExpiresAt = &expiresAt
}
// 后台钱包一步支付逻辑
if req.PaymentMethod == "wallet" && isAdminContext(ctx) {
// 立即扣款并支付
order.PaymentStatus = model.PaymentStatusPaid
order.ExpiresAt = nil // 已支付订单无需过期时间
}
}
```
---
### Decision 6: 订单支付成功后清除过期时间
**选择**: 在订单支付成功时(`payment_status` 变更为 2将 `expires_at` 设置为 NULL
**理由**:
- 已支付订单不需要过期时间
- 避免查询混淆(`expires_at IS NOT NULL` 可快速筛选待支付订单)
- 节省存储NULL 值不占用索引空间)
**实现位置**:
- `OrderService.WalletPay()` - H5 端钱包支付成功
- `OrderService.HandlePaymentCallback()` - 第三方支付回调成功
**实现细节**:
```go
func (s *Service) WalletPay(ctx context.Context, orderID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// ... 扣款逻辑
// 更新订单状态并清除过期时间
err := s.orderStore.UpdatePaymentStatus(ctx, tx, orderID, model.PaymentStatusPaid, time.Now(), nil)
})
}
```
---
### Decision 7: 查询过期状态实现方式
**选择**: 在 DTO 响应中动态计算 `is_expired` 字段,不存储在数据库
**理由**:
- 避免数据冗余(`is_expired` 可由 `expires_at` 和当前时间计算得出)
- 避免定时任务更新 `is_expired` 字段(增加数据库写负载)
- 支持按过期状态筛选(查询时使用 SQL 条件 `expires_at <= NOW()`
**替代方案**:
- ~~在数据库中存储 `is_expired` 布尔字段~~:需要定时更新,增加数据库负载
- ~~使用数据库视图~~:不符合项目架构(不使用视图)
**实现细节**:
```go
// DTO 响应
type OrderResponse struct {
// ... 其他字段
ExpiresAt *time.Time `json:"expires_at"`
IsExpired bool `json:"is_expired"` // 动态计算
}
// 动态计算逻辑
func buildOrderResponse(order *model.Order) *dto.OrderResponse {
resp := &dto.OrderResponse{
ExpiresAt: order.ExpiresAt,
}
// 动态计算是否过期
if order.ExpiresAt != nil && order.PaymentStatus == model.PaymentStatusPending {
resp.IsExpired = time.Now().After(*order.ExpiresAt)
}
return resp
}
// 查询过期订单的 SQL 条件
// WHERE expires_at <= NOW() AND payment_status = 1
```
---
### Decision 8: 性能优化策略
**选择**: 使用复合索引 + 批量操作 + 事务优化
**优化措施**:
1. **索引优化**: 复合索引 `idx_order_expires(expires_at, payment_status)` 覆盖查询条件
2. **批量更新**: 单 SQL 语句批量更新订单状态(避免 N 次数据库调用)
3. **批量解冻**: 钱包解冻支持批量操作(单事务中处理多个钱包)
4. **限制批次大小**: 单次最多处理 100 条,避免长事务
**性能指标**:
- 定时任务查询耗时:< 50ms
- 单批次处理耗时:< 5s
- 数据库连接池无阻塞
**监控指标**:
- 每次任务处理的订单数量
- 任务执行耗时
- 钱包解冻次数
- 失败订单数量
---
### Decision 9: 错误处理和重试策略
**选择**: 使用 Asynq 的重试机制,最多重试 3 次
**重试策略**:
- 可重试错误数据库连接失败、Redis 连接失败、钱包服务暂时不可用
- 不可重试错误:数据不一致(如钱包不存在)、业务逻辑错误
**实现细节**:
```go
func (h *OrderExpireHandler) HandleOrderExpire(ctx context.Context, task *asynq.Task) error {
count, err := h.service.CancelExpiredOrders(ctx)
if err != nil {
h.logger.Error("取消超时订单失败", zap.Error(err))
// 判断是否可重试
if isRetryableError(err) {
return err // 返回错误Asynq 自动重试
}
return asynq.SkipRetry // 不可重试错误,跳过重试
}
h.logger.Info("取消超时订单成功", zap.Int("count", count))
return nil
}
```
---
### Decision 10: 常量定义
**选择**: 在 `pkg/constants/constants.go` 中定义超时相关常量
**常量列表**:
```go
// 订单超时时间30 分钟)
const OrderExpireTimeout = 30 * time.Minute
// 订单超时取消任务类型
const TaskTypeOrderExpire = "order:expire"
// 单批处理订单数量上限
const OrderExpireBatchSize = 100
```
**理由**:
- 统一管理常量,避免硬编码
- 便于后续调整(如修改超时时间)
- 符合项目规范(所有常量定义在 `pkg/constants/`
---
### Decision 11: 重构现有定时任务为 Asynq Scheduler
**选择**: 将现有的 `time.Ticker`/`time.Timer` 定时任务迁移到 Asynq Scheduler
**理由**:
- 统一任务调度机制:项目架构设计初衷就是用 Asynq 承载所有任务和定时功能
- 分布式支持Asynq Scheduler 原生支持多 Worker 分布式执行,避免重复执行
- 持久化和可靠性:任务存储在 RedisWorker 重启不丢失任务
- 监控和管理:通过 Asynq Dashboard 统一监控所有定时任务执行状态
- 代码一致性:避免混用多种定时任务实现方式
**迁移范围**:
| 定时任务 | 当前实现 | 迁移后 |
|---------|---------|--------|
| 告警检查器 (`startAlertChecker`) | `time.NewTicker(1 * time.Minute)` | Asynq Scheduler `@every 1m` + `TaskTypeAlertCheck` |
| 数据清理定时任务 (`startCleanupScheduler`) | `time.NewTimer` (每天凌晨2点) | Asynq Scheduler `0 2 * * *` + `TaskTypeDataCleanup` |
**对比分析**:
| 特性 | time.Ticker/Timer | Asynq Scheduler |
|-----|------------------|-----------------|
| 分布式支持 | ❌ 多 Worker 重复执行 | ✅ 自动去重,单次执行 |
| 任务持久化 | ❌ Worker 重启丢失 | ✅ 存储在 Redis |
| 监控和管理 | ❌ 无统一界面 | ✅ Asynq Dashboard |
| 错误重试 | ❌ 需手动实现 | ✅ 内置重试机制 |
| 代码复杂度 | 中等(需手动管理 goroutine | 低(声明式配置) |
| 依赖 | 无Go 标准库) | Redis |
**实现细节**:
```go
// 告警检查任务 Handler
type AlertCheckHandler struct {
service *pollingSvc.AlertService
logger *zap.Logger
}
func (h *AlertCheckHandler) HandleAlertCheck(ctx context.Context, task *asynq.Task) error {
if err := h.service.CheckAlerts(ctx); err != nil {
h.logger.Error("告警检查失败", zap.Error(err))
return err // Asynq 自动重试
}
h.logger.Info("告警检查成功")
return nil
}
// 数据清理任务 Handler
type DataCleanupHandler struct {
service *pollingSvc.CleanupService
logger *zap.Logger
}
func (h *DataCleanupHandler) HandleDataCleanup(ctx context.Context, task *asynq.Task) error {
if err := h.service.RunScheduledCleanup(ctx); err != nil {
h.logger.Error("数据清理失败", zap.Error(err))
return err
}
h.logger.Info("数据清理成功")
return nil
}
// 注册到 Asynq Schedulercmd/worker/main.go
scheduler.Register("@every 1m", asynq.NewTask(constants.TaskTypeAlertCheck, nil))
scheduler.Register("0 2 * * *", asynq.NewTask(constants.TaskTypeDataCleanup, nil))
```
**Cron 表达式说明**:
- `@every 1m` - 每分钟执行(告警检查)
- `0 2 * * *` - 每天凌晨 2:00 执行(数据清理)
**迁移后的优势**:
1. **统一架构**: 所有定时任务都使用 Asynq Scheduler代码风格一致
2. **易于管理**: 通过 Asynq Dashboard 查看所有定时任务的执行历史和状态
3. **易于扩展**: 新增定时任务只需注册 Cron 表达式,无需管理 goroutine
4. **可靠性提升**: 任务持久化在 RedisWorker 重启后自动恢复
5. **分布式友好**: 多 Worker 部署时自动避免重复执行
**风险和缓解**:
- **Redis 依赖**: 如果 Redis 故障,定时任务无法执行
- 缓解Redis 高可用部署(主从 + 哨兵)
- **迁移风险**: 迁移过程中可能遗漏某些任务
- 缓解:保留旧代码注释,测试验证所有任务正常执行后再删除
## Risks / Trade-offs
### Risk 1: 定时任务延迟导致订单超时时间不精确
**风险**: 定时任务每分钟执行一次,订单实际取消时间可能晚于过期时间 1 分钟
**影响**: 低。30 分钟超时容忍 1 分钟误差(最多 3.3% 误差)
**缓解措施**:
- 在用户支付时检查订单是否过期(前端 + 后端双重校验)
- 在订单详情中显示过期时间,提示用户尽快支付
---
### Risk 2: 批量处理可能导致部分订单取消失败
**风险**: 批量处理 100 条订单时,如果某个订单的钱包解冻失败,整个事务回滚
**影响**: 中。失败的订单会在下次任务执行时重新处理,但可能延迟 1 分钟
**缓解措施**:
- 使用 Asynq 重试机制(最多重试 3 次)
- 记录失败日志,便于排查问题
- 后续优化:考虑单个订单失败不影响其他订单(分批事务)
---
### Risk 3: 钱包余额解冻失败导致用户损失
**风险**: 订单取消成功但钱包解冻失败(如钱包不存在、冻结余额不足)
**影响**: 高。用户钱包余额永久冻结
**缓解措施**:
- 在同一事务中处理订单取消和钱包解冻,任一失败则全部回滚
- 记录详细日志,包含订单 ID、钱包 ID、解冻金额
- 提供人工介入机制(运营后台手动解冻)
---
### Risk 4: 数据库索引失效导致查询性能下降
**风险**: 随着订单数量增长,索引选择性下降,查询性能降低
**影响**: 中。定时任务查询耗时超过 50ms
**缓解措施**:
- 定期监控查询耗时
- 定期归档历史订单(如 6 个月前的已完成/已取消订单)
- 必要时调整索引策略(如分区表)
---
### Risk 5: Redis 故障导致定时任务无法执行
**风险**: Redis 故障导致 Asynq 任务调度失败,超时订单无法取消
**影响**: 高。订单堆积,数据库膨胀
**缓解措施**:
- Redis 高可用部署(主从复制 + 哨兵)
- 监控 Redis 可用性和 Asynq 任务执行状态
- 提供手动触发取消超时订单的 API运营后台
---
### Trade-off: 性能 vs 准确性
**选择**: 优先保证性能(每分钟执行,单批 100 条),牺牲部分准确性(延迟 1 分钟)
**理由**: 30 分钟超时场景下1 分钟延迟影响可接受;性能更重要(避免数据库负载过高)
---
### Trade-off: 代码复用 vs 逻辑独立
**选择**: `Cancel()` 方法同时支持手动取消和自动取消,逻辑复用
**理由**: 避免代码重复,降低维护成本;风险是逻辑耦合,但通过参数区分场景(手动 vs 自动)可缓解
## Migration Plan
### Phase 1: 数据库迁移(不影响业务)
1. 执行迁移脚本 `migrations/000xxx_add_order_expiration.up.sql`
```sql
ALTER TABLE tb_order ADD COLUMN expires_at TIMESTAMP NULL COMMENT '订单过期时间';
CREATE INDEX idx_order_expires ON tb_order(expires_at, payment_status);
```
2. 验证迁移成功:
```sql
SHOW INDEX FROM tb_order WHERE Key_name = 'idx_order_expires';
```
3. 已存在的订单 `expires_at` 为 NULL不影响现有业务
**回滚方案**:
```sql
DROP INDEX idx_order_expires ON tb_order;
ALTER TABLE tb_order DROP COLUMN expires_at;
```
---
### Phase 2: 代码部署API 服务)
1. 部署修改后的 API 服务(包含 `Create()` 和 `Cancel()` 逻辑)
2. 验证新创建的订单 `expires_at` 字段正确设置
3. 验证手动取消订单时钱包解冻正常
**验证步骤**:
- 创建待支付订单,检查 `expires_at` 是否为 `created_at + 30min`
- 手动取消混合支付订单,检查钱包余额是否解冻
- 监控错误日志,确认无异常
---
### Phase 3: 定时任务部署Worker 服务)
1. 部署修改后的 Worker 服务(包含定时任务)
2. 在 `cmd/worker/main.go` 中注册周期任务
3. 验证定时任务执行正常
**验证步骤**:
- 检查 Asynq 日志,确认任务每分钟执行
- 人工创建过期订单(修改 `expires_at` 为过去时间),等待 1 分钟后检查订单状态
- 监控任务执行耗时和处理订单数量
---
### Phase 4: 监控和告警
1. 配置 Prometheus 监控指标(任务执行次数、耗时、处理订单数)
2. 配置告警规则(任务执行失败、耗时超过 5s
3. 定期检查定时任务执行日志
**监控指标**:
- `order_expire_task_duration_seconds` - 任务执行耗时
- `order_expire_task_processed_total` - 处理订单总数
- `order_expire_task_failed_total` - 失败次数
---
### Rollback Strategy
**如果出现严重问题,按以下顺序回滚**:
1. **立即停止 Worker 服务**(停止定时任务执行)
2. **回滚 API 服务代码**(恢复到未修改的版本)
3. **回滚数据库**(执行 `migrations/000xxx_add_order_expiration.down.sql`
**触发回滚的条件**:
- 定时任务导致大量订单误取消
- 钱包余额解冻失败率 > 5%
- 数据库性能严重下降(查询耗时 > 500ms
## Open Questions
1. **是否需要发送订单超时通知?**
- 当前不发送通知Non-Goal
- 后续可扩展(如微信模板消息、短信提醒)
2. **是否支持可配置的超时时间?**
- 当前固定 30 分钟Non-Goal
- 后续可考虑按订单类型配置不同超时时间(如大额订单 1 小时)
3. **历史待支付订单如何处理?**
- 当前不处理(`expires_at` 为 NULL不会被定时任务取消
- 建议:运营后台提供批量取消功能,人工清理历史订单
4. **是否需要订单超时后自动重建订单?**
- 当前不支持Non-Goal
- 用户需要手动重新创建订单
5. **是否需要支持订单续期?**
- 当前不支持Non-Goal
- 如需支持,需增加 API 端点和业务逻辑

View File

@@ -0,0 +1,74 @@
## Why
当前系统中待支付订单创建后不会自动失效,导致大量"僵尸订单"占用数据库空间,且用户体验不佳(无法明确订单是否有效)。虽然现有规格文档(`iot-order``order-payment`)中提到了订单超时取消机制,但实际代码中完全未实现:缺少超时时间字段、定时任务、钱包解冻逻辑等。这是一个关键缺失功能,影响系统可用性和数据质量。
## What Changes
### 订单超时自动失效(主要功能)
- 新增订单超时自动失效机制,待支付订单 30 分钟后自动取消
- 新增数据库字段:`tb_order.expires_at`(订单过期时间)
- 新增 Asynq 定时任务:每分钟扫描并取消超时订单
- 新增常量定义:`OrderExpireTimeout``TaskTypeOrderExpire`
- 完善订单取消逻辑:支持钱包余额自动解冻(混合支付场景)
- 新增订单列表查询条件:过期状态筛选
- 完善订单创建流程:自动设置 `expires_at = created_at + 30分钟`
### 架构优化:重构现有定时任务为 Asynq Scheduler
- 将现有的 `time.Ticker`/`time.Timer` 定时任务迁移到 Asynq Scheduler
- 重构告警检查器(`startAlertChecker`)为 Asynq 周期任务(`@every 1m`
- 重构数据清理定时任务(`startCleanupScheduler`)为 Asynq 周期任务每天凌晨2点
- 新增常量定义:`TaskTypeAlertCheck``TaskTypeDataCleanup`
- 移除 `cmd/worker/main.go` 中的原生定时任务实现(`startAlertChecker``startCleanupScheduler`
- 统一所有定时任务调度机制为 Asynq Scheduler
## Capabilities
### New Capabilities
- `order-expiration`:订单超时自动失效机制。包含:超时时间配置、定时扫描任务、自动取消逻辑、钱包余额解冻、过期状态查询。
### Modified Capabilities
- `iot-order`:补充订单超时失效的需求(原规格中提到但未详细定义)
- `order-payment`:补充钱包支付订单取消时的余额解冻需求
## Impact
**数据模型**
- `tb_order` 表新增字段:`expires_at TIMESTAMP`
- 新增索引:`idx_order_expires(expires_at, payment_status)`
**代码影响**
- `internal/model/order.go`:新增 `ExpiresAt` 字段
- `internal/service/order/service.go`
- `Create()` 方法设置过期时间
- `Cancel()` 方法支持钱包解冻
- 新增 `CancelExpiredOrders()` 方法
- `internal/task/`:新增 `order_expire.go``alert_check.go``data_cleanup.go` 定时任务 Handler
- `pkg/constants/constants.go`:新增超时和任务类型相关常量(`TaskTypeOrderExpire``TaskTypeAlertCheck``TaskTypeDataCleanup`
- `internal/store/postgres/order_store.go`:新增批量查询超时订单方法
- `cmd/worker/main.go`
- 创建和启动 Asynq Scheduler 实例
- 注册 3 个周期任务(订单超时、告警检查、数据清理)
- 移除原生定时任务实现(`startAlertChecker``startCleanupScheduler`
- `pkg/queue/handler.go`:注册 3 个定时任务 Handler
**API 影响**
- 订单列表 API`GET /api/admin/orders``GET /api/h5/orders`):新增过期状态筛选条件
**依赖**
- Asynq 任务队列(已有)
- Redis已有用于任务调度
- 钱包服务(`internal/service/wallet/`,已有)
**性能考虑**
- 定时任务每分钟执行一次,批量处理超时订单(单次最多 100 条)
- 使用复合索引 `idx_order_expires(expires_at, payment_status)` 优化查询
- 预估查询耗时 < 50ms单批次处理耗时 < 5s
**数据库迁移**
- 需要执行迁移脚本:`migrations/000xxx_add_order_expiration.up.sql`
- 需要回滚脚本:`migrations/000xxx_add_order_expiration.down.sql`
- 对现有数据的影响:已存在的待支付订单 `expires_at` 初始化为 `NULL`(需手动处理或忽略)

View File

@@ -0,0 +1,54 @@
## MODIFIED Requirements
### Requirement: 订单状态流转
系统 SHALL 管理订单的状态流转,确保状态变更符合业务规则。**新增订单超时自动取消的详细场景。**
**状态定义**:
- **1-待支付**: 订单已创建,等待用户支付
- **2-已支付**: 用户已支付,等待系统处理
- **3-已完成**: 订单已完成(激活/发货等)
- **4-已取消**: 订单已取消
- **5-已退款**: 订单已退款
**状态流转规则**:
- 待支付(1) → 已支付(2): 用户完成支付
- 待支付(1) → 已取消(4): 用户手动取消订单或订单超时30 分钟)
- 已支付(2) → 已完成(3): 系统完成订单处理(激活/发货)
- 已支付(2) → 已退款(5): 用户申请退款且审核通过
- 已完成(3) → 已退款(5): 用户申请退款且审核通过(特殊情况)
#### Scenario: 用户支付订单
- **WHEN** 用户支付待支付订单(ID 为 10001),支付金额为 30.00 元
- **THEN** 系统将订单状态从 1(待支付) 变更为 2(已支付),`paid_at` 记录支付时间
#### Scenario: 单卡套餐订单完成
- **WHEN** 系统处理完单卡套餐订单(ID 为 10001),激活 IoT 卡并分配套餐
- **THEN** 系统将订单状态从 2(已支付) 变更为 3(已完成),`completed_at` 记录完成时间
#### Scenario: 设备级套餐订单完成
- **WHEN** 系统处理完设备级套餐订单(ID 为 10002),为设备绑定的所有 IoT 卡分配套餐
- **THEN** 系统将订单状态从 2(已支付) 变更为 3(已完成),`completed_at` 记录完成时间
#### Scenario: 用户手动取消订单
- **WHEN** 用户手动取消待支付订单(ID 为 10003)
- **THEN** 系统将订单状态从 1(待支付) 变更为 4(已取消),`expires_at` 设置为 NULL如有钱包预扣则解冻余额
#### Scenario: 订单超时自动取消
- **WHEN** 订单创建后 30 分钟未支付,定时任务扫描到该订单
- **THEN** 系统自动将订单状态从 1(待支付) 变更为 4(已取消),`expires_at` 设置为 NULL如有钱包预扣则解冻余额
#### Scenario: 订单超时自动取消(混合支付)
- **WHEN** 混合支付订单创建后 30 分钟未完成在线支付,钱包已预扣 2000 分
- **THEN** 系统自动取消订单,解冻钱包余额 2000 分
#### Scenario: 订单超时自动取消(纯在线支付)
- **WHEN** 纯在线支付订单创建后 30 分钟未支付
- **THEN** 系统自动取消订单,无需钱包解冻操作

View File

@@ -0,0 +1,237 @@
# Order Expiration
## Purpose
自动管理订单的超时失效,确保待支付订单在超时后自动取消,防止"僵尸订单"堆积,并自动释放已冻结的资源(如钱包余额)。
This capability supports:
- 订单超时时间配置和管理
- 定时扫描和自动取消超时订单
- 钱包余额自动解冻
- 过期订单查询和筛选
## ADDED Requirements
### Requirement: 订单过期时间字段
系统 SHALL 为每个订单设置过期时间字段(`expires_at`),用于判断订单是否超时。
**字段定义**
- `expires_at`订单过期时间TIMESTAMP可为 NULL
- 创建时自动设置:`expires_at = created_at + 30分钟`(仅待支付订单)
- 已支付/已取消/已退款订单的 `expires_at` 为 NULL
**索引设计**
- 复合索引:`idx_order_expires(expires_at, payment_status)` 优化定时任务查询
#### Scenario: 创建待支付订单时设置过期时间
- **WHEN** 用户创建订单,支付方式为 wechat 或 alipay订单状态为待支付payment_status = 1
- **THEN** 系统设置 `expires_at = created_at + 30分钟`
#### Scenario: 创建钱包支付订单(后台)不设置过期时间
- **WHEN** 代理在后台创建订单,支付方式为 wallet订单立即支付成功payment_status = 2
- **THEN** 系统不设置 `expires_at`,字段值为 NULL
#### Scenario: 订单支付成功后清除过期时间
- **WHEN** 待支付订单支付成功状态变更为已支付payment_status = 2
- **THEN** 系统将 `expires_at` 设置为 NULL
#### Scenario: 订单取消后清除过期时间
- **WHEN** 订单被取消payment_status = 3
- **THEN** 系统将 `expires_at` 设置为 NULL
---
### Requirement: 订单超时自动取消
系统 SHALL 通过定时任务自动扫描并取消超时订单。任务每分钟执行一次,批量处理超时订单。
**任务配置**
- 任务类型:`TaskTypeOrderExpire = "order:expire"`
- 执行频率:每分钟
- 单批处理量:最多 100 条
- 超时时间:`OrderExpireTimeout = 30 * time.Minute`
**任务逻辑**
1. 查询条件:`expires_at <= NOW() AND payment_status = 1`
2. 批量取消订单:更新 `payment_status = 3``expires_at = NULL`
3. 钱包余额解冻(如果订单涉及钱包预扣)
4. 记录日志
#### Scenario: 定时任务扫描超时订单
- **WHEN** 定时任务执行,当前时间为 2026-02-28 10:30:00
- **THEN** 系统查询 `expires_at <= '2026-02-28 10:30:00' AND payment_status = 1` 的订单,最多 100 条
#### Scenario: 批量取消超时订单
- **WHEN** 查询到 50 条超时订单
- **THEN** 系统批量更新订单状态为已取消payment_status = 3`expires_at = NULL`
#### Scenario: 钱包余额解冻(混合支付)
- **WHEN** 超时订单使用了混合支付,钱包预扣 2000 分
- **THEN** 系统解冻钱包余额 2000 分(`frozen_balance` 减少 2000
#### Scenario: 钱包余额解冻纯钱包支付H5 端)
- **WHEN** 超时订单使用了钱包支付H5 端创建待支付订单),钱包预扣 3000 分
- **THEN** 系统解冻钱包余额 3000 分
#### Scenario: 无需解冻钱包(在线支付)
- **WHEN** 超时订单使用了纯在线支付wechat/alipay没有钱包预扣
- **THEN** 系统不执行钱包解冻操作
#### Scenario: 任务执行日志
- **WHEN** 定时任务执行完成
- **THEN** 系统记录日志:处理订单数量、解冻钱包次数、执行耗时
---
### Requirement: 订单过期状态查询
系统 SHALL 支持按过期状态筛选订单,便于运营人员查询和分析超时订单。
**查询条件**(新增):
- `is_expired`(布尔值):
- `true`:查询已过期的待支付订单(`expires_at <= NOW() AND payment_status = 1`
- `false`:查询未过期的待支付订单(`expires_at > NOW() AND payment_status = 1`
- 不传:不按过期状态筛选
#### Scenario: 查询已过期的待支付订单
- **WHEN** 运营人员查询订单列表,筛选 `is_expired = true`
- **THEN** 系统返回 `expires_at <= NOW() AND payment_status = 1` 的订单列表
#### Scenario: 查询未过期的待支付订单
- **WHEN** 运营人员查询订单列表,筛选 `is_expired = false`
- **THEN** 系统返回 `expires_at > NOW() AND payment_status = 1` 的订单列表
#### Scenario: 订单详情显示过期状态
- **WHEN** 查询订单详情,订单为待支付且已超时
- **THEN** 响应包含 `is_expired = true``expires_at` 字段显示过期时间
#### Scenario: 订单列表响应包含过期时间
- **WHEN** 查询订单列表
- **THEN** 每个订单响应包含 `expires_at` 字段(可为 NULL
---
### Requirement: 钱包余额解冻逻辑
系统 SHALL 在订单取消(手动或自动)时,根据支付方式自动解冻钱包余额。
**解冻规则**
- 钱包支付H5 端待支付订单):解冻 `total_amount`
- 混合支付:解冻 `wallet_payment_amount`
- 纯在线支付:无需解冻
- 后台钱包一步支付:无需解冻(订单创建时已完成支付)
#### Scenario: 手动取消订单,解冻钱包
- **WHEN** 用户手动取消待支付订单,订单使用混合支付,钱包预扣 2000 分
- **THEN** 系统解冻钱包余额 2000 分,订单状态变更为已取消
#### Scenario: 自动取消订单,解冻钱包
- **WHEN** 定时任务自动取消超时订单,订单使用钱包支付,钱包预扣 3000 分
- **THEN** 系统解冻钱包余额 3000 分,订单状态变更为已取消
#### Scenario: 取消订单,无钱包预扣
- **WHEN** 用户取消待支付订单订单使用纯在线支付wechat
- **THEN** 系统不执行钱包解冻操作
#### Scenario: 钱包解冻事务保证
- **WHEN** 订单取消涉及钱包解冻
- **THEN** 订单状态更新和钱包余额解冻在同一事务中完成,任一失败则全部回滚
---
### Requirement: 超时配置常量
系统 SHALL 定义订单超时相关常量,统一管理超时时间和任务类型。
**常量定义**`pkg/constants/constants.go`
- `OrderExpireTimeout = 30 * time.Minute`订单超时时间30 分钟)
- `TaskTypeOrderExpire = "order:expire"`:订单超时取消任务类型
#### Scenario: 使用常量设置过期时间
- **WHEN** 创建待支付订单
- **THEN** 系统使用 `constants.OrderExpireTimeout` 计算 `expires_at`
#### Scenario: 使用常量注册任务
- **WHEN** 注册 Asynq 定时任务
- **THEN** 系统使用 `constants.TaskTypeOrderExpire` 作为任务类型
---
### Requirement: 性能优化
系统 SHALL 通过索引优化和批量处理确保超时任务的性能符合要求。
**性能指标**
- 定时任务查询耗时 < 50ms
- 单批次处理耗时 < 5s
- 单批处理量100 条
**优化措施**
- 使用复合索引 `idx_order_expires(expires_at, payment_status)` 优化查询
- 批量更新订单状态(单 SQL 语句)
- 钱包解冻支持批量操作(单事务)
#### Scenario: 复合索引优化查询
- **WHEN** 定时任务查询超时订单
- **THEN** 数据库使用 `idx_order_expires` 索引,查询耗时 < 50ms
#### Scenario: 批量处理限制
- **WHEN** 超时订单数量超过 100 条
- **THEN** 系统单次最多处理 100 条,剩余订单下次执行时处理
#### Scenario: 任务执行时间限制
- **WHEN** 定时任务执行
- **THEN** 单批次处理耗时 < 5s包括查询、更新、解冻、日志记录
---
### Requirement: 数据库迁移
系统 SHALL 提供数据库迁移脚本,添加 `expires_at` 字段和索引。
**迁移内容**
- 添加字段:`ALTER TABLE tb_order ADD COLUMN expires_at TIMESTAMP NULL COMMENT '订单过期时间'`
- 添加索引:`CREATE INDEX idx_order_expires ON tb_order(expires_at, payment_status)`
**回滚脚本**
- 删除索引:`DROP INDEX idx_order_expires ON tb_order`
- 删除字段:`ALTER TABLE tb_order DROP COLUMN expires_at`
#### Scenario: 迁移脚本执行成功
- **WHEN** 执行 `migrate up`
- **THEN** `tb_order` 表新增 `expires_at` 字段和 `idx_order_expires` 索引
#### Scenario: 回滚脚本执行成功
- **WHEN** 执行 `migrate down`
- **THEN** `tb_order` 表删除 `expires_at` 字段和 `idx_order_expires` 索引
#### Scenario: 迁移对现有数据的影响
- **WHEN** 执行迁移脚本
- **THEN** 已存在的订单 `expires_at` 字段值为 NULL不影响现有业务

View File

@@ -0,0 +1,67 @@
## MODIFIED Requirements
### Requirement: 订单支付处理
系统 SHALL 根据支付方式正确处理订单支付,包括钱包扣款、在线支付、混合支付等。**新增订单取消(手动或自动)时的钱包余额解冻逻辑。**
**钱包支付流程**
1. 检查钱包可用余额是否充足
2. 冻结钱包余额(`frozen_balance` 增加)
3. 创建订单,状态为"待支付"
4. 订单完成后,扣减钱包余额(`balance` 减少,`frozen_balance` 减少),创建钱包明细记录
5. 订单取消时(手动或自动),解冻钱包余额(`frozen_balance` 减少)
**在线支付流程**
1. 创建订单,状态为"待支付"
2. 调用第三方支付接口
3. 用户完成支付后,订单状态变更为"已支付"
4. 订单完成后,订单状态变更为"已完成"
**混合支付流程**
1. 检查钱包可用余额是否充足(钱包支付部分)
2. 冻结钱包余额
3. 创建订单,状态为"待支付"
4. 调用第三方支付接口(在线支付部分)
5. 用户完成在线支付后,扣减钱包余额,订单状态变更为"已支付"
6. 订单完成后,订单状态变更为"已完成"
7. 订单取消时(手动或自动),解冻钱包余额
#### Scenario: 钱包支付订单完成
- **WHEN** 用户使用钱包支付购买套餐,订单金额为 3000 分
- **THEN** 系统:
1. 创建订单,状态为"待支付",冻结钱包余额 3000 分
2. 订单处理完成后,扣减钱包余额 3000 分,解冻 3000 分,创建钱包明细记录(类型为"扣款"),订单状态变更为"已完成"
#### Scenario: 混合支付订单完成
- **WHEN** 用户使用混合支付购买套餐,钱包支付 2000 分 + 在线支付 3000 分
- **THEN** 系统:
1. 创建订单,状态为"待支付",冻结钱包余额 2000 分
2. 用户完成在线支付 3000 分后,扣减钱包余额 2000 分,解冻 2000 分,创建钱包明细记录,订单状态变更为"已支付"
3. 订单处理完成后,订单状态变更为"已完成"
#### Scenario: 订单手动取消,解冻钱包余额
- **WHEN** 用户使用钱包支付创建订单,订单金额为 3000 分,然后手动取消订单
- **THEN** 系统解冻钱包余额 3000 分(`frozen_balance` 减少 3000订单状态变更为"已取消"
#### Scenario: 订单超时自动取消,解冻钱包余额
- **WHEN** 用户使用混合支付创建订单,钱包预扣 2000 分30 分钟后订单超时
- **THEN** 系统自动取消订单,解冻钱包余额 2000 分(`frozen_balance` 减少 2000订单状态变更为"已取消"
#### Scenario: 订单取消(纯在线支付),无需解冻
- **WHEN** 用户使用纯在线支付创建订单30 分钟后订单超时
- **THEN** 系统自动取消订单,不执行钱包解冻操作(因为没有钱包预扣)
#### Scenario: 钱包解冻事务保证
- **WHEN** 订单取<E58D95><E58F96>涉及钱包解冻
- **THEN** 订单状态更新(`payment_status = 3``expires_at = NULL`)和钱包余额解冻在同一事务中完成,任一失败则全部回滚
#### Scenario: 钱包解冻失败回滚
- **WHEN** 订单取消时,钱包解冻失败(如钱包不存在、冻结余额不足)
- **THEN** 事务回滚,订单状态不变,返回错误信息"订单取消失败"

View File

@@ -0,0 +1,184 @@
## 1. 数据库迁移
- [ ] 1.1 创建迁移文件 `migrations/000xxx_add_order_expiration.up.sql`:添加 `expires_at` 字段和复合索引 `idx_order_expires(expires_at, payment_status)`
- [ ] 1.2 创建回滚文件 `migrations/000xxx_add_order_expiration.down.sql`:删除索引和字段
- [ ] 1.3 执行迁移验证:运行 `migrate up` 并检查表结构,确认字段和索引创建成功
- [ ] 1.4 测试回滚:运行 `migrate down` 并验证字段和索引删除成功,然后重新 `migrate up`
## 2. 常量定义
- [ ] 2.1 在 `pkg/constants/constants.go` 中添加订单超时时间常量 `OrderExpireTimeout = 30 * time.Minute`
- [ ] 2.2 在 `pkg/constants/constants.go` 中添加任务类型常量 `TaskTypeOrderExpire = "order:expire"`
- [ ] 2.3 在 `pkg/constants/constants.go` 中添加批量处理数量常量 `OrderExpireBatchSize = 100`
- [ ] 2.4 验证编译:运行 `go build ./...` 确认无编译错误
## 3. Model 层修改
- [ ] 3.1 在 `internal/model/order.go` 中的 `Order` 结构体添加 `ExpiresAt *time.Time` 字段(指针类型,支持 NULL
- [ ] 3.2 在 `internal/model/dto/order_dto.go` 中的 `OrderResponse` 添加 `ExpiresAt *time.Time``IsExpired bool` 字段
- [ ] 3.3 验证编译:运行 `go build ./internal/model/...` 确认无编译错误
## 4. Store 层新增方法
- [ ] 4.1 在 `internal/store/postgres/order_store.go` 添加 `FindExpiredOrders(ctx, limit int) ([]*model.Order, error)` 方法:查询 `expires_at <= NOW() AND payment_status = 1` 的订单
- [ ] 4.2 在 `internal/store/postgres/order_store.go``UpdatePaymentStatus()` 方法中添加 `expiresAt *time.Time` 参数,支持更新过期时间
- [ ] 4.3 验证编译:运行 `go build ./internal/store/...` 确认无编译错误
- [ ] 4.4 使用 PostgreSQL MCP 工具验证查询:执行 `FindExpiredOrders` 的 SQL确认索引使用正确且查询耗时 < 50ms
## 5. Service 层修改 - 订单创建
- [ ] 5.1 修改 `internal/service/order/service.go``Create()` 方法:待支付订单设置 `expires_at = now + 30min`
- [ ] 5.2 修改 `Create()` 方法:后台钱包一步支付订单和线下支付订单 `expires_at = nil`
- [ ] 5.3 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误
## 6. Service 层修改 - 订单取消和钱包解冻
- [ ] 6.1 修改 `internal/service/order/service.go``Cancel()` 方法:添加钱包解冻逻辑(判断支付方式,计算解冻金额)
- [ ] 6.2 在 `Cancel()` 方法中添加事务处理:订单状态更新(`payment_status = 3`, `expires_at = nil`)和钱包解冻在同一事务
- [ ] 6.3 在 `Cancel()` 方法中添加解冻规则判断逻辑钱包支付H5、混合支付需解冻纯在线支付不解冻
- [ ] 6.4 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误
## 7. Service 层新增方法 - 批量取消超时订单
- [ ] 7.1 在 `internal/service/order/service.go` 添加 `CancelExpiredOrders(ctx context.Context) (int, error)` 方法
- [ ] 7.2 实现 `CancelExpiredOrders()` 逻辑:调用 `FindExpiredOrders()` 查询超时订单(最多 100 条)
- [ ] 7.3 实现批量取消逻辑:遍历订单,调用 `Cancel()` 方法(复用钱包解冻逻辑)
- [ ] 7.4 添加日志记录:处理订单数量、解冻钱包次数、执行耗时
- [ ] 7.5 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误
## 8. Service 层修改 - 支付成功清除过期时间
- [ ] 8.1 修改 `internal/service/order/service.go``WalletPay()` 方法:调用 `UpdatePaymentStatus()` 时传入 `expiresAt = nil`
- [ ] 8.2 修改 `HandlePaymentCallback()` 方法:调用 `UpdatePaymentStatus()` 时传入 `expiresAt = nil`
- [ ] 8.3 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误
## 9. Task 层新增定时任务
- [ ] 9.1 创建 `internal/task/order_expire.go` 文件,定义 `OrderExpireHandler` 结构体
- [ ] 9.2 实现 `NewOrderExpireHandler()` 构造函数,依赖注入 `db`, `orderService`, `logger`
- [ ] 9.3 实现 `HandleOrderExpire(ctx context.Context, task *asynq.Task) error` 方法,调用 `orderService.CancelExpiredOrders()`
- [ ] 9.4 添加错误处理和重试逻辑:可重试错误返回 `err`,不可重试错误返回 `asynq.SkipRetry`
- [ ] 9.5 添加日志记录:任务开始、成功处理订单数、失败错误
- [ ] 9.6 验证编译:运行 `go build ./internal/task/...` 确认无编译错误
## 10. Worker 注册定时任务 Handler
- [ ] 10.1 在 `pkg/queue/handler.go``RegisterHandlers()` 方法中调用 `registerOrderExpireHandler()`
- [ ] 10.2 实现 `registerOrderExpireHandler()` 方法:创建 `OrderExpireHandler` 并注册到 `mux.HandleFunc(constants.TaskTypeOrderExpire, ...)`
- [ ] 10.3 验证编译:运行 `go build ./pkg/queue/...` 确认无编译错误
## 11. Worker 创建和启动 Asynq Scheduler
- [ ] 11.1 在 `cmd/worker/main.go` 中创建 Asynq Scheduler 实例:`asynq.NewScheduler(redisOpt, &asynq.SchedulerOpts{Location: time.Local})`
- [ ] 11.2 注册订单超时周期任务:`scheduler.Register("@every 1m", asynq.NewTask(constants.TaskTypeOrderExpire, nil), asynq.Queue(constants.QueueDefault))`
- [ ] 11.3 启动 Scheduler`scheduler.Start()`,并在 defer 中调用 `scheduler.Shutdown()`
- [ ] 11.4 验证编译:运行 `go build ./cmd/worker/...` 确认无编译错误
## 12. Handler 层修改 - DTO 响应
- [ ] 12.1 修改 `internal/handler/admin/order.go``internal/handler/h5/order.go` 的订单响应构建逻辑:添加 `ExpiresAt` 字段
- [ ] 12.2 实现 `IsExpired` 动态计算逻辑:`if expiresAt != nil && paymentStatus == 1 { isExpired = now.After(expiresAt) }`
- [ ] 12.3 验证编译:运行 `go build ./internal/handler/...` 确认无编译错误
## 13. Handler 层修改 - 查询过期状态
- [ ] 13.1 修改 `internal/model/dto/order_dto.go``ListOrderRequest` 添加 `IsExpired *bool` 查询参数(可选)
- [ ] 13.2 修改 `internal/store/postgres/order_store.go``List()` 方法:添加过期状态筛选条件(`is_expired = true` 映射为 `expires_at <= NOW() AND payment_status = 1`
- [ ] 12.3 验证编译:运行 `go build ./...` 确认无编译错误
## 14. 功能验证 - 订单创建
- [ ] 14.1 启动 API 服务,使用 Postman/curl 创建待支付订单H5 端,支付方式 wechat验证 `expires_at` 字段设置正确(约 `now + 30min`
- [ ] 14.2 使用 PostgreSQL MCP 工具查询订单:`SELECT id, expires_at, payment_status FROM tb_order WHERE id = ?`,确认 `expires_at` 不为 NULL
- [ ] 14.3 创建后台钱包支付订单,验证 `expires_at` 为 NULL订单立即支付成功
## 15. 功能验证 - 订单取消和钱包解冻
- [ ] 15.1 创建混合支付待支付订单(钱包预扣 2000 分),使用 PostgreSQL MCP 查询钱包冻结余额
- [ ] 15.2 调用取消订单 API验证订单状态变更为已取消`payment_status = 3``expires_at` 变更为 NULL
- [ ] 15.3 使用 PostgreSQL MCP 查询钱包:确认冻结余额减少 2000 分
- [ ] 15.4 创建纯在线支付订单wechat取消订单确认不执行钱包解冻操作
## 16. 功能验证 - 支付成功清除过期时间
- [ ] 16.1 创建待支付订单wechat确认 `expires_at` 不为 NULL
- [ ] 16.2 模拟第三方支付回调成功,验证订单状态变更为已支付(`payment_status = 2``expires_at` 变更为 NULL
- [ ] 16.3 使用 PostgreSQL MCP 查询订单:`SELECT id, expires_at, payment_status FROM tb_order WHERE id = ?`,确认 `expires_at` 为 NULL
## 17. 功能验证 - 定时任务自动取消
- [ ] 17.1 使用 PostgreSQL MCP 手动修改订单的 `expires_at` 为过去时间:`UPDATE tb_order SET expires_at = NOW() - INTERVAL '1 minute' WHERE id = ?`
- [ ] 17.2 启动 Worker 服务,等待 1 分钟后检查日志,确认定时任务执行成功
- [ ] 17.3 使用 PostgreSQL MCP 查询订单:确认订单状态变更为已取消,`expires_at` 变更为 NULL
- [ ] 17.4 如果是混合支付订单,使用 PostgreSQL MCP 查询钱包:确认冻结余额解冻
## 18. 功能验证 - 查询过期状态
- [ ] 18.1 使用 Postman/curl 调用订单列表 API筛选 `is_expired = true`,验证返回已过期的待支付订单
- [ ] 18.2 调用订单列表 API筛选 `is_expired = false`,验证返回未过期的待支付订单
- [ ] 18.3 调用订单详情 API验证响应包含 `is_expired` 字段且计算正确
## 19. 性能验证
- [ ] 19.1 使用 PostgreSQL MCP 的 `explain_query` 工具分析 `FindExpiredOrders` 查询:确认使用 `idx_order_expires` 索引
- [ ] 19.2 验证查询耗时:在订单数量 > 10000 的情况下,查询耗时 < 50ms
- [ ] 19.3 验证定时任务处理耗时:单批次处理 100 条订单,总耗时 < 5s
- [ ] 19.4 使用 PostgreSQL MCP 检查数据库连接池状态:确认无连接池阻塞
## 20. 错误处理验证
- [ ] 20.1 模拟数据库连接失败场景确认定时任务返回可重试错误Asynq 自动重试
- [ ] 20.2 模拟钱包不存在场景:确认订单取消失败,事务回滚,订单状态不变
- [ ] 20.3 模拟冻结余额不足场景:确认订单取消失败,事务回滚,记录错误日志
- [ ] 20.4 检查日志:确认所有错误场景都记录了详细日志(包含订单 ID、错误原因
## 21. 代码质量检查
- [ ] 21.1 运行 `gofmt -s -w .` 格式化代码
- [ ] 21.2 运行 `go vet ./...` 检查代码问题
- [ ] 21.3 运行 `go build ./...` 确认全部编译通过
- [ ] 21.4 检查所有新增代码的中文注释:确认符合注释规范(导出符号有文档注释,复杂逻辑有实现注释)
## 22. 文档更新
- [ ] 22.1 创建功能总结文档 `docs/order-expiration/功能总结.md`:说明超时机制、钱包解冻、查询过期状态
- [ ] 22.2 更新 `README.md`:在"已实现功能"部分添加"订单超时自动失效"
- [ ] 22.3 更新 `openspec/specs/iot-order/spec.md`:同步 delta spec 到主规格文档(归档后)
- [ ] 22.4 更新 `openspec/specs/order-payment/spec.md`:同步 delta spec 到主规格文档(归档后)
## 23. 最终验证
- [ ] 23.1 在开发环境完整测试一次完整流程:创建订单 → 超时自动取消 → 钱包解冻
- [ ] 23.2 检查所有日志输出确认日志级别正确Info/Error日志内容完整
- [ ] 23.3 检查数据库:确认无脏数据(如订单已取消但钱包未解冻)
- [ ] 23.4 使用 Postman 导出 API 测试用例集(包含订单创建、取消、查询过期状态)
## 24. 重构现有定时任务为 Asynq Scheduler
- [ ] 24.1 在 `pkg/constants/constants.go` 中添加告警检查任务类型常量 `TaskTypeAlertCheck = "alert:check"`
- [ ] 24.2 在 `pkg/constants/constants.go` 中添加数据清理任务类型常量 `TaskTypeDataCleanup = "data:cleanup"`
- [ ] 24.3 创建 `internal/task/alert_check.go` 文件,定义 `AlertCheckHandler` 结构体
- [ ] 24.4 实现 `NewAlertCheckHandler()` 构造函数,依赖注入 `alertService`, `logger`
- [ ] 24.5 实现 `HandleAlertCheck(ctx context.Context, task *asynq.Task) error` 方法,调用 `alertService.CheckAlerts()`
- [ ] 24.6 创建 `internal/task/data_cleanup.go` 文件,定义 `DataCleanupHandler` 结构体
- [ ] 24.7 实现 `NewDataCleanupHandler()` 构造函数,依赖注入 `cleanupService`, `logger`
- [ ] 24.8 实现 `HandleDataCleanup(ctx context.Context, task *asynq.Task) error` 方法,调用 `cleanupService.RunScheduledCleanup()`
- [ ] 24.9 在 `pkg/queue/handler.go``RegisterHandlers()` 方法中调用 `registerAlertCheckHandler()`
- [ ] 24.10 实现 `registerAlertCheckHandler()` 方法:创建 `AlertCheckHandler` 并注册到 `mux.HandleFunc(constants.TaskTypeAlertCheck, ...)`
- [ ] 24.11 在 `pkg/queue/handler.go``RegisterHandlers()` 方法中调用 `registerDataCleanupHandler()`
- [ ] 24.12 实现 `registerDataCleanupHandler()` 方法:创建 `DataCleanupHandler` 并注册到 `mux.HandleFunc(constants.TaskTypeDataCleanup, ...)`
- [ ] 24.13 在 `cmd/worker/main.go` 的 Asynq Scheduler 中注册告警检查周期任务:`scheduler.Register("@every 1m", asynq.NewTask(constants.TaskTypeAlertCheck, nil))`
- [ ] 24.14 在 `cmd/worker/main.go` 的 Asynq Scheduler 中注册数据清理周期任务:`scheduler.Register("0 2 * * *", asynq.NewTask(constants.TaskTypeDataCleanup, nil))` 每天凌晨2点
- [ ] 24.15 移除 `cmd/worker/main.go` 中的 `startAlertChecker` 函数定义(第 239-265 行)
- [ ] 24.16 移除 `cmd/worker/main.go` 中的 `startCleanupScheduler` 函数定义(第 267-303 行)
- [ ] 24.17 移除 `cmd/worker/main.go` 中对 `startAlertChecker``startCleanupScheduler` 的调用和相关代码
- [ ] 24.18 验证编译:运行 `go build ./cmd/worker/...` 确认无编译错误
- [ ] 24.19 验证编译:运行 `go build ./internal/task/...` 确认无编译错误
- [ ] 24.20 验证编译:运行 `go build ./pkg/queue/...` 确认无编译错误
## 25. 提交和归档
- [ ] 25.1 使用 `/commit` 创建 Git commit提交消息"实现订单超时自动失效机制并重构定时任务为 Asynq Scheduler"
- [ ] 25.2 使用 `/opsx:verify` 验证实现与规格一致
- [ ] 25.3 使用 `/opsx:archive` 归档变更,同步 delta specs 到主规格文档
- [ ] 25.4 确认归档后 `openspec/specs/iot-order/spec.md``openspec/specs/order-payment/spec.md` 已更新

View File

@@ -0,0 +1,248 @@
# Admin Order Creation
## Purpose
后台订单创建流程,为代理和平台账号提供订单创建功能。与 H5 端订单创建的核心区别:后台仅支持 wallet/offline 支付方式,且 wallet 支付立即完成扣款和套餐激活(一步到位),不创建待支付订单。
This capability supports:
- 参数验证和支付方式限制
- 钱包余额检查和一步扣款
- 权限校验(代理、平台、超管)
- 错误处理和防御性编程
## ADDED Requirements
### Requirement: 后台订单创建 API 参数验证
系统 SHALL 在后台订单创建 API 中强制验证请求参数,拒绝非法的支付方式。
后台订单创建使用独立的 DTO`CreateAdminOrderRequest`),仅允许 `wallet``offline` 两种支付方式。Handler 层 MUST 调用 `middleware.ValidateStruct(&req)` 验证参数,确保 DTO 的 `validate:"oneof=wallet offline"` 规则生效。
#### Scenario: DTO 验证拒绝非法支付方式
- **WHEN** 后台创建订单请求中 `payment_method``wechat``alipay`
- **THEN** 系统在 Handler 层验证失败,返回错误"请求参数解析失败"`CodeInvalidParam`),订单创建失败
#### Scenario: DTO 验证拒绝空支付方式
- **WHEN** 后台创建订单请求中缺少 `payment_method` 字段或值为空字符串
- **THEN** 系统在 Handler 层验证失败,返回错误"请求参数解析失败"`CodeInvalidParam`),订单创建失败
#### Scenario: DTO 验证允许 wallet 支付
- **WHEN** 后台创建订单请求中 `payment_method``wallet`
- **THEN** 系统通过 DTO 验证,继续后续业务逻辑
#### Scenario: DTO 验证允许 offline 支付
- **WHEN** 后台创建订单请求中 `payment_method``offline`
- **THEN** 系统通过 DTO 验证,继续后续业务逻辑
---
### Requirement: 后台订单创建权限检查
系统 SHALL 在后台订单创建时完整检查支付方式权限,所有支付方式(包括非法的)都必须经过权限校验。
权限规则:
- `offline` 支付:仅超管和平台账号可用
- `wallet` 支付:代理、平台、超管均可用
- 其他支付方式:一律拒绝(兜底检查)
#### Scenario: 超管可以使用 offline 支付
- **WHEN** 超管账号创建订单,支付方式为 `offline`
- **THEN** 系统通过权限检查,继续创建订单
#### Scenario: 平台账号可以使用 offline 支付
- **WHEN** 平台账号创建订单,支付方式为 `offline`
- **THEN** 系统通过权限检查,继续创建订单
#### Scenario: 代理账号不能使用 offline 支付
- **WHEN** 代理账号创建订单,支付方式为 `offline`
- **THEN** 系统返回错误"只有平台可以使用线下支付"`CodeForbidden`),订单创建失败
#### Scenario: 代理账号可以使用 wallet 支付
- **WHEN** 代理账号创建订单,支付方式为 `wallet`,钱包余额充足
- **THEN** 系统通过权限检查,继续创建订单
#### Scenario: 兜底检查拒绝其他支付方式
- **WHEN** 后台创建订单请求中 `payment_method``wechat`(虽然 DTO 验证应该已拒绝,但作为防御性编程)
- **THEN** 系统在 Handler 层返回错误"后台仅支持钱包支付或线下支付"`CodeInvalidParam`
---
### Requirement: 后台 wallet 订单一步到位
系统 SHALL 在后台创建 wallet 订单时立即完成余额扣款和套餐激活,不创建待支付订单。订单创建成功后 `payment_status` MUST 为 2已支付
与 H5 端的核心区别:
- **后台**:检查余额 → 扣款 → 创建已支付订单 → 激活套餐(一步完成)
- **H5 端**:冻结余额 → 创建待支付订单 → 用户调用支付接口 → 扣款 + 激活(两步流程)
#### Scenario: 后台 wallet 订单立即扣款
- **WHEN** 代理在后台创建订单,支付方式为 `wallet`,钱包余额 5000 分,订单金额 3000 分
- **THEN** 系统立即扣减钱包余额 3000 分,余额变为 2000 分,创建订单时 `payment_status` = 2`paid_at` 为当前时间
#### Scenario: 后台 wallet 订单立即激活套餐
- **WHEN** 代理在后台创建 wallet 订单成功
- **THEN** 系统在同一事务中创建 `PackageUsage` 记录,套餐状态为已激活
#### Scenario: 后台 wallet 订单不创建待支付状态
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** 系统不创建 `payment_status` = 1待支付的订单订单创建后立即为已支付状态
#### Scenario: 后台 wallet 订单余额不足直接拒绝
- **WHEN** 代理在后台创建 wallet 订单,钱包余额 1000 分,订单金额 3000 分
- **THEN** 系统在事务外快速检查余额,返回错误"余额不足"`CodeInsufficientBalance`),订单创建失败
#### Scenario: 后台 wallet 订单事务保证
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** 订单创建、余额扣减、套餐激活在同一事务中完成,任一步骤失败则全部回滚
---
### Requirement: 后台 offline 订单立即激活
系统 SHALL 在后台创建 offline 订单时立即激活套餐,不扣减钱包余额。订单创建成功后 `payment_status` MUST 为 2已支付
#### Scenario: 平台创建 offline 订单立即激活
- **WHEN** 平台账号创建订单,支付方式为 `offline`
- **THEN** 系统创建订单时 `payment_status` = 2`paid_at` 为当前时间,立即激活套餐
#### Scenario: offline 订单不扣钱包
- **WHEN** 平台账号创建 offline 订单
- **THEN** 系统不扣减任何钱包余额(因为是线下支付)
#### Scenario: offline 订单不检查余额
- **WHEN** 平台账号创建 offline 订单,钱包余额为 0
- **THEN** 系统仍然创建订单成功(因为线下支付不依赖钱包)
---
### Requirement: 后台订单创建错误处理
系统 SHALL 在后台订单创建失败时返回明确的错误信息,不泄露底层细节。
错误码使用规范:
- 参数验证失败:`CodeInvalidParam`(不泄露具体校验错误)
- 权限不足:`CodeForbidden`
- 余额不足:`CodeInsufficientBalance`
- 钱包不存在:`CodeWalletNotFound`
- 其他错误:`CodeInternalError`
#### Scenario: 参数验证失败不泄露细节
- **WHEN** 后台创建订单请求参数验证失败(如支付方式非法)
- **THEN** 系统返回 `CodeInvalidParam` 错误码,错误消息为通用的"请求参数解析失败",不包含具体的 validator 错误信息
#### Scenario: 钱包余额不足返回明确错误
- **WHEN** 代理创建 wallet 订单,余额不足
- **THEN** 系统返回 `CodeInsufficientBalance` 错误码,错误消息为"余额不足"
#### Scenario: 钱包不存在返回明确错误
- **WHEN** 代理创建 wallet 订单,钱包不存在
- **THEN** 系统返回 `CodeWalletNotFound` 错误码,错误消息为"钱包不存在"
#### Scenario: 套餐激活失败回滚并返回错误
- **WHEN** 后台创建订单时余额扣减成功但套餐激活失败
- **THEN** 事务回滚,钱包余额恢复,返回套餐激活失败错误(`CodeInternalError`
---
### Requirement: 后台订单创建防重复
系统 SHALL 使用幂等性检查防止同一订单重复创建和重复扣款。
幂等性策略:
- 使用 Redis 业务键:`order:idempotency:{buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}`
- TTL3 分钟
- 分布式锁:`order:create:lock:{carrier_type}:{carrier_id}`TTL 10 秒
#### Scenario: 重复创建订单返回已创建结果
- **WHEN** 代理在后台对同一张卡的同一套餐组合在 3 分钟内重复创建订单
- **THEN** 系统返回第一次创建的订单信息,不重复扣款
#### Scenario: 并发创建订单使用分布式锁
- **WHEN** 两个请求同时为同一张卡创建订单
- **THEN** 只有一个请求获取到分布式锁并创建订单,另一个请求返回"操作进行中,请勿重复提交"`CodeTooManyRequests`
#### Scenario: 幂等性 key 超时后可重新创建
- **WHEN** 订单创建成功 3 分钟后,代理再次创建相同订单
- **THEN** 系统创建新订单(因为幂等性 key 已过期)
---
### Requirement: 后台订单 API 响应格式
系统 SHALL 在后台订单创建成功后返回完整的订单信息,包含支付状态、实际支付金额、操作者信息等。
响应字段(`OrderResponse`
- `id`:订单 ID
- `order_no`:订单号
- `payment_status`:支付状态(后台订单必为 2-已支付)
- `payment_method`支付方式wallet 或 offline
- `paid_at`:支付时间(不为 NULL
- `total_amount`:订单总金额
- `actual_paid_amount`:实际支付金额(仅 wallet 有值)
- `operator_id`:操作者 ID
- `operator_type`操作者类型agent/platform
- `purchase_role`购买角色self_purchase/purchase_for_subordinate/purchased_by_platform
#### Scenario: wallet 订单响应包含实际支付金额
- **WHEN** 代理在后台创建 wallet 订单成功
- **THEN** 响应包含 `actual_paid_amount` 字段,值为实际扣减的钱包金额
#### Scenario: offline 订单响应不包含实际支付金额
- **WHEN** 平台创建 offline 订单成功
- **THEN** 响应的 `actual_paid_amount` 字段为 NULL因为线下支付不扣钱包
#### Scenario: 代购订单响应包含操作者信息
- **WHEN** 上级代理为下级代理购买套餐
- **THEN** 响应包含 `operator_id`(上级店铺 ID`operator_type` = "agent"、`purchase_role` = "purchase_for_subordinate"
---
### Requirement: 后台订单创建与 H5 端隔离
系统 SHALL 使用独立的 Service 方法处理后台订单创建,避免与 H5 端订单创建逻辑混淆。
架构设计:
- 后台:`OrderHandler.Create()``OrderService.CreateAdminOrder()`
- H5 端:`OrderHandler.Create()``OrderService.CreateH5Order()`
#### Scenario: 后台调用独立的 Service 方法
- **WHEN** 后台创建订单
- **THEN** Handler 层调用 `OrderService.CreateAdminOrder()` 方法,不调用通用的 `Create()` 方法
#### Scenario: H5 端调用独立的 Service 方法
- **WHEN** H5 端创建订单
- **THEN** Handler 层调用 `OrderService.CreateH5Order()` 方法,不影响后台订单创建逻辑
#### Scenario: Service 方法命名明确职责
- **WHEN** 开发人员查看代码
- **THEN** 方法命名(`CreateAdminOrder` vs `CreateH5Order`)清楚表明了后台和 H5 端的差异,防止误用

View File

@@ -107,45 +107,80 @@
#### Scenario: 余额扣减后套餐激活失败
- **WHEN** 余额扣减成功但套餐激活失败
- **THEN** 事务回滚,余额恢复,订单状态不变
## ADDED Requirements
---
### Requirement: 后台钱包一步支付
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。后台订单创建使用独立的 Service 方法(`CreateAdminOrder()`),与 H5 端的 `CreateH5Order()` 方法隔离,避免逻辑混淆。
**后台钱包支付流程**(一步到位):
1. 检查钱包余额是否充足(事务外快速失败)
2. 在事务中:扣减钱包余额 → 创建已支付订单(`payment_status` = 2→ 激活套餐
3. 返回已支付的订单信息
**与 H5 端的区别**
- 后台:立即扣款,订单创建后即为已支付状态(`payment_status` = 2
- H5 端:冻结余额,创建待支付订单(`payment_status` = 1需用户调用支付接口
#### Scenario: 后台订单创建时钱包支付
- **WHEN** 代理在后台创建订单,支付方式为 wallet钱包余额充足
- **THEN** 系统创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2激活套餐
- **THEN** 系统调用 `CreateAdminOrder()` 方法,创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2激活套餐
#### Scenario: 后台钱包支付余额不足
- **WHEN** 代理在后台创建订单,支付方式为 wallet钱包余额不足
- **THEN** 系统返回错误"余额不足",订单创建失败
- **THEN** 系统调用 `CreateAdminOrder()` 方法,在事务外检查余额,返回错误"余额不足",订单创建失败
#### Scenario: 后台钱包支付订单响应
- **WHEN** 后台钱包支付订单创建成功
- **THEN** API 响应包含已支付的订单信息,`payment_status` = 2`payment_method` = "wallet"`paid_at` 为当前时间
#### Scenario: 后台钱包支付不创建待支付订单
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** 系统不创建待支付订单(`payment_status` != 1直接完成支付
- **THEN** 系统不创建待支付订单(`payment_status` != 1直接完成支付和套餐激活
#### Scenario: 后台钱包支付使用独立方法
- **WHEN** 代理在后台创建 wallet 订单
- **THEN** Handler 层调用 `OrderService.CreateAdminOrder()` 方法,不调用通用的 `Create()``CreateH5Order()` 方法
---
### Requirement: H5 钱包两步支付保持不变
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。H5 端订单创建使用独立的 Service 方法(`CreateH5Order()`),与后台的 `CreateAdminOrder()` 方法隔离。
**H5 钱包支付流程**(两步流程):
1. 创建订单:冻结钱包余额 → 创建待支付订单(`payment_status` = 1
2. 用户调用支付接口:扣减钱包余额 → 更新订单状态为已支付 → 激活套餐
**与后台的区别**
- H5 端:创建待支付订单,用户需调用支付接口完成支付
- 后台:立即扣款,订单创建后即为已支付状态
#### Scenario: H5 创建待支付订单
- **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet
- **THEN** 系统创建订单,`payment_status` = 1待支付不扣减钱包余额
- **THEN** 系统调用 `CreateH5Order()` 方法,创建订单,`payment_status` = 1待支付冻结钱包余额,不立即扣款
#### Scenario: H5 调用 WalletPay 接口支付
- **WHEN** 个人客户调用 WalletPay 接口支付待支付订单
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐
#### Scenario: H5 和后台钱包支付流程独立
- **WHEN** H5 端创建 wallet 订单
- **THEN** 不影响后台 wallet 订单的一步支付逻辑
- **THEN** 系统调用 `CreateH5Order()` 方法,不影响后台 wallet 订单的一步支付逻辑
#### Scenario: H5 钱包支付使用独立方法
- **WHEN** 个人客户在 H5 端创建 wallet 订单
- **THEN** Handler 层调用 `OrderService.CreateH5Order()` 方法,不调用 `CreateAdminOrder()` 方法
---
@@ -253,16 +288,38 @@
### Requirement: 钱包支付与第三方支付的区别
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。后台订单创建 MUST 在 Handler 层强制验证支付方式,拒绝 `wechat``alipay` 支付方式。
#### Scenario: 后台不支持第三方支付
- **WHEN** 代理在后台创建订单时选择 wechat 或 alipay
- **THEN** 系统返回错误"后台只支持 wallet 和 offline 支付方式"
**后台支付方式限制**
- 允许:`wallet``offline`
- 拒绝:`wechat``alipay`、其他任何值
**实现层级**
1. **DTO 验证**(第一道防线):`CreateAdminOrderRequest``payment_method` 字段使用 `validate:"oneof=wallet offline"` 规则
2. **Handler 验证**(第二道防线):调用 `middleware.ValidateStruct(&req)` 验证 DTO
3. **Handler 兜底检查**(第三道防线):对所有支付方式进行权限检查,包括非法值
#### Scenario: 后台参数验证拒绝第三方支付
- **WHEN** 代理在后台创建订单时 `payment_method` 为 wechat 或 alipay
- **THEN** 系统在 Handler 层的 DTO 验证阶段拒绝请求,返回错误"请求参数解析失败"`CodeInvalidParam`),订单创建失败
#### Scenario: 后台兜底检查拒绝其他支付方式
- **WHEN** 代理在后台创建订单时 `payment_method` 为未知值(防御性编程)
- **THEN** 系统在 Handler 层的兜底检查阶段拒绝请求,返回错误"后台仅支持钱包支付或线下支付"`CodeInvalidParam`
#### Scenario: H5 支持第三方支付
- **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay
- **THEN** 系统创建待支付订单返回支付参数prepay_id 或 h5_url
- **THEN** 系统调用 `CreateH5Order()` 方法,创建待支付订单返回支付参数prepay_id 或 h5_url
#### Scenario: 钱包支付不需要支付参数
- **WHEN** 后台钱包支付订单创建成功
- **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数
#### Scenario: 后台使用独立的 DTO
- **WHEN** 后台创建订单
- **THEN** Handler 层使用 `CreateAdminOrderRequest` DTO仅允许 wallet/offlineH5 端使用 `CreateOrderRequest` DTO允许 wallet/wechat/alipay

View File

@@ -43,7 +43,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(nil),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
AdminOrder: admin.NewOrderHandler(nil),
AdminOrder: admin.NewOrderHandler(nil, nil),
H5Order: h5.NewOrderHandler(nil),
H5Recharge: h5.NewRechargeHandler(nil),
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil),