fix: 修复代理钱包订单创建逻辑,拆分后台/H5端下单方法并归档变更
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
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:
@@ -157,13 +157,20 @@ result := tx.Model(&model.AgentWallet{}).
|
|||||||
|
|
||||||
## API 变更
|
## API 变更
|
||||||
|
|
||||||
### 后台订单创建 API
|
### 后台订单创建 API(❗ Breaking Change)
|
||||||
|
|
||||||
**端点**:`POST /api/admin/orders`
|
**端点**:`POST /api/admin/orders`
|
||||||
|
|
||||||
|
**请求参数变更**:
|
||||||
|
|
||||||
|
| 字段 | 变更前 | 变更后 | 说明 |
|
||||||
|
|------|--------|--------|------|
|
||||||
|
| `payment_method` | 可选,任意值 | **必填**,仅允许 `wallet` 或 `offline` | 不传或传其他值均返回 1001 错误 |
|
||||||
|
|
||||||
**行为变更**:
|
**行为变更**:
|
||||||
- 代理使用 wallet 支付时,订单直接完成(`payment_status = 2`),无需后续支付
|
- `wallet` 支付:订单直接完成(`payment_status = 2`),无需后续支付接口
|
||||||
- 平台使用 offline 支付逻辑保持不变
|
- `offline` 支付:逻辑保持不变
|
||||||
|
- 传入 `wechat`/`alipay` → 返回 `{"code": 1001, "msg": "请求参数解析失败"}`
|
||||||
|
|
||||||
**响应新增字段**:
|
**响应新增字段**:
|
||||||
```json
|
```json
|
||||||
@@ -178,7 +185,11 @@ result := tx.Model(&model.AgentWallet{}).
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
### H5 端订单创建 API(无变更)
|
||||||
|
|
||||||
|
**端点**:`POST /api/h5/orders`
|
||||||
|
|
||||||
|
行为完全不变,仍支持 `wallet`/`wechat`/`alipay`,仍创建待支付订单。
|
||||||
|
|
||||||
### 订单列表 API
|
### 订单列表 API
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(svc.ShopPackageAllocation),
|
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(svc.ShopPackageAllocation),
|
||||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||||
AdminOrder: admin.NewOrderHandler(svc.Order),
|
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
||||||
H5Order: h5.NewOrderHandler(svc.Order),
|
H5Order: h5.NewOrderHandler(svc.Order),
|
||||||
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
|
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
|
||||||
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, deps.WechatPayment),
|
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, deps.WechatPayment),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
@@ -14,24 +15,35 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// OrderHandler 后台订单处理器
|
||||||
type OrderHandler struct {
|
type OrderHandler struct {
|
||||||
service *orderService.Service
|
service *orderService.Service
|
||||||
|
validator *validator.Validate
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOrderHandler(service *orderService.Service) *OrderHandler {
|
// NewOrderHandler 创建后台订单处理器
|
||||||
return &OrderHandler{service: service}
|
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 {
|
func (h *OrderHandler) Create(c *fiber.Ctx) error {
|
||||||
var req dto.CreateOrderRequest
|
var req dto.CreateAdminOrderRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
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()
|
ctx := c.UserContext()
|
||||||
userType := middleware.GetUserTypeFromContext(ctx)
|
userType := middleware.GetUserTypeFromContext(ctx)
|
||||||
shopID := middleware.GetShopIDFromContext(ctx)
|
shopID := middleware.GetShopIDFromContext(ctx)
|
||||||
|
|
||||||
|
// 线下支付仅限平台用户
|
||||||
if req.PaymentMethod == model.PaymentMethodOffline {
|
if req.PaymentMethod == model.PaymentMethodOffline {
|
||||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||||
return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付")
|
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 {
|
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
|
||||||
return errors.New(errors.CodeForbidden, "无权创建订单")
|
return errors.New(errors.CodeForbidden, "无权创建订单")
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 防御性分支:DTO 验证已限制,此处兜底
|
||||||
|
return errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付和线下支付")
|
||||||
}
|
}
|
||||||
|
|
||||||
buyerType := ""
|
buyerType := ""
|
||||||
@@ -49,7 +64,7 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error {
|
|||||||
buyerID = shopID
|
buyerID = shopID
|
||||||
}
|
}
|
||||||
|
|
||||||
order, err := h.service.Create(ctx, &req, buyerType, buyerID)
|
order, err := h.service.CreateAdminOrder(ctx, &req, buyerType, buyerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error {
|
|||||||
return errors.New(errors.CodeForbidden, "不支持的用户类型")
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,15 @@ package dto
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type CreateOrderRequest struct {
|
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:设备购买)"`
|
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(单卡购买时必填)"`
|
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(设备购买时必填)"`
|
DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"`
|
||||||
|
|||||||
@@ -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 validationResult *purchase_validation.PurchaseValidationResult
|
||||||
var err error
|
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) {
|
func (s *Service) resolvePurchaseOnBehalfInfo(ctx context.Context, result *purchase_validation.PurchaseValidationResult) (uint, int64, *time.Time, error) {
|
||||||
var resourceShopID *uint
|
var resourceShopID *uint
|
||||||
var seriesID *uint
|
var seriesID *uint
|
||||||
@@ -1013,6 +1484,17 @@ func resolveCarrierInfo(req *dto.CreateOrderRequest) (carrierType string, carrie
|
|||||||
return "", 0
|
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 生成订单创建的幂等性业务键
|
// buildOrderIdempotencyKey 生成订单创建的幂等性业务键
|
||||||
// 格式: {buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}
|
// 格式: {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 {
|
func buildOrderIdempotencyKey(buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) string {
|
||||||
|
|||||||
@@ -1,617 +1,403 @@
|
|||||||
## Context
|
## 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`),绕过验证
|
||||||
|
|
||||||
**现有代码分析**:
|
**第二层:Handler 层权限检查不完整**
|
||||||
- `Service.Create()` 方法中,只有 `payment_method == "offline"` 才会调用 `createOrderWithActivation()`(立即完成)
|
- `internal/handler/admin/order.go:35-43` 只检查了 `offline` 和 `wallet` 两种支付方式的权限
|
||||||
- `wallet` 支付会直接调用 `orderStore.Create()`,创建待支付订单,不扣款不激活
|
- `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 / Non-Goals
|
||||||
|
|
||||||
**Goals:**
|
**Goals:**
|
||||||
1. 支持代理在后台使用 wallet 一步完成订单(检查余额 → 扣款 → 激活套餐)
|
|
||||||
2. 区分订单中的"操作者"(谁下单)和"买家"(资源所属者),支持数据追溯和业务分析
|
1. **修复 Handler 层参数验证**:确保 DTO 的 `validate` 规则生效
|
||||||
3. 正确处理三种代购场景的价格逻辑:
|
2. **修复 Handler 层权限检查**:完整覆盖所有支付方式的权限校验
|
||||||
- 代理自购:订单金额 = 实际支付 = 自己成本价
|
3. **拆分 Service 方法**:后台和 H5 端使用独立的 Service 方法,避免逻辑混淆
|
||||||
- 代理代购(给下级):订单金额 = 下级成本价,实际支付 = 自己成本价
|
4. **后台订单一步到位**:wallet 支付立即扣款,offline 支付立即激活,不创建待支付订单
|
||||||
- 平台代购:订单金额 = 下级成本价,实际支付 = 0(不扣款)
|
5. **保持 H5 端行为不变**:不影响 H5 端订单创建流程
|
||||||
4. 支持按订单角色筛选查询(自购、上级购买、平台购买、给下级购买)
|
6. **向后兼容**:保留回滚方案,避免破坏现有功能
|
||||||
5. 钱包流水记录支持场景区分和关联店铺追踪
|
|
||||||
6. 佣金逻辑调整:代理代购不产生佣金(已赚差价)
|
|
||||||
|
|
||||||
**Non-Goals:**
|
**Non-Goals:**
|
||||||
- 不修改 H5 端的支付流程(H5 端仍然是两步:创建待支付订单 → 调用 WalletPay)
|
|
||||||
- 不修改平台代购(offline)的现有逻辑
|
1. 修改订单数据模型(无需新增字段)
|
||||||
- 不支持代理在后台使用微信/支付宝支付(后台只支持 wallet 和 offline)
|
2. 修改支付方式枚举(保持现有 wallet/wechat/alipay/offline)
|
||||||
- 不涉及个人客户(C 端)的订单流程
|
3. 修改 H5 端订单创建逻辑(只拆分,不改逻辑)
|
||||||
|
4. 重构整个订单系统(仅针对性修复代理钱包订单问题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
### 决策 1:新增订单角色追踪字段
|
### Decision 1: Service 方法拆分策略
|
||||||
|
|
||||||
**决策**:在 `tb_order` 表新增 4 个字段:
|
**选择**: 拆分 `OrderService.Create()` 为两个独立方法
|
||||||
- `operator_id` (INT, nullable):操作者 ID(店铺 ID)
|
|
||||||
- `operator_type` (VARCHAR, nullable):操作者类型(`platform` / `agent`)
|
|
||||||
- `actual_paid_amount` (BIGINT, nullable):实际支付金额(分)
|
|
||||||
- `purchase_role` (VARCHAR):订单角色枚举
|
|
||||||
|
|
||||||
**理由**:
|
**方案对比**:
|
||||||
- 现有 `buyer_id` 字段只记录买家(资源所属者),无法区分"谁下单"和"谁买单"
|
|
||||||
- `operator_id` 记录操作者,支持追溯代购关系(如:平台为代理 A 代购,代理 A 为代理 B 代购)
|
|
||||||
- `actual_paid_amount` 记录实际扣款金额,与 `total_amount`(订单金额)可能不同(代理代购场景)
|
|
||||||
- `purchase_role` 枚举字段支持高效筛选,避免依赖文本备注
|
|
||||||
|
|
||||||
**替代方案(已拒绝)**:
|
| 方案 | 优点 | 缺点 |
|
||||||
- ❌ 使用 `creator` 字段代替 `operator_id`:`creator` 是审计字段,语义不同,不应混用
|
|------|------|------|
|
||||||
- ❌ 使用 `remark` 字段记录代购信息:文本字段无法高效索引和筛选
|
| **方案 A:在现有 `Create()` 中添加 `isAdminContext` 参数** | 改动最小,无 breaking change | 逻辑更复杂,if-else 嵌套增加,难以维护 |
|
||||||
- ❌ 创建单独的代购订单表:增加系统复杂度,查询需要 JOIN 多表
|
| **方案 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
|
```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 (
|
const (
|
||||||
PurchaseRoleSelfPurchase = "self_purchase" // 自己购买
|
// ... 现有错误码
|
||||||
PurchaseRolePurchasedByParent = "purchased_by_parent" // 上级代理购买
|
CodeInvalidPaymentMethodForAdmin = 40008 // 后台不支持的支付方式
|
||||||
PurchaseRolePurchasedByPlatform = "purchased_by_platform" // 平台代购
|
|
||||||
PurchaseRolePurchaseForSubordinate = "purchase_for_subordinate" // 给下级购买
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
**索引设计**:
|
**错误返回规范**:
|
||||||
- `idx_orders_operator_id` (operator_id):支持"我作为操作者的订单"查询
|
- Handler 层参数验证失败:`errors.New(errors.CodeInvalidParam)`(不泄露细节)
|
||||||
- `idx_orders_purchase_role` (purchase_role):支持按角色筛选
|
- Service 层钱包余额不足:`errors.New(errors.CodeInsufficientBalance, "余额不足")`
|
||||||
|
- Service 层支付方式非法:`errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")`
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 遵循项目错误处理规范(参考 CLAUDE.md)
|
||||||
|
- Handler 层不直接返回底层错误,防止信息泄露
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 决策 2:订单创建流程重构
|
### Decision 5: 向后兼容和回滚策略
|
||||||
|
|
||||||
**决策**:在 `Service.Create()` 方法中,根据 `payment_method` 和资源归属判断场景:
|
**选择**: 保留原 `Create()` 方法作为 `CreateLegacy()`,灰度发布
|
||||||
|
|
||||||
```
|
**迁移步骤**:
|
||||||
IF payment_method == "offline":
|
1. **Phase 1**: 新增 `CreateAdminOrder()` 和 `CreateH5Order()` 方法
|
||||||
→ 平台代购场景(保持现有逻辑)
|
2. **Phase 2**: 将原 `Create()` 重命名为 `CreateLegacy()`(保留,暂不调用)
|
||||||
→ buyer = 资源所属者, operator = nil, operator_type = "platform"
|
3. **Phase 3**: 修改 Handler 层调用新方法
|
||||||
→ 价格 = 买家成本价, 实际支付 = nil(不扣款)
|
4. **Phase 4**: 测试环境验证
|
||||||
→ purchase_role = "purchased_by_platform"
|
5. **Phase 5**: 生产环境灰度发布(先 1% 流量,观察 1 天,再逐步放量)
|
||||||
→ 调用 createOrderWithActivation()
|
6. **Phase 6**: 全量上线后观察 1 周,无问题后删除 `CreateLegacy()` 方法
|
||||||
|
|
||||||
ELSE IF payment_method == "wallet":
|
**回滚方案**:
|
||||||
→ 获取操作者店铺 ID
|
- 如果出现问题,立即修改 Handler 层调用 `CreateLegacy()` 方法
|
||||||
→ 获取资源所属店铺 ID
|
- 重新部署后端(5 分钟内可完成)
|
||||||
|
- 前端无需回滚(因为 DTO 结构兼容)
|
||||||
IF 资源所属 == 操作者:
|
|
||||||
→ 代理自购场景
|
**理由**:
|
||||||
→ buyer = 操作者, operator = 操作者
|
- breaking change 风险高,需要谨慎迁移
|
||||||
→ 价格 = 操作者成本价, 实际支付 = 操作者成本价
|
- 保留 `CreateLegacy()` 方法可以快速回滚,避免长时间故障
|
||||||
→ purchase_role = "self_purchase"
|
|
||||||
→ is_purchase_on_behalf = false
|
|
||||||
ELSE:
|
|
||||||
→ 代理代购场景
|
|
||||||
→ buyer = 资源所属者, operator = 操作者
|
|
||||||
→ 价格 = 买家成本价, 实际支付 = 操作者成本价
|
|
||||||
→ purchase_role = "purchase_for_subordinate"
|
|
||||||
→ is_purchase_on_behalf = true
|
|
||||||
|
|
||||||
→ 调用 createOrderWithWalletPayment()
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- 清晰区分三种场景,每种场景的价格逻辑和字段填充规则不同
|
|
||||||
- 复用现有的 `createOrderWithActivation()`(平台代购)
|
|
||||||
- 新增 `createOrderWithWalletPayment()`(代理钱包支付,含扣款逻辑)
|
|
||||||
|
|
||||||
**替代方案(已拒绝)**:
|
|
||||||
- ❌ 使用策略模式分离三种场景:过度设计,增加代码复杂度
|
|
||||||
- ❌ 在 Handler 层判断场景:违反分层架构,业务逻辑应在 Service 层
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 决策 3:价格计算逻辑
|
|
||||||
|
|
||||||
**决策**:区分"订单金额"(`total_amount`)和"实际支付"(`actual_paid_amount`):
|
|
||||||
|
|
||||||
| 场景 | 订单金额(total_amount) | 实际支付(actual_paid_amount) | 说明 |
|
|
||||||
|------|------------------------|------------------------------|------|
|
|
||||||
| 代理自购 | 操作者成本价 | 操作者成本价 | 两者相同 |
|
|
||||||
| 代理代购 | 买家成本价 | 操作者成本价 | 操作者实际扣款少于订单金额(赚取差价) |
|
|
||||||
| 平台代购 | 买家成本价 | NULL | 平台不扣款 |
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- **订单金额**面向买家:买家看到的应该是"他的成本价",这样才符合业务逻辑
|
|
||||||
- **实际支付**面向操作者:操作者实际扣款金额,用于钱包流水和财务对账
|
|
||||||
- 代理代购时,操作者按自己的成本价扣款,但订单显示下级成本价,差价即为利润
|
|
||||||
|
|
||||||
**示例**:
|
|
||||||
```
|
|
||||||
一级代理 A 成本价:80 元
|
|
||||||
二级代理 B 成本价:100 元
|
|
||||||
|
|
||||||
A 为 B 的卡购买套餐:
|
|
||||||
- total_amount = 100(B 看到的订单金额)
|
|
||||||
- actual_paid_amount = 80(A 实际扣款)
|
|
||||||
- A 赚取差价:20 元
|
|
||||||
```
|
|
||||||
|
|
||||||
**成本价查询**:
|
|
||||||
```go
|
|
||||||
// 通过 ShopPackageAllocation 表查询店铺对套餐的成本价
|
|
||||||
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
|
|
||||||
costPrice := allocation.CostPrice
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 决策 4:事务处理设计
|
|
||||||
|
|
||||||
**决策**:新增 `createOrderWithWalletPayment()` 方法,使用 GORM 事务确保原子性:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Service) createOrderWithWalletPayment(
|
|
||||||
ctx context.Context,
|
|
||||||
order *model.Order,
|
|
||||||
items []*model.OrderItem,
|
|
||||||
operatorShopID uint,
|
|
||||||
buyerShopID uint,
|
|
||||||
) (*dto.OrderResponse, error) {
|
|
||||||
actualAmount := *order.ActualPaidAmount
|
|
||||||
|
|
||||||
// 1. 事务外:检查钱包余额(快速失败)
|
|
||||||
wallet, err := s.agentWalletStore.GetMainWallet(ctx, operatorShopID)
|
|
||||||
if wallet.Balance < actualAmount {
|
|
||||||
return nil, errors.New(errors.CodeInsufficientBalance, "余额不足")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 事务内:创建订单 + 扣款 + 创建流水 + 激活套餐
|
|
||||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
// 2.1 创建订单
|
|
||||||
if err := tx.Create(order).Error; err != nil {
|
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.2 创建订单明细
|
|
||||||
for _, item := range items {
|
|
||||||
item.OrderID = order.ID
|
|
||||||
}
|
|
||||||
if err := tx.CreateInBatches(items, 100).Error; err != nil {
|
|
||||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.3 扣减钱包余额(乐观锁)
|
|
||||||
walletResult := tx.Model(&model.AgentWallet{}).
|
|
||||||
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, actualAmount, wallet.Version).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"balance": gorm.Expr("balance - ?", actualAmount),
|
|
||||||
"version": gorm.Expr("version + 1"),
|
|
||||||
})
|
|
||||||
if walletResult.RowsAffected == 0 {
|
|
||||||
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.4 创建钱包流水
|
|
||||||
if err := s.createWalletTransaction(ctx, tx, wallet.ID, order.ID, actualAmount, order.PurchaseRole, buyerShopID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2.5 激活套餐
|
|
||||||
return s.activatePackage(ctx, tx, order)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 3. 事务外:佣金计算(异步)
|
|
||||||
if order.OperatorID == nil {
|
|
||||||
s.enqueueCommissionCalculation(ctx, order.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.buildOrderResponse(order, items), nil
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**事务步骤顺序理由**:
|
|
||||||
1. **先创建订单**:确保订单记录存在,后续步骤失败时可以回滚
|
|
||||||
2. **后扣款**:避免扣款成功但订单创建失败的情况(钱扣了但没有订单)
|
|
||||||
3. **最后激活套餐**:套餐激活依赖订单 ID,必须在订单创建后执行
|
|
||||||
|
|
||||||
**乐观锁设计**:
|
|
||||||
- 使用 `version` 字段防止并发扣款导致余额不一致
|
|
||||||
- `WHERE balance >= ?` 确保余额充足
|
|
||||||
- `RowsAffected == 0` 表示余额不足或版本冲突,回滚事务
|
|
||||||
|
|
||||||
**替代方案(已拒绝)**:
|
|
||||||
- ❌ 使用悲观锁(SELECT FOR UPDATE):会降低并发性能,乐观锁已足够
|
|
||||||
- ❌ 先扣款后创建订单:扣款成功但订单失败时难以回滚(需要补偿事务)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 决策 5:订单查询 OR 逻辑
|
|
||||||
|
|
||||||
**决策**:修改 `OrderStore.List()` 方法,支持 `buyer_id = shopID OR operator_id = shopID` 查询:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *OrderStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]any) ([]*model.Order, int64, error) {
|
|
||||||
query := s.db.WithContext(ctx).Model(&model.Order{})
|
|
||||||
|
|
||||||
// 代理用户:查询作为买家或操作者的订单
|
|
||||||
if shopID, ok := filters["shop_id"].(uint); ok {
|
|
||||||
query = query.Where(
|
|
||||||
"(buyer_type = ? AND buyer_id = ?) OR operator_id = ?",
|
|
||||||
model.BuyerTypeAgent, shopID, shopID,
|
|
||||||
)
|
|
||||||
delete(filters, "shop_id")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 其他筛选条件...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- 代理需要看到两类订单:
|
|
||||||
1. 作为买家的订单(`buyer_id = 自己`):别人为自己代购、自己购买
|
|
||||||
2. 作为操作者的订单(`operator_id = 自己`):自己为下级代购
|
|
||||||
- OR 查询在 PostgreSQL 中性能可接受(已有索引)
|
|
||||||
|
|
||||||
**性能优化**:
|
|
||||||
- `buyer_id` 已有索引 `idx_order_buyer`
|
|
||||||
- `operator_id` 新建索引 `idx_orders_operator_id`
|
|
||||||
- OR 查询会走两个索引的 UNION,性能可接受
|
|
||||||
|
|
||||||
**替代方案(已拒绝)**:
|
|
||||||
- ❌ 两次查询后合并结果:代码复杂,分页逻辑难以实现
|
|
||||||
- ❌ 冗余存储(同一订单插入两次):违反数据一致性原则
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 决策 6:钱包流水记录设计
|
|
||||||
|
|
||||||
**决策**:钱包流水表新增字段(如果不存在):
|
|
||||||
- `transaction_subtype` (VARCHAR):交易子类型,细分 `order_payment` 场景
|
|
||||||
- `related_shop_id` (INT, nullable):关联店铺 ID,代购时记录下级店铺
|
|
||||||
|
|
||||||
**子类型枚举**(在 `pkg/constants/wallet.go` 中定义):
|
|
||||||
```go
|
|
||||||
// 钱包交易子类型(当 transaction_type = "order_payment" 时)
|
|
||||||
const (
|
|
||||||
WalletTransactionSubtypeSelfPurchase = "self_purchase"
|
|
||||||
WalletTransactionSubtypePurchaseForSubordinate = "purchase_for_subordinate"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**流水创建逻辑**:
|
|
||||||
```go
|
|
||||||
func (s *Service) createWalletTransaction(
|
|
||||||
ctx context.Context,
|
|
||||||
tx *gorm.DB,
|
|
||||||
walletID uint,
|
|
||||||
orderID uint,
|
|
||||||
amount int64,
|
|
||||||
purchaseRole string,
|
|
||||||
relatedShopID *uint,
|
|
||||||
) error {
|
|
||||||
var subtype string
|
|
||||||
var remark string
|
|
||||||
|
|
||||||
switch purchaseRole {
|
|
||||||
case constants.PurchaseRoleSelfPurchase:
|
|
||||||
subtype = constants.WalletTransactionSubtypeSelfPurchase
|
|
||||||
remark = "购买套餐"
|
|
||||||
|
|
||||||
case constants.PurchaseRolePurchaseForSubordinate:
|
|
||||||
subtype = constants.WalletTransactionSubtypePurchaseForSubordinate
|
|
||||||
// 查询下级店铺名称
|
|
||||||
buyerShop, _ := s.shopStore.GetByID(ctx, *relatedShopID)
|
|
||||||
if buyerShop != nil {
|
|
||||||
remark = fmt.Sprintf("为下级代理【%s】购买套餐", buyerShop.ShopName)
|
|
||||||
} else {
|
|
||||||
remark = "为下级代理购买套餐"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
transaction := &model.AgentWalletTransaction{
|
|
||||||
WalletID: walletID,
|
|
||||||
TransactionType: constants.AgentTransactionTypeDeduct,
|
|
||||||
TransactionSubtype: subtype,
|
|
||||||
Amount: -amount,
|
|
||||||
OrderID: &orderID,
|
|
||||||
RelatedShopID: relatedShopID,
|
|
||||||
Remark: remark,
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Create(transaction).Error
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- `transaction_subtype` 支持高效筛选(如:查询所有自购流水)
|
|
||||||
- `related_shop_id` 支持追溯(如:查询为哪些下级代理购买过)
|
|
||||||
- `remark` 作为辅助展示字段,包含店铺名称便于人工查看
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 决策 7:佣金逻辑调整
|
|
||||||
|
|
||||||
**决策**:在 `Service.Create()` 方法中,只有 `operator_id == nil`(平台代购)才入队佣金计算任务:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// 佣金计算(异步)
|
|
||||||
if order.OperatorID == nil {
|
|
||||||
// 只有平台代购才产生佣金
|
|
||||||
s.enqueueCommissionCalculation(ctx, order.ID)
|
|
||||||
}
|
|
||||||
// 代理代购不入队佣金(operator_id != nil)
|
|
||||||
```
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- **代理代购**:操作者从自己钱包扣自己的成本价,已经赚取了成本价差(自己成本价 vs 下级成本价),不应再产生佣金
|
|
||||||
- **平台代购**:平台不扣款,按买家成本价计算差价佣金,激励上级代理
|
|
||||||
|
|
||||||
**示例**:
|
|
||||||
```
|
|
||||||
一级代理 A 成本价:80 元
|
|
||||||
二级代理 B 成本价:100 元
|
|
||||||
|
|
||||||
场景 1:A 为 B 的卡购买套餐(代理代购)
|
|
||||||
- A 扣款:80 元
|
|
||||||
- A 利润:100 - 80 = 20 元
|
|
||||||
- 佣金:无(A 已赚取差价)
|
|
||||||
|
|
||||||
场景 2:平台为 B 的卡购买套餐(平台代购)
|
|
||||||
- 平台扣款:0 元
|
|
||||||
- 订单金额:100 元
|
|
||||||
- 佣金:给 A(B 的上级),金额 = 100 - 80 = 20 元
|
|
||||||
```
|
|
||||||
|
|
||||||
**替代方案(已拒绝)**:
|
|
||||||
- ❌ 代理代购也计算佣金:会导致双重利润(差价 + 佣金),不符合业务逻辑
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 决策 8:常量定义位置
|
|
||||||
|
|
||||||
**决策**:
|
|
||||||
- **订单角色枚举**(`PurchaseRole*`):定义在 `internal/model/order.go`,紧邻 Order 模型
|
|
||||||
- **钱包交易子类型**:扩展现有 `pkg/constants/wallet.go`,新增 `WalletTransactionSubtype*` 常量
|
|
||||||
- **操作者类型**:复用现有 `pkg/constants/iot.go` 中的 `OwnerTypePlatform` 和自定义 `"agent"` 字符串
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- 订单角色枚举只在订单模块使用,放在 `model/order.go` 避免常量文件膨胀
|
|
||||||
- 钱包交易子类型属于钱包系统,应在 `wallet.go` 中管理
|
|
||||||
- 避免重复定义已有常量(如 `OwnerTypePlatform`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 决策 9:DTO 响应字段设计
|
|
||||||
|
|
||||||
**决策**:`OrderResponse` 新增字段:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type OrderResponse struct {
|
|
||||||
// ... 现有字段
|
|
||||||
|
|
||||||
// 操作者信息
|
|
||||||
OperatorID *uint `json:"operator_id,omitempty"`
|
|
||||||
OperatorType string `json:"operator_type,omitempty"`
|
|
||||||
OperatorName string `json:"operator_name,omitempty"`
|
|
||||||
ActualPaidAmount *int64 `json:"actual_paid_amount,omitempty"`
|
|
||||||
|
|
||||||
// 订单角色
|
|
||||||
PurchaseRole string `json:"purchase_role"`
|
|
||||||
IsPurchasedByParent bool `json:"is_purchased_by_parent"`
|
|
||||||
PurchaseRemark string `json:"purchase_remark,omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**字段说明**:
|
|
||||||
- `operator_id`、`operator_type`、`actual_paid_amount`:直接映射数据库字段
|
|
||||||
- `operator_name`:查询操作者名称(Shop 表的 `shop_name`),便于前端展示
|
|
||||||
- `purchase_role`:订单角色枚举,支持前端筛选和标签展示
|
|
||||||
- `is_purchased_by_parent`:派生字段,`purchase_role == "purchased_by_parent"`
|
|
||||||
- `purchase_remark`:派生字段,如"由上级代理【XX】购买"或"由平台代购"
|
|
||||||
|
|
||||||
**理由**:
|
|
||||||
- 前端需要友好的文本展示(如操作者名称、购买备注)
|
|
||||||
- `is_purchased_by_parent` 便于前端判断是否显示"上级代购"标签
|
|
||||||
- `purchase_remark` 避免前端拼接文本逻辑
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Risks / Trade-offs
|
## Risks / Trade-offs
|
||||||
|
|
||||||
### [风险] 数据库迁移失败 → 回滚策略
|
### Risk 1: 前端未同步修改导致参数缺失
|
||||||
|
|
||||||
**问题**:新增字段的迁移脚本可能在生产环境执行失败(如表锁、超时)
|
**风险**: 前端没有传 `payment_method` 参数,后端拒绝请求
|
||||||
|
|
||||||
**缓解措施**:
|
**影响**: 高。后台订单创建功能完全不可用
|
||||||
1. 所有新增字段设为 `nullable`,不影响现有数据
|
|
||||||
2. 迁移脚本分步执行:
|
**缓解措施**:
|
||||||
- Step 1: 添加字段(不加 NOT NULL 约束)
|
1. 后端先部署(兼容旧前端,如果 `payment_method` 为空,默认使用 `wallet`)
|
||||||
- Step 2: 数据回填(如有需要)
|
2. 前端修改后再部署
|
||||||
- Step 3: 添加索引(CONCURRENTLY 方式,不锁表)
|
3. 灰度发布,先在测试环境验证前后端联调
|
||||||
3. 回滚脚本:`DROP COLUMN IF EXISTS`
|
|
||||||
4. 测试环境充分验证后再上生产
|
**检测方式**:
|
||||||
|
- 监控错误日志中 `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
|
1. 提取公共逻辑为私有方法(如 `validatePurchase()`、`checkOrderIdempotency()`、`buildOrderItems()`)
|
||||||
- 订单表数据量级:百万级
|
2. `CreateAdminOrder()` 和 `CreateH5Order()` 只保留差异化逻辑
|
||||||
- OR 查询在 PostgreSQL 中会走 BITMAP INDEX SCAN,性能可接受
|
|
||||||
|
**示例**:
|
||||||
|
```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`)会增加一次数据库查询
|
**选择**: 优先代码拆分(清晰的职责划分),接受一定的代码重复
|
||||||
|
|
||||||
**权衡理由**:
|
**理由**:
|
||||||
- **优势**:备注字段包含店铺名称,便于人工查看,无需二次查询
|
- 后台和 H5 端的订单创建逻辑差异巨大,强行复用会导致 if-else 嵌套过深,难以维护
|
||||||
- **劣势**:订单创建时增加一次查询(~5ms)
|
- "一点点代码重复" 优于 "过度抽象"(遵循 Go 惯用模式)
|
||||||
- **结论**:可接受,因为:
|
- 未来如果需要修改后台订单逻辑,不会影响 H5 端
|
||||||
1. 查询频率低(仅代购场景)
|
|
||||||
2. Shop 表有缓存机制
|
|
||||||
3. 事务内查询,不影响一致性
|
|
||||||
4. 如果性能敏感,可以异步更新备注
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### [风险] 佣金逻辑调整导致收入计算错误 → 回归测试
|
### Trade-off: breaking change vs 保持现状
|
||||||
|
|
||||||
**问题**:佣金计算逻辑变更可能影响现有代理的收入
|
**选择**: 接受 breaking change,彻底解决问题
|
||||||
|
|
||||||
**缓解措施**:
|
**理由**:
|
||||||
1. 充分的单元测试和集成测试
|
- 当前问题严重(代理可以创建永远无法完成的订单),必须彻底修复
|
||||||
2. 上线前在测试环境验证佣金计算结果
|
- breaking change 影响范围可控(只有 2 个 Handler 文件)
|
||||||
3. 灰度发布,监控佣金数据异常
|
- 保留回滚方案,风险可控
|
||||||
4. 保留 `operator_id == nil` 的判断逻辑,避免误伤平台代购
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migration Plan
|
## 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
|
1. 后台订单创建界面添加 `payment_method` 下拉框(wallet/offline)
|
||||||
-- 添加字段(nullable)
|
2. 修改请求参数,使用新 DTO
|
||||||
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);
|
|
||||||
|
|
||||||
-- 添加注释
|
### Phase 3: 联调测试(0.5 天)
|
||||||
COMMENT ON COLUMN tb_order.operator_id IS '操作者ID(谁下的单)';
|
|
||||||
COMMENT ON COLUMN tb_order.operator_type IS '操作者类型(platform/agent)';
|
|
||||||
COMMENT ON COLUMN tb_order.actual_paid_amount IS '实际支付金额(分)';
|
|
||||||
COMMENT ON COLUMN tb_order.purchase_role IS '订单角色(self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate)';
|
|
||||||
|
|
||||||
-- 添加索引(CONCURRENTLY 避免锁表)
|
1. 测试环境部署后端
|
||||||
CREATE INDEX CONCURRENTLY idx_orders_operator_id ON tb_order(operator_id);
|
2. 测试环境部署前端
|
||||||
CREATE INDEX CONCURRENTLY idx_orders_purchase_role ON tb_order(purchase_role);
|
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
|
### Phase 5: 清理(1 天)
|
||||||
-- 检查字段是否存在,不存在则添加
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_name='tb_agent_wallet_transaction'
|
|
||||||
AND column_name='transaction_subtype') THEN
|
|
||||||
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN transaction_subtype VARCHAR(50);
|
|
||||||
COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_subtype IS '交易子类型(细分 order_payment 场景)';
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
1. 观察 1 周,无问题后删除 `CreateLegacy()` 方法
|
||||||
WHERE table_name='tb_agent_wallet_transaction'
|
2. 更新文档(API 文档、技术文档)
|
||||||
AND column_name='related_shop_id') THEN
|
|
||||||
ALTER TABLE tb_agent_wallet_transaction ADD COLUMN related_shop_id INT;
|
|
||||||
COMMENT ON COLUMN tb_agent_wallet_transaction.related_shop_id IS '关联店铺ID(代购时记录下级店铺)';
|
|
||||||
END IF;
|
|
||||||
END
|
|
||||||
$$;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: 数据回填**(可选,视历史数据需求而定)
|
**总时长**: 约 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. **测试环境验证**:
|
**回滚步骤**:
|
||||||
- 执行迁移脚本
|
1. 立即修改 Handler 层调用 `CreateLegacy()` 方法
|
||||||
- 验证索引创建成功
|
2. 重新部署后端(5 分钟)
|
||||||
- 运行集成测试
|
3. 验证功能恢复
|
||||||
- 手工测试三种代购场景
|
4. 排查问题,修复后重新上线
|
||||||
|
|
||||||
2. **灰度发布**:
|
|
||||||
- 代码部署到灰度环境
|
|
||||||
- 观察日志和监控指标
|
|
||||||
- 验证订单创建、查询、钱包扣款功能
|
|
||||||
|
|
||||||
3. **生产环境部署**:
|
|
||||||
- 低峰期执行数据库迁移
|
|
||||||
- 部署代码
|
|
||||||
- 监控错误日志和业务指标
|
|
||||||
- 验证核心功能
|
|
||||||
|
|
||||||
### 回滚策略
|
|
||||||
|
|
||||||
**代码回滚**:
|
|
||||||
- 回滚到上一版本代码即可,新增字段为 `nullable`,不影响老代码
|
|
||||||
|
|
||||||
**数据库回滚**:
|
|
||||||
- 文件:`migrations/xxx_add_operator_fields_to_orders.down.sql`
|
|
||||||
```sql
|
|
||||||
DROP INDEX IF EXISTS idx_orders_operator_id;
|
|
||||||
DROP INDEX IF EXISTS idx_orders_purchase_role;
|
|
||||||
ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_id;
|
|
||||||
ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_type;
|
|
||||||
ALTER TABLE tb_order DROP COLUMN IF EXISTS actual_paid_amount;
|
|
||||||
ALTER TABLE tb_order DROP COLUMN IF EXISTS purchase_role;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
1. **是否需要支持代理在后台为个人客户(C 端)代购?**
|
1. **前端是否已经传递 `payment_method` 参数?**
|
||||||
- 当前设计只支持代理为代理(B2B),不支持代理为个人客户(B2C)
|
- 需要确认前端当前行为
|
||||||
- 如果未来需要,需要扩展 `buyer_type` 判断逻辑
|
- 如果未传递,后端需要提供默认值(wallet)以兼容旧版本
|
||||||
|
|
||||||
2. **钱包流水的 `related_shop_id` 是否需要索引?**
|
2. **是否需要新增错误码 `CodeInvalidPaymentMethodForAdmin`?**
|
||||||
- 当前设计未加索引,因为查询频率低
|
- 当前可以复用 `CodeInvalidParam`
|
||||||
- 如果未来需要"查询我为哪些下级购买过"功能,需要添加索引
|
- 如果需要区分"参数格式错误"和"支付方式不支持",可以新增
|
||||||
|
|
||||||
3. **是否需要支持订单角色的批量变更?**
|
3. **灰度发布的维度是什么?**
|
||||||
- 当前设计 `purchase_role` 在订单创建时填充,后续不可修改
|
- 按用户 ID 灰度(推荐)
|
||||||
- 如果历史订单需要回填角色,需要单独的数据修复脚本
|
- 按流量百分比灰度
|
||||||
|
- 需要与运维确认灰度策略
|
||||||
|
|
||||||
4. **代理代购时,下级代理是否能看到操作者信息?**
|
4. **是否需要支持后台创建待支付订单(未来需求)?**
|
||||||
- 当前设计:下级可以看到 `operator_id` 和 `operator_name`
|
- 当前不支持(Non-Goal)
|
||||||
- 是否需要隐藏?(业务决策)
|
- 如果未来有需求,可以新增 `CreateAdminPendingOrder()` 方法
|
||||||
|
|||||||
@@ -1,61 +1,95 @@
|
|||||||
## Why
|
## Why
|
||||||
|
|
||||||
代理在后台使用钱包支付(wallet)创建订单时,系统只创建待支付订单(payment_status = pending),不扣款也不激活套餐,导致订单无法完成。后台没有支付接口,代理无法对待支付订单进行支付。这个问题阻塞了代理的核心业务场景:代理帮客户购买套餐(代购),从自己钱包扣款并立即激活。
|
当前代理在后台创建订单时存在严重的逻辑漏洞:缺少参数验证、权限检查不完整、后台和 H5 端共用同一个 Service 方法,导致代理可以在钱包余额不足时创建"待支付"状态的订单,但后台没有支付界面,订单永远无法完成。这是一个关键的业务逻辑缺陷,影响代理订单的正常流转和用户体验。
|
||||||
|
|
||||||
## What Changes
|
## What Changes
|
||||||
|
|
||||||
- **新增订单角色追踪字段**:在订单表中新增 `operator_id`(操作者ID)、`operator_type`(操作者类型)、`actual_paid_amount`(实际支付金额)、`purchase_role`(订单角色)字段,用于区分"谁下单"和"谁买单"
|
### Handler 层修复(短期)
|
||||||
- **支持代理钱包一步购买**:代理在后台使用 wallet 创建订单时,立即检查余额、扣款、激活套餐,订单状态直接为已支付(一步完成,无需后续支付接口)
|
|
||||||
- **区分代购场景**:
|
- 在 `internal/handler/admin/order.go` 的 `Create()` 方法中添加参数验证(调用 `middleware.ValidateStruct(&req)`)
|
||||||
- 代理自购(资源属于自己):从自己钱包扣自己的成本价,订单金额 = 实际支付
|
- 在 Handler 层严格检查支付方式:后台只允许 `wallet` 和 `offline`,拒绝其他支付方式
|
||||||
- 代理代购(资源属于下级):从自己钱包扣自己的成本价,但订单金额显示下级成本价(让下级看到他的成本)
|
- 修复权限检查逻辑:完整覆盖所有支付方式的权限校验
|
||||||
- 平台代购(offline):保持现有逻辑(不扣款,立即激活)
|
|
||||||
- **订单查询增强**:代理可以查询 `buyer_id = 自己` 或 `operator_id = 自己` 的订单(看到自己作为买家或操作者的所有订单)
|
### Service 层重构(长期)
|
||||||
- **钱包流水记录**:钱包扣款时记录交易子类型(自购 / 给下级购买)和关联店铺ID,支持按场景筛选
|
|
||||||
- **佣金逻辑调整**:代理代购订单不产生佣金(操作者已赚取成本价差),只有平台代购才触发佣金计算
|
- **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
|
## Capabilities
|
||||||
|
|
||||||
### New Capabilities
|
### New Capabilities
|
||||||
|
|
||||||
- `agent-order-role-tracking`: 订单角色追踪能力,记录并区分订单中的操作者、买家、支付者等角色关系,支持多种代购场景的数据查询和业务分析
|
- `admin-order-creation`: 后台订单创建流程。包含:参数验证、支付方式限制、钱包余额检查、一步到位扣款逻辑、错误处理。
|
||||||
|
|
||||||
### Modified Capabilities
|
### Modified Capabilities
|
||||||
|
|
||||||
- `purchase-on-behalf`: 扩展代购订单需求,新增代理使用钱包(wallet)代购的场景,区别于现有的平台线下(offline)代购
|
- `order-payment`: 修改订单支付流程需求,明确区分后台和 H5 端的支付行为差异(后台立即扣款 vs H5 端支持待支付状态)。
|
||||||
- `order-payment`: 新增后台订单钱包一步支付需求,代理创建订单时立即扣款并激活套餐,区别于 H5 端的两步支付流程(创建待支付订单 → 调用支付接口)
|
|
||||||
|
|
||||||
## Impact
|
## 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 场景)
|
- `internal/model/dto/order_dto.go`:
|
||||||
- `related_shop_id` (INT, 可空):关联店铺ID(代购时记录下级店铺)
|
- 新增 `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`)
|
||||||
|
|
||||||
### 受影响的模块
|
**API 影响**:
|
||||||
- `internal/model/order.go`:新增字段和枚举常量
|
- `POST /api/admin/orders` - 请求参数 DTO 变更(新增 `CreateAdminOrderRequest`)
|
||||||
- `internal/model/agent_wallet.go`:确认流水表字段
|
- `POST /api/h5/orders` - 无变更(继续使用 `CreateOrderRequest`)
|
||||||
- `internal/model/dto/order_dto.go`:OrderResponse 新增字段,OrderListRequest 新增筛选参数
|
|
||||||
- `internal/service/order/service.go`:重构 `Create()` 方法,新增 `createOrderWithWalletPayment()` 方法
|
|
||||||
- `internal/store/postgres/order_store.go`:修改 `List()` 支持 OR 查询(buyer_id 或 operator_id)
|
|
||||||
- `internal/handler/admin/order.go`:调整权限检查和查询逻辑
|
|
||||||
|
|
||||||
### API 影响
|
**依赖**:
|
||||||
- **后台订单创建 API**(POST `/api/admin/orders`):
|
- 无新增依赖
|
||||||
- 行为变更:代理使用 wallet 支付时,订单直接完成(payment_status = paid),无需后续支付
|
|
||||||
- 响应新增字段:`operator_id`, `operator_type`, `actual_paid_amount`, `purchase_role`, `is_purchased_by_parent`, `purchase_remark`
|
|
||||||
- **订单列表 API**(GET `/api/admin/orders`):
|
|
||||||
- 新增查询参数:`purchase_role`(可选,筛选订单角色类型)
|
|
||||||
- 查询逻辑变更:代理可以看到作为操作者或买家的所有订单
|
|
||||||
|
|
||||||
### 兼容性
|
**性能考虑**:
|
||||||
- **向后兼容**:现有订单字段为空值,不影响已有订单查询
|
- 无性能影响(逻辑重构,不增加额外查询)
|
||||||
- **平台代购(offline)逻辑不变**:保持现有行为
|
|
||||||
- **H5 钱包支付不受影响**:H5 端仍使用两步流程(创建待支付订单 → 调用 WalletPay 接口)
|
**测试要求**:
|
||||||
|
- 单元测试:
|
||||||
|
- `CreateAdminOrder()` 方法的各种场景(余额充足、余额不足、支付方式非法)
|
||||||
|
- `CreateH5Order()` 方法的各种场景(保持现有行为)
|
||||||
|
- 集成测试:
|
||||||
|
- 后台创建订单 API(wallet 支付、offline 支付、非法支付方式)
|
||||||
|
- H5 端创建订单 API(保持现有测试)
|
||||||
|
- 回归测试:
|
||||||
|
- 验证 H5 端订单创建流程不受影响
|
||||||
|
|
||||||
|
**迁移风险**:
|
||||||
|
- **BREAKING CHANGE**: 后台和 H5 端调用不同的 Service 方法
|
||||||
|
- 前端需要同步修改(确保传递 `payment_method` 参数)
|
||||||
|
- 部署顺序:先部署后端(向后兼容),再部署前端
|
||||||
|
|
||||||
|
**回滚方案**:
|
||||||
|
- 保留原 `Create()` 方法作为 `CreateLegacy()`,出现问题时快速回滚
|
||||||
|
- 灰度发布:先在测试环境验证,再逐步上线生产环境
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
- TTL:3 分钟
|
||||||
|
- 分布式锁:`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 端的差异,防止误用
|
||||||
@@ -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)
|
|
||||||
@@ -1,159 +1,113 @@
|
|||||||
## ADDED Requirements
|
## MODIFIED Requirements
|
||||||
|
|
||||||
### Requirement: 后台钱包一步支付
|
### Requirement: 后台钱包一步支付
|
||||||
|
|
||||||
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。
|
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。后台订单创建使用独立的 Service 方法(`CreateAdminOrder()`),与 H5 端的 `CreateH5Order()` 方法隔离,避免逻辑混淆。
|
||||||
|
|
||||||
|
**后台钱包支付流程**(一步到位):
|
||||||
|
1. 检查钱包余额是否充足(事务外快速失败)
|
||||||
|
2. 在事务中:扣减钱包余额 → 创建已支付订单(`payment_status` = 2)→ 激活套餐
|
||||||
|
3. 返回已支付的订单信息
|
||||||
|
|
||||||
|
**与 H5 端的区别**:
|
||||||
|
- 后台:立即扣款,订单创建后即为已支付状态(`payment_status` = 2)
|
||||||
|
- H5 端:冻结余额,创建待支付订单(`payment_status` = 1),需用户调用支付接口
|
||||||
|
|
||||||
#### Scenario: 后台订单创建时钱包支付
|
#### Scenario: 后台订单创建时钱包支付
|
||||||
|
|
||||||
- **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额充足
|
- **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额充足
|
||||||
- **THEN** 系统创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2),激活套餐
|
- **THEN** 系统调用 `CreateAdminOrder()` 方法,创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2),激活套餐
|
||||||
|
|
||||||
#### Scenario: 后台钱包支付余额不足
|
#### Scenario: 后台钱包支付余额不足
|
||||||
|
|
||||||
- **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额不足
|
- **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额不足
|
||||||
- **THEN** 系统返回错误"余额不足",订单创建失败
|
- **THEN** 系统调用 `CreateAdminOrder()` 方法,在事务外检查余额,返回错误"余额不足",订单创建失败
|
||||||
|
|
||||||
#### Scenario: 后台钱包支付订单响应
|
#### Scenario: 后台钱包支付订单响应
|
||||||
|
|
||||||
- **WHEN** 后台钱包支付订单创建成功
|
- **WHEN** 后台钱包支付订单创建成功
|
||||||
- **THEN** API 响应包含已支付的订单信息,`payment_status` = 2,`payment_method` = "wallet",`paid_at` 为当前时间
|
- **THEN** API 响应包含已支付的订单信息,`payment_status` = 2,`payment_method` = "wallet",`paid_at` 为当前时间
|
||||||
|
|
||||||
#### Scenario: 后台钱包支付不创建待支付订单
|
#### Scenario: 后台钱包支付不创建待支付订单
|
||||||
|
|
||||||
- **WHEN** 代理在后台创建 wallet 订单
|
- **WHEN** 代理在后台创建 wallet 订单
|
||||||
- **THEN** 系统不创建待支付订单(`payment_status` != 1),直接完成支付
|
- **THEN** 系统不创建待支付订单(`payment_status` != 1),直接完成支付和套餐激活
|
||||||
|
|
||||||
|
#### Scenario: 后台钱包支付使用独立方法
|
||||||
|
|
||||||
|
- **WHEN** 代理在后台创建 wallet 订单
|
||||||
|
- **THEN** Handler 层调用 `OrderService.CreateAdminOrder()` 方法,不调用通用的 `Create()` 或 `CreateH5Order()` 方法
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: H5 钱包两步支付保持不变
|
### Requirement: H5 钱包两步支付保持不变
|
||||||
|
|
||||||
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。
|
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。H5 端订单创建使用独立的 Service 方法(`CreateH5Order()`),与后台的 `CreateAdminOrder()` 方法隔离。
|
||||||
|
|
||||||
|
**H5 钱包支付流程**(两步流程):
|
||||||
|
1. 创建订单:冻结钱包余额 → 创建待支付订单(`payment_status` = 1)
|
||||||
|
2. 用户调用支付接口:扣减钱包余额 → 更新订单状态为已支付 → 激活套餐
|
||||||
|
|
||||||
|
**与后台的区别**:
|
||||||
|
- H5 端:创建待支付订单,用户需调用支付接口完成支付
|
||||||
|
- 后台:立即扣款,订单创建后即为已支付状态
|
||||||
|
|
||||||
#### Scenario: H5 创建待支付订单
|
#### Scenario: H5 创建待支付订单
|
||||||
|
|
||||||
- **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet
|
- **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet
|
||||||
- **THEN** 系统创建订单,`payment_status` = 1(待支付),不扣减钱包余额
|
- **THEN** 系统调用 `CreateH5Order()` 方法,创建订单,`payment_status` = 1(待支付),冻结钱包余额,不立即扣款
|
||||||
|
|
||||||
#### Scenario: H5 调用 WalletPay 接口支付
|
#### Scenario: H5 调用 WalletPay 接口支付
|
||||||
|
|
||||||
- **WHEN** 个人客户调用 WalletPay 接口支付待支付订单
|
- **WHEN** 个人客户调用 WalletPay 接口支付待支付订单
|
||||||
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐
|
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐
|
||||||
|
|
||||||
#### Scenario: H5 和后台钱包支付流程独立
|
#### Scenario: H5 和后台钱包支付流程独立
|
||||||
|
|
||||||
- **WHEN** H5 端创建 wallet 订单
|
- **WHEN** H5 端创建 wallet 订单
|
||||||
- **THEN** 不影响后台 wallet 订单的一步支付逻辑
|
- **THEN** 系统调用 `CreateH5Order()` 方法,不影响后台 wallet 订单的一步支付逻辑
|
||||||
|
|
||||||
---
|
#### Scenario: H5 钱包支付使用独立方法
|
||||||
|
|
||||||
### Requirement: 钱包流水记录扩展
|
- **WHEN** 个人客户在 H5 端创建 wallet 订单
|
||||||
|
- **THEN** Handler 层调用 `OrderService.CreateH5Order()` 方法,不调用 `CreateAdminOrder()` 方法
|
||||||
系统 SHALL 在钱包流水中记录交易子类型和关联店铺,支持按场景筛选。
|
|
||||||
|
|
||||||
#### Scenario: 自购钱包流水
|
|
||||||
- **WHEN** 代理为自己的资源购买套餐,使用 wallet
|
|
||||||
- **THEN** 钱包流水的 `transaction_subtype` = "self_purchase",`related_shop_id` 为 NULL,`remark` = "购买套餐"
|
|
||||||
|
|
||||||
#### Scenario: 代购钱包流水
|
|
||||||
- **WHEN** 代理为下级代理购买套餐,使用 wallet
|
|
||||||
- **THEN** 钱包流水的 `transaction_subtype` = "purchase_for_subordinate",`related_shop_id` = 下级代理店铺 ID,`remark` = "为下级代理【XX】购买套餐"
|
|
||||||
|
|
||||||
#### Scenario: 钱包流水查询店铺名称
|
|
||||||
- **WHEN** 创建代购钱包流水
|
|
||||||
- **THEN** 系统查询下级店铺名称,填充到 `remark` 字段
|
|
||||||
|
|
||||||
#### Scenario: 钱包流水筛选
|
|
||||||
- **WHEN** 代理查询钱包流水,筛选 `transaction_subtype` = "purchase_for_subordinate"
|
|
||||||
- **THEN** 系统返回所有为下级代理购买的流水记录
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Requirement: 钱包支付乐观锁
|
|
||||||
|
|
||||||
系统 SHALL 使用乐观锁防止钱包并发扣款导致余额不一致。
|
|
||||||
|
|
||||||
#### Scenario: 钱包扣款使用 version 字段
|
|
||||||
- **WHEN** 扣减钱包余额
|
|
||||||
- **THEN** SQL 语句包含 `WHERE balance >= ? AND version = ?`,更新时 `version + 1`
|
|
||||||
|
|
||||||
#### Scenario: 钱包并发扣款失败
|
|
||||||
- **WHEN** 两个请求同时扣减同一钱包
|
|
||||||
- **THEN** 只有一个请求成功,另一个返回"余额不足或并发冲突"
|
|
||||||
|
|
||||||
#### Scenario: 乐观锁重试逻辑
|
|
||||||
- **WHEN** 钱包扣款因 version 冲突失败
|
|
||||||
- **THEN** 系统不自动重试,返回错误(由客户端决定是否重试)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Requirement: 钱包支付幂等性
|
|
||||||
|
|
||||||
系统 SHALL 防止同一订单重复创建和重复扣款。
|
|
||||||
|
|
||||||
#### Scenario: 订单创建幂等性检查
|
|
||||||
- **WHEN** 同一买家对同一载体的同一套餐组合在短时间内重复创建订单
|
|
||||||
- **THEN** 系统返回已创建的订单,不重复扣款
|
|
||||||
|
|
||||||
#### Scenario: 幂等性使用 Redis 业务键
|
|
||||||
- **WHEN** 检查订单幂等性
|
|
||||||
- **THEN** 系统使用 Redis key `order:idempotency:{buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}`
|
|
||||||
|
|
||||||
#### Scenario: 幂等性 TTL
|
|
||||||
- **WHEN** 订单创建成功后标记幂等性
|
|
||||||
- **THEN** Redis key 的 TTL 为 3 分钟
|
|
||||||
|
|
||||||
#### Scenario: 分布式锁防止并发
|
|
||||||
- **WHEN** 订单创建前检查幂等性
|
|
||||||
- **THEN** 系统使用分布式锁 `order:create:lock:{carrier_type}:{carrier_id}`,TTL 10 秒
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Requirement: 后台订单 API 响应扩展
|
|
||||||
|
|
||||||
系统 SHALL 在后台订单创建和查询 API 响应中包含钱包支付相关字段。
|
|
||||||
|
|
||||||
#### Scenario: 订单响应包含实际支付金额
|
|
||||||
- **WHEN** 查询钱包支付的订单
|
|
||||||
- **THEN** 响应包含 `actual_paid_amount` 字段
|
|
||||||
|
|
||||||
#### Scenario: 订单响应包含操作者信息
|
|
||||||
- **WHEN** 查询代购订单
|
|
||||||
- **THEN** 响应包含 `operator_id`、`operator_type`、`operator_name` 字段
|
|
||||||
|
|
||||||
#### Scenario: 订单响应包含购买备注
|
|
||||||
- **WHEN** 查询上级代理购买的订单
|
|
||||||
- **THEN** 响应包含 `purchase_remark` 字段,如"由上级代理【XX】购买"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Requirement: 钱包支付错误处理
|
|
||||||
|
|
||||||
系统 SHALL 在钱包支付失败时返回明确的错误信息。
|
|
||||||
|
|
||||||
#### Scenario: 钱包不存在
|
|
||||||
- **WHEN** 钱包支付时钱包不存在
|
|
||||||
- **THEN** 系统返回错误"钱包不存在"(`CodeWalletNotFound`)
|
|
||||||
|
|
||||||
#### Scenario: 余额不足
|
|
||||||
- **WHEN** 钱包支付时余额不足
|
|
||||||
- **THEN** 系统返回错误"余额不足"(`CodeInsufficientBalance`)
|
|
||||||
|
|
||||||
#### Scenario: 并发冲突
|
|
||||||
- **WHEN** 钱包扣款因 version 冲突失败
|
|
||||||
- **THEN** 系统返回错误"余额不足或并发冲突"(`CodeInsufficientBalance`)
|
|
||||||
|
|
||||||
#### Scenario: 套餐激活失败
|
|
||||||
- **WHEN** 钱包扣款成功但套餐激活失败
|
|
||||||
- **THEN** 事务回滚,钱包余额恢复,返回激活失败错误
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: 钱包支付与第三方支付的区别
|
### Requirement: 钱包支付与第三方支付的区别
|
||||||
|
|
||||||
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。
|
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。后台订单创建 MUST 在 Handler 层强制验证支付方式,拒绝 `wechat` 和 `alipay` 支付方式。
|
||||||
|
|
||||||
#### Scenario: 后台不支持第三方支付
|
**后台支付方式限制**:
|
||||||
- **WHEN** 代理在后台创建订单时选择 wechat 或 alipay
|
- 允许:`wallet`、`offline`
|
||||||
- **THEN** 系统返回错误"后台只支持 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 支持第三方支付
|
#### Scenario: H5 支持第三方支付
|
||||||
|
|
||||||
- **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay
|
- **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay
|
||||||
- **THEN** 系统创建待支付订单,返回支付参数(prepay_id 或 h5_url)
|
- **THEN** 系统调用 `CreateH5Order()` 方法,创建待支付订单,返回支付参数(prepay_id 或 h5_url)
|
||||||
|
|
||||||
#### Scenario: 钱包支付不需要支付参数
|
#### Scenario: 钱包支付不需要支付参数
|
||||||
|
|
||||||
- **WHEN** 后台钱包支付订单创建成功
|
- **WHEN** 后台钱包支付订单创建成功
|
||||||
- **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数
|
- **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数
|
||||||
|
|
||||||
|
#### Scenario: 后台使用独立的 DTO
|
||||||
|
|
||||||
|
- **WHEN** 后台创建订单
|
||||||
|
- **THEN** Handler 层使用 `CreateAdminOrderRequest` DTO(仅允许 wallet/offline),H5 端使用 `CreateOrderRequest` DTO(允许 wallet/wechat/alipay)
|
||||||
|
|||||||
@@ -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 字段)和幂等性检查防止并发问题
|
|
||||||
@@ -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.1 在 `internal/model/dto/order_dto.go` 创建 `CreateAdminOrderRequest` 结构体,仅允许 wallet/offline 支付方式
|
||||||
- [x] 1.2 在迁移脚本中创建索引(`idx_orders_operator_id`、`idx_orders_purchase_role`),使用 CONCURRENTLY 避免锁表
|
- [x] 1.2 添加字段验证规则:`payment_method` 使用 `validate:"required,oneof=wallet offline"`
|
||||||
- [x] 1.3 创建钱包流水表字段迁移脚本(`migrations/xxx_add_transaction_subtype_to_wallet_transaction.up.sql`),检查并添加 `transaction_subtype` 和 `related_shop_id` 字段(如果不存在)
|
- [x] 1.3 复制其他字段定义:`order_type`、`iot_card_id`、`device_id`、`package_ids`(与 `CreateOrderRequest` 保持一致)
|
||||||
- [x] 1.4 创建数据回滚迁移脚本(`*.down.sql`),包含 DROP INDEX 和 DROP COLUMN 语句
|
- [x] 1.4 验证编译:运行 `go build ./internal/model/...` 确认无编译错误
|
||||||
- [x] 1.5 在测试环境执行迁移,验证字段创建成功,检查 `\d tb_order` 和 `\d tb_agent_wallet_transaction` 输出
|
|
||||||
|
|
||||||
## 2. Model 层:订单角色追踪
|
## 2. Service 层新增 CreateAdminOrder 方法
|
||||||
|
|
||||||
- [x] 2.1 在 `internal/model/order.go` 中的 `Order` 结构体添加新字段:`OperatorID`、`OperatorType`、`ActualPaidAmount`、`PurchaseRole`,添加 gorm 标签和中文注释
|
- [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 在 `internal/model/order.go` 中定义订单角色枚举常量(`PurchaseRoleSelfPurchase`、`PurchaseRolePurchasedByParent`、`PurchaseRolePurchasedByPlatform`、`PurchaseRolePurchaseForSubordinate`),添加中文注释
|
- [x] 2.2 实现步骤 1:调用 `validatePurchase()` 验证购买合法性(单卡/设备购买、套餐有效性)
|
||||||
- [x] 2.3 在 `internal/model/agent_wallet.go` 中确认 `AgentWalletTransaction` 结构体包含 `TransactionSubtype` 和 `RelatedShopID` 字段(如果不存在则添加)
|
- [x] 2.3 实现步骤 2:调用 `checkOrderIdempotency()` 检查幂等性,如果已创建则返回现有订单
|
||||||
- [x] 2.4 运行 `go build ./...` 验证编译通过
|
- [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.1 在 `internal/service/order/service.go` 新增 `CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error)` 方法签名
|
||||||
- [x] 3.2 运行 `go build ./...` 验证编译通过
|
- [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.1 将 `internal/service/order/service.go` 的 `Create()` 方法重命名为 `CreateLegacy()`(保留作为回滚方案)
|
||||||
- [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.2 添加注释标记:`// Deprecated: 使用 CreateAdminOrder 或 CreateH5Order 替代。保留用于回滚。`
|
||||||
- [x] 4.3 运行 `go build ./...` 验证编译通过
|
- [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.1 修改 `internal/handler/admin/order.go` 的 `Create()` 方法:将 DTO 类型从 `CreateOrderRequest` 改为 `CreateAdminOrderRequest`
|
||||||
- [x] 5.2 在 `List()` 方法中添加 `purchase_role` 精确匹配筛选支持
|
- [x] 5.2 在 `c.BodyParser(&req)` 后添加参数验证:调用 `h.validator.Struct(&req)`,验证失败返回 `errors.New(errors.CodeInvalidParam)`
|
||||||
- [x] 5.3 运行 `go build ./...` 验证编译通过
|
- [x] 5.3 修改权限检查逻辑:在 `else` 分支添加兜底检查,返回错误"后台仅支持钱包支付或线下支付"
|
||||||
- [x] 5.4 使用 PostgreSQL MCP 工具验证查询逻辑:创建测试订单,执行 `SELECT * FROM tb_order WHERE (buyer_id = X) OR (operator_id = X)` 并检查 EXPLAIN 输出
|
- [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.1 修改 `internal/handler/h5/order.go` 的 `Create()` 方法:将 Service 调用从 `h.service.Create(...)` 改为 `h.service.CreateH5Order(...)`
|
||||||
- [x] 6.2 添加错误处理:如果查询失败,返回 `errors.New(errors.CodeInvalidParam, "店铺没有该套餐的分配配置")`
|
- [x] 6.2 确认其他逻辑保持不变(DTO 仍使用 `CreateOrderRequest`,支持 wallet/wechat/alipay)
|
||||||
- [x] 6.3 运行 `go build ./...` 验证编译通过
|
- [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.1 检查 `pkg/errors/errors.go` 是否已定义以下错误码:`CodeInvalidParam`、`CodeForbidden`、`CodeInsufficientBalance`、`CodeWalletNotFound`
|
||||||
- [x] 7.2 在方法中根据 `purchaseRole` 确定 `transaction_subtype` 和 `remark`:自购场景填充"购买套餐",代购场景查询下级店铺名称填充"为下级代理【XX】购买套餐"
|
- [x] 7.2 如果缺少,添加错误码定义(如 `CodeInvalidPaymentMethodForAdmin = 40008`)— 不需要,所有错误码已存在
|
||||||
- [x] 7.3 创建 `AgentWalletTransaction` 记录,设置 `TransactionType` = `AgentTransactionTypeDeduct`,`TransactionSubtype`、`Amount`(负数)、`RelatedShopID`、`Remark`
|
- [x] 7.3 验证编译:运行 `go build ./pkg/errors/...` 确认无编译错误
|
||||||
- [x] 7.4 运行 `go build ./...` 验证编译通过
|
|
||||||
|
|
||||||
## 8. Service 层:钱包支付订单创建方法
|
## 8. 单元测试 - CreateAdminOrder 方法(跳过:项目禁止自动化测试)
|
||||||
|
|
||||||
- [x] 8.1 在 `internal/service/order/service.go` 中新增 `createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID)` 方法
|
- [x] ~~8.1-8.8~~ 跳过:项目规范禁止编写自动化测试代码(AGENTS.md)
|
||||||
- [x] 8.2 在方法开头(事务外)检查钱包余额,如果余额不足返回错误
|
|
||||||
- [x] 8.3 开启 GORM 事务,在事务中依次执行:创建订单(`tx.Create(order)`)、创建订单明细(`tx.CreateInBatches(items, 100)`)
|
|
||||||
- [x] 8.4 在事务中扣减钱包余额,使用乐观锁:`WHERE id = ? AND balance >= ? AND version = ?`,更新 `balance = balance - ?` 和 `version = version + 1`
|
|
||||||
- [x] 8.5 检查 `RowsAffected`,如果为 0 返回 `errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")`
|
|
||||||
- [x] 8.6 在事务中调用 `createWalletTransaction()` 创建钱包流水
|
|
||||||
- [x] 8.7 在事务中调用 `activatePackage()` 激活套餐
|
|
||||||
- [x] 8.8 事务外判断是否入队佣金计算:`if order.OperatorID == nil { s.enqueueCommissionCalculation() }`(平台代购才入队)
|
|
||||||
- [x] 8.9 运行 `go build ./...` 验证编译通过
|
|
||||||
|
|
||||||
## 9. Service 层:订单创建流程重构
|
## 9. 单元测试 - CreateH5Order 方法(跳过:项目禁止自动化测试)
|
||||||
|
|
||||||
- [x] 9.1 在 `internal/service/order/service.go` 的 `Create()` 方法中,在幂等性检查后添加场景判断逻辑
|
- [x] ~~9.1-9.4~~ 跳过:项目规范禁止编写自动化测试代码(AGENTS.md)
|
||||||
- [x] 9.2 提取资源所属店铺 ID(从 `validationResult.Card.ShopID` 或 `validationResult.Device.ShopID`)
|
|
||||||
- [x] 9.3 处理 `offline` 场景:设置 `operator_id = nil`、`operator_type = "platform"`、`purchase_role = "purchased_by_platform"`,调用 `resolvePurchaseOnBehalfInfo()` 获取买家成本价,保持现有逻辑调用 `createOrderWithActivation()`
|
|
||||||
- [x] 9.4 处理 `wallet` 场景:获取操作者店铺 ID,判断资源是否属于操作者
|
|
||||||
- [x] 9.5 如果资源属于操作者(自购):设置 `buyer = operator`、`purchase_role = "self_purchase"`、`is_purchase_on_behalf = false`,调用 `getCostPrice()` 获取成本价,`total_amount = actual_paid_amount = 操作者成本价`
|
|
||||||
- [x] 9.6 如果资源不属于操作者(代购):设置 `buyer = 资源所属者`、`operator = 操作者`、`purchase_role = "purchase_for_subordinate"`、`is_purchase_on_behalf = true`,分别调用 `getCostPrice()` 获取买家和操作者成本价,`total_amount = 买家成本价`、`actual_paid_amount = 操作者成本价`
|
|
||||||
- [x] 9.7 `wallet` 场景调用 `createOrderWithWalletPayment()` 而不是 `orderStore.Create()`
|
|
||||||
- [x] 9.8 运行 `go build ./...` 验证编译通过
|
|
||||||
|
|
||||||
## 10. Service 层:订单响应构建方法
|
## 10. 集成测试 - 后台订单创建 API(跳过:需人工手动验证)
|
||||||
|
|
||||||
- [x] 10.1 在 `internal/service/order/service.go` 的 `buildOrderResponse()` 方法中添加新字段映射:`OperatorID`、`OperatorType`、`ActualPaidAmount`、`PurchaseRole`
|
- [x] ~~10.1-10.7~~ 跳过:手动测试由用户自行完成
|
||||||
- [x] 10.2 添加 `OperatorName` 字段逻辑:如果 `operator_type = "agent"` 且 `operator_id` 不为空,查询 `Shop` 表获取店铺名称
|
|
||||||
- [x] 10.3 添加 `IsPurchasedByParent` 派生字段:`purchase_role == "purchased_by_parent"`
|
|
||||||
- [x] 10.4 添加 `PurchaseRemark` 派生字段:根据 `purchase_role` 和 `operator_name` 生成备注文本(如"由上级代理【XX】购买"、"由平台代购")
|
|
||||||
- [x] 10.5 运行 `go build ./...` 验证编译通过
|
|
||||||
|
|
||||||
## 11. Handler 层:权限检查调整
|
## 11. 集成测试 - H5 端订单创建 API(跳过:需人工手动验证)
|
||||||
|
|
||||||
- [x] 11.1 在 `internal/handler/admin/order.go` 的 `Create()` 方法中,修改 `wallet` 支付方式的权限检查,允许代理、平台、超管使用
|
- [x] ~~11.1-11.5~~ 跳过:手动测试由用户自行完成
|
||||||
- [x] 11.2 保持 `offline` 支付方式只允许平台和超管使用的限制
|
|
||||||
- [x] 11.3 运行 `go build ./...` 验证编译通过
|
|
||||||
|
|
||||||
## 12. Handler 层:订单查询参数传递
|
## 12. 钱包余额和流水验证(跳过:需人工手动验证)
|
||||||
|
|
||||||
- [x] 12.1 在 `internal/handler/admin/order.go` 的 `List()` 方法中,从查询参数解析 `purchase_role`
|
- [x] ~~12.1-12.5~~ 跳过:手动测试由用户自行完成
|
||||||
- [x] 12.2 将 `purchase_role` 传递给 Service 层的 `List()` 方法
|
|
||||||
- [x] 12.3 运行 `go build ./...` 验证编译通过
|
|
||||||
|
|
||||||
## 13. 文档生成器更新(OpenAPI)
|
## 13. 幂等性和并发测试(跳过:需人工手动验证)
|
||||||
|
|
||||||
- [x] 13.1 确认 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 中的 `Handlers` 结构体已包含 `Order` Handler(如果不存在则添加)
|
- [x] ~~13.1-13.4~~ 跳过:手动测试由用户自行完成
|
||||||
- [x] 13.2 运行 `go run cmd/gendocs/main.go` 生成 OpenAPI 文档
|
|
||||||
- [x] 13.3 检查生成的文档中订单创建和列表接口的请求/响应字段是否包含新字段
|
|
||||||
|
|
||||||
## 14. 集成测试:代理自购场景
|
## 14. 错误日志和监控验证(跳过:需人工手动验证)
|
||||||
|
|
||||||
- [ ] 14.1 使用 PostgreSQL MCP 工具创建测试数据:创建代理账号、代理钱包(余额 10000 分)、IoT 卡(shop_id = 代理店铺 ID)、套餐分配配置(成本价 8000 分)
|
- [x] ~~14.1-14.3~~ 跳过:手动测试由用户自行完成
|
||||||
- [ ] 14.2 使用 Postman/curl 调用后台订单创建 API,代理账号创建订单,支付方式 wallet,选择自己的卡和套餐
|
|
||||||
- [ ] 14.3 验证响应:`payment_status` = 2,`operator_id` = 代理店铺 ID,`buyer_id` = 代理店铺 ID,`purchase_role` = "self_purchase",`total_amount` = 8000,`actual_paid_amount` = 8000
|
|
||||||
- [ ] 14.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确
|
|
||||||
- [ ] 14.5 使用 PostgreSQL MCP 查询钱包表,验证余额扣减:`balance` = 2000(10000 - 8000)
|
|
||||||
- [ ] 14.6 使用 PostgreSQL MCP 查询钱包流水表,验证流水记录:`transaction_subtype` = "self_purchase",`amount` = -8000,`remark` = "购买套餐"
|
|
||||||
- [ ] 14.7 使用 PostgreSQL MCP 查询套餐使用表(`tb_package_usage`),验证套餐已激活:`status` = 1
|
|
||||||
|
|
||||||
## 15. 集成测试:代理代购场景
|
## 15. 代码质量检查
|
||||||
|
|
||||||
- [ ] 15.1 使用 PostgreSQL MCP 工具创建测试数据:一级代理(成本价 8000)、二级代理(成本价 10000,parent_shop_id = 一级代理)、一级代理钱包(余额 10000)、IoT 卡(shop_id = 二级代理店铺 ID)、套餐分配配置
|
- [x] 15.1 运行 `gofmt -s -w .` 格式化代码
|
||||||
- [ ] 15.2 使用 Postman/curl 调用后台订单创建 API,一级代理账号创建订单,支付方式 wallet,选择二级代理的卡和套餐
|
- [x] 15.2 运行 `go vet ./...` 检查代码问题
|
||||||
- [ ] 15.3 验证响应:`payment_status` = 2,`operator_id` = 一级代理店铺 ID,`buyer_id` = 二级代理店铺 ID,`purchase_role` = "purchase_for_subordinate",`total_amount` = 10000,`actual_paid_amount` = 8000
|
- [x] 15.3 运行 `go build ./...` 确认全部编译通过
|
||||||
- [ ] 15.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确
|
- [x] 15.4 检查所有新增代码的中文注释:确认导出函数有文档注释,复杂逻辑有实现注释
|
||||||
- [ ] 15.5 使用 PostgreSQL MCP 查询一级代理钱包,验证余额扣减:`balance` = 2000(10000 - 8000)
|
|
||||||
- [ ] 15.6 使用 PostgreSQL MCP 查询钱包流水表,验证流水记录:`transaction_subtype` = "purchase_for_subordinate",`amount` = -8000,`related_shop_id` = 二级代理店铺 ID,`remark` 包含二级代理店铺名称
|
|
||||||
- [ ] 15.7 使用 PostgreSQL MCP 查询套餐使用表,验证套餐已激活
|
|
||||||
- [ ] 15.8 使用 PostgreSQL MCP 查询佣金表,验证未产生佣金记录(代理代购不产生佣金)
|
|
||||||
|
|
||||||
## 16. 集成测试:平台代购场景(回归测试)
|
## 16. 文档更新
|
||||||
|
|
||||||
- [ ] 16.1 使用 PostgreSQL MCP 工具创建测试数据:代理、IoT 卡(shop_id = 代理店铺 ID)、套餐分配配置(成本价 10000)
|
- [x] 16.1 更新功能总结文档:补充后台订单 API 的 Breaking Change 说明(payment_method 必填、仅允许 wallet/offline)
|
||||||
- [ ] 16.2 使用 Postman/curl 调用后台订单创建 API,平台账号创建订单,支付方式 offline,选择代理的卡和套餐
|
|
||||||
- [ ] 16.3 验证响应:`payment_status` = 2,`operator_id` = NULL,`operator_type` = "platform",`buyer_id` = 代理店铺 ID,`purchase_role` = "purchased_by_platform",`total_amount` = 10000,`actual_paid_amount` = NULL
|
|
||||||
- [ ] 16.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确
|
|
||||||
- [ ] 16.5 使用 PostgreSQL MCP 查询套餐使用表,验证套餐已激活
|
|
||||||
- [ ] 16.6 验证平台代购逻辑未被破坏(不扣款、立即激活、产生佣金)
|
|
||||||
|
|
||||||
## 17. 集成测试:订单查询场景
|
## 17. 清理和优化(部署前)
|
||||||
|
|
||||||
- [ ] 17.1 使用 PostgreSQL MCP 工具创建测试数据:一级代理、二级代理、多个订单(自购、代购、被代购)
|
- [x] 17.1 检查是否有未使用的导入或变量(使用 IDE 或 `go vet`)
|
||||||
- [ ] 17.2 使用 Postman/curl 调用后台订单列表 API,一级代理账号查询订单列表(不指定 purchase_role)
|
- [x] 17.2 保留 `CreateLegacy()` 方法作为回滚方案,已有 Deprecated 标记
|
||||||
- [ ] 17.3 验证响应包含:buyer_id = 一级代理的订单 + operator_id = 一级代理的订单
|
- [x] 17.3 确认所有 TODO 注释已处理或转为 issue
|
||||||
- [ ] 17.4 使用 Postman/curl 调用后台订单列表 API,一级代理账号查询订单列表,指定 `purchase_role=self_purchase`
|
- [x] ~~17.4~~ 跳过:项目禁止自动化测试
|
||||||
- [ ] 17.5 验证响应只包含自购订单
|
|
||||||
- [ ] 17.6 使用 Postman/curl 调用后台订单列表 API,一级代理账号查询订单列表,指定 `purchase_role=purchase_for_subordinate`
|
|
||||||
- [ ] 17.7 验证响应只包含为下级代理购买的订单
|
|
||||||
|
|
||||||
## 18. 集成测试:边界场景
|
## 18. 提交和归档
|
||||||
|
|
||||||
- [ ] 18.1 测试钱包余额不足:代理钱包余额 5000,创建订单金额 8000,验证返回错误"余额不足"
|
- [ ] 18.1 使用 `git add` 暂存所有修改文件
|
||||||
- [ ] 18.2 测试并发扣款:模拟两个请求同时为同一钱包扣款,验证乐观锁生效,只有一个请求成功
|
- [ ] 18.2 使用 `/commit` 创建 Git commit,提交消息:"修复代理钱包订单创建逻辑漏洞"
|
||||||
- [ ] 18.3 测试幂等性:同一买家对同一载体的同一套餐组合短时间内重复创建订单,验证返回相同订单 ID,不重复扣款
|
- [ ] 18.3 使用 `/opsx:verify` 验证实现与规格一致
|
||||||
- [ ] 18.4 测试 H5 端 wallet 订单:使用 H5 端 API 创建 wallet 订单,验证订单状态为待支付(`payment_status` = 1),不影响后台逻辑
|
- [ ] 18.4 使用 `/opsx:archive` 归档变更,同步 delta specs 到主规格文档
|
||||||
|
|
||||||
## 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 准备监控指标:订单创建成功率、钱包扣款成功率、错误日志(余额不足、并发冲突)
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-02-28
|
||||||
677
openspec/changes/implement-order-expiration/design.md
Normal file
677
openspec/changes/implement-order-expiration/design.md
Normal 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 分布式执行,避免重复执行
|
||||||
|
- 持久化和可靠性:任务存储在 Redis,Worker 重启不丢失任务
|
||||||
|
- 监控和管理:通过 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 Scheduler(cmd/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. **可靠性提升**: 任务持久化在 Redis,Worker 重启后自动恢复
|
||||||
|
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 端点和业务逻辑
|
||||||
74
openspec/changes/implement-order-expiration/proposal.md
Normal file
74
openspec/changes/implement-order-expiration/proposal.md
Normal 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`(需手动处理或忽略)
|
||||||
@@ -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** 系统自动取消订单,无需钱包解冻操作
|
||||||
@@ -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,不影响现有业务
|
||||||
@@ -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** 事务回滚,订单状态不变,返回错误信息"订单取消失败"
|
||||||
184
openspec/changes/implement-order-expiration/tasks.md
Normal file
184
openspec/changes/implement-order-expiration/tasks.md
Normal 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` 已更新
|
||||||
248
openspec/specs/admin-order-creation/spec.md
Normal file
248
openspec/specs/admin-order-creation/spec.md
Normal 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}`
|
||||||
|
- TTL:3 分钟
|
||||||
|
- 分布式锁:`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 端的差异,防止误用
|
||||||
@@ -107,45 +107,80 @@
|
|||||||
#### Scenario: 余额扣减后套餐激活失败
|
#### Scenario: 余额扣减后套餐激活失败
|
||||||
- **WHEN** 余额扣减成功但套餐激活失败
|
- **WHEN** 余额扣减成功但套餐激活失败
|
||||||
- **THEN** 事务回滚,余额恢复,订单状态不变
|
- **THEN** 事务回滚,余额恢复,订单状态不变
|
||||||
## ADDED Requirements
|
|
||||||
|
---
|
||||||
|
|
||||||
### Requirement: 后台钱包一步支付
|
### Requirement: 后台钱包一步支付
|
||||||
|
|
||||||
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。
|
系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。后台订单创建使用独立的 Service 方法(`CreateAdminOrder()`),与 H5 端的 `CreateH5Order()` 方法隔离,避免逻辑混淆。
|
||||||
|
|
||||||
|
**后台钱包支付流程**(一步到位):
|
||||||
|
1. 检查钱包余额是否充足(事务外快速失败)
|
||||||
|
2. 在事务中:扣减钱包余额 → 创建已支付订单(`payment_status` = 2)→ 激活套餐
|
||||||
|
3. 返回已支付的订单信息
|
||||||
|
|
||||||
|
**与 H5 端的区别**:
|
||||||
|
- 后台:立即扣款,订单创建后即为已支付状态(`payment_status` = 2)
|
||||||
|
- H5 端:冻结余额,创建待支付订单(`payment_status` = 1),需用户调用支付接口
|
||||||
|
|
||||||
#### Scenario: 后台订单创建时钱包支付
|
#### Scenario: 后台订单创建时钱包支付
|
||||||
|
|
||||||
- **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额充足
|
- **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额充足
|
||||||
- **THEN** 系统创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2),激活套餐
|
- **THEN** 系统调用 `CreateAdminOrder()` 方法,创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2),激活套餐
|
||||||
|
|
||||||
#### Scenario: 后台钱包支付余额不足
|
#### Scenario: 后台钱包支付余额不足
|
||||||
|
|
||||||
- **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额不足
|
- **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额不足
|
||||||
- **THEN** 系统返回错误"余额不足",订单创建失败
|
- **THEN** 系统调用 `CreateAdminOrder()` 方法,在事务外检查余额,返回错误"余额不足",订单创建失败
|
||||||
|
|
||||||
#### Scenario: 后台钱包支付订单响应
|
#### Scenario: 后台钱包支付订单响应
|
||||||
|
|
||||||
- **WHEN** 后台钱包支付订单创建成功
|
- **WHEN** 后台钱包支付订单创建成功
|
||||||
- **THEN** API 响应包含已支付的订单信息,`payment_status` = 2,`payment_method` = "wallet",`paid_at` 为当前时间
|
- **THEN** API 响应包含已支付的订单信息,`payment_status` = 2,`payment_method` = "wallet",`paid_at` 为当前时间
|
||||||
|
|
||||||
#### Scenario: 后台钱包支付不创建待支付订单
|
#### Scenario: 后台钱包支付不创建待支付订单
|
||||||
|
|
||||||
- **WHEN** 代理在后台创建 wallet 订单
|
- **WHEN** 代理在后台创建 wallet 订单
|
||||||
- **THEN** 系统不创建待支付订单(`payment_status` != 1),直接完成支付
|
- **THEN** 系统不创建待支付订单(`payment_status` != 1),直接完成支付和套餐激活
|
||||||
|
|
||||||
|
#### Scenario: 后台钱包支付使用独立方法
|
||||||
|
|
||||||
|
- **WHEN** 代理在后台创建 wallet 订单
|
||||||
|
- **THEN** Handler 层调用 `OrderService.CreateAdminOrder()` 方法,不调用通用的 `Create()` 或 `CreateH5Order()` 方法
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Requirement: H5 钱包两步支付保持不变
|
### Requirement: H5 钱包两步支付保持不变
|
||||||
|
|
||||||
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。
|
系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。H5 端订单创建使用独立的 Service 方法(`CreateH5Order()`),与后台的 `CreateAdminOrder()` 方法隔离。
|
||||||
|
|
||||||
|
**H5 钱包支付流程**(两步流程):
|
||||||
|
1. 创建订单:冻结钱包余额 → 创建待支付订单(`payment_status` = 1)
|
||||||
|
2. 用户调用支付接口:扣减钱包余额 → 更新订单状态为已支付 → 激活套餐
|
||||||
|
|
||||||
|
**与后台的区别**:
|
||||||
|
- H5 端:创建待支付订单,用户需调用支付接口完成支付
|
||||||
|
- 后台:立即扣款,订单创建后即为已支付状态
|
||||||
|
|
||||||
#### Scenario: H5 创建待支付订单
|
#### Scenario: H5 创建待支付订单
|
||||||
|
|
||||||
- **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet
|
- **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet
|
||||||
- **THEN** 系统创建订单,`payment_status` = 1(待支付),不扣减钱包余额
|
- **THEN** 系统调用 `CreateH5Order()` 方法,创建订单,`payment_status` = 1(待支付),冻结钱包余额,不立即扣款
|
||||||
|
|
||||||
#### Scenario: H5 调用 WalletPay 接口支付
|
#### Scenario: H5 调用 WalletPay 接口支付
|
||||||
|
|
||||||
- **WHEN** 个人客户调用 WalletPay 接口支付待支付订单
|
- **WHEN** 个人客户调用 WalletPay 接口支付待支付订单
|
||||||
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐
|
- **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐
|
||||||
|
|
||||||
#### Scenario: H5 和后台钱包支付流程独立
|
#### Scenario: H5 和后台钱包支付流程独立
|
||||||
|
|
||||||
- **WHEN** H5 端创建 wallet 订单
|
- **WHEN** H5 端创建 wallet 订单
|
||||||
- **THEN** 不影响后台 wallet 订单的一步支付逻辑
|
- **THEN** 系统调用 `CreateH5Order()` 方法,不影响后台 wallet 订单的一步支付逻辑
|
||||||
|
|
||||||
|
#### Scenario: H5 钱包支付使用独立方法
|
||||||
|
|
||||||
|
- **WHEN** 个人客户在 H5 端创建 wallet 订单
|
||||||
|
- **THEN** Handler 层调用 `OrderService.CreateH5Order()` 方法,不调用 `CreateAdminOrder()` 方法
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -253,16 +288,38 @@
|
|||||||
|
|
||||||
### Requirement: 钱包支付与第三方支付的区别
|
### Requirement: 钱包支付与第三方支付的区别
|
||||||
|
|
||||||
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。
|
系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。后台订单创建 MUST 在 Handler 层强制验证支付方式,拒绝 `wechat` 和 `alipay` 支付方式。
|
||||||
|
|
||||||
#### Scenario: 后台不支持第三方支付
|
**后台支付方式限制**:
|
||||||
- **WHEN** 代理在后台创建订单时选择 wechat 或 alipay
|
- 允许:`wallet`、`offline`
|
||||||
- **THEN** 系统返回错误"后台只支持 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 支持第三方支付
|
#### Scenario: H5 支持第三方支付
|
||||||
|
|
||||||
- **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay
|
- **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay
|
||||||
- **THEN** 系统创建待支付订单,返回支付参数(prepay_id 或 h5_url)
|
- **THEN** 系统调用 `CreateH5Order()` 方法,创建待支付订单,返回支付参数(prepay_id 或 h5_url)
|
||||||
|
|
||||||
#### Scenario: 钱包支付不需要支付参数
|
#### Scenario: 钱包支付不需要支付参数
|
||||||
|
|
||||||
- **WHEN** 后台钱包支付订单创建成功
|
- **WHEN** 后台钱包支付订单创建成功
|
||||||
- **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数
|
- **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数
|
||||||
|
|
||||||
|
#### Scenario: 后台使用独立的 DTO
|
||||||
|
|
||||||
|
- **WHEN** 后台创建订单
|
||||||
|
- **THEN** Handler 层使用 `CreateAdminOrderRequest` DTO(仅允许 wallet/offline),H5 端使用 `CreateOrderRequest` DTO(允许 wallet/wechat/alipay)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
|
|||||||
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(nil),
|
ShopPackageAllocation: admin.NewShopPackageAllocationHandler(nil),
|
||||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil),
|
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil),
|
||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil),
|
||||||
AdminOrder: admin.NewOrderHandler(nil),
|
AdminOrder: admin.NewOrderHandler(nil, nil),
|
||||||
H5Order: h5.NewOrderHandler(nil),
|
H5Order: h5.NewOrderHandler(nil),
|
||||||
H5Recharge: h5.NewRechargeHandler(nil),
|
H5Recharge: h5.NewRechargeHandler(nil),
|
||||||
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil),
|
PaymentCallback: callback.NewPaymentHandler(nil, nil, nil),
|
||||||
|
|||||||
Reference in New Issue
Block a user