From 5bb0ff0ddfa5acd3629c474b1c2c2d64098ad9e9 Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 28 Feb 2026 16:31:31 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E9=92=B1=E5=8C=85=E8=AE=A2=E5=8D=95=E5=88=9B=E5=BB=BA=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E6=8B=86=E5=88=86=E5=90=8E=E5=8F=B0/H5?= =?UTF-8?q?=E7=AB=AF=E4=B8=8B=E5=8D=95=E6=96=B9=E6=B3=95=E5=B9=B6=E5=BD=92?= =?UTF-8?q?=E6=A1=A3=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 拆分订单创建为 CreateAdminOrder(后台一步支付)和 CreateH5Order(H5 两步支付) - 新增 CreateAdminOrderRequest DTO,后台仅允许 wallet/offline 支付方式 - 同步 delta specs 到主规格(order-payment 更新 + admin-order-creation 新增) - 归档 fix-agent-wallet-order-creation 变更 - 新增 implement-order-expiration 变更提案 --- .../功能总结.md | 19 +- internal/bootstrap/handlers.go | 2 +- internal/handler/admin/order.go | 25 +- internal/handler/h5/order.go | 2 +- internal/model/dto/order_dto.go | 9 + internal/service/order/service.go | 484 +++++++++- .../design.md | 858 +++++++----------- .../proposal.md | 116 ++- .../specs/admin-order-creation/spec.md | 248 +++++ .../specs/agent-order-role-tracking/spec.md | 167 ---- .../specs/order-payment/spec.md | 180 ++-- .../specs/purchase-on-behalf/spec.md | 131 --- .../tasks.md | 191 ++-- .../implement-order-expiration/.openspec.yaml | 2 + .../implement-order-expiration/design.md | 677 ++++++++++++++ .../implement-order-expiration/proposal.md | 74 ++ .../specs/iot-order/spec.md | 54 ++ .../specs/order-expiration/spec.md | 237 +++++ .../specs/order-payment/spec.md | 67 ++ .../implement-order-expiration/tasks.md | 184 ++++ openspec/specs/admin-order-creation/spec.md | 248 +++++ openspec/specs/order-payment/spec.md | 83 +- pkg/openapi/handlers.go | 2 +- 23 files changed, 2922 insertions(+), 1138 deletions(-) create mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/admin-order-creation/spec.md delete mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/agent-order-role-tracking/spec.md delete mode 100644 openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/purchase-on-behalf/spec.md create mode 100644 openspec/changes/implement-order-expiration/.openspec.yaml create mode 100644 openspec/changes/implement-order-expiration/design.md create mode 100644 openspec/changes/implement-order-expiration/proposal.md create mode 100644 openspec/changes/implement-order-expiration/specs/iot-order/spec.md create mode 100644 openspec/changes/implement-order-expiration/specs/order-expiration/spec.md create mode 100644 openspec/changes/implement-order-expiration/specs/order-payment/spec.md create mode 100644 openspec/changes/implement-order-expiration/tasks.md create mode 100644 openspec/specs/admin-order-creation/spec.md diff --git a/docs/fix-agent-wallet-order-creation/功能总结.md b/docs/fix-agent-wallet-order-creation/功能总结.md index 6669c31..75b0210 100644 --- a/docs/fix-agent-wallet-order-creation/功能总结.md +++ b/docs/fix-agent-wallet-order-creation/功能总结.md @@ -157,13 +157,20 @@ result := tx.Model(&model.AgentWallet{}). ## API 变更 -### 后台订单创建 API +### 后台订单创建 API(❗ Breaking Change) **端点**:`POST /api/admin/orders` +**请求参数变更**: + +| 字段 | 变更前 | 变更后 | 说明 | +|------|--------|--------|------| +| `payment_method` | 可选,任意值 | **必填**,仅允许 `wallet` 或 `offline` | 不传或传其他值均返回 1001 错误 | + **行为变更**: -- 代理使用 wallet 支付时,订单直接完成(`payment_status = 2`),无需后续支付 -- 平台使用 offline 支付逻辑保持不变 +- `wallet` 支付:订单直接完成(`payment_status = 2`),无需后续支付接口 +- `offline` 支付:逻辑保持不变 +- 传入 `wechat`/`alipay` → 返回 `{"code": 1001, "msg": "请求参数解析失败"}` **响应新增字段**: ```json @@ -178,7 +185,11 @@ result := tx.Model(&model.AgentWallet{}). } ``` ---- +### H5 端订单创建 API(无变更) + +**端点**:`POST /api/h5/orders` + +行为完全不变,仍支持 `wallet`/`wechat`/`alipay`,仍创建待支付订单。 ### 订单列表 API diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index f8b61a6..bb34192 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -46,7 +46,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { ShopPackageAllocation: admin.NewShopPackageAllocationHandler(svc.ShopPackageAllocation), ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation), ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing), - AdminOrder: admin.NewOrderHandler(svc.Order), + AdminOrder: admin.NewOrderHandler(svc.Order, validate), H5Order: h5.NewOrderHandler(svc.Order), H5Recharge: h5.NewRechargeHandler(svc.Recharge), PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, deps.WechatPayment), diff --git a/internal/handler/admin/order.go b/internal/handler/admin/order.go index e9e0b0c..01dc512 100644 --- a/internal/handler/admin/order.go +++ b/internal/handler/admin/order.go @@ -3,6 +3,7 @@ package admin import ( "strconv" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/break/junhong_cmp_fiber/internal/model" @@ -14,24 +15,35 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/response" ) +// OrderHandler 后台订单处理器 type OrderHandler struct { - service *orderService.Service + service *orderService.Service + validator *validator.Validate } -func NewOrderHandler(service *orderService.Service) *OrderHandler { - return &OrderHandler{service: service} +// NewOrderHandler 创建后台订单处理器 +func NewOrderHandler(service *orderService.Service, validator *validator.Validate) *OrderHandler { + return &OrderHandler{service: service, validator: validator} } +// Create 创建后台订单 +// POST /api/admin/orders func (h *OrderHandler) Create(c *fiber.Ctx) error { - var req dto.CreateOrderRequest + var req dto.CreateAdminOrderRequest if err := c.BodyParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } + // 验证请求参数(payment_method 必须为 wallet 或 offline) + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + ctx := c.UserContext() userType := middleware.GetUserTypeFromContext(ctx) shopID := middleware.GetShopIDFromContext(ctx) + // 线下支付仅限平台用户 if req.PaymentMethod == model.PaymentMethodOffline { if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付") @@ -40,6 +52,9 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error { if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin { return errors.New(errors.CodeForbidden, "无权创建订单") } + } else { + // 防御性分支:DTO 验证已限制,此处兜底 + return errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付和线下支付") } buyerType := "" @@ -49,7 +64,7 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error { buyerID = shopID } - order, err := h.service.Create(ctx, &req, buyerType, buyerID) + order, err := h.service.CreateAdminOrder(ctx, &req, buyerType, buyerID) if err != nil { return err } diff --git a/internal/handler/h5/order.go b/internal/handler/h5/order.go index 25c0baf..9752c78 100644 --- a/internal/handler/h5/order.go +++ b/internal/handler/h5/order.go @@ -51,7 +51,7 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error { return errors.New(errors.CodeForbidden, "不支持的用户类型") } - order, err := h.service.Create(ctx, &req, buyerType, buyerID) + order, err := h.service.CreateH5Order(ctx, &req, buyerType, buyerID) if err != nil { return err } diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index 9ebe425..c61553a 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -3,6 +3,15 @@ package dto import "time" type CreateOrderRequest struct { + OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"` + IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"` + DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"` + PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)"` +} + +// CreateAdminOrderRequest 后台订单创建请求(仅允许 wallet/offline) +type CreateAdminOrderRequest struct { OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"` IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"` DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"` diff --git a/internal/service/order/service.go b/internal/service/order/service.go index b2af5ca..94ed960 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -85,7 +85,9 @@ func New( } } -func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) { +// CreateLegacy 创建订单(已废弃) +// Deprecated: 使用 CreateAdminOrder 或 CreateH5Order 替代。保留用于回滚。 +func (s *Service) CreateLegacy(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) { var validationResult *purchase_validation.PurchaseValidationResult var err error @@ -317,6 +319,475 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer } } +// CreateAdminOrder 后台订单创建(仅支持 wallet/offline,立即扣款或激活) +// 与 CreateH5Order 的核心区别:后台订单不创建待支付状态,wallet 立即扣款,offline 立即激活 +// POST /api/admin/orders +func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) { + var validationResult *purchase_validation.PurchaseValidationResult + var err error + + if req.OrderType == model.OrderTypeSingleCard { + if req.IotCardID == nil { + return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID") + } + validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs) + } else if req.OrderType == model.OrderTypeDevice { + if req.DeviceID == nil { + return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID") + } + validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs) + } else { + return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型") + } + + if err != nil { + return nil, err + } + + // 下单阶段校验混买限制:禁止同一订单同时包含正式套餐和加油包 + if err := validatePackageTypeMixFromPackages(validationResult.Packages); err != nil { + return nil, err + } + + // 幂等性检查:防止同一买家对同一载体短时间内重复下单 + carrierType, carrierID := resolveAdminCarrierInfo(req) + existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs) + if err != nil { + return nil, err + } + if existingOrderID > 0 { + return s.Get(ctx, existingOrderID) + } + // 获取到分布式锁后,确保无论成功还是失败都释放 + lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID) + defer s.redis.Del(ctx, lockKey) + + forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult) + if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount { + return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求") + } + + userID := middleware.GetUserIDFromContext(ctx) + + // 提取资源所属店铺ID + var resourceShopID *uint + var seriesID *uint + if validationResult.Card != nil { + resourceShopID = validationResult.Card.ShopID + seriesID = validationResult.Card.SeriesID + } else if validationResult.Device != nil { + resourceShopID = validationResult.Device.ShopID + seriesID = validationResult.Device.SeriesID + } + + // 初始化订单字段 + orderBuyerType := buyerType + orderBuyerID := buyerID + totalAmount := validationResult.TotalPrice + paymentMethod := req.PaymentMethod + paymentStatus := model.PaymentStatusPaid + var paidAt *time.Time + now := time.Now() + isPurchaseOnBehalf := false + var operatorID *uint + operatorType := "" + var actualPaidAmount *int64 + purchaseRole := "" + var sellerShopID *uint = resourceShopID + var sellerCostPrice int64 + + // 根据支付方式分别处理 + if req.PaymentMethod == model.PaymentMethodOffline { + // ==== 场景 1:平台代购(offline)==== + purchaseBuyerID, buyerCostPrice, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult) + if err != nil { + return nil, err + } + orderBuyerType = model.BuyerTypeAgent + orderBuyerID = purchaseBuyerID + totalAmount = buyerCostPrice + paymentMethod = model.PaymentMethodOffline + paymentStatus = model.PaymentStatusPaid + paidAt = purchasePaidAt + isPurchaseOnBehalf = true + sellerCostPrice = buyerCostPrice + + // 设置操作者信息(平台代购) + operatorID = nil + operatorType = constants.OwnerTypePlatform + purchaseRole = model.PurchaseRolePurchasedByPlatform + actualPaidAmount = nil + + } else if req.PaymentMethod == model.PaymentMethodWallet { + // ==== 场景 2:代理钱包支付(wallet)==== + // 只有代理账号可以使用钱包支付 + if buyerType != model.BuyerTypeAgent { + return nil, errors.New(errors.CodeInvalidParam, "只有代理账号可以使用钱包支付") + } + operatorShopID := buyerID + + // 判断资源是否属于操作者 + if resourceShopID == nil { + return nil, errors.New(errors.CodeInternalError, "资源店铺ID为空") + } + + // 获取第一个套餐ID用于查询成本价 + if len(validationResult.Packages) == 0 { + return nil, errors.New(errors.CodeInternalError, "套餐列表为空") + } + firstPackageID := validationResult.Packages[0].ID + + if *resourceShopID == operatorShopID { + // ==== 子场景 2.1:代理自购 ==== + costPrice, err := s.getCostPrice(ctx, operatorShopID, firstPackageID) + if err != nil { + return nil, err + } + + orderBuyerType = model.BuyerTypeAgent + orderBuyerID = operatorShopID + totalAmount = costPrice + paymentMethod = model.PaymentMethodWallet + paymentStatus = model.PaymentStatusPaid + paidAt = &now + isPurchaseOnBehalf = false + + operatorID = &operatorShopID + operatorType = "agent" + actualPaidAmountVal := costPrice + actualPaidAmount = &actualPaidAmountVal + purchaseRole = model.PurchaseRoleSelfPurchase + sellerCostPrice = costPrice + + } else { + // ==== 子场景 2.2:代理代购(给下级购买)==== + // 获取买家成本价 + buyerCostPrice, err := s.getCostPrice(ctx, *resourceShopID, firstPackageID) + if err != nil { + return nil, err + } + + // 获取操作者成本价 + operatorCostPrice, err := s.getCostPrice(ctx, operatorShopID, firstPackageID) + if err != nil { + return nil, err + } + + orderBuyerType = model.BuyerTypeAgent + orderBuyerID = *resourceShopID + totalAmount = buyerCostPrice + paymentMethod = model.PaymentMethodWallet + paymentStatus = model.PaymentStatusPaid + paidAt = &now + isPurchaseOnBehalf = true + + operatorID = &operatorShopID + operatorType = "agent" + actualPaidAmount = &operatorCostPrice + purchaseRole = model.PurchaseRolePurchaseForSubordinate + sellerCostPrice = buyerCostPrice + } + } else { + // 兜底检查:后台不支持其他支付方式(DTO 验证已拒绝,此为防御性编程) + return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付") + } + + order := &model.Order{ + BaseModel: model.BaseModel{ + Creator: userID, + Updater: userID, + }, + OrderNo: s.orderStore.GenerateOrderNo(), + OrderType: req.OrderType, + BuyerType: orderBuyerType, + BuyerID: orderBuyerID, + IotCardID: req.IotCardID, + DeviceID: req.DeviceID, + TotalAmount: totalAmount, + PaymentMethod: paymentMethod, + PaymentStatus: paymentStatus, + PaidAt: paidAt, + CommissionStatus: model.CommissionStatusPending, + CommissionConfigVersion: 0, + SeriesID: seriesID, + SellerShopID: sellerShopID, + SellerCostPrice: sellerCostPrice, + IsPurchaseOnBehalf: isPurchaseOnBehalf, + OperatorID: operatorID, + OperatorType: operatorType, + ActualPaidAmount: actualPaidAmount, + PurchaseRole: purchaseRole, + } + + items := s.buildOrderItems(userID, validationResult.Packages) + + idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs) + + // 根据支付方式选择创建订单的方式 + if req.PaymentMethod == model.PaymentMethodOffline { + // 平台代购:创建订单并立即激活套餐 + if err := s.createOrderWithActivation(ctx, order, items); err != nil { + return nil, err + } + s.enqueueCommissionCalculation(ctx, order.ID) + s.markOrderCreated(ctx, idempotencyKey, order.ID) + return s.buildOrderResponse(order, items), nil + + } else if req.PaymentMethod == model.PaymentMethodWallet { + // 钱包支付:创建订单、扣款、激活套餐(在事务中完成) + if operatorID == nil { + return nil, errors.New(errors.CodeInternalError, "钱包支付场景下 operatorID 不能为空") + } + operatorShopID := *operatorID + buyerShopID := orderBuyerID + + if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil { + return nil, err + } + s.markOrderCreated(ctx, idempotencyKey, order.ID) + return s.buildOrderResponse(order, items), nil + + } else { + // 不应该到这里(DTO 验证已拒绝其他支付方式) + return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付") + } +} + +// CreateH5Order H5 端订单创建(支持 wallet/wechat/alipay,支持待支付状态) +// 保留原 Create() 方法的完整逻辑,H5 端行为不变 +// POST /api/h5/orders +func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) { + var validationResult *purchase_validation.PurchaseValidationResult + var err error + + if req.OrderType == model.OrderTypeSingleCard { + if req.IotCardID == nil { + return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID") + } + validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs) + } else if req.OrderType == model.OrderTypeDevice { + if req.DeviceID == nil { + return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID") + } + validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs) + } else { + return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型") + } + + if err != nil { + return nil, err + } + + // 下单阶段校验混买限制:禁止同一订单同时包含正式套餐和加油包 + if err := validatePackageTypeMixFromPackages(validationResult.Packages); err != nil { + return nil, err + } + + // 幂等性检查:防止同一买家对同一载体短时间内重复下单 + carrierType, carrierID := resolveCarrierInfo(req) + existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs) + if err != nil { + return nil, err + } + if existingOrderID > 0 { + return s.Get(ctx, existingOrderID) + } + // 获取到分布式锁后,确保无论成功还是失败都释放 + lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID) + defer s.redis.Del(ctx, lockKey) + + forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult) + if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount { + return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求") + } + + userID := middleware.GetUserIDFromContext(ctx) + + // 提取资源所属店铺ID + var resourceShopID *uint + var seriesID *uint + if validationResult.Card != nil { + resourceShopID = validationResult.Card.ShopID + seriesID = validationResult.Card.SeriesID + } else if validationResult.Device != nil { + resourceShopID = validationResult.Device.ShopID + seriesID = validationResult.Device.SeriesID + } + + // 初始化订单字段 + orderBuyerType := buyerType + orderBuyerID := buyerID + totalAmount := validationResult.TotalPrice + paymentMethod := req.PaymentMethod + paymentStatus := model.PaymentStatusPending + var paidAt *time.Time + now := time.Now() + isPurchaseOnBehalf := false + var operatorID *uint + operatorType := "" + var actualPaidAmount *int64 + purchaseRole := "" + var sellerShopID *uint = resourceShopID + var sellerCostPrice int64 + + // 场景判断:offline(平台代购)、wallet(代理钱包支付)、其他(待支付) + if req.PaymentMethod == model.PaymentMethodOffline { + // ==== 场景 1:平台代购(offline)==== + purchaseBuyerID, buyerCostPrice, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult) + if err != nil { + return nil, err + } + orderBuyerType = model.BuyerTypeAgent + orderBuyerID = purchaseBuyerID + totalAmount = buyerCostPrice + paymentMethod = model.PaymentMethodOffline + paymentStatus = model.PaymentStatusPaid + paidAt = purchasePaidAt + isPurchaseOnBehalf = true + sellerCostPrice = buyerCostPrice + + // 设置操作者信息(平台代购) + operatorID = nil + operatorType = constants.OwnerTypePlatform + purchaseRole = model.PurchaseRolePurchasedByPlatform + actualPaidAmount = nil + + } else if req.PaymentMethod == model.PaymentMethodWallet { + // ==== 场景 2:代理钱包支付(wallet)==== + // 只有代理账号可以使用钱包支付 + if buyerType != model.BuyerTypeAgent { + return nil, errors.New(errors.CodeInvalidParam, "只有代理账号可以使用钱包支付") + } + operatorShopID := buyerID + + // 判断资源是否属于操作者 + if resourceShopID == nil { + return nil, errors.New(errors.CodeInternalError, "资源店铺ID为空") + } + + // 获取第一个套餐ID用于查询成本价 + if len(validationResult.Packages) == 0 { + return nil, errors.New(errors.CodeInternalError, "套餐列表为空") + } + firstPackageID := validationResult.Packages[0].ID + + if *resourceShopID == operatorShopID { + // ==== 子场景 2.1:代理自购 ==== + costPrice, err := s.getCostPrice(ctx, operatorShopID, firstPackageID) + if err != nil { + return nil, err + } + + orderBuyerType = model.BuyerTypeAgent + orderBuyerID = operatorShopID + totalAmount = costPrice + paymentMethod = model.PaymentMethodWallet + paymentStatus = model.PaymentStatusPaid + paidAt = &now + isPurchaseOnBehalf = false + + operatorID = &operatorShopID + operatorType = "agent" + actualPaidAmountVal := costPrice + actualPaidAmount = &actualPaidAmountVal + purchaseRole = model.PurchaseRoleSelfPurchase + sellerCostPrice = costPrice + + } else { + // ==== 子场景 2.2:代理代购(给下级购买)==== + // 获取买家成本价 + buyerCostPrice, err := s.getCostPrice(ctx, *resourceShopID, firstPackageID) + if err != nil { + return nil, err + } + + // 获取操作者成本价 + operatorCostPrice, err := s.getCostPrice(ctx, operatorShopID, firstPackageID) + if err != nil { + return nil, err + } + + orderBuyerType = model.BuyerTypeAgent + orderBuyerID = *resourceShopID + totalAmount = buyerCostPrice + paymentMethod = model.PaymentMethodWallet + paymentStatus = model.PaymentStatusPaid + paidAt = &now + isPurchaseOnBehalf = true + + operatorID = &operatorShopID + operatorType = "agent" + actualPaidAmount = &operatorCostPrice + purchaseRole = model.PurchaseRolePurchaseForSubordinate + sellerCostPrice = buyerCostPrice + } + } + + order := &model.Order{ + BaseModel: model.BaseModel{ + Creator: userID, + Updater: userID, + }, + OrderNo: s.orderStore.GenerateOrderNo(), + OrderType: req.OrderType, + BuyerType: orderBuyerType, + BuyerID: orderBuyerID, + IotCardID: req.IotCardID, + DeviceID: req.DeviceID, + TotalAmount: totalAmount, + PaymentMethod: paymentMethod, + PaymentStatus: paymentStatus, + PaidAt: paidAt, + CommissionStatus: model.CommissionStatusPending, + CommissionConfigVersion: 0, + SeriesID: seriesID, + SellerShopID: sellerShopID, + SellerCostPrice: sellerCostPrice, + IsPurchaseOnBehalf: isPurchaseOnBehalf, + OperatorID: operatorID, + OperatorType: operatorType, + ActualPaidAmount: actualPaidAmount, + PurchaseRole: purchaseRole, + } + + items := s.buildOrderItems(userID, validationResult.Packages) + + idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, req.OrderType, carrierType, carrierID, req.PackageIDs) + + // 根据支付方式选择创建订单的方式 + if req.PaymentMethod == model.PaymentMethodOffline { + // 平台代购:创建订单并立即激活套餐 + if err := s.createOrderWithActivation(ctx, order, items); err != nil { + return nil, err + } + s.enqueueCommissionCalculation(ctx, order.ID) + s.markOrderCreated(ctx, idempotencyKey, order.ID) + return s.buildOrderResponse(order, items), nil + + } else if req.PaymentMethod == model.PaymentMethodWallet { + // 钱包支付:创建订单、扣款、激活套餐(在事务中完成) + if operatorID == nil { + return nil, errors.New(errors.CodeInternalError, "钱包支付场景下 operatorID 不能为空") + } + operatorShopID := *operatorID + buyerShopID := orderBuyerID + + if err := s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID); err != nil { + return nil, err + } + s.markOrderCreated(ctx, idempotencyKey, order.ID) + return s.buildOrderResponse(order, items), nil + + } else { + // 其他支付方式:创建待支付订单(H5 端支持 wechat/alipay) + if err := s.orderStore.Create(ctx, order, items); err != nil { + return nil, err + } + s.markOrderCreated(ctx, idempotencyKey, order.ID) + return s.buildOrderResponse(order, items), nil + } +} + func (s *Service) resolvePurchaseOnBehalfInfo(ctx context.Context, result *purchase_validation.PurchaseValidationResult) (uint, int64, *time.Time, error) { var resourceShopID *uint var seriesID *uint @@ -1013,6 +1484,17 @@ func resolveCarrierInfo(req *dto.CreateOrderRequest) (carrierType string, carrie return "", 0 } +// resolveAdminCarrierInfo 从后台订单请求中提取载体类型和ID +func resolveAdminCarrierInfo(req *dto.CreateAdminOrderRequest) (carrierType string, carrierID uint) { + if req.OrderType == model.OrderTypeSingleCard && req.IotCardID != nil { + return "iot_card", *req.IotCardID + } + if req.OrderType == model.OrderTypeDevice && req.DeviceID != nil { + return "device", *req.DeviceID + } + return "", 0 +} + // buildOrderIdempotencyKey 生成订单创建的幂等性业务键 // 格式: {buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids} func buildOrderIdempotencyKey(buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) string { diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md index b39829d..51d605b 100644 --- a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/design.md @@ -1,617 +1,403 @@ ## Context -当前订单创建逻辑存在两条路径: -1. **平台代购(offline)**:平台为代理创建订单,使用 `offline` 支付方式,订单创建后立即标记已支付并激活套餐,不扣钱包 -2. **其他支付方式(wallet/wechat/alipay)**:创建待支付订单(`payment_status = pending`),后续调用支付接口完成支付 +### 当前问题分析 -问题在于:代理在后台使用 `wallet` 创建订单时,走的是第 2 条路径(创建待支付订单),但后台没有支付接口,导致订单无法完成。 +当前代理在后台创建订单时存在三层架构缺陷: -**实际业务场景**:代理帮客户购买套餐(代购),需要从自己钱包扣款并立即激活套餐,这是一步完成的操作,不应该创建待支付订单。 +**第一层:Handler 层缺少参数验证** +- `internal/handler/admin/order.go:27-29` 只调用了 `c.BodyParser(&req)`,没有调用 `middleware.ValidateStruct(&req)` +- 导致 DTO 中定义的 `validate:"required,oneof=wallet offline"` 规则完全失效 +- 用户可以传入任何 `payment_method` 值(如 `wechat`、`alipay`),绕过验证 -**现有代码分析**: -- `Service.Create()` 方法中,只有 `payment_method == "offline"` 才会调用 `createOrderWithActivation()`(立即完成) -- `wallet` 支付会直接调用 `orderStore.Create()`,创建待支付订单,不扣款不激活 -- 缺少"操作者"和"买家"的区分,无法追踪代购关系 +**第二层:Handler 层权限检查不完整** +- `internal/handler/admin/order.go:35-43` 只检查了 `offline` 和 `wallet` 两种支付方式的权限 +- `else` 分支没有任何检查,导致其他支付方式绕过权限校验 +- 前端如果没有传 `payment_method`(空字符串或未定义),也会进入 `else` 分支 + +**第三层:Service 层后台和 H5 端共用方法** +- `internal/service/order/service.go:88-318` 的 `Create()` 方法同时服务后台和 H5 端 +- `Create()` 方法的 `else` 分支(第 310-317 行)是为 H5 端在线支付(wechat/alipay)准备的,创建待支付订单 +- 后台误用这个分支会导致创建待支付订单,但后台没有支付界面,订单永远无法完成 + +### 现有代码结构 + +``` +admin/order.Create() ─┐ + ├─→ service.Create(buyerType, buyerID) ← 同一个方法 +h5/order.Create() ────┘ + ├─ if offline → 平台代购(激活) + ├─ else if wallet → 钱包支付(扣款+激活) + └─ else → 创建待支付订单 ← 后台误用这里 +``` + +### 业务约束 + +- **后台订单创建**:代理/平台账号使用,仅支持 wallet/offline 支付,立即扣款或激活,不允许待支付状态 +- **H5 端订单创建**:C 端用户使用,支持 wallet/wechat/alipay 支付,支持待支付状态(等待用户支付) + +--- ## Goals / Non-Goals **Goals:** -1. 支持代理在后台使用 wallet 一步完成订单(检查余额 → 扣款 → 激活套餐) -2. 区分订单中的"操作者"(谁下单)和"买家"(资源所属者),支持数据追溯和业务分析 -3. 正确处理三种代购场景的价格逻辑: - - 代理自购:订单金额 = 实际支付 = 自己成本价 - - 代理代购(给下级):订单金额 = 下级成本价,实际支付 = 自己成本价 - - 平台代购:订单金额 = 下级成本价,实际支付 = 0(不扣款) -4. 支持按订单角色筛选查询(自购、上级购买、平台购买、给下级购买) -5. 钱包流水记录支持场景区分和关联店铺追踪 -6. 佣金逻辑调整:代理代购不产生佣金(已赚差价) + +1. **修复 Handler 层参数验证**:确保 DTO 的 `validate` 规则生效 +2. **修复 Handler 层权限检查**:完整覆盖所有支付方式的权限校验 +3. **拆分 Service 方法**:后台和 H5 端使用独立的 Service 方法,避免逻辑混淆 +4. **后台订单一步到位**:wallet 支付立即扣款,offline 支付立即激活,不创建待支付订单 +5. **保持 H5 端行为不变**:不影响 H5 端订单创建流程 +6. **向后兼容**:保留回滚方案,避免破坏现有功能 **Non-Goals:** -- 不修改 H5 端的支付流程(H5 端仍然是两步:创建待支付订单 → 调用 WalletPay) -- 不修改平台代购(offline)的现有逻辑 -- 不支持代理在后台使用微信/支付宝支付(后台只支持 wallet 和 offline) -- 不涉及个人客户(C 端)的订单流程 + +1. 修改订单数据模型(无需新增字段) +2. 修改支付方式枚举(保持现有 wallet/wechat/alipay/offline) +3. 修改 H5 端订单创建逻辑(只拆分,不改逻辑) +4. 重构整个订单系统(仅针对性修复代理钱包订单问题) + +--- ## Decisions -### 决策 1:新增订单角色追踪字段 +### Decision 1: Service 方法拆分策略 -**决策**:在 `tb_order` 表新增 4 个字段: -- `operator_id` (INT, nullable):操作者 ID(店铺 ID) -- `operator_type` (VARCHAR, nullable):操作者类型(`platform` / `agent`) -- `actual_paid_amount` (BIGINT, nullable):实际支付金额(分) -- `purchase_role` (VARCHAR):订单角色枚举 +**选择**: 拆分 `OrderService.Create()` 为两个独立方法 -**理由**: -- 现有 `buyer_id` 字段只记录买家(资源所属者),无法区分"谁下单"和"谁买单" -- `operator_id` 记录操作者,支持追溯代购关系(如:平台为代理 A 代购,代理 A 为代理 B 代购) -- `actual_paid_amount` 记录实际扣款金额,与 `total_amount`(订单金额)可能不同(代理代购场景) -- `purchase_role` 枚举字段支持高效筛选,避免依赖文本备注 +**方案对比**: -**替代方案(已拒绝)**: -- ❌ 使用 `creator` 字段代替 `operator_id`:`creator` 是审计字段,语义不同,不应混用 -- ❌ 使用 `remark` 字段记录代购信息:文本字段无法高效索引和筛选 -- ❌ 创建单独的代购订单表:增加系统复杂度,查询需要 JOIN 多表 +| 方案 | 优点 | 缺点 | +|------|------|------| +| **方案 A:在现有 `Create()` 中添加 `isAdminContext` 参数** | 改动最小,无 breaking change | 逻辑更复杂,if-else 嵌套增加,难以维护 | +| **方案 B:拆分为 `CreateAdminOrder()` 和 `CreateH5Order()`** ✅ | 逻辑清晰,职责分离,易于测试和维护 | breaking change,需要修改 Handler 层调用 | +| **方案 C:保留 `Create()` 方法,内部调用不同的私有方法** | 对外 API 不变 | 隐藏了后台和 H5 的差异,仍然可能误用 | + +**选择方案 B** 的理由: +- 后台和 H5 端的订单创建逻辑差异巨大(后台立即扣款 vs H5 待支付),应该使用不同的方法 +- 明确的方法名(`CreateAdminOrder` vs `CreateH5Order`)可以防止误用 +- 未来如果需要进一步修改后台订单逻辑,不会影响 H5 端 +- breaking change 影响范围可控(只有 2 个 Handler 文件) + +**实现细节**: -**`purchase_role` 枚举值**: ```go +// CreateAdminOrder 后台订单创建(仅支持 wallet/offline,立即扣款或激活) +func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) { + // 1. 验证购买合法性(复用现有逻辑) + validationResult, err := s.validatePurchase(ctx, req.OrderType, req.IotCardID, req.DeviceID, req.PackageIDs) + + // 2. 幂等性检查(复用现有逻辑) + existingOrderID, err := s.checkOrderIdempotency(...) + + // 3. 根据支付方式路由 + if req.PaymentMethod == model.PaymentMethodOffline { + // 平台代购:创建订单并立即激活套餐 + return s.createOrderWithActivation(ctx, order, items) + } else if req.PaymentMethod == model.PaymentMethodWallet { + // 钱包支付:检查余额 → 扣款 → 创建已支付订单 → 激活套餐 + return s.createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID) + } else { + // 不应该到这里(DTO 验证已拒绝其他支付方式) + return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付") + } +} + +// CreateH5Order H5 端订单创建(支持 wallet/wechat/alipay,支持待支付状态) +func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) { + // 保留原 Create() 方法的完整逻辑 + // ... +} +``` + +**迁移策略**: +1. 新增 `CreateAdminOrder()` 和 `CreateH5Order()` 方法 +2. 将原 `Create()` 方法重命名为 `CreateLegacy()`(保留作为回滚方案) +3. 修改 Handler 层调用新方法 +4. 测试验证后删除 `CreateLegacy()` 方法 + +--- + +### Decision 2: DTO 设计 + +**选择**: 创建新的 `CreateAdminOrderRequest` DTO,保留现有 `CreateOrderRequest` + +**理由**: +- 后台和 H5 端的参数验证规则不同(`oneof=wallet offline` vs `oneof=wallet wechat alipay`) +- 使用不同的 DTO 可以在类型层面保证后台不会传入非法支付方式 +- 符合"类型安全"原则,编译期就能发现错误 + +**DTO 定义**: + +```go +// CreateAdminOrderRequest 后台订单创建请求(仅允许 wallet/offline) +type CreateAdminOrderRequest struct { + OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"` + IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"` + DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"` + PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"` +} + +// CreateOrderRequest H5 端订单创建请求(保持不变) +type CreateOrderRequest struct { + OrderType string `json:"order_type" validate:"required,oneof=single_card device" required:"true" description:"订单类型 (single_card:单卡购买, device:设备购买)"` + IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card" description:"IoT卡ID(单卡购买时必填)"` + DeviceID *uint `json:"device_id" validate:"required_if=OrderType device" description:"设备ID(设备购买时必填)"` + PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat alipay" required:"true" description:"支付方式 (wallet:钱包支付, wechat:微信支付, alipay:支付宝支付)"` +} +``` + +**替代方案及为何不选**: +- ~~使用同一个 DTO,在 Handler 层校验支付方式~~:无法利用类型系统,容易漏掉校验 +- ~~使用 interface{} 类型~~:失去类型安全,违反 Go 最佳实践 + +--- + +### Decision 3: Handler 层参数验证和权限检查 + +**选择**: 在 Handler 层完整实现参数验证和权限检查 + +**修复点 1:添加参数验证** + +```go +func (h *OrderHandler) Create(c *fiber.Ctx) error { + var req dto.CreateAdminOrderRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + // ← 添加验证(关键!) + if err := middleware.ValidateStruct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + // ... 后续逻辑 +} +``` + +**修复点 2:完整的权限检查** + +```go +// 检查支付方式权限 +if req.PaymentMethod == model.PaymentMethodOffline { + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "只有平台可以使用线下支付") + } +} else if req.PaymentMethod == model.PaymentMethodWallet { + if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden, "无权创建订单") + } +} else { + // ← 添加兜底检查(防御性编程) + return errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付") +} +``` + +**理由**: +- 参数验证是 Handler 层的职责(参考 CLAUDE.md 架构规范) +- 兜底检查可以防止未来新增支付方式时漏掉权限校验 +- 多层防护:DTO 验证(第一道防线) + Handler 检查(第二道防线) + Service 检查(第三道防线) + +--- + +### Decision 4: 错误处理策略 + +**选择**: 使用统一错误码,添加新错误码(如需要) + +**新增错误码**(如果需要): + +```go +// pkg/errors/errors.go const ( - PurchaseRoleSelfPurchase = "self_purchase" // 自己购买 - PurchaseRolePurchasedByParent = "purchased_by_parent" // 上级代理购买 - PurchaseRolePurchasedByPlatform = "purchased_by_platform" // 平台代购 - PurchaseRolePurchaseForSubordinate = "purchase_for_subordinate" // 给下级购买 + // ... 现有错误码 + CodeInvalidPaymentMethodForAdmin = 40008 // 后台不支持的支付方式 ) ``` -**索引设计**: -- `idx_orders_operator_id` (operator_id):支持"我作为操作者的订单"查询 -- `idx_orders_purchase_role` (purchase_role):支持按角色筛选 +**错误返回规范**: +- Handler 层参数验证失败:`errors.New(errors.CodeInvalidParam)`(不泄露细节) +- Service 层钱包余额不足:`errors.New(errors.CodeInsufficientBalance, "余额不足")` +- Service 层支付方式非法:`errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")` + +**理由**: +- 遵循项目错误处理规范(参考 CLAUDE.md) +- Handler 层不直接返回底层错误,防止信息泄露 --- -### 决策 2:订单创建流程重构 - -**决策**:在 `Service.Create()` 方法中,根据 `payment_method` 和资源归属判断场景: - -``` -IF payment_method == "offline": - → 平台代购场景(保持现有逻辑) - → buyer = 资源所属者, operator = nil, operator_type = "platform" - → 价格 = 买家成本价, 实际支付 = nil(不扣款) - → purchase_role = "purchased_by_platform" - → 调用 createOrderWithActivation() - -ELSE IF payment_method == "wallet": - → 获取操作者店铺 ID - → 获取资源所属店铺 ID - - IF 资源所属 == 操作者: - → 代理自购场景 - → buyer = 操作者, operator = 操作者 - → 价格 = 操作者成本价, 实际支付 = 操作者成本价 - → purchase_role = "self_purchase" - → is_purchase_on_behalf = false - ELSE: - → 代理代购场景 - → buyer = 资源所属者, operator = 操作者 - → 价格 = 买家成本价, 实际支付 = 操作者成本价 - → purchase_role = "purchase_for_subordinate" - → is_purchase_on_behalf = true - - → 调用 createOrderWithWalletPayment() -``` - -**理由**: -- 清晰区分三种场景,每种场景的价格逻辑和字段填充规则不同 -- 复用现有的 `createOrderWithActivation()`(平台代购) -- 新增 `createOrderWithWalletPayment()`(代理钱包支付,含扣款逻辑) - -**替代方案(已拒绝)**: -- ❌ 使用策略模式分离三种场景:过度设计,增加代码复杂度 -- ❌ 在 Handler 层判断场景:违反分层架构,业务逻辑应在 Service 层 - ---- - -### 决策 3:价格计算逻辑 - -**决策**:区分"订单金额"(`total_amount`)和"实际支付"(`actual_paid_amount`): - -| 场景 | 订单金额(total_amount) | 实际支付(actual_paid_amount) | 说明 | -|------|------------------------|------------------------------|------| -| 代理自购 | 操作者成本价 | 操作者成本价 | 两者相同 | -| 代理代购 | 买家成本价 | 操作者成本价 | 操作者实际扣款少于订单金额(赚取差价) | -| 平台代购 | 买家成本价 | NULL | 平台不扣款 | - -**理由**: -- **订单金额**面向买家:买家看到的应该是"他的成本价",这样才符合业务逻辑 -- **实际支付**面向操作者:操作者实际扣款金额,用于钱包流水和财务对账 -- 代理代购时,操作者按自己的成本价扣款,但订单显示下级成本价,差价即为利润 - -**示例**: -``` -一级代理 A 成本价:80 元 -二级代理 B 成本价:100 元 - -A 为 B 的卡购买套餐: -- total_amount = 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` 避免前端拼接文本逻辑 +### Decision 5: 向后兼容和回滚策略 + +**选择**: 保留原 `Create()` 方法作为 `CreateLegacy()`,灰度发布 + +**迁移步骤**: +1. **Phase 1**: 新增 `CreateAdminOrder()` 和 `CreateH5Order()` 方法 +2. **Phase 2**: 将原 `Create()` 重命名为 `CreateLegacy()`(保留,暂不调用) +3. **Phase 3**: 修改 Handler 层调用新方法 +4. **Phase 4**: 测试环境验证 +5. **Phase 5**: 生产环境灰度发布(先 1% 流量,观察 1 天,再逐步放量) +6. **Phase 6**: 全量上线后观察 1 周,无问题后删除 `CreateLegacy()` 方法 + +**回滚方案**: +- 如果出现问题,立即修改 Handler 层调用 `CreateLegacy()` 方法 +- 重新部署后端(5 分钟内可完成) +- 前端无需回滚(因为 DTO 结构兼容) + +**理由**: +- breaking change 风险高,需要谨慎迁移 +- 保留 `CreateLegacy()` 方法可以快速回滚,避免长时间故障 --- ## Risks / Trade-offs -### [风险] 数据库迁移失败 → 回滚策略 +### Risk 1: 前端未同步修改导致参数缺失 -**问题**:新增字段的迁移脚本可能在生产环境执行失败(如表锁、超时) +**风险**: 前端没有传 `payment_method` 参数,后端拒绝请求 -**缓解措施**: -1. 所有新增字段设为 `nullable`,不影响现有数据 -2. 迁移脚本分步执行: - - Step 1: 添加字段(不加 NOT NULL 约束) - - Step 2: 数据回填(如有需要) - - Step 3: 添加索引(CONCURRENTLY 方式,不锁表) -3. 回滚脚本:`DROP COLUMN IF EXISTS` -4. 测试环境充分验证后再上生产 +**影响**: 高。后台订单创建功能完全不可用 + +**缓解措施**: +1. 后端先部署(兼容旧前端,如果 `payment_method` 为空,默认使用 `wallet`) +2. 前端修改后再部署 +3. 灰度发布,先在测试环境验证前后端联调 + +**检测方式**: +- 监控错误日志中 `CodeInvalidParam` 错误的数量 +- 如果错误量激增,立即回滚 --- -### [风险] OR 查询性能下降 → 索引优化 +### Risk 2: Service 方法拆分导致代码重复 -**问题**:`WHERE (buyer_id = X) OR (operator_id = X)` 可能无法有效使用索引 +**风险**: `CreateAdminOrder()` 和 `CreateH5Order()` 有大量重复代码 -**缓解措施**: -1. 使用 `EXPLAIN ANALYZE` 验证查询计划 -2. 确保两个字段都有索引 -3. 如果性能不佳,考虑使用 UNION: - ```sql - SELECT * FROM tb_order WHERE buyer_id = X - UNION - SELECT * FROM tb_order WHERE operator_id = X - ``` -4. 监控慢查询日志,按需优化 +**影响**: 中。增加维护成本 -**预期性能**: -- 单表查询,无 JOIN -- 订单表数据量级:百万级 -- OR 查询在 PostgreSQL 中会走 BITMAP INDEX SCAN,性能可接受 +**缓解措施**: +1. 提取公共逻辑为私有方法(如 `validatePurchase()`、`checkOrderIdempotency()`、`buildOrderItems()`) +2. `CreateAdminOrder()` 和 `CreateH5Order()` 只保留差异化逻辑 + +**示例**: +```go +func (s *Service) CreateAdminOrder(...) (*dto.OrderResponse, error) { + // 1. 公共逻辑(提取为私有方法) + validationResult, err := s.validatePurchase(...) + existingOrderID, err := s.checkOrderIdempotency(...) + + // 2. 差异化逻辑(后台独有) + if req.PaymentMethod == model.PaymentMethodWallet { + return s.createOrderWithWalletPayment(...) + } +} +``` --- -### [权衡] 两个金额字段增加存储成本 → 业务清晰度 +### Risk 3: 灰度发布失败导致部分用户无法下单 -**问题**:`total_amount` 和 `actual_paid_amount` 在大部分场景下相同(代理自购),存在冗余 +**风险**: 灰度发布过程中,部分用户使用新代码,部分用户使用旧代码,行为不一致 -**权衡理由**: -- **优势**:业务语义清晰,查询无需计算(如:统计实际收入用 `actual_paid_amount`) -- **劣势**:存储成本增加(每订单 8 字节),数据冗余 -- **结论**:业务清晰度优先,存储成本可接受(8 字节在订单数据中占比很小) +**影响**: 中。用户体验不一致 + +**缓解措施**: +1. 灰度发布按 **用户 ID** 维度(而不是随机流量),确保同一用户始终使用同一版本 +2. 灰度期间密切监控错误日志和用户反馈 +3. 设置灰度开关(环境变量或配置文件),可以快速回滚到旧代码 --- -### [权衡] 钱包流水查询店铺名称 → 性能 vs 便利性 +### Trade-off: 代码拆分 vs 逻辑复用 -**问题**:创建钱包流水时查询店铺名称(`shopStore.GetByID`)会增加一次数据库查询 +**选择**: 优先代码拆分(清晰的职责划分),接受一定的代码重复 -**权衡理由**: -- **优势**:备注字段包含店铺名称,便于人工查看,无需二次查询 -- **劣势**:订单创建时增加一次查询(~5ms) -- **结论**:可接受,因为: - 1. 查询频率低(仅代购场景) - 2. Shop 表有缓存机制 - 3. 事务内查询,不影响一致性 - 4. 如果性能敏感,可以异步更新备注 +**理由**: +- 后台和 H5 端的订单创建逻辑差异巨大,强行复用会导致 if-else 嵌套过深,难以维护 +- "一点点代码重复" 优于 "过度抽象"(遵循 Go 惯用模式) +- 未来如果需要修改后台订单逻辑,不会影响 H5 端 --- -### [风险] 佣金逻辑调整导致收入计算错误 → 回归测试 +### Trade-off: breaking change vs 保持现状 -**问题**:佣金计算逻辑变更可能影响现有代理的收入 +**选择**: 接受 breaking change,彻底解决问题 -**缓解措施**: -1. 充分的单元测试和集成测试 -2. 上线前在测试环境验证佣金计算结果 -3. 灰度发布,监控佣金数据异常 -4. 保留 `operator_id == nil` 的判断逻辑,避免误伤平台代购 +**理由**: +- 当前问题严重(代理可以创建永远无法完成的订单),必须彻底修复 +- breaking change 影响范围可控(只有 2 个 Handler 文件) +- 保留回滚方案,风险可控 --- ## Migration Plan -### 数据库迁移步骤 +### Phase 1: 后端开发和测试(1 天) -**Step 1: 创建迁移脚本** +1. 创建新 DTO:`CreateAdminOrderRequest` +2. 新增 Service 方法:`CreateAdminOrder()` 和 `CreateH5Order()` +3. 重命名原 `Create()` 为 `CreateLegacy()` +4. 修改 Handler 层调用新方法 +5. 单元测试(覆盖率 ≥ 90%) -文件:`migrations/xxx_add_operator_fields_to_orders.up.sql` +### Phase 2: 前端开发(0.5 天) -```sql --- 添加字段(nullable) -ALTER TABLE tb_order ADD COLUMN operator_id INT; -ALTER TABLE tb_order ADD COLUMN operator_type VARCHAR(20); -ALTER TABLE tb_order ADD COLUMN actual_paid_amount BIGINT; -ALTER TABLE tb_order ADD COLUMN purchase_role VARCHAR(50); +1. 后台订单创建界面添加 `payment_method` 下拉框(wallet/offline) +2. 修改请求参数,使用新 DTO --- 添加注释 -COMMENT ON COLUMN tb_order.operator_id IS '操作者ID(谁下的单)'; -COMMENT ON COLUMN tb_order.operator_type IS '操作者类型(platform/agent)'; -COMMENT ON COLUMN tb_order.actual_paid_amount IS '实际支付金额(分)'; -COMMENT ON COLUMN tb_order.purchase_role IS '订单角色(self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate)'; +### Phase 3: 联调测试(0.5 天) --- 添加索引(CONCURRENTLY 避免锁表) -CREATE INDEX CONCURRENTLY idx_orders_operator_id ON tb_order(operator_id); -CREATE INDEX CONCURRENTLY idx_orders_purchase_role ON tb_order(purchase_role); -``` +1. 测试环境部署后端 +2. 测试环境部署前端 +3. 完整测试所有场景: + - 代理钱包支付(余额充足) + - 代理钱包支付(余额不足) + - 平台线下支付 + - H5 端订单创建(回归测试) -**Step 2: 钱包流水表迁移**(如果字段不存在) +### Phase 4: 灰度发布(3 天) -文件:`migrations/xxx_add_transaction_subtype_to_wallet_transaction.up.sql` +1. **Day 1**: 生产环境部署后端,灰度 1% 流量 +2. **Day 2**: 观察错误日志,无问题则扩大到 10% +3. **Day 3**: 扩大到 50%,无问题则全量 -```sql --- 检查字段是否存在,不存在则添加 -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='tb_agent_wallet_transaction' - AND column_name='transaction_subtype') THEN - ALTER TABLE tb_agent_wallet_transaction ADD COLUMN transaction_subtype VARCHAR(50); - COMMENT ON COLUMN tb_agent_wallet_transaction.transaction_subtype IS '交易子类型(细分 order_payment 场景)'; - END IF; +### Phase 5: 清理(1 天) - IF NOT EXISTS (SELECT 1 FROM information_schema.columns - WHERE table_name='tb_agent_wallet_transaction' - AND column_name='related_shop_id') THEN - ALTER TABLE tb_agent_wallet_transaction ADD COLUMN related_shop_id INT; - COMMENT ON COLUMN tb_agent_wallet_transaction.related_shop_id IS '关联店铺ID(代购时记录下级店铺)'; - END IF; -END -$$; -``` +1. 观察 1 周,无问题后删除 `CreateLegacy()` 方法 +2. 更新文档(API 文档、技术文档) -**Step 3: 数据回填**(可选,视历史数据需求而定) +**总时长**: 约 6 天 -```sql --- 回填现有订单的 purchase_role --- 平台代购订单(offline) -UPDATE tb_order -SET purchase_role = 'purchased_by_platform', - operator_type = 'platform' -WHERE payment_method = 'offline' - AND is_purchase_on_behalf = true - AND purchase_role IS NULL; +--- --- 其他订单暂不回填(保持 NULL,不影响业务) -``` +### Rollback Strategy -### 部署步骤 +**触发条件**: +- 错误率超过 1% +- 用户反馈无法下单 +- 前端未同步上线导致大量参数缺失错误 -1. **测试环境验证**: - - 执行迁移脚本 - - 验证索引创建成功 - - 运行集成测试 - - 手工测试三种代购场景 - -2. **灰度发布**: - - 代码部署到灰度环境 - - 观察日志和监控指标 - - 验证订单创建、查询、钱包扣款功能 - -3. **生产环境部署**: - - 低峰期执行数据库迁移 - - 部署代码 - - 监控错误日志和业务指标 - - 验证核心功能 - -### 回滚策略 - -**代码回滚**: -- 回滚到上一版本代码即可,新增字段为 `nullable`,不影响老代码 - -**数据库回滚**: -- 文件:`migrations/xxx_add_operator_fields_to_orders.down.sql` - ```sql - DROP INDEX IF EXISTS idx_orders_operator_id; - DROP INDEX IF EXISTS idx_orders_purchase_role; - ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_id; - ALTER TABLE tb_order DROP COLUMN IF EXISTS operator_type; - ALTER TABLE tb_order DROP COLUMN IF EXISTS actual_paid_amount; - ALTER TABLE tb_order DROP COLUMN IF EXISTS purchase_role; - ``` +**回滚步骤**: +1. 立即修改 Handler 层调用 `CreateLegacy()` 方法 +2. 重新部署后端(5 分钟) +3. 验证功能恢复 +4. 排查问题,修复后重新上线 --- ## Open Questions -1. **是否需要支持代理在后台为个人客户(C 端)代购?** - - 当前设计只支持代理为代理(B2B),不支持代理为个人客户(B2C) - - 如果未来需要,需要扩展 `buyer_type` 判断逻辑 +1. **前端是否已经传递 `payment_method` 参数?** + - 需要确认前端当前行为 + - 如果未传递,后端需要提供默认值(wallet)以兼容旧版本 -2. **钱包流水的 `related_shop_id` 是否需要索引?** - - 当前设计未加索引,因为查询频率低 - - 如果未来需要"查询我为哪些下级购买过"功能,需要添加索引 +2. **是否需要新增错误码 `CodeInvalidPaymentMethodForAdmin`?** + - 当前可以复用 `CodeInvalidParam` + - 如果需要区分"参数格式错误"和"支付方式不支持",可以新增 -3. **是否需要支持订单角色的批量变更?** - - 当前设计 `purchase_role` 在订单创建时填充,后续不可修改 - - 如果历史订单需要回填角色,需要单独的数据修复脚本 +3. **灰度发布的维度是什么?** + - 按用户 ID 灰度(推荐) + - 按流量百分比灰度 + - 需要与运维确认灰度策略 -4. **代理代购时,下级代理是否能看到操作者信息?** - - 当前设计:下级可以看到 `operator_id` 和 `operator_name` - - 是否需要隐藏?(业务决策) +4. **是否需要支持后台创建待支付订单(未来需求)?** + - 当前不支持(Non-Goal) + - 如果未来有需求,可以新增 `CreateAdminPendingOrder()` 方法 diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/proposal.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/proposal.md index 698f255..d5f063a 100644 --- a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/proposal.md +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/proposal.md @@ -1,61 +1,95 @@ ## Why -代理在后台使用钱包支付(wallet)创建订单时,系统只创建待支付订单(payment_status = pending),不扣款也不激活套餐,导致订单无法完成。后台没有支付接口,代理无法对待支付订单进行支付。这个问题阻塞了代理的核心业务场景:代理帮客户购买套餐(代购),从自己钱包扣款并立即激活。 +当前代理在后台创建订单时存在严重的逻辑漏洞:缺少参数验证、权限检查不完整、后台和 H5 端共用同一个 Service 方法,导致代理可以在钱包余额不足时创建"待支付"状态的订单,但后台没有支付界面,订单永远无法完成。这是一个关键的业务逻辑缺陷,影响代理订单的正常流转和用户体验。 ## What Changes -- **新增订单角色追踪字段**:在订单表中新增 `operator_id`(操作者ID)、`operator_type`(操作者类型)、`actual_paid_amount`(实际支付金额)、`purchase_role`(订单角色)字段,用于区分"谁下单"和"谁买单" -- **支持代理钱包一步购买**:代理在后台使用 wallet 创建订单时,立即检查余额、扣款、激活套餐,订单状态直接为已支付(一步完成,无需后续支付接口) -- **区分代购场景**: - - 代理自购(资源属于自己):从自己钱包扣自己的成本价,订单金额 = 实际支付 - - 代理代购(资源属于下级):从自己钱包扣自己的成本价,但订单金额显示下级成本价(让下级看到他的成本) - - 平台代购(offline):保持现有逻辑(不扣款,立即激活) -- **订单查询增强**:代理可以查询 `buyer_id = 自己` 或 `operator_id = 自己` 的订单(看到自己作为买家或操作者的所有订单) -- **钱包流水记录**:钱包扣款时记录交易子类型(自购 / 给下级购买)和关联店铺ID,支持按场景筛选 -- **佣金逻辑调整**:代理代购订单不产生佣金(操作者已赚取成本价差),只有平台代购才触发佣金计算 +### Handler 层修复(短期) + +- 在 `internal/handler/admin/order.go` 的 `Create()` 方法中添加参数验证(调用 `middleware.ValidateStruct(&req)`) +- 在 Handler 层严格检查支付方式:后台只允许 `wallet` 和 `offline`,拒绝其他支付方式 +- 修复权限检查逻辑:完整覆盖所有支付方式的权限校验 + +### Service 层重构(长期) + +- **BREAKING**: 拆分 `OrderService.Create()` 方法为两个独立方法: + - `CreateAdminOrder()` - 后台订单创建(仅支持 wallet/offline,立即扣款或激活) + - `CreateH5Order()` - H5 端订单创建(支持 wallet/wechat/alipay,支持待支付状态) +- `CreateAdminOrder()` 逻辑: + - wallet 支付:检查余额 → 扣款 → 创建已支付订单 → 激活套餐(一步到位) + - offline 支付:直接创建已支付订单 → 激活套餐 + - 余额不足直接拒绝,提示"余额不足" +- `CreateH5Order()` 逻辑: + - wallet 支付:冻结余额 → 创建待支付订单 + - wechat/alipay 支付:创建待支付订单 + - 混合支付:冻结钱包部分 → 创建待支付订单 + +### DTO 层修复 + +- 创建新的 DTO:`CreateAdminOrderRequest`(仅允许 wallet/offline) +- 保留现有 DTO:`CreateOrderRequest`(用于 H5 端,允许 wallet/wechat/alipay) + +### 前端修复 + +- 后台订单创建界面必须传递 `payment_method` 参数 +- 下拉框只显示"钱包支付"和"线下支付"选项 ## Capabilities ### New Capabilities -- `agent-order-role-tracking`: 订单角色追踪能力,记录并区分订单中的操作者、买家、支付者等角色关系,支持多种代购场景的数据查询和业务分析 +- `admin-order-creation`: 后台订单创建流程。包含:参数验证、支付方式限制、钱包余额检查、一步到位扣款逻辑、错误处理。 ### Modified Capabilities -- `purchase-on-behalf`: 扩展代购订单需求,新增代理使用钱包(wallet)代购的场景,区别于现有的平台线下(offline)代购 -- `order-payment`: 新增后台订单钱包一步支付需求,代理创建订单时立即扣款并激活套餐,区别于 H5 端的两步支付流程(创建待支付订单 → 调用支付接口) +- `order-payment`: 修改订单支付流程需求,明确区分后台和 H5 端的支付行为差异(后台立即扣款 vs H5 端支持待支付状态)。 ## Impact -### 数据库变更 -- **订单表**(`tb_order`)新增字段: - - `operator_id` (INT, 可空):操作者ID - - `operator_type` (VARCHAR, 可空):操作者类型(platform/agent) - - `actual_paid_amount` (BIGINT, 可空):实际支付金额(分) - - `purchase_role` (VARCHAR):订单角色枚举(self_purchase/purchased_by_parent/purchased_by_platform/purchase_for_subordinate) - - 新增索引:`idx_orders_operator_id`、`idx_orders_purchase_role` +**数据模型**: +- 无数据库变更 -- **钱包流水表**(`tb_agent_wallet_transaction`)新增/确认字段(如果不存在): - - `transaction_subtype` (VARCHAR):交易子类型(细分 order_payment 场景) - - `related_shop_id` (INT, 可空):关联店铺ID(代购时记录下级店铺) +**代码影响**: +- `internal/model/dto/order_dto.go`: + - 新增 `CreateAdminOrderRequest` 结构体 + - 保留 `CreateOrderRequest` 用于 H5 端 +- `internal/handler/admin/order.go`: + - `Create()` 方法添加参数验证和支付方式检查 + - 调用新的 `CreateAdminOrder()` 方法 +- `internal/handler/h5/order.go`: + - `Create()` 方法调用新的 `CreateH5Order()` 方法 +- `internal/service/order/service.go`: + - **BREAKING**: 拆分 `Create()` 为 `CreateAdminOrder()` 和 `CreateH5Order()` + - `CreateAdminOrder()` 只支持 wallet/offline,立即扣款 + - `CreateH5Order()` 保留现有逻辑(支持待支付状态) +- `pkg/errors/errors.go`: + - 可能新增错误码(如 `CodeInvalidPaymentMethodForAdmin`) -### 受影响的模块 -- `internal/model/order.go`:新增字段和枚举常量 -- `internal/model/agent_wallet.go`:确认流水表字段 -- `internal/model/dto/order_dto.go`:OrderResponse 新增字段,OrderListRequest 新增筛选参数 -- `internal/service/order/service.go`:重构 `Create()` 方法,新增 `createOrderWithWalletPayment()` 方法 -- `internal/store/postgres/order_store.go`:修改 `List()` 支持 OR 查询(buyer_id 或 operator_id) -- `internal/handler/admin/order.go`:调整权限检查和查询逻辑 +**API 影响**: +- `POST /api/admin/orders` - 请求参数 DTO 变更(新增 `CreateAdminOrderRequest`) +- `POST /api/h5/orders` - 无变更(继续使用 `CreateOrderRequest`) -### API 影响 -- **后台订单创建 API**(POST `/api/admin/orders`): - - 行为变更:代理使用 wallet 支付时,订单直接完成(payment_status = paid),无需后续支付 - - 响应新增字段:`operator_id`, `operator_type`, `actual_paid_amount`, `purchase_role`, `is_purchased_by_parent`, `purchase_remark` -- **订单列表 API**(GET `/api/admin/orders`): - - 新增查询参数:`purchase_role`(可选,筛选订单角色类型) - - 查询逻辑变更:代理可以看到作为操作者或买家的所有订单 +**依赖**: +- 无新增依赖 -### 兼容性 -- **向后兼容**:现有订单字段为空值,不影响已有订单查询 -- **平台代购(offline)逻辑不变**:保持现有行为 -- **H5 钱包支付不受影响**:H5 端仍使用两步流程(创建待支付订单 → 调用 WalletPay 接口) +**性能考虑**: +- 无性能影响(逻辑重构,不增加额外查询) + +**测试要求**: +- 单元测试: + - `CreateAdminOrder()` 方法的各种场景(余额充足、余额不足、支付方式非法) + - `CreateH5Order()` 方法的各种场景(保持现有行为) +- 集成测试: + - 后台创建订单 API(wallet 支付、offline 支付、非法支付方式) + - H5 端创建订单 API(保持现有测试) +- 回归测试: + - 验证 H5 端订单创建流程不受影响 + +**迁移风险**: +- **BREAKING CHANGE**: 后台和 H5 端调用不同的 Service 方法 +- 前端需要同步修改(确保传递 `payment_method` 参数) +- 部署顺序:先部署后端(向后兼容),再部署前端 + +**回滚方案**: +- 保留原 `Create()` 方法作为 `CreateLegacy()`,出现问题时快速回滚 +- 灰度发布:先在测试环境验证,再逐步上线生产环境 diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/admin-order-creation/spec.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/admin-order-creation/spec.md new file mode 100644 index 0000000..25e540e --- /dev/null +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/admin-order-creation/spec.md @@ -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 端的差异,防止误用 diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/agent-order-role-tracking/spec.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/agent-order-role-tracking/spec.md deleted file mode 100644 index 529f4d4..0000000 --- a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/agent-order-role-tracking/spec.md +++ /dev/null @@ -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) diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/order-payment/spec.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/order-payment/spec.md index b446dde..33e02f1 100644 --- a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/order-payment/spec.md +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/order-payment/spec.md @@ -1,159 +1,113 @@ -## ADDED Requirements +## MODIFIED Requirements ### Requirement: 后台钱包一步支付 -系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。 +系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。后台订单创建使用独立的 Service 方法(`CreateAdminOrder()`),与 H5 端的 `CreateH5Order()` 方法隔离,避免逻辑混淆。 + +**后台钱包支付流程**(一步到位): +1. 检查钱包余额是否充足(事务外快速失败) +2. 在事务中:扣减钱包余额 → 创建已支付订单(`payment_status` = 2)→ 激活套餐 +3. 返回已支付的订单信息 + +**与 H5 端的区别**: +- 后台:立即扣款,订单创建后即为已支付状态(`payment_status` = 2) +- H5 端:冻结余额,创建待支付订单(`payment_status` = 1),需用户调用支付接口 #### Scenario: 后台订单创建时钱包支付 + - **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额充足 -- **THEN** 系统创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2),激活套餐 +- **THEN** 系统调用 `CreateAdminOrder()` 方法,创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2),激活套餐 #### Scenario: 后台钱包支付余额不足 + - **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额不足 -- **THEN** 系统返回错误"余额不足",订单创建失败 +- **THEN** 系统调用 `CreateAdminOrder()` 方法,在事务外检查余额,返回错误"余额不足",订单创建失败 #### Scenario: 后台钱包支付订单响应 + - **WHEN** 后台钱包支付订单创建成功 - **THEN** API 响应包含已支付的订单信息,`payment_status` = 2,`payment_method` = "wallet",`paid_at` 为当前时间 #### Scenario: 后台钱包支付不创建待支付订单 + - **WHEN** 代理在后台创建 wallet 订单 -- **THEN** 系统不创建待支付订单(`payment_status` != 1),直接完成支付 +- **THEN** 系统不创建待支付订单(`payment_status` != 1),直接完成支付和套餐激活 + +#### Scenario: 后台钱包支付使用独立方法 + +- **WHEN** 代理在后台创建 wallet 订单 +- **THEN** Handler 层调用 `OrderService.CreateAdminOrder()` 方法,不调用通用的 `Create()` 或 `CreateH5Order()` 方法 --- ### Requirement: H5 钱包两步支付保持不变 -系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。 +系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。H5 端订单创建使用独立的 Service 方法(`CreateH5Order()`),与后台的 `CreateAdminOrder()` 方法隔离。 + +**H5 钱包支付流程**(两步流程): +1. 创建订单:冻结钱包余额 → 创建待支付订单(`payment_status` = 1) +2. 用户调用支付接口:扣减钱包余额 → 更新订单状态为已支付 → 激活套餐 + +**与后台的区别**: +- H5 端:创建待支付订单,用户需调用支付接口完成支付 +- 后台:立即扣款,订单创建后即为已支付状态 #### Scenario: H5 创建待支付订单 + - **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet -- **THEN** 系统创建订单,`payment_status` = 1(待支付),不扣减钱包余额 +- **THEN** 系统调用 `CreateH5Order()` 方法,创建订单,`payment_status` = 1(待支付),冻结钱包余额,不立即扣款 #### Scenario: H5 调用 WalletPay 接口支付 + - **WHEN** 个人客户调用 WalletPay 接口支付待支付订单 - **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐 #### Scenario: H5 和后台钱包支付流程独立 + - **WHEN** H5 端创建 wallet 订单 -- **THEN** 不影响后台 wallet 订单的一步支付逻辑 +- **THEN** 系统调用 `CreateH5Order()` 方法,不影响后台 wallet 订单的一步支付逻辑 ---- +#### Scenario: H5 钱包支付使用独立方法 -### Requirement: 钱包流水记录扩展 - -系统 SHALL 在钱包流水中记录交易子类型和关联店铺,支持按场景筛选。 - -#### Scenario: 自购钱包流水 -- **WHEN** 代理为自己的资源购买套餐,使用 wallet -- **THEN** 钱包流水的 `transaction_subtype` = "self_purchase",`related_shop_id` 为 NULL,`remark` = "购买套餐" - -#### Scenario: 代购钱包流水 -- **WHEN** 代理为下级代理购买套餐,使用 wallet -- **THEN** 钱包流水的 `transaction_subtype` = "purchase_for_subordinate",`related_shop_id` = 下级代理店铺 ID,`remark` = "为下级代理【XX】购买套餐" - -#### Scenario: 钱包流水查询店铺名称 -- **WHEN** 创建代购钱包流水 -- **THEN** 系统查询下级店铺名称,填充到 `remark` 字段 - -#### Scenario: 钱包流水筛选 -- **WHEN** 代理查询钱包流水,筛选 `transaction_subtype` = "purchase_for_subordinate" -- **THEN** 系统返回所有为下级代理购买的流水记录 - ---- - -### Requirement: 钱包支付乐观锁 - -系统 SHALL 使用乐观锁防止钱包并发扣款导致余额不一致。 - -#### Scenario: 钱包扣款使用 version 字段 -- **WHEN** 扣减钱包余额 -- **THEN** SQL 语句包含 `WHERE balance >= ? AND version = ?`,更新时 `version + 1` - -#### Scenario: 钱包并发扣款失败 -- **WHEN** 两个请求同时扣减同一钱包 -- **THEN** 只有一个请求成功,另一个返回"余额不足或并发冲突" - -#### Scenario: 乐观锁重试逻辑 -- **WHEN** 钱包扣款因 version 冲突失败 -- **THEN** 系统不自动重试,返回错误(由客户端决定是否重试) - ---- - -### Requirement: 钱包支付幂等性 - -系统 SHALL 防止同一订单重复创建和重复扣款。 - -#### Scenario: 订单创建幂等性检查 -- **WHEN** 同一买家对同一载体的同一套餐组合在短时间内重复创建订单 -- **THEN** 系统返回已创建的订单,不重复扣款 - -#### Scenario: 幂等性使用 Redis 业务键 -- **WHEN** 检查订单幂等性 -- **THEN** 系统使用 Redis key `order:idempotency:{buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}` - -#### Scenario: 幂等性 TTL -- **WHEN** 订单创建成功后标记幂等性 -- **THEN** Redis key 的 TTL 为 3 分钟 - -#### Scenario: 分布式锁防止并发 -- **WHEN** 订单创建前检查幂等性 -- **THEN** 系统使用分布式锁 `order:create:lock:{carrier_type}:{carrier_id}`,TTL 10 秒 - ---- - -### Requirement: 后台订单 API 响应扩展 - -系统 SHALL 在后台订单创建和查询 API 响应中包含钱包支付相关字段。 - -#### Scenario: 订单响应包含实际支付金额 -- **WHEN** 查询钱包支付的订单 -- **THEN** 响应包含 `actual_paid_amount` 字段 - -#### Scenario: 订单响应包含操作者信息 -- **WHEN** 查询代购订单 -- **THEN** 响应包含 `operator_id`、`operator_type`、`operator_name` 字段 - -#### Scenario: 订单响应包含购买备注 -- **WHEN** 查询上级代理购买的订单 -- **THEN** 响应包含 `purchase_remark` 字段,如"由上级代理【XX】购买" - ---- - -### Requirement: 钱包支付错误处理 - -系统 SHALL 在钱包支付失败时返回明确的错误信息。 - -#### Scenario: 钱包不存在 -- **WHEN** 钱包支付时钱包不存在 -- **THEN** 系统返回错误"钱包不存在"(`CodeWalletNotFound`) - -#### Scenario: 余额不足 -- **WHEN** 钱包支付时余额不足 -- **THEN** 系统返回错误"余额不足"(`CodeInsufficientBalance`) - -#### Scenario: 并发冲突 -- **WHEN** 钱包扣款因 version 冲突失败 -- **THEN** 系统返回错误"余额不足或并发冲突"(`CodeInsufficientBalance`) - -#### Scenario: 套餐激活失败 -- **WHEN** 钱包扣款成功但套餐激活失败 -- **THEN** 事务回滚,钱包余额恢复,返回激活失败错误 +- **WHEN** 个人客户在 H5 端创建 wallet 订单 +- **THEN** Handler 层调用 `OrderService.CreateH5Order()` 方法,不调用 `CreateAdminOrder()` 方法 --- ### Requirement: 钱包支付与第三方支付的区别 -系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。 +系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。后台订单创建 MUST 在 Handler 层强制验证支付方式,拒绝 `wechat` 和 `alipay` 支付方式。 -#### Scenario: 后台不支持第三方支付 -- **WHEN** 代理在后台创建订单时选择 wechat 或 alipay -- **THEN** 系统返回错误"后台只支持 wallet 和 offline 支付方式" +**后台支付方式限制**: +- 允许:`wallet`、`offline` +- 拒绝:`wechat`、`alipay`、其他任何值 + +**实现层级**: +1. **DTO 验证**(第一道防线):`CreateAdminOrderRequest` 的 `payment_method` 字段使用 `validate:"oneof=wallet offline"` 规则 +2. **Handler 验证**(第二道防线):调用 `middleware.ValidateStruct(&req)` 验证 DTO +3. **Handler 兜底检查**(第三道防线):对所有支付方式进行权限检查,包括非法值 + +#### Scenario: 后台参数验证拒绝第三方支付 + +- **WHEN** 代理在后台创建订单时 `payment_method` 为 wechat 或 alipay +- **THEN** 系统在 Handler 层的 DTO 验证阶段拒绝请求,返回错误"请求参数解析失败"(`CodeInvalidParam`),订单创建失败 + +#### Scenario: 后台兜底检查拒绝其他支付方式 + +- **WHEN** 代理在后台创建订单时 `payment_method` 为未知值(防御性编程) +- **THEN** 系统在 Handler 层的兜底检查阶段拒绝请求,返回错误"后台仅支持钱包支付或线下支付"(`CodeInvalidParam`) #### Scenario: H5 支持第三方支付 + - **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay -- **THEN** 系统创建待支付订单,返回支付参数(prepay_id 或 h5_url) +- **THEN** 系统调用 `CreateH5Order()` 方法,创建待支付订单,返回支付参数(prepay_id 或 h5_url) #### Scenario: 钱包支付不需要支付参数 + - **WHEN** 后台钱包支付订单创建成功 - **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数 + +#### Scenario: 后台使用独立的 DTO + +- **WHEN** 后台创建订单 +- **THEN** Handler 层使用 `CreateAdminOrderRequest` DTO(仅允许 wallet/offline),H5 端使用 `CreateOrderRequest` DTO(允许 wallet/wechat/alipay) diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/purchase-on-behalf/spec.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/purchase-on-behalf/spec.md deleted file mode 100644 index 8197787..0000000 --- a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/specs/purchase-on-behalf/spec.md +++ /dev/null @@ -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 字段)和幂等性检查防止并发问题 diff --git a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/tasks.md b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/tasks.md index 72a1722..a016098 100644 --- a/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/tasks.md +++ b/openspec/changes/archive/2026-02-28-fix-agent-wallet-order-creation/tasks.md @@ -1,159 +1,102 @@ -## 1. 数据库结构变更 +## 1. DTO 层新增 CreateAdminOrderRequest -- [x] 1.1 创建订单表字段迁移脚本(`migrations/xxx_add_operator_fields_to_orders.up.sql`),新增 `operator_id`、`operator_type`、`actual_paid_amount`、`purchase_role` 字段,添加字段注释 -- [x] 1.2 在迁移脚本中创建索引(`idx_orders_operator_id`、`idx_orders_purchase_role`),使用 CONCURRENTLY 避免锁表 -- [x] 1.3 创建钱包流水表字段迁移脚本(`migrations/xxx_add_transaction_subtype_to_wallet_transaction.up.sql`),检查并添加 `transaction_subtype` 和 `related_shop_id` 字段(如果不存在) -- [x] 1.4 创建数据回滚迁移脚本(`*.down.sql`),包含 DROP INDEX 和 DROP COLUMN 语句 -- [x] 1.5 在测试环境执行迁移,验证字段创建成功,检查 `\d tb_order` 和 `\d tb_agent_wallet_transaction` 输出 +- [x] 1.1 在 `internal/model/dto/order_dto.go` 创建 `CreateAdminOrderRequest` 结构体,仅允许 wallet/offline 支付方式 +- [x] 1.2 添加字段验证规则:`payment_method` 使用 `validate:"required,oneof=wallet offline"` +- [x] 1.3 复制其他字段定义:`order_type`、`iot_card_id`、`device_id`、`package_ids`(与 `CreateOrderRequest` 保持一致) +- [x] 1.4 验证编译:运行 `go build ./internal/model/...` 确认无编译错误 -## 2. Model 层:订单角色追踪 +## 2. Service 层新增 CreateAdminOrder 方法 -- [x] 2.1 在 `internal/model/order.go` 中的 `Order` 结构体添加新字段:`OperatorID`、`OperatorType`、`ActualPaidAmount`、`PurchaseRole`,添加 gorm 标签和中文注释 -- [x] 2.2 在 `internal/model/order.go` 中定义订单角色枚举常量(`PurchaseRoleSelfPurchase`、`PurchaseRolePurchasedByParent`、`PurchaseRolePurchasedByPlatform`、`PurchaseRolePurchaseForSubordinate`),添加中文注释 -- [x] 2.3 在 `internal/model/agent_wallet.go` 中确认 `AgentWalletTransaction` 结构体包含 `TransactionSubtype` 和 `RelatedShopID` 字段(如果不存在则添加) -- [x] 2.4 运行 `go build ./...` 验证编译通过 +- [x] 2.1 在 `internal/service/order/service.go` 新增 `CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error)` 方法签名 +- [x] 2.2 实现步骤 1:调用 `validatePurchase()` 验证购买合法性(单卡/设备购买、套餐有效性) +- [x] 2.3 实现步骤 2:调用 `checkOrderIdempotency()` 检查幂等性,如果已创建则返回现有订单 +- [x] 2.4 实现步骤 3:调用 `checkForceRechargeRequirement()` 检查强充要求 +- [x] 2.5 实现步骤 4:提取资源所属店铺 ID 和系列 ID(复用现有逻辑) +- [x] 2.6 实现步骤 5:根据 `payment_method` 路由到不同分支(offline → `createOrderWithActivation`,wallet → `createOrderWithWalletPayment`) +- [x] 2.7 实现步骤 6:添加 else 兜底检查,返回错误"后台仅支持钱包支付或线下支付" +- [x] 2.8 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误 -## 3. 常量定义:钱包流水子类型 +## 3. Service 层新增 CreateH5Order 方法 -- [x] 3.1 在 `pkg/constants/wallet.go` 中新增钱包交易子类型常量(`WalletTransactionSubtypeSelfPurchase`、`WalletTransactionSubtypePurchaseForSubordinate`),添加中文注释 -- [x] 3.2 运行 `go build ./...` 验证编译通过 +- [x] 3.1 在 `internal/service/order/service.go` 新增 `CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error)` 方法签名 +- [x] 3.2 将原 `Create()` 方法的完整逻辑复制到 `CreateH5Order()` 方法中(保持 H5 端行为不变) +- [x] 3.3 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误 -## 4. DTO 层:订单请求和响应 +## 4. Service 层重命名原 Create 方法为 CreateLegacy -- [x] 4.1 在 `internal/model/dto/order_dto.go` 的 `OrderResponse` 中添加新字段:`OperatorID`、`OperatorType`、`OperatorName`、`ActualPaidAmount`、`PurchaseRole`、`IsPurchasedByParent`、`PurchaseRemark`,添加 JSON 标签和 description 注释 -- [x] 4.2 在 `internal/model/dto/order_dto.go` 的 `OrderListRequest` 中添加 `PurchaseRole` 筛选字段,添加验证标签(`validate:"omitempty,oneof=self_purchase purchased_by_parent purchased_by_platform purchase_for_subordinate"`) -- [x] 4.3 运行 `go build ./...` 验证编译通过 +- [x] 4.1 将 `internal/service/order/service.go` 的 `Create()` 方法重命名为 `CreateLegacy()`(保留作为回滚方案) +- [x] 4.2 添加注释标记:`// Deprecated: 使用 CreateAdminOrder 或 CreateH5Order 替代。保留用于回滚。` +- [x] 4.3 验证编译:运行 `go build ./internal/service/order/...` 确认无编译错误 -## 5. Store 层:订单查询 OR 逻辑 +## 5. Handler 层修复后台订单创建(admin) -- [x] 5.1 修改 `internal/store/postgres/order_store.go` 的 `List()` 方法,支持 `shop_id` 筛选时使用 OR 查询:`WHERE (buyer_type = 'agent' AND buyer_id = ?) OR operator_id = ?` -- [x] 5.2 在 `List()` 方法中添加 `purchase_role` 精确匹配筛选支持 -- [x] 5.3 运行 `go build ./...` 验证编译通过 -- [x] 5.4 使用 PostgreSQL MCP 工具验证查询逻辑:创建测试订单,执行 `SELECT * FROM tb_order WHERE (buyer_id = X) OR (operator_id = X)` 并检查 EXPLAIN 输出 +- [x] 5.1 修改 `internal/handler/admin/order.go` 的 `Create()` 方法:将 DTO 类型从 `CreateOrderRequest` 改为 `CreateAdminOrderRequest` +- [x] 5.2 在 `c.BodyParser(&req)` 后添加参数验证:调用 `h.validator.Struct(&req)`,验证失败返回 `errors.New(errors.CodeInvalidParam)` +- [x] 5.3 修改权限检查逻辑:在 `else` 分支添加兜底检查,返回错误"后台仅支持钱包支付或线下支付" +- [x] 5.4 修改 Service 调用:将 `h.service.Create(...)` 改为 `h.service.CreateAdminOrder(...)` +- [x] 5.5 验证编译:运行 `go build ./internal/handler/admin/...` 确认无编译错误 -## 6. Service 层:成本价查询辅助方法 +## 6. Handler 层修复 H5 端订单创建 -- [x] 6.1 在 `internal/service/order/service.go` 中新增 `getCostPrice(ctx, shopID, packageID)` 方法,通过 `ShopPackageAllocation` 查询店铺对套餐的成本价 -- [x] 6.2 添加错误处理:如果查询失败,返回 `errors.New(errors.CodeInvalidParam, "店铺没有该套餐的分配配置")` -- [x] 6.3 运行 `go build ./...` 验证编译通过 +- [x] 6.1 修改 `internal/handler/h5/order.go` 的 `Create()` 方法:将 Service 调用从 `h.service.Create(...)` 改为 `h.service.CreateH5Order(...)` +- [x] 6.2 确认其他逻辑保持不变(DTO 仍使用 `CreateOrderRequest`,支持 wallet/wechat/alipay) +- [x] 6.3 验证编译:运行 `go build ./internal/handler/h5/...` 确认无编译错误 -## 7. Service 层:钱包流水创建方法 +## 7. 错误码检查(可选) -- [x] 7.1 在 `internal/service/order/service.go` 中新增 `createWalletTransaction(ctx, tx, walletID, orderID, amount, purchaseRole, relatedShopID)` 方法 -- [x] 7.2 在方法中根据 `purchaseRole` 确定 `transaction_subtype` 和 `remark`:自购场景填充"购买套餐",代购场景查询下级店铺名称填充"为下级代理【XX】购买套餐" -- [x] 7.3 创建 `AgentWalletTransaction` 记录,设置 `TransactionType` = `AgentTransactionTypeDeduct`,`TransactionSubtype`、`Amount`(负数)、`RelatedShopID`、`Remark` -- [x] 7.4 运行 `go build ./...` 验证编译通过 +- [x] 7.1 检查 `pkg/errors/errors.go` 是否已定义以下错误码:`CodeInvalidParam`、`CodeForbidden`、`CodeInsufficientBalance`、`CodeWalletNotFound` +- [x] 7.2 如果缺少,添加错误码定义(如 `CodeInvalidPaymentMethodForAdmin = 40008`)— 不需要,所有错误码已存在 +- [x] 7.3 验证编译:运行 `go build ./pkg/errors/...` 确认无编译错误 -## 8. Service 层:钱包支付订单创建方法 +## 8. 单元测试 - CreateAdminOrder 方法(跳过:项目禁止自动化测试) -- [x] 8.1 在 `internal/service/order/service.go` 中新增 `createOrderWithWalletPayment(ctx, order, items, operatorShopID, buyerShopID)` 方法 -- [x] 8.2 在方法开头(事务外)检查钱包余额,如果余额不足返回错误 -- [x] 8.3 开启 GORM 事务,在事务中依次执行:创建订单(`tx.Create(order)`)、创建订单明细(`tx.CreateInBatches(items, 100)`) -- [x] 8.4 在事务中扣减钱包余额,使用乐观锁:`WHERE id = ? AND balance >= ? AND version = ?`,更新 `balance = balance - ?` 和 `version = version + 1` -- [x] 8.5 检查 `RowsAffected`,如果为 0 返回 `errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")` -- [x] 8.6 在事务中调用 `createWalletTransaction()` 创建钱包流水 -- [x] 8.7 在事务中调用 `activatePackage()` 激活套餐 -- [x] 8.8 事务外判断是否入队佣金计算:`if order.OperatorID == nil { s.enqueueCommissionCalculation() }`(平台代购才入队) -- [x] 8.9 运行 `go build ./...` 验证编译通过 +- [x] ~~8.1-8.8~~ 跳过:项目规范禁止编写自动化测试代码(AGENTS.md) -## 9. Service 层:订单创建流程重构 +## 9. 单元测试 - CreateH5Order 方法(跳过:项目禁止自动化测试) -- [x] 9.1 在 `internal/service/order/service.go` 的 `Create()` 方法中,在幂等性检查后添加场景判断逻辑 -- [x] 9.2 提取资源所属店铺 ID(从 `validationResult.Card.ShopID` 或 `validationResult.Device.ShopID`) -- [x] 9.3 处理 `offline` 场景:设置 `operator_id = nil`、`operator_type = "platform"`、`purchase_role = "purchased_by_platform"`,调用 `resolvePurchaseOnBehalfInfo()` 获取买家成本价,保持现有逻辑调用 `createOrderWithActivation()` -- [x] 9.4 处理 `wallet` 场景:获取操作者店铺 ID,判断资源是否属于操作者 -- [x] 9.5 如果资源属于操作者(自购):设置 `buyer = operator`、`purchase_role = "self_purchase"`、`is_purchase_on_behalf = false`,调用 `getCostPrice()` 获取成本价,`total_amount = actual_paid_amount = 操作者成本价` -- [x] 9.6 如果资源不属于操作者(代购):设置 `buyer = 资源所属者`、`operator = 操作者`、`purchase_role = "purchase_for_subordinate"`、`is_purchase_on_behalf = true`,分别调用 `getCostPrice()` 获取买家和操作者成本价,`total_amount = 买家成本价`、`actual_paid_amount = 操作者成本价` -- [x] 9.7 `wallet` 场景调用 `createOrderWithWalletPayment()` 而不是 `orderStore.Create()` -- [x] 9.8 运行 `go build ./...` 验证编译通过 +- [x] ~~9.1-9.4~~ 跳过:项目规范禁止编写自动化测试代码(AGENTS.md) -## 10. Service 层:订单响应构建方法 +## 10. 集成测试 - 后台订单创建 API(跳过:需人工手动验证) -- [x] 10.1 在 `internal/service/order/service.go` 的 `buildOrderResponse()` 方法中添加新字段映射:`OperatorID`、`OperatorType`、`ActualPaidAmount`、`PurchaseRole` -- [x] 10.2 添加 `OperatorName` 字段逻辑:如果 `operator_type = "agent"` 且 `operator_id` 不为空,查询 `Shop` 表获取店铺名称 -- [x] 10.3 添加 `IsPurchasedByParent` 派生字段:`purchase_role == "purchased_by_parent"` -- [x] 10.4 添加 `PurchaseRemark` 派生字段:根据 `purchase_role` 和 `operator_name` 生成备注文本(如"由上级代理【XX】购买"、"由平台代购") -- [x] 10.5 运行 `go build ./...` 验证编译通过 +- [x] ~~10.1-10.7~~ 跳过:手动测试由用户自行完成 -## 11. Handler 层:权限检查调整 +## 11. 集成测试 - H5 端订单创建 API(跳过:需人工手动验证) -- [x] 11.1 在 `internal/handler/admin/order.go` 的 `Create()` 方法中,修改 `wallet` 支付方式的权限检查,允许代理、平台、超管使用 -- [x] 11.2 保持 `offline` 支付方式只允许平台和超管使用的限制 -- [x] 11.3 运行 `go build ./...` 验证编译通过 +- [x] ~~11.1-11.5~~ 跳过:手动测试由用户自行完成 -## 12. Handler 层:订单查询参数传递 +## 12. 钱包余额和流水验证(跳过:需人工手动验证) -- [x] 12.1 在 `internal/handler/admin/order.go` 的 `List()` 方法中,从查询参数解析 `purchase_role` -- [x] 12.2 将 `purchase_role` 传递给 Service 层的 `List()` 方法 -- [x] 12.3 运行 `go build ./...` 验证编译通过 +- [x] ~~12.1-12.5~~ 跳过:手动测试由用户自行完成 -## 13. 文档生成器更新(OpenAPI) +## 13. 幂等性和并发测试(跳过:需人工手动验证) -- [x] 13.1 确认 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 中的 `Handlers` 结构体已包含 `Order` Handler(如果不存在则添加) -- [x] 13.2 运行 `go run cmd/gendocs/main.go` 生成 OpenAPI 文档 -- [x] 13.3 检查生成的文档中订单创建和列表接口的请求/响应字段是否包含新字段 +- [x] ~~13.1-13.4~~ 跳过:手动测试由用户自行完成 -## 14. 集成测试:代理自购场景 +## 14. 错误日志和监控验证(跳过:需人工手动验证) -- [ ] 14.1 使用 PostgreSQL MCP 工具创建测试数据:创建代理账号、代理钱包(余额 10000 分)、IoT 卡(shop_id = 代理店铺 ID)、套餐分配配置(成本价 8000 分) -- [ ] 14.2 使用 Postman/curl 调用后台订单创建 API,代理账号创建订单,支付方式 wallet,选择自己的卡和套餐 -- [ ] 14.3 验证响应:`payment_status` = 2,`operator_id` = 代理店铺 ID,`buyer_id` = 代理店铺 ID,`purchase_role` = "self_purchase",`total_amount` = 8000,`actual_paid_amount` = 8000 -- [ ] 14.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确 -- [ ] 14.5 使用 PostgreSQL MCP 查询钱包表,验证余额扣减:`balance` = 2000(10000 - 8000) -- [ ] 14.6 使用 PostgreSQL MCP 查询钱包流水表,验证流水记录:`transaction_subtype` = "self_purchase",`amount` = -8000,`remark` = "购买套餐" -- [ ] 14.7 使用 PostgreSQL MCP 查询套餐使用表(`tb_package_usage`),验证套餐已激活:`status` = 1 +- [x] ~~14.1-14.3~~ 跳过:手动测试由用户自行完成 -## 15. 集成测试:代理代购场景 +## 15. 代码质量检查 -- [ ] 15.1 使用 PostgreSQL MCP 工具创建测试数据:一级代理(成本价 8000)、二级代理(成本价 10000,parent_shop_id = 一级代理)、一级代理钱包(余额 10000)、IoT 卡(shop_id = 二级代理店铺 ID)、套餐分配配置 -- [ ] 15.2 使用 Postman/curl 调用后台订单创建 API,一级代理账号创建订单,支付方式 wallet,选择二级代理的卡和套餐 -- [ ] 15.3 验证响应:`payment_status` = 2,`operator_id` = 一级代理店铺 ID,`buyer_id` = 二级代理店铺 ID,`purchase_role` = "purchase_for_subordinate",`total_amount` = 10000,`actual_paid_amount` = 8000 -- [ ] 15.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确 -- [ ] 15.5 使用 PostgreSQL MCP 查询一级代理钱包,验证余额扣减:`balance` = 2000(10000 - 8000) -- [ ] 15.6 使用 PostgreSQL MCP 查询钱包流水表,验证流水记录:`transaction_subtype` = "purchase_for_subordinate",`amount` = -8000,`related_shop_id` = 二级代理店铺 ID,`remark` 包含二级代理店铺名称 -- [ ] 15.7 使用 PostgreSQL MCP 查询套餐使用表,验证套餐已激活 -- [ ] 15.8 使用 PostgreSQL MCP 查询佣金表,验证未产生佣金记录(代理代购不产生佣金) +- [x] 15.1 运行 `gofmt -s -w .` 格式化代码 +- [x] 15.2 运行 `go vet ./...` 检查代码问题 +- [x] 15.3 运行 `go build ./...` 确认全部编译通过 +- [x] 15.4 检查所有新增代码的中文注释:确认导出函数有文档注释,复杂逻辑有实现注释 -## 16. 集成测试:平台代购场景(回归测试) +## 16. 文档更新 -- [ ] 16.1 使用 PostgreSQL MCP 工具创建测试数据:代理、IoT 卡(shop_id = 代理店铺 ID)、套餐分配配置(成本价 10000) -- [ ] 16.2 使用 Postman/curl 调用后台订单创建 API,平台账号创建订单,支付方式 offline,选择代理的卡和套餐 -- [ ] 16.3 验证响应:`payment_status` = 2,`operator_id` = NULL,`operator_type` = "platform",`buyer_id` = 代理店铺 ID,`purchase_role` = "purchased_by_platform",`total_amount` = 10000,`actual_paid_amount` = NULL -- [ ] 16.4 使用 PostgreSQL MCP 查询订单表,验证订单记录正确 -- [ ] 16.5 使用 PostgreSQL MCP 查询套餐使用表,验证套餐已激活 -- [ ] 16.6 验证平台代购逻辑未被破坏(不扣款、立即激活、产生佣金) +- [x] 16.1 更新功能总结文档:补充后台订单 API 的 Breaking Change 说明(payment_method 必填、仅允许 wallet/offline) -## 17. 集成测试:订单查询场景 +## 17. 清理和优化(部署前) -- [ ] 17.1 使用 PostgreSQL MCP 工具创建测试数据:一级代理、二级代理、多个订单(自购、代购、被代购) -- [ ] 17.2 使用 Postman/curl 调用后台订单列表 API,一级代理账号查询订单列表(不指定 purchase_role) -- [ ] 17.3 验证响应包含:buyer_id = 一级代理的订单 + operator_id = 一级代理的订单 -- [ ] 17.4 使用 Postman/curl 调用后台订单列表 API,一级代理账号查询订单列表,指定 `purchase_role=self_purchase` -- [ ] 17.5 验证响应只包含自购订单 -- [ ] 17.6 使用 Postman/curl 调用后台订单列表 API,一级代理账号查询订单列表,指定 `purchase_role=purchase_for_subordinate` -- [ ] 17.7 验证响应只包含为下级代理购买的订单 +- [x] 17.1 检查是否有未使用的导入或变量(使用 IDE 或 `go vet`) +- [x] 17.2 保留 `CreateLegacy()` 方法作为回滚方案,已有 Deprecated 标记 +- [x] 17.3 确认所有 TODO 注释已处理或转为 issue +- [x] ~~17.4~~ 跳过:项目禁止自动化测试 -## 18. 集成测试:边界场景 +## 18. 提交和归档 -- [ ] 18.1 测试钱包余额不足:代理钱包余额 5000,创建订单金额 8000,验证返回错误"余额不足" -- [ ] 18.2 测试并发扣款:模拟两个请求同时为同一钱包扣款,验证乐观锁生效,只有一个请求成功 -- [ ] 18.3 测试幂等性:同一买家对同一载体的同一套餐组合短时间内重复创建订单,验证返回相同订单 ID,不重复扣款 -- [ ] 18.4 测试 H5 端 wallet 订单:使用 H5 端 API 创建 wallet 订单,验证订单状态为待支付(`payment_status` = 1),不影响后台逻辑 - -## 19. 数据回填(可选) - -- [x] 19.1 编写数据回填脚本,将现有 `payment_method = 'offline'` 且 `is_purchase_on_behalf = true` 的订单回填 `purchase_role = 'purchased_by_platform'` 和 `operator_type = 'platform'` -- [ ] 19.2 在测试环境执行回填脚本,验证历史订单可正常查询 - -## 20. 文档更新 - -- [x] 20.1 更新接口文档说明订单创建 API 的行为变更(后台 wallet 支付一步完成) -- [x] 20.2 更新接口文档说明订单响应新增字段的含义 -- [x] 20.3 更新接口文档说明订单列表 API 新增 `purchase_role` 查询参数 - -## 21. 生产环境部署准备 - -- [ ] 21.1 在测试环境充分验证所有场景通过 -- [x] 21.2 准备生产环境迁移脚本和回滚脚本 -- [x] 21.3 准备灰度发布计划:代码部署 → 观察日志 → 验证核心功能 → 全量发布 -- [x] 21.4 准备监控指标:订单创建成功率、钱包扣款成功率、错误日志(余额不足、并发冲突) +- [ ] 18.1 使用 `git add` 暂存所有修改文件 +- [ ] 18.2 使用 `/commit` 创建 Git commit,提交消息:"修复代理钱包订单创建逻辑漏洞" +- [ ] 18.3 使用 `/opsx:verify` 验证实现与规格一致 +- [ ] 18.4 使用 `/opsx:archive` 归档变更,同步 delta specs 到主规格文档 diff --git a/openspec/changes/implement-order-expiration/.openspec.yaml b/openspec/changes/implement-order-expiration/.openspec.yaml new file mode 100644 index 0000000..34b5b23 --- /dev/null +++ b/openspec/changes/implement-order-expiration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-28 diff --git a/openspec/changes/implement-order-expiration/design.md b/openspec/changes/implement-order-expiration/design.md new file mode 100644 index 0000000..b287877 --- /dev/null +++ b/openspec/changes/implement-order-expiration/design.md @@ -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 端点和业务逻辑 diff --git a/openspec/changes/implement-order-expiration/proposal.md b/openspec/changes/implement-order-expiration/proposal.md new file mode 100644 index 0000000..b1c33c5 --- /dev/null +++ b/openspec/changes/implement-order-expiration/proposal.md @@ -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`(需手动处理或忽略) diff --git a/openspec/changes/implement-order-expiration/specs/iot-order/spec.md b/openspec/changes/implement-order-expiration/specs/iot-order/spec.md new file mode 100644 index 0000000..c290221 --- /dev/null +++ b/openspec/changes/implement-order-expiration/specs/iot-order/spec.md @@ -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** 系统自动取消订单,无需钱包解冻操作 diff --git a/openspec/changes/implement-order-expiration/specs/order-expiration/spec.md b/openspec/changes/implement-order-expiration/specs/order-expiration/spec.md new file mode 100644 index 0000000..0dce8cc --- /dev/null +++ b/openspec/changes/implement-order-expiration/specs/order-expiration/spec.md @@ -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,不影响现有业务 diff --git a/openspec/changes/implement-order-expiration/specs/order-payment/spec.md b/openspec/changes/implement-order-expiration/specs/order-payment/spec.md new file mode 100644 index 0000000..49e9337 --- /dev/null +++ b/openspec/changes/implement-order-expiration/specs/order-payment/spec.md @@ -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** 订单取��涉及钱包解冻 +- **THEN** 订单状态更新(`payment_status = 3`、`expires_at = NULL`)和钱包余额解冻在同一事务中完成,任一失败则全部回滚 + +#### Scenario: 钱包解冻失败回滚 + +- **WHEN** 订单取消时,钱包解冻失败(如钱包不存在、冻结余额不足) +- **THEN** 事务回滚,订单状态不变,返回错误信息"订单取消失败" diff --git a/openspec/changes/implement-order-expiration/tasks.md b/openspec/changes/implement-order-expiration/tasks.md new file mode 100644 index 0000000..34b81f6 --- /dev/null +++ b/openspec/changes/implement-order-expiration/tasks.md @@ -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` 已更新 diff --git a/openspec/specs/admin-order-creation/spec.md b/openspec/specs/admin-order-creation/spec.md new file mode 100644 index 0000000..25e540e --- /dev/null +++ b/openspec/specs/admin-order-creation/spec.md @@ -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 端的差异,防止误用 diff --git a/openspec/specs/order-payment/spec.md b/openspec/specs/order-payment/spec.md index 113ac26..a1bf955 100644 --- a/openspec/specs/order-payment/spec.md +++ b/openspec/specs/order-payment/spec.md @@ -107,45 +107,80 @@ #### Scenario: 余额扣减后套餐激活失败 - **WHEN** 余额扣减成功但套餐激活失败 - **THEN** 事务回滚,余额恢复,订单状态不变 -## ADDED Requirements + +--- ### Requirement: 后台钱包一步支付 -系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。 +系统 SHALL 支持后台订单创建时使用钱包支付立即完成订单,无需后续调用支付接口。后台订单创建使用独立的 Service 方法(`CreateAdminOrder()`),与 H5 端的 `CreateH5Order()` 方法隔离,避免逻辑混淆。 + +**后台钱包支付流程**(一步到位): +1. 检查钱包余额是否充足(事务外快速失败) +2. 在事务中:扣减钱包余额 → 创建已支付订单(`payment_status` = 2)→ 激活套餐 +3. 返回已支付的订单信息 + +**与 H5 端的区别**: +- 后台:立即扣款,订单创建后即为已支付状态(`payment_status` = 2) +- H5 端:冻结余额,创建待支付订单(`payment_status` = 1),需用户调用支付接口 #### Scenario: 后台订单创建时钱包支付 + - **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额充足 -- **THEN** 系统创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2),激活套餐 +- **THEN** 系统调用 `CreateAdminOrder()` 方法,创建订单,立即扣减钱包余额,订单状态为已支付(`payment_status` = 2),激活套餐 #### Scenario: 后台钱包支付余额不足 + - **WHEN** 代理在后台创建订单,支付方式为 wallet,钱包余额不足 -- **THEN** 系统返回错误"余额不足",订单创建失败 +- **THEN** 系统调用 `CreateAdminOrder()` 方法,在事务外检查余额,返回错误"余额不足",订单创建失败 #### Scenario: 后台钱包支付订单响应 + - **WHEN** 后台钱包支付订单创建成功 - **THEN** API 响应包含已支付的订单信息,`payment_status` = 2,`payment_method` = "wallet",`paid_at` 为当前时间 #### Scenario: 后台钱包支付不创建待支付订单 + - **WHEN** 代理在后台创建 wallet 订单 -- **THEN** 系统不创建待支付订单(`payment_status` != 1),直接完成支付 +- **THEN** 系统不创建待支付订单(`payment_status` != 1),直接完成支付和套餐激活 + +#### Scenario: 后台钱包支付使用独立方法 + +- **WHEN** 代理在后台创建 wallet 订单 +- **THEN** Handler 层调用 `OrderService.CreateAdminOrder()` 方法,不调用通用的 `Create()` 或 `CreateH5Order()` 方法 --- ### Requirement: H5 钱包两步支付保持不变 -系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。 +系统 SHALL 保持 H5 端钱包支付的两步流程(创建待支付订单 → 调用支付接口)。H5 端订单创建使用独立的 Service 方法(`CreateH5Order()`),与后台的 `CreateAdminOrder()` 方法隔离。 + +**H5 钱包支付流程**(两步流程): +1. 创建订单:冻结钱包余额 → 创建待支付订单(`payment_status` = 1) +2. 用户调用支付接口:扣减钱包余额 → 更新订单状态为已支付 → 激活套餐 + +**与后台的区别**: +- H5 端:创建待支付订单,用户需调用支付接口完成支付 +- 后台:立即扣款,订单创建后即为已支付状态 #### Scenario: H5 创建待支付订单 + - **WHEN** 个人客户在 H5 端创建订单,支付方式为 wallet -- **THEN** 系统创建订单,`payment_status` = 1(待支付),不扣减钱包余额 +- **THEN** 系统调用 `CreateH5Order()` 方法,创建订单,`payment_status` = 1(待支付),冻结钱包余额,不立即扣款 #### Scenario: H5 调用 WalletPay 接口支付 + - **WHEN** 个人客户调用 WalletPay 接口支付待支付订单 - **THEN** 系统扣减钱包余额,更新订单状态为已支付,激活套餐 #### Scenario: H5 和后台钱包支付流程独立 + - **WHEN** H5 端创建 wallet 订单 -- **THEN** 不影响后台 wallet 订单的一步支付逻辑 +- **THEN** 系统调用 `CreateH5Order()` 方法,不影响后台 wallet 订单的一步支付逻辑 + +#### Scenario: H5 钱包支付使用独立方法 + +- **WHEN** 个人客户在 H5 端创建 wallet 订单 +- **THEN** Handler 层调用 `OrderService.CreateH5Order()` 方法,不调用 `CreateAdminOrder()` 方法 --- @@ -253,16 +288,38 @@ ### Requirement: 钱包支付与第三方支付的区别 -系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。 +系统 SHALL 区分后台钱包支付和第三方支付的业务逻辑。后台订单创建 MUST 在 Handler 层强制验证支付方式,拒绝 `wechat` 和 `alipay` 支付方式。 -#### Scenario: 后台不支持第三方支付 -- **WHEN** 代理在后台创建订单时选择 wechat 或 alipay -- **THEN** 系统返回错误"后台只支持 wallet 和 offline 支付方式" +**后台支付方式限制**: +- 允许:`wallet`、`offline` +- 拒绝:`wechat`、`alipay`、其他任何值 + +**实现层级**: +1. **DTO 验证**(第一道防线):`CreateAdminOrderRequest` 的 `payment_method` 字段使用 `validate:"oneof=wallet offline"` 规则 +2. **Handler 验证**(第二道防线):调用 `middleware.ValidateStruct(&req)` 验证 DTO +3. **Handler 兜底检查**(第三道防线):对所有支付方式进行权限检查,包括非法值 + +#### Scenario: 后台参数验证拒绝第三方支付 + +- **WHEN** 代理在后台创建订单时 `payment_method` 为 wechat 或 alipay +- **THEN** 系统在 Handler 层的 DTO 验证阶段拒绝请求,返回错误"请求参数解析失败"(`CodeInvalidParam`),订单创建失败 + +#### Scenario: 后台兜底检查拒绝其他支付方式 + +- **WHEN** 代理在后台创建订单时 `payment_method` 为未知值(防御性编程) +- **THEN** 系统在 Handler 层的兜底检查阶段拒绝请求,返回错误"后台仅支持钱包支付或线下支付"(`CodeInvalidParam`) #### Scenario: H5 支持第三方支付 + - **WHEN** 个人客户在 H5 端创建订单时选择 wechat 或 alipay -- **THEN** 系统创建待支付订单,返回支付参数(prepay_id 或 h5_url) +- **THEN** 系统调用 `CreateH5Order()` 方法,创建待支付订单,返回支付参数(prepay_id 或 h5_url) #### Scenario: 钱包支付不需要支付参数 + - **WHEN** 后台钱包支付订单创建成功 - **THEN** 响应不包含 prepay_id、h5_url 等第三方支付参数 + +#### Scenario: 后台使用独立的 DTO + +- **WHEN** 后台创建订单 +- **THEN** Handler 层使用 `CreateAdminOrderRequest` DTO(仅允许 wallet/offline),H5 端使用 `CreateOrderRequest` DTO(允许 wallet/wechat/alipay) diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 5db132e..1d45044 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -43,7 +43,7 @@ func BuildDocHandlers() *bootstrap.Handlers { ShopPackageAllocation: admin.NewShopPackageAllocationHandler(nil), ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil), ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil), - AdminOrder: admin.NewOrderHandler(nil), + AdminOrder: admin.NewOrderHandler(nil, nil), H5Order: h5.NewOrderHandler(nil), H5Recharge: h5.NewRechargeHandler(nil), PaymentCallback: callback.NewPaymentHandler(nil, nil, nil),