From d81bd242a4cafdeb6e4dd6e0997646467cc8e7a3 Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 31 Jan 2026 15:34:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(force-recharge):=20=E8=A1=A5=E5=85=85?= =?UTF-8?q?=E5=BC=BA=E5=85=85=E9=85=8D=E7=BD=AE=E7=BC=BA=E5=A4=B1=E7=9A=84?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=92=8C=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AD=97?= =?UTF-8?q?=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 订单管理:增加 payment_method 字段支持,合并代购订单逻辑 - 套餐系列分配:增加强充配置字段(enable_force_recharge、force_recharge_amount、force_recharge_trigger_type) - 数据库迁移:添加 force_recharge_trigger_type 字段 - 测试:更新订单服务测试用例 - OpenSpec:归档 fix-force-recharge-missing-interfaces 变更 --- internal/handler/admin/order.go | 19 +- internal/handler/h5/order.go | 4 + internal/model/dto/order_dto.go | 17 +- internal/model/dto/shop_series_allocation.go | 51 +-- internal/model/shop_series_allocation.go | 5 +- internal/service/order/service.go | 221 +++++------- internal/service/order/service_test.go | 340 +++++++----------- .../service/shop_series_allocation/service.go | 34 +- ...7_add_force_recharge_trigger_type.down.sql | 3 + ...037_add_force_recharge_trigger_type.up.sql | 6 + .../.openspec.yaml | 2 + .../design.md | 267 ++++++++++++++ .../proposal.md | 76 ++++ .../specs/order-management/spec.md | 118 ++++++ .../specs/shop-series-allocation/spec.md | 100 ++++++ .../tasks.md | 64 ++++ openspec/specs/order-management/spec.md | 88 ++++- openspec/specs/shop-series-allocation/spec.md | 35 +- pkg/storage/service.go | 3 +- pkg/storage/types.go | 2 +- tests/testutils/db.go | 23 +- 21 files changed, 1090 insertions(+), 388 deletions(-) create mode 100644 migrations/000037_add_force_recharge_trigger_type.down.sql create mode 100644 migrations/000037_add_force_recharge_trigger_type.up.sql create mode 100644 openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/.openspec.yaml create mode 100644 openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/design.md create mode 100644 openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/proposal.md create mode 100644 openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/specs/order-management/spec.md create mode 100644 openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/specs/shop-series-allocation/spec.md create mode 100644 openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/tasks.md diff --git a/internal/handler/admin/order.go b/internal/handler/admin/order.go index 94ec143..e9e0b0c 100644 --- a/internal/handler/admin/order.go +++ b/internal/handler/admin/order.go @@ -32,11 +32,24 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error { userType := middleware.GetUserTypeFromContext(ctx) shopID := middleware.GetShopIDFromContext(ctx) - if userType != constants.UserTypeAgent { - return errors.New(errors.CodeForbidden, "只有代理账号可以创建订单") + 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, "无权创建订单") + } } - order, err := h.service.Create(ctx, &req, model.BuyerTypeAgent, shopID) + buyerType := "" + buyerID := uint(0) + if userType == constants.UserTypeAgent { + buyerType = model.BuyerTypeAgent + buyerID = shopID + } + + order, err := h.service.Create(ctx, &req, buyerType, buyerID) if err != nil { return err } diff --git a/internal/handler/h5/order.go b/internal/handler/h5/order.go index 8fe8bda..25c0baf 100644 --- a/internal/handler/h5/order.go +++ b/internal/handler/h5/order.go @@ -28,6 +28,10 @@ func (h *OrderHandler) Create(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } + if req.PaymentMethod != model.PaymentMethodWallet { + return errors.New(errors.CodeInvalidParam, "H5端只支持钱包支付") + } + ctx := c.UserContext() userType := middleware.GetUserTypeFromContext(ctx) diff --git a/internal/model/dto/order_dto.go b/internal/model/dto/order_dto.go index cc9520a..4de917c 100644 --- a/internal/model/dto/order_dto.go +++ b/internal/model/dto/order_dto.go @@ -3,10 +3,11 @@ 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列表"` + 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:线下支付)"` } type OrderListRequest struct { @@ -45,6 +46,7 @@ type OrderResponse struct { PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"` PaymentStatusText string `json:"payment_status_text" description:"支付状态文本"` PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"` + IsPurchaseOnBehalf bool `json:"is_purchase_on_behalf" description:"是否为代购订单"` CommissionStatus int `json:"commission_status" description:"佣金状态 (1:待计算, 2:已计算)"` CommissionConfigVersion int `json:"commission_config_version" description:"佣金配置版本"` Items []*OrderItemResponse `json:"items" description:"订单明细列表"` @@ -87,10 +89,3 @@ type PurchaseCheckResponse struct { WalletCredit int64 `json:"wallet_credit" description:"钱包到账金额(分)"` Message string `json:"message" description:"提示信息"` } - -type CreatePurchaseOnBehalfRequest 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列表"` -} diff --git a/internal/model/dto/shop_series_allocation.go b/internal/model/dto/shop_series_allocation.go index 3f7f42f..5b87dcb 100644 --- a/internal/model/dto/shop_series_allocation.go +++ b/internal/model/dto/shop_series_allocation.go @@ -26,18 +26,24 @@ type OneTimeCommissionTierEntry struct { // CreateShopSeriesAllocationRequest 创建套餐系列分配请求 type CreateShopSeriesAllocationRequest struct { - ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"` - SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"` - BaseCommission BaseCommissionConfig `json:"base_commission" validate:"required" required:"true" description:"基础返佣配置"` - EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` - OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置(启用一次性佣金时必填)"` + ShopID uint `json:"shop_id" validate:"required" required:"true" description:"被分配的店铺ID"` + SeriesID uint `json:"series_id" validate:"required" required:"true" description:"套餐系列ID"` + BaseCommission BaseCommissionConfig `json:"base_commission" validate:"required" required:"true" description:"基础返佣配置"` + EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` + OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置(启用一次性佣金时必填)"` + EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用强充(累计充值强充)"` + ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"强充金额(分,0表示使用阈值金额)"` + ForceRechargeTriggerType *int `json:"force_recharge_trigger_type,omitempty" description:"强充触发类型(1:单次充值, 2:累计充值)"` } // UpdateShopSeriesAllocationRequest 更新套餐系列分配请求 type UpdateShopSeriesAllocationRequest struct { - BaseCommission *BaseCommissionConfig `json:"base_commission" validate:"omitempty" description:"基础返佣配置"` - EnableOneTimeCommission *bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` - OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置"` + BaseCommission *BaseCommissionConfig `json:"base_commission" validate:"omitempty" description:"基础返佣配置"` + EnableOneTimeCommission *bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` + OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config" validate:"omitempty" description:"一次性佣金配置"` + EnableForceRecharge *bool `json:"enable_force_recharge,omitempty" description:"是否启用强充(累计充值强充)"` + ForceRechargeAmount *int64 `json:"force_recharge_amount,omitempty" description:"强充金额(分,0表示使用阈值金额)"` + ForceRechargeTriggerType *int `json:"force_recharge_trigger_type,omitempty" description:"强充触发类型(1:单次充值, 2:累计充值)"` } // ShopSeriesAllocationListRequest 套餐系列分配列表请求 @@ -56,19 +62,22 @@ type UpdateShopSeriesAllocationStatusRequest struct { // ShopSeriesAllocationResponse 套餐系列分配响应 type ShopSeriesAllocationResponse struct { - ID uint `json:"id" description:"分配ID"` - ShopID uint `json:"shop_id" description:"被分配的店铺ID"` - ShopName string `json:"shop_name" description:"被分配的店铺名称"` - SeriesID uint `json:"series_id" description:"套餐系列ID"` - SeriesName string `json:"series_name" description:"套餐系列名称"` - AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID"` - AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"` - BaseCommission BaseCommissionConfig `json:"base_commission" description:"基础返佣配置"` - EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` - OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config,omitempty" description:"一次性佣金配置"` - Status int `json:"status" description:"状态 (1:启用, 2:禁用)"` - CreatedAt string `json:"created_at" description:"创建时间"` - UpdatedAt string `json:"updated_at" description:"更新时间"` + ID uint `json:"id" description:"分配ID"` + ShopID uint `json:"shop_id" description:"被分配的店铺ID"` + ShopName string `json:"shop_name" description:"被分配的店铺名称"` + SeriesID uint `json:"series_id" description:"套餐系列ID"` + SeriesName string `json:"series_name" description:"套餐系列名称"` + AllocatorShopID uint `json:"allocator_shop_id" description:"分配者店铺ID"` + AllocatorShopName string `json:"allocator_shop_name" description:"分配者店铺名称"` + BaseCommission BaseCommissionConfig `json:"base_commission" description:"基础返佣配置"` + EnableOneTimeCommission bool `json:"enable_one_time_commission" description:"是否启用一次性佣金"` + OneTimeCommissionConfig *OneTimeCommissionConfig `json:"one_time_commission_config,omitempty" description:"一次性佣金配置"` + EnableForceRecharge bool `json:"enable_force_recharge" description:"是否启用强充"` + ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"` + ForceRechargeTriggerType int `json:"force_recharge_trigger_type" description:"强充触发类型(1:单次充值, 2:累计充值)"` + Status int `json:"status" description:"状态 (1:启用, 2:禁用)"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"更新时间"` } // ShopSeriesAllocationPageResult 套餐系列分配分页结果 diff --git a/internal/model/shop_series_allocation.go b/internal/model/shop_series_allocation.go index 31e79a6..a1cac7c 100644 --- a/internal/model/shop_series_allocation.go +++ b/internal/model/shop_series_allocation.go @@ -25,8 +25,9 @@ type ShopSeriesAllocation struct { OneTimeCommissionValue int64 `gorm:"column:one_time_commission_value;type:bigint;default:0;comment:佣金金额(分)或比例(千分比)" json:"one_time_commission_value"` // 强充配置 - EnableForceRecharge bool `gorm:"column:enable_force_recharge;type:boolean;default:false;comment:是否启用强充(累计充值时可选)" json:"enable_force_recharge"` - ForceRechargeAmount int64 `gorm:"column:force_recharge_amount;type:bigint;default:0;comment:强充金额(分,0表示使用阈值金额)" json:"force_recharge_amount"` + EnableForceRecharge bool `gorm:"column:enable_force_recharge;type:boolean;default:false;comment:是否启用强充(累计充值时可选)" json:"enable_force_recharge"` + ForceRechargeAmount int64 `gorm:"column:force_recharge_amount;type:bigint;default:0;comment:强充金额(分,0表示使用阈值金额)" json:"force_recharge_amount"` + ForceRechargeTriggerType int `gorm:"column:force_recharge_trigger_type;type:int;default:2;comment:强充触发类型(1:单次充值, 2:累计充值)" json:"force_recharge_trigger_type"` Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"` } diff --git a/internal/service/order/service.go b/internal/service/order/service.go index bba1bf3..018cd1b 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -95,6 +95,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer userID := middleware.GetUserIDFromContext(ctx) configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID) + orderBuyerType := buyerType + orderBuyerID := buyerID + totalAmount := validationResult.TotalPrice + paymentMethod := req.PaymentMethod + paymentStatus := model.PaymentStatusPending + var paidAt *time.Time + isPurchaseOnBehalf := false + var seriesID *uint var sellerShopID *uint var sellerCostPrice int64 @@ -102,6 +110,22 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer if validationResult.Allocation != nil { seriesID = &validationResult.Allocation.SeriesID sellerShopID = &validationResult.Allocation.ShopID + } + + if req.PaymentMethod == model.PaymentMethodOffline { + 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 + } else if validationResult.Allocation != nil { sellerCostPrice = utils.CalculateCostPrice(validationResult.Allocation, validationResult.TotalPrice) } @@ -112,25 +136,68 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer }, OrderNo: s.orderStore.GenerateOrderNo(), OrderType: req.OrderType, - BuyerType: buyerType, - BuyerID: buyerID, + BuyerType: orderBuyerType, + BuyerID: orderBuyerID, IotCardID: req.IotCardID, DeviceID: req.DeviceID, - TotalAmount: validationResult.TotalPrice, - PaymentStatus: model.PaymentStatusPending, + TotalAmount: totalAmount, + PaymentMethod: paymentMethod, + PaymentStatus: paymentStatus, + PaidAt: paidAt, CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: configVersion, SeriesID: seriesID, SellerShopID: sellerShopID, SellerCostPrice: sellerCostPrice, + IsPurchaseOnBehalf: isPurchaseOnBehalf, } - var items []*model.OrderItem - for _, pkg := range validationResult.Packages { + items := s.buildOrderItems(userID, validationResult.Packages) + + if req.PaymentMethod == model.PaymentMethodOffline { + if err := s.createOrderWithActivation(ctx, order, items); err != nil { + return nil, err + } + s.enqueueCommissionCalculation(ctx, order.ID) + return s.buildOrderResponse(order, items), nil + } + + if err := s.orderStore.Create(ctx, order, items); err != nil { + return nil, err + } + + 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 + if result.Card != nil { + resourceShopID = result.Card.ShopID + } else if result.Device != nil { + resourceShopID = result.Device.ShopID + } + + if resourceShopID == nil || *resourceShopID == 0 { + return 0, 0, nil, errors.New(errors.CodeInvalidParam, "资源未分配给代理商,无法代购") + } + + buyerAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, *resourceShopID, result.Allocation.SeriesID) + if err != nil { + return 0, 0, nil, errors.New(errors.CodeInvalidParam, "买家没有该套餐系列的分配配置") + } + + buyerCostPrice := utils.CalculateCostPrice(buyerAllocation, result.TotalPrice) + now := time.Now() + return *resourceShopID, buyerCostPrice, &now, nil +} + +func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package) []*model.OrderItem { + items := make([]*model.OrderItem, 0, len(packages)) + for _, pkg := range packages { item := &model.OrderItem{ BaseModel: model.BaseModel{ - Creator: userID, - Updater: userID, + Creator: operatorID, + Updater: operatorID, }, PackageID: pkg.ID, PackageName: pkg.PackageName, @@ -141,11 +208,24 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, buyer items = append(items, item) } - if err := s.orderStore.Create(ctx, order, items); err != nil { - return nil, err - } + return items +} - return s.buildOrderResponse(order, items), nil +func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Order, items []*model.OrderItem) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(order).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建代购订单失败") + } + + for _, item := range items { + item.OrderID = order.ID + } + if err := tx.CreateInBatches(items, 100).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败") + } + + return s.activatePackage(ctx, tx, order) + }) } func (s *Service) Get(ctx context.Context, id uint) (*dto.OrderResponse, error) { @@ -544,6 +624,7 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte PaymentStatus: order.PaymentStatus, PaymentStatusText: statusText, PaidAt: order.PaidAt, + IsPurchaseOnBehalf: order.IsPurchaseOnBehalf, CommissionStatus: order.CommissionStatus, CommissionConfigVersion: order.CommissionConfigVersion, Items: itemResponses, @@ -751,119 +832,3 @@ func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRe return response, nil } - -func (s *Service) CreatePurchaseOnBehalf(ctx context.Context, req *dto.CreatePurchaseOnBehalfRequest, operatorID uint) (*dto.OrderResponse, error) { - var validationResult *purchase_validation.PurchaseValidationResult - var resourceShopID *uint - 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) - if err != nil { - return nil, err - } - if validationResult.Card != nil { - resourceShopID = validationResult.Card.ShopID - } - } 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) - if err != nil { - return nil, err - } - if validationResult.Device != nil { - resourceShopID = validationResult.Device.ShopID - } - } else { - return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型") - } - - if resourceShopID == nil || *resourceShopID == 0 { - return nil, errors.New(errors.CodeInvalidParam, "资源未分配给代理商,无法代购") - } - - buyerID := *resourceShopID - buyerAllocation, err := s.seriesAllocationStore.GetByShopAndSeries(ctx, buyerID, validationResult.Allocation.SeriesID) - if err != nil { - return nil, errors.New(errors.CodeInvalidParam, "买家没有该套餐系列的分配配置") - } - - buyerCostPrice := utils.CalculateCostPrice(buyerAllocation, validationResult.TotalPrice) - - configVersion := s.snapshotCommissionConfig(ctx, validationResult.Allocation.ID) - - var seriesID *uint - var sellerShopID *uint - if validationResult.Allocation != nil { - seriesID = &validationResult.Allocation.SeriesID - sellerShopID = &validationResult.Allocation.ShopID - } - - now := time.Now() - order := &model.Order{ - BaseModel: model.BaseModel{ - Creator: operatorID, - Updater: operatorID, - }, - OrderNo: s.orderStore.GenerateOrderNo(), - OrderType: req.OrderType, - BuyerType: model.BuyerTypeAgent, - BuyerID: buyerID, - IotCardID: req.IotCardID, - DeviceID: req.DeviceID, - TotalAmount: buyerCostPrice, - PaymentMethod: model.PaymentMethodOffline, - PaymentStatus: model.PaymentStatusPaid, - PaidAt: &now, - CommissionStatus: model.CommissionStatusPending, - CommissionConfigVersion: configVersion, - SeriesID: seriesID, - SellerShopID: sellerShopID, - SellerCostPrice: buyerCostPrice, - IsPurchaseOnBehalf: true, - } - - var items []*model.OrderItem - for _, pkg := range validationResult.Packages { - item := &model.OrderItem{ - BaseModel: model.BaseModel{ - Creator: operatorID, - Updater: operatorID, - }, - PackageID: pkg.ID, - PackageName: pkg.PackageName, - Quantity: 1, - UnitPrice: pkg.SuggestedRetailPrice, - Amount: pkg.SuggestedRetailPrice, - } - items = append(items, item) - } - - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Create(order).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建代购订单失败") - } - - for _, item := range items { - item.OrderID = order.ID - } - if err := tx.CreateInBatches(items, 100).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败") - } - - return s.activatePackage(ctx, tx, order) - }) - - if err != nil { - return nil, err - } - - s.enqueueCommissionCalculation(ctx, order.ID) - - return s.buildOrderResponse(order, items), nil -} diff --git a/internal/service/order/service_test.go b/internal/service/order/service_test.go index fe5a465..cd7ab1e 100644 --- a/internal/service/order/service_test.go +++ b/internal/service/order/service_test.go @@ -44,7 +44,10 @@ func setupOrderTestEnv(t *testing.T) *testEnv { orderItemStore := postgres.NewOrderItemStore(tx, rdb) walletStore := postgres.NewWalletStore(tx, rdb) - ctx := context.Background() + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) carrier := &model.Carrier{ CarrierCode: "TEST_CARRIER_ORDER", @@ -151,9 +154,10 @@ func TestOrderService_Create(t *testing.T) { t.Run("创建单卡订单成功", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) @@ -170,9 +174,10 @@ func TestOrderService_Create(t *testing.T) { t.Run("创建设备订单成功", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeDevice, - DeviceID: &env.device.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeDevice, + DeviceID: &env.device.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) @@ -183,8 +188,9 @@ func TestOrderService_Create(t *testing.T) { t.Run("单卡订单缺少卡ID", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } _, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) @@ -196,8 +202,9 @@ func TestOrderService_Create(t *testing.T) { t.Run("设备订单缺少设备ID", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeDevice, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeDevice, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } _, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) @@ -206,15 +213,50 @@ func TestOrderService_Create(t *testing.T) { require.True(t, ok) assert.Equal(t, errors.CodeInvalidParam, appErr.Code) }) + + t.Run("钱包支付创建订单", func(t *testing.T) { + req := &dto.CreateOrderRequest{ + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, + } + + resp, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) + require.NoError(t, err) + assert.Equal(t, model.PaymentStatusPending, resp.PaymentStatus) + assert.False(t, resp.IsPurchaseOnBehalf) + }) + + t.Run("线下支付创建订单", func(t *testing.T) { + req := &dto.CreateOrderRequest{ + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodOffline, + } + + platformCtx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) + + resp, err := env.svc.Create(platformCtx, req, "", 0) + require.NoError(t, err) + assert.Equal(t, model.PaymentStatusPaid, resp.PaymentStatus) + assert.True(t, resp.IsPurchaseOnBehalf) + assert.NotNil(t, resp.PaidAt) + }) } func TestOrderService_Get(t *testing.T) { env := setupOrderTestEnv(t) req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -240,9 +282,10 @@ func TestOrderService_List(t *testing.T) { for i := 0; i < 3; i++ { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } _, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -278,9 +321,10 @@ func TestOrderService_Cancel(t *testing.T) { env := setupOrderTestEnv(t) req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -304,9 +348,10 @@ func TestOrderService_Cancel(t *testing.T) { t.Run("无权操作", func(t *testing.T) { newReq := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } newOrder, err := env.svc.Create(env.ctx, newReq, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -324,9 +369,10 @@ func TestOrderService_WalletPay(t *testing.T) { t.Run("钱包支付成功", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -351,9 +397,10 @@ func TestOrderService_WalletPay(t *testing.T) { t.Run("无权操作", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -367,9 +414,10 @@ func TestOrderService_WalletPay(t *testing.T) { t.Run("重复支付-幂等成功", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -383,9 +431,10 @@ func TestOrderService_WalletPay(t *testing.T) { t.Run("已取消订单无法支付", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -406,9 +455,10 @@ func TestOrderService_HandlePaymentCallback(t *testing.T) { t.Run("支付回调成功", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -424,9 +474,10 @@ func TestOrderService_HandlePaymentCallback(t *testing.T) { t.Run("幂等处理-已支付订单", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &env.card.ID, - PackageIDs: []uint{env.pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &env.card.ID, + PackageIDs: []uint{env.pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := env.svc.Create(env.ctx, req, model.BuyerTypeAgent, env.shop.ID) require.NoError(t, err) @@ -463,7 +514,10 @@ func TestOrderService_IdempotencyAndConcurrency(t *testing.T) { orderItemStore := postgres.NewOrderItemStore(tx, rdb) walletStore := postgres.NewWalletStore(tx, rdb) - ctx := context.Background() + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) carrier := &model.Carrier{ CarrierCode: "TEST_CARRIER_IDEM", @@ -546,9 +600,10 @@ func TestOrderService_IdempotencyAndConcurrency(t *testing.T) { t.Run("串行幂等支付测试", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) require.NoError(t, err) @@ -574,9 +629,10 @@ func TestOrderService_IdempotencyAndConcurrency(t *testing.T) { t.Run("串行回调幂等测试", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) require.NoError(t, err) @@ -598,9 +654,10 @@ func TestOrderService_IdempotencyAndConcurrency(t *testing.T) { t.Run("已取消订单回调测试", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{pkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{pkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } created, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) require.NoError(t, err) @@ -637,7 +694,10 @@ func TestOrderService_ForceRechargeValidation(t *testing.T) { orderItemStore := postgres.NewOrderItemStore(tx, rdb) walletStore := postgres.NewWalletStore(tx, rdb) - ctx := context.Background() + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) carrier := &model.Carrier{ CarrierCode: "TEST_CARRIER_FR", @@ -728,9 +788,10 @@ func TestOrderService_ForceRechargeValidation(t *testing.T) { t.Run("强充验证-金额不足拒绝", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{cheapPkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{cheapPkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } _, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) @@ -742,9 +803,10 @@ func TestOrderService_ForceRechargeValidation(t *testing.T) { t.Run("强充验证-金额足够通过", func(t *testing.T) { req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{expensivePkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{expensivePkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } resp, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) @@ -766,9 +828,10 @@ func TestOrderService_ForceRechargeValidation(t *testing.T) { require.NoError(t, iotCardStore.Create(ctx, card2)) req := &dto.CreateOrderRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card2.ID, - PackageIDs: []uint{cheapPkg.ID}, + OrderType: model.OrderTypeSingleCard, + IotCardID: &card2.ID, + PackageIDs: []uint{cheapPkg.ID}, + PaymentMethod: model.PaymentMethodWallet, } resp, err := orderSvc.Create(userCtx, req, model.BuyerTypeAgent, shop.ID) @@ -793,7 +856,10 @@ func TestOrderService_GetPurchaseCheck(t *testing.T) { orderItemStore := postgres.NewOrderItemStore(tx, rdb) walletStore := postgres.NewWalletStore(tx, rdb) - ctx := context.Background() + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) carrier := &model.Carrier{ CarrierCode: "TEST_CARRIER_PC", @@ -905,140 +971,6 @@ func TestOrderService_GetPurchaseCheck(t *testing.T) { }) } -func TestOrderService_CreatePurchaseOnBehalf(t *testing.T) { - tx := testutils.NewTestTransaction(t) - rdb := testutils.GetTestRedis(t) - testutils.CleanTestRedisKeys(t, rdb) - - iotCardStore := postgres.NewIotCardStore(tx, rdb) - deviceStore := postgres.NewDeviceStore(tx, rdb) - packageStore := postgres.NewPackageStore(tx) - seriesAllocationStore := postgres.NewShopSeriesAllocationStore(tx) - packageSeriesStore := postgres.NewPackageSeriesStore(tx) - carrierStore := postgres.NewCarrierStore(tx) - shopStore := postgres.NewShopStore(tx, rdb) - orderStore := postgres.NewOrderStore(tx, rdb) - orderItemStore := postgres.NewOrderItemStore(tx, rdb) - walletStore := postgres.NewWalletStore(tx, rdb) - - ctx := context.Background() - - carrier := &model.Carrier{ - CarrierCode: "TEST_CARRIER_POB", - CarrierName: "测试运营商代购", - CarrierType: constants.CarrierTypeCMCC, - Status: constants.StatusEnabled, - } - require.NoError(t, carrierStore.Create(ctx, carrier)) - - shop := &model.Shop{ - ShopName: "测试店铺POB", - ShopCode: "TEST_SHOP_POB", - Level: 1, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, shopStore.Create(ctx, shop)) - - series := &model.PackageSeries{ - SeriesCode: "TEST_SERIES_POB", - SeriesName: "测试套餐系列代购", - Description: "测试用", - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, packageSeriesStore.Create(ctx, series)) - - allocation := &model.ShopSeriesAllocation{ - ShopID: shop.ID, - SeriesID: series.ID, - AllocatorShopID: 0, - BaseCommissionMode: model.CommissionModePercent, - BaseCommissionValue: 100, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, seriesAllocationStore.Create(ctx, allocation)) - - pkg := &model.Package{ - PackageCode: "TEST_PKG_POB", - PackageName: "测试套餐代购", - SeriesID: series.ID, - PackageType: "formal", - DurationMonths: 1, - DataAmountMB: 1024, - SuggestedRetailPrice: 10000, - Status: constants.StatusEnabled, - ShelfStatus: constants.ShelfStatusOn, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, packageStore.Create(ctx, pkg)) - - shopIDPtr := &shop.ID - card := &model.IotCard{ - ICCID: "89860000000000000POB", - ShopID: shopIDPtr, - CarrierID: carrier.ID, - SeriesAllocationID: &allocation.ID, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, iotCardStore.Create(ctx, card)) - - purchaseValidationSvc := purchase_validation.New(tx, iotCardStore, deviceStore, packageStore, seriesAllocationStore) - logger := zap.NewNop() - orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) - - t.Run("代购订单创建成功", func(t *testing.T) { - req := &dto.CreatePurchaseOnBehalfRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{pkg.ID}, - } - - resp, err := orderSvc.CreatePurchaseOnBehalf(ctx, req, 1) - require.NoError(t, err) - assert.NotZero(t, resp.ID) - assert.Equal(t, model.PaymentStatusPaid, resp.PaymentStatus) - assert.Equal(t, model.PaymentMethodOffline, resp.PaymentMethod) - assert.Equal(t, model.BuyerTypeAgent, resp.BuyerType) - assert.Equal(t, shop.ID, resp.BuyerID) - assert.NotNil(t, resp.PaidAt) - - expectedCostPrice := pkg.SuggestedRetailPrice - (pkg.SuggestedRetailPrice * allocation.BaseCommissionValue / 1000) - assert.Equal(t, expectedCostPrice, resp.TotalAmount) - - var usageCount int64 - err = tx.Model(&model.PackageUsage{}).Where("order_id = ?", resp.ID).Count(&usageCount).Error - require.NoError(t, err) - assert.Equal(t, int64(1), usageCount) - }) - - t.Run("代购订单-资源未分配拒绝", func(t *testing.T) { - cardNoShop := &model.IotCard{ - ICCID: "89860000000000NOSHOP", - ShopID: nil, - CarrierID: carrier.ID, - SeriesAllocationID: &allocation.ID, - Status: constants.StatusEnabled, - BaseModel: model.BaseModel{Creator: 1, Updater: 1}, - } - require.NoError(t, iotCardStore.Create(ctx, cardNoShop)) - - req := &dto.CreatePurchaseOnBehalfRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &cardNoShop.ID, - PackageIDs: []uint{pkg.ID}, - } - - _, err := orderSvc.CreatePurchaseOnBehalf(ctx, req, 1) - require.Error(t, err) - appErr, ok := err.(*errors.AppError) - require.True(t, ok) - assert.Equal(t, errors.CodeInvalidParam, appErr.Code) - }) -} - func TestOrderService_WalletPay_PurchaseOnBehalf(t *testing.T) { tx := testutils.NewTestTransaction(t) rdb := testutils.GetTestRedis(t) @@ -1055,7 +987,10 @@ func TestOrderService_WalletPay_PurchaseOnBehalf(t *testing.T) { orderItemStore := postgres.NewOrderItemStore(tx, rdb) walletStore := postgres.NewWalletStore(tx, rdb) - ctx := context.Background() + ctx := middleware.SetUserContext(context.Background(), &middleware.UserContextInfo{ + UserID: 1, + UserType: constants.UserTypePlatform, + }) carrier := &model.Carrier{ CarrierCode: "TEST_CARRIER_WP", @@ -1134,13 +1069,14 @@ func TestOrderService_WalletPay_PurchaseOnBehalf(t *testing.T) { orderSvc := New(tx, orderStore, orderItemStore, walletStore, purchaseValidationSvc, nil, seriesAllocationStore, iotCardStore, deviceStore, nil, nil, logger) t.Run("代购订单无法进行钱包支付", func(t *testing.T) { - req := &dto.CreatePurchaseOnBehalfRequest{ - OrderType: model.OrderTypeSingleCard, - IotCardID: &card.ID, - PackageIDs: []uint{pkg.ID}, + req := &dto.CreateOrderRequest{ + OrderType: model.OrderTypeSingleCard, + IotCardID: &card.ID, + PackageIDs: []uint{pkg.ID}, + PaymentMethod: model.PaymentMethodOffline, } - created, err := orderSvc.CreatePurchaseOnBehalf(ctx, req, 1) + created, err := orderSvc.Create(ctx, req, model.BuyerTypeAgent, shop.ID) require.NoError(t, err) err = orderSvc.WalletPay(ctx, created.ID, model.BuyerTypeAgent, shop.ID) diff --git a/internal/service/shop_series_allocation/service.go b/internal/service/shop_series_allocation/service.go index 354849b..87c09c6 100644 --- a/internal/service/shop_series_allocation/service.go +++ b/internal/service/shop_series_allocation/service.go @@ -124,6 +124,18 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesAllocatio allocation.OneTimeCommissionValue = cfg.Value } } + + // 处理强充配置 + if req.EnableForceRecharge != nil { + allocation.EnableForceRecharge = *req.EnableForceRecharge + } + if req.ForceRechargeAmount != nil { + allocation.ForceRechargeAmount = *req.ForceRechargeAmount + } + if req.ForceRechargeTriggerType != nil { + allocation.ForceRechargeTriggerType = *req.ForceRechargeTriggerType + } + allocation.Creator = currentUserID if err := s.allocationStore.Create(ctx, allocation); err != nil { @@ -224,6 +236,17 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries allocation.OneTimeCommissionValue = 0 } } + + if req.EnableForceRecharge != nil { + allocation.EnableForceRecharge = *req.EnableForceRecharge + } + if req.ForceRechargeAmount != nil { + allocation.ForceRechargeAmount = *req.ForceRechargeAmount + } + if req.ForceRechargeTriggerType != nil { + allocation.ForceRechargeTriggerType = *req.ForceRechargeTriggerType + } + allocation.Updater = currentUserID if configChanged { @@ -399,10 +422,13 @@ func (s *Service) buildResponse(ctx context.Context, a *model.ShopSeriesAllocati Mode: a.BaseCommissionMode, Value: a.BaseCommissionValue, }, - EnableOneTimeCommission: a.EnableOneTimeCommission, - Status: a.Status, - CreatedAt: a.CreatedAt.Format(time.RFC3339), - UpdatedAt: a.UpdatedAt.Format(time.RFC3339), + EnableOneTimeCommission: a.EnableOneTimeCommission, + EnableForceRecharge: a.EnableForceRecharge, + ForceRechargeAmount: a.ForceRechargeAmount, + ForceRechargeTriggerType: a.ForceRechargeTriggerType, + Status: a.Status, + CreatedAt: a.CreatedAt.Format(time.RFC3339), + UpdatedAt: a.UpdatedAt.Format(time.RFC3339), } if a.EnableOneTimeCommission { diff --git a/migrations/000037_add_force_recharge_trigger_type.down.sql b/migrations/000037_add_force_recharge_trigger_type.down.sql new file mode 100644 index 0000000..9313c58 --- /dev/null +++ b/migrations/000037_add_force_recharge_trigger_type.down.sql @@ -0,0 +1,3 @@ +-- 回滚: 删除强充触发类型字段 + +ALTER TABLE tb_shop_series_allocation DROP COLUMN force_recharge_trigger_type; diff --git a/migrations/000037_add_force_recharge_trigger_type.up.sql b/migrations/000037_add_force_recharge_trigger_type.up.sql new file mode 100644 index 0000000..e57f750 --- /dev/null +++ b/migrations/000037_add_force_recharge_trigger_type.up.sql @@ -0,0 +1,6 @@ +-- 为 tb_shop_series_allocation 表添加强充触发类型字段 +-- 补充强充配置完整性 + +ALTER TABLE tb_shop_series_allocation ADD COLUMN force_recharge_trigger_type INT DEFAULT 2; + +COMMENT ON COLUMN tb_shop_series_allocation.force_recharge_trigger_type IS '强充触发类型(1:单次充值, 2:累计充值)'; diff --git a/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/.openspec.yaml b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/.openspec.yaml new file mode 100644 index 0000000..71f0dad --- /dev/null +++ b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-01-31 diff --git a/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/design.md b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/design.md new file mode 100644 index 0000000..97074c8 --- /dev/null +++ b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/design.md @@ -0,0 +1,267 @@ +## Context + +在 `add-force-recharge-system` 功能归档后,发现两个关键遗漏: + +1. **套餐系列分配强充配置不可管理**: + - 数据库字段已添加:`enable_force_recharge`、`force_recharge_amount`、`force_recharge_trigger_type` + - Service 层已使用这些字段进行强充验证(`RechargeService.GetRechargeCheck`、`OrderService.GetPurchaseCheck`) + - 但 DTO 层完全没有暴露这些字段,管理员无法通过 API 配置 + +2. **后台订单创建逻辑重复**: + - 存在两套 DTO:`CreateOrderRequest` 和 `CreatePurchaseOnBehalfRequest`(字段完全相同) + - 存在两个 Service 方法:`Create` 和 `CreatePurchaseOnBehalf`(核心逻辑相似,仅支付方式和成本价计算不同) + - 实际业务中,后台订单只有两种支付方式: + - `wallet`:扣代理钱包,需要强充验证,触发佣金 + - `offline`:线下已收款,不扣钱包,不触发佣金(即代购) + +**现有架构**: +- 强充验证逻辑完整:`RechargeService`、`OrderService` 已实现 +- 数据模型完整:`ShopSeriesAllocation.enable_force_recharge` 等字段已存在 +- 仅缺少管理接口暴露 + +## Goals / Non-Goals + +**Goals:** +- 暴露强充配置字段到套餐系列分配的 CRUD 接口 +- 统一后台订单创建接口,使用 `payment_method` 字段区分普通订单和代购订单 +- 删除重复代码和冗余 DTO +- 保持现有业务逻辑不变(强充验证、佣金计算、成本价计算) + +**Non-Goals:** +- 不修改强充验证逻辑(已在 `add-force-recharge-system` 中实现) +- 不修改数据库结构(字段已存在) +- 不修改 H5 订单接口(H5 仅支持微信/支付宝支付,不涉及 offline) +- 不修改佣金计算逻辑(`CommissionCalculationService` 已正确处理 `is_purchase_on_behalf`) + +## Decisions + +### 决策 1:强充配置字段作为可选字段暴露 + +**决策**:在套餐系列分配的 DTO 中增加强充配置字段,作为可选字段(`omitempty`) + +**理由**: +- 强充是累计充值强充的**可选配置**(`enable_force_recharge` 默认 false) +- 与一次性佣金配置保持一致(也是可选配置) +- 向后兼容:现有数据默认值为 false/0,不影响现有逻辑 + +**实现**: +```go +// CreateShopSeriesAllocationRequest +type CreateShopSeriesAllocationRequest struct { + // ... 现有字段 + EnableForceRecharge *bool `json:"enable_force_recharge" description:"是否启用强充(累计充值强充)"` + ForceRechargeAmount *int64 `json:"force_recharge_amount" description:"强充金额(分,0表示使用阈值金额)"` + ForceRechargeTriggerType *int `json:"force_recharge_trigger_type" description:"强充触发类型(1:单次充值, 2:累计充值)"` +} + +// UpdateShopSeriesAllocationRequest (同上) + +// ShopSeriesAllocationResponse +type ShopSeriesAllocationResponse struct { + // ... 现有字段 + EnableForceRecharge bool `json:"enable_force_recharge" description:"是否启用强充"` + ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强充金额(分)"` + ForceRechargeTriggerType int `json:"force_recharge_trigger_type" description:"强充触发类型"` +} +``` + +**替代方案**: +- ❌ 独立接口配置强充:增加接口复杂度,与现有设计不一致 +- ❌ 强制字段:破坏向后兼容性,现有创建请求会失败 + +### 决策 2:使用 payment_method 字段统一订单创建 + +**决策**:在 `CreateOrderRequest` 增加 `payment_method` 必填字段,删除 `CreatePurchaseOnBehalfRequest` + +**理由**: +- 后台订单本质上只有两种支付方式:`wallet` 和 `offline` +- `is_purchase_on_behalf` 是业务标识,应由系统自动设置,不应由前端传递 +- 减少 DTO 冗余,统一接口设计 + +**映射关系**: +``` +payment_method = "wallet" → is_purchase_on_behalf = false (普通订单) +payment_method = "offline" → is_purchase_on_behalf = true (代购订单) +``` + +**实现**: +```go +type CreateOrderRequest struct { + OrderType string `json:"order_type" validate:"required,oneof=single_card device"` + IotCardID *uint `json:"iot_card_id" validate:"required_if=OrderType single_card"` + DeviceID *uint `json:"device_id" validate:"required_if=OrderType device"` + PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline"` // 新增 +} +``` + +**替代方案**: +- ❌ 保留两个 DTO:代码重复,维护成本高 +- ❌ 使用 `is_purchase_on_behalf` 字段:业务标识不应由前端控制,存在安全风险 + +### 决策 3:Service 层合并逻辑但保留代码分支 + +**决策**:合并 `Service.Create` 和 `Service.CreatePurchaseOnBehalf` 为统一方法,内部使用 `if/else` 分支处理 + +**理由**: +- 两个方法核心流程相似(验证 → 计算价格 → 创建订单 → 激活套餐 → 触发佣金) +- 关键差异仅在: + - 成本价计算:普通订单用卖家成本价,代购用买家成本价 + - 支付状态:普通订单待支付,代购直接已支付 + - 佣金触发:通过 `is_purchase_on_behalf` 标识控制 +- 统一方法减少接口暴露,降低复杂度 + +**实现结构**: +```go +func (s *Service) Create(ctx context.Context, req *dto.CreateOrderRequest, userType string, shopID, userID uint) (*dto.OrderResponse, error) { + // 1. 通用验证(资源存在、套餐有效) + validationResult, err := s.validatePurchase(ctx, req) + + // 2. 根据 payment_method 分支处理 + var order *model.Order + if req.PaymentMethod == model.PaymentMethodOffline { + // 代购逻辑 + order = s.buildPurchaseOnBehalfOrder(ctx, req, validationResult, userID) + } else { + // 普通逻辑 + order = s.buildNormalOrder(ctx, req, validationResult, shopID, userID) + } + + // 3. 通用创建流程(保存订单 → 激活套餐 → 触发佣金) + return s.createOrderCommon(ctx, order, validationResult) +} +``` + +**替代方案**: +- ❌ 保留两个独立方法:Handler 需要路由逻辑,接口复杂度高 +- ❌ 完全合并为一个线性方法:可读性差,分支逻辑混乱 + +### 决策 4:Handler 层权限验证前置 + +**决策**:在 `OrderHandler.Create` 中根据 `payment_method` 进行权限验证,再调用 Service + +**理由**: +- 权限验证是 Handler 层职责 +- 提前拦截非法请求,避免无效的 Service 调用 +- 明确业务规则:offline 仅平台可用,wallet 代理和平台都可用 + +**实现**: +```go +func (h *OrderHandler) Create(c *fiber.Ctx) error { + var req dto.CreateOrderRequest + // ... 解析请求 + + ctx := c.UserContext() + userType := middleware.GetUserTypeFromContext(ctx) + shopID := middleware.GetShopIDFromContext(ctx) + userID := middleware.GetUserIDFromContext(ctx) + + // 权限验证 + 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, "无权创建订单") + } + } + + // 调用统一方法 + order, err := h.service.Create(ctx, &req, userType, shopID, userID) + return response.Success(c, order) +} +``` + +**替代方案**: +- ❌ Service 层验证:违反分层原则,Handler 应负责权限 +- ❌ 中间件验证:无法访问请求体字段 + +## Risks / Trade-offs + +### 风险 1:`CreateOrderRequest` 字段变更可能影响前端 + +**风险**:新增 `payment_method` 必填字段后,现有前端调用会失败 + +**缓解措施**: +- 这是后台管理接口,前端由团队控制,可同步修改 +- 如需兼容,可设置默认值(wallet),但不推荐(隐式行为会引起混淆) + +**推荐**:要求前端同步修改,明确传递 `payment_method` + +### 风险 2:删除 `CreatePurchaseOnBehalfRequest` 可能影响现有调用 + +**风险**:如果其他代码引用了 `CreatePurchaseOnBehalfRequest` DTO,会编译失败 + +**缓解措施**: +- 通过 `grep` 搜索确认无引用(仅在 Service 测试中使用) +- 修改测试用例,使用 `CreateOrderRequest` 替代 + +**验证步骤**: +```bash +grep -r "CreatePurchaseOnBehalfRequest" internal/ +``` + +### 风险 3:Service 方法签名变更可能影响现有调用 + +**风险**:`Service.Create` 方法签名变更(增加 `userType`、`userID` 参数),现有调用方可能失败 + +**缓解措施**: +- 通过 LSP 查找所有调用方 +- 仅有 `OrderHandler.Create` 和测试用例调用,影响范围可控 + +**影响范围**: +- `internal/handler/admin/order.go` +- `internal/handler/h5/order.go`(H5 订单不使用 offline,不受影响) +- `internal/service/order/service_test.go` + +### 权衡 4:合并方法增加了单个方法的复杂度 + +**权衡**:`Service.Create` 方法内部有 if/else 分支,复杂度略微增加 + +**接受理由**: +- 两个独立方法的维护成本更高(重复代码、接口复杂) +- 内部分支清晰,使用辅助方法拆分逻辑(`buildPurchaseOnBehalfOrder`、`buildNormalOrder`) +- 测试覆盖两种分支场景,确保正确性 + +## Migration Plan + +### 部署步骤 + +1. **代码变更**: + - 修改 DTO(3 个文件) + - 修改 Service(2 个文件) + - 修改 Handler(1 个文件) + - 修改测试用例(2 个文件) + +2. **测试验证**: + - 运行单元测试确保所有测试通过 + - 运行 `lsp_diagnostics` 检查类型错误 + - 本地验证接口功能 + +3. **部署**: + - 无数据库迁移,直接部署即可 + - 通知前端团队同步修改 `POST /api/admin/orders` 调用 + +4. **验证**: + - 测试套餐系列分配的创建/更新/查询,确认强充配置正常显示 + - 测试后台订单创建(wallet 和 offline),确认业务逻辑正确 + +### 回滚策略 + +如果发现问题,可以: +1. 回滚代码到上一版本(Git revert) +2. 无数据库变更,回滚无风险 +3. 强充配置字段可选,即使旧代码未传递也不会出错 + +### 兼容性保障 + +- **强充配置字段**:可选字段,默认值 false/0,现有数据不受影响 +- **订单创建接口**:`payment_method` 必填,需前端同步修改(可控) +- **删除的 DTO 和方法**:仅内部使用,无外部依赖 + +## Open Questions + +无待解决问题。 diff --git a/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/proposal.md b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/proposal.md new file mode 100644 index 0000000..2ffe3ce --- /dev/null +++ b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/proposal.md @@ -0,0 +1,76 @@ +## Why + +在 `add-force-recharge-system` 功能归档后发现遗漏了关键的管理接口:(1) 套餐系列分配表虽然增加了强充配置字段(enable_force_recharge、force_recharge_amount、force_recharge_trigger_type),但创建/更新/查询接口完全没有暴露这些字段,管理员无法通过 API 配置强充要求;(2) 后台订单接口设计不合理,存在两套独立的创建订单逻辑(普通订单和代购订单),但实际业务中后台订单只有钱包支付和线下支付两种方式,代购本质就是线下支付,不应该独立处理。这导致管理员只能通过直接修改数据库来配置强充,且代码存在重复逻辑和冗余 DTO。 + +## What Changes + +- **修复套餐系列分配接口**:在 `CreateShopSeriesAllocationRequest`、`UpdateShopSeriesAllocationRequest`、`ShopSeriesAllocationResponse` 中增加强充配置字段 +- **修复 ShopSeriesAllocationService**:在 `Create`、`Update`、`buildResponse` 方法中处理强充配置的创建、更新和返回 +- **统一后台订单接口**:在 `CreateOrderRequest` 增加 `payment_method` 字段(wallet/offline),删除 `CreatePurchaseOnBehalfRequest` 冗余 DTO +- **合并订单创建逻辑**:将 `Service.Create` 和 `Service.CreatePurchaseOnBehalf` 合并为统一方法,根据 `payment_method` 自动设置 `is_purchase_on_behalf` 标识 +- **修改订单权限验证**:`OrderHandler.Create` 增加支付方式权限检查(offline 仅平台可用,wallet 代理和平台都可用) + +## Capabilities + +### New Capabilities +无新增 capabilities + +### Modified Capabilities +- `shop-series-allocation`: 增加强充配置字段的 CRUD 接口支持(enable_force_recharge、force_recharge_amount、force_recharge_trigger_type) +- `order-management`: 统一后台订单创建接口,使用 payment_method 字段替代独立的代购接口,合并重复逻辑 + +## Impact + +### API 变更 +- **修改接口**: + - `POST /api/admin/shop-series-allocations` - Request 增加强充配置字段(enable_force_recharge、force_recharge_amount、force_recharge_trigger_type) + - `PUT /api/admin/shop-series-allocations/:id` - Request 增加强充配置字段 + - `GET /api/admin/shop-series-allocations/:id` - Response 增加强充配置字段 + - `GET /api/admin/shop-series-allocations` - Response 列表项增加强充配置字段 + - `POST /api/admin/orders` - Request 增加 payment_method 字段(wallet/offline),支持统一创建普通订单和代购订单 +- **删除接口**: + - 无需删除接口(原 `POST /api/admin/orders/purchase-check` 保留,仍然有效) + +### DTO 变更 +- **修改 DTO**: + - `CreateShopSeriesAllocationRequest` - 增加 3 个字段 + - `UpdateShopSeriesAllocationRequest` - 增加 3 个字段 + - `ShopSeriesAllocationResponse` - 增加 3 个字段 + - `CreateOrderRequest` - 增加 `payment_method` 字段 +- **删除 DTO**: + - `CreatePurchaseOnBehalfRequest` - 冗余,已被统一到 `CreateOrderRequest` + +### Service 层变更 +- **ShopSeriesAllocationService**: + - `Create` 方法:处理强充配置字段的保存 + - `Update` 方法:处理强充配置字段的更新 + - `buildResponse` 方法:返回强充配置字段 +- **OrderService**: + - `Create` 方法:增加 `payment_method` 参数处理,合并代购逻辑(根据 payment_method 自动设置 is_purchase_on_behalf) + - 删除 `CreatePurchaseOnBehalf` 方法(逻辑合并到 Create) + +### Handler 层变更 +- **OrderHandler.Create**: + - 增加支付方式权限验证(offline 仅平台,wallet 代理和平台) + - 调用统一的 `service.Create` 方法 + +### 数据库变更 +无(字段已在 `add-force-recharge-system` 中添加) + +### 业务逻辑影响 +- **强充配置管理**:管理员可以通过 API 配置强充要求,无需直接修改数据库 +- **订单创建逻辑**:统一处理,减少代码重复,`is_purchase_on_behalf` 自动根据 `payment_method` 设置(offline = true, wallet = false) +- **权限控制**:明确 offline 支付仅平台可用,wallet 支付代理和平台都可用 + +### 测试影响 +- **单元测试**:需要修改 `ShopSeriesAllocationService` 和 `OrderService` 的测试用例 +- **集成测试**:需要修改套餐系列分配和订单创建的集成测试 +- **测试覆盖率**:保持 90%+ 覆盖率 + +### 向后兼容性 +- **✅ 向后兼容**: + - 套餐系列分配的强充字段为可选(默认 false/0),现有数据不受影响 + - 订单接口增加 payment_method 字段为必填,但这是管理后台接口,无外部集成 +- **⚠️ 需要注意**: + - `CreatePurchaseOnBehalfRequest` DTO 删除,如有使用需迁移到 `CreateOrderRequest` + - `OrderService.CreatePurchaseOnBehalf` 方法删除,调用方需改为 `Create` 方法 diff --git a/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/specs/order-management/spec.md b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/specs/order-management/spec.md new file mode 100644 index 0000000..3abb700 --- /dev/null +++ b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/specs/order-management/spec.md @@ -0,0 +1,118 @@ +## MODIFIED Requirements + +### Requirement: 创建套餐购买订单 + +系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限和强充要求。**后台订单接口 MUST 支持 `payment_method` 字段(wallet/offline),根据支付方式自动设置 `is_purchase_on_behalf` 标识**。 + +**支付方式和订单类型映射**: +- `payment_method = "wallet"`:扣买家钱包,`is_purchase_on_behalf = false`(普通订单) +- `payment_method = "offline"`:线下已收款,`is_purchase_on_behalf = true`(代购订单) + +**权限规则**: +- `wallet` 支付:代理、平台、超级管理员可使用 +- `offline` 支付:仅平台、超级管理员可使用 + +#### Scenario: 个人客户创建单卡订单 +- **WHEN** 个人客户为自己的卡创建订单,选择一个套餐 +- **THEN** 系统创建订单,状态为待支付,is_purchase_on_behalf = false,返回订单信息 + +#### Scenario: 个人客户创建设备订单 +- **WHEN** 个人客户为自己的设备创建订单 +- **THEN** 系统创建订单,订单类型为设备购买,is_purchase_on_behalf = false + +#### Scenario: 代理创建普通订单(钱包支付) +- **WHEN** 代理为店铺关联的卡/设备创建订单,payment_method = "wallet" +- **THEN** 系统创建订单,买家类型为代理商,买家ID为店铺ID,is_purchase_on_behalf = false,payment_status = 1(待支付) + +#### Scenario: 平台创建代购订单(线下支付) +- **WHEN** 平台账号为代理的卡/设备创建订单,payment_method = "offline" +- **THEN** 系统创建订单,is_purchase_on_behalf = true,payment_method = "offline",payment_status = 2(已支付),直接激活套餐 + +#### Scenario: 代理尝试使用线下支付 +- **WHEN** 代理账号创建订单,payment_method = "offline" +- **THEN** 系统返回错误 "只有平台可以使用线下支付" + +#### Scenario: 平台使用钱包支付 +- **WHEN** 平台账号创建订单,payment_method = "wallet",指定目标代理 +- **THEN** 系统创建普通订单,扣目标代理钱包,is_purchase_on_behalf = false + +#### Scenario: 套餐购买验证强充要求 +- **WHEN** 个人客户创建订单,存在强充要求,订单金额低于强充金额 +- **THEN** 系统返回错误 "支付金额不符合强充要求" + +#### Scenario: 套餐不在可购买范围 +- **WHEN** 买家尝试购买不在关联系列下的套餐 +- **THEN** 系统返回错误 "该套餐不在可购买范围内" + +#### Scenario: 套餐已下架 +- **WHEN** 买家尝试购买已下架的套餐 +- **THEN** 系统返回错误 "该套餐已下架" + +--- + +## ADDED Requirements + +### Requirement: 后台订单 payment_method 字段 + +后台订单创建接口 MUST 支持 `payment_method` 字段,值为 `wallet` 或 `offline`。系统 SHALL 根据 payment_method 自动设置 is_purchase_on_behalf 标识。 + +#### Scenario: payment_method 为 wallet +- **WHEN** 创建订单时 payment_method = "wallet" +- **THEN** 系统设置 is_purchase_on_behalf = false,payment_status = 1(待支付) + +#### Scenario: payment_method 为 offline +- **WHEN** 创建订单时 payment_method = "offline" +- **THEN** 系统设置 is_purchase_on_behalf = true,payment_status = 2(已支付),paid_at = 当前时间 + +#### Scenario: payment_method 验证 +- **WHEN** 创建订单时 payment_method 为无效值 +- **THEN** 系统返回参数验证错误 + +--- + +### Requirement: 代购订单成本价计算 + +线下支付(代购订单)MUST 使用买家的成本价,钱包支付(普通订单)使用卖家的成本价。 + +#### Scenario: 线下支付使用买家成本价 +- **WHEN** 平台创建线下支付订单,目标卡归属于代理 A,代理 A 的系列分配成本价为 100 元 +- **THEN** 订单总金额为 100 元(买家成本价) + +#### Scenario: 钱包支付使用卖家成本价 +- **WHEN** 代理 A 为自己的卡创建钱包支付订单,代理 A 的上级代理 B 的系列分配成本价为 120 元 +- **THEN** 订单总金额为 120 元(卖家成本价) + +--- + +### Requirement: 代购订单不触发佣金和累计充值 + +代购订单(is_purchase_on_behalf = true)SHALL 计算差价佣金,MUST NOT 触发一次性佣金,MUST NOT 更新累计充值。 + +#### Scenario: 代购订单计算差价佣金 +- **WHEN** 代购订单支付成功,买家成本价 100 元,套餐建议成本价 80 元 +- **THEN** 系统计算差价佣金 20 元,分配给上级代理 + +#### Scenario: 代购订单不触发一次性佣金 +- **WHEN** 代购订单支付成功,符合一次性佣金触发条件 +- **THEN** 系统 MUST NOT 触发一次性佣金 + +#### Scenario: 代购订单不更新累计充值 +- **WHEN** 代购订单支付成功 +- **THEN** 系统 MUST NOT 更新卡/设备的 accumulated_recharge 字段 + +--- + +## REMOVED Requirements + +### Requirement: 独立的代购订单接口 + +**❌ REMOVED** - 此 requirement 已废弃 + +**原内容**: 系统提供独立的代购订单创建接口 `POST /api/admin/orders/purchase-on-behalf` + +**Reason**: 代购订单本质是线下支付的订单,不应独立处理。统一使用 `POST /api/admin/orders` 接口,通过 `payment_method` 字段区分。 + +**Migration**: +- 删除 `CreatePurchaseOnBehalfRequest` DTO +- 使用 `CreateOrderRequest` 替代,增加 `payment_method` 字段 +- 前端调用统一接口,传递 `payment_method = "offline"` 创建代购订单 diff --git a/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/specs/shop-series-allocation/spec.md b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/specs/shop-series-allocation/spec.md new file mode 100644 index 0000000..b0d7346 --- /dev/null +++ b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/specs/shop-series-allocation/spec.md @@ -0,0 +1,100 @@ +## MODIFIED Requirements + +### Requirement: 为下级店铺分配套餐系列 + +系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定基础返佣配置(返佣模式和返佣值),MAY 启用一次性佣金和强充配置。分配者只能分配自己已被分配的套餐系列。 + +**API 接口 MUST 在请求和响应中包含强充配置字段**: +- `enable_force_recharge`:是否启用强充 +- `force_recharge_amount`:强充金额(分,0 表示使用阈值) +- `force_recharge_trigger_type`:强充触发类型(1: 单次充值,2: 累计充值) + +#### Scenario: 成功分配套餐系列 +- **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列,设置基础返佣为百分比200(20%) +- **THEN** 系统创建分配记录 + +#### Scenario: 分配时启用一次性佣金和强充 +- **WHEN** 代理为下级分配系列,启用一次性佣金,触发类型为累计充值,阈值 100000(1000元),启用强充,强充金额 10000(100元) +- **THEN** 系统保存配置:enable_one_time_commission = true,trigger = "accumulated_recharge",threshold = 100000,enable_force_recharge = true,force_recharge_amount = 10000 + +#### Scenario: API 请求包含强充配置字段 +- **WHEN** 创建分配时,请求包含 enable_force_recharge = true,force_recharge_amount = 10000,force_recharge_trigger_type = 2 +- **THEN** 系统接受并保存这些字段,响应中返回相同的配置 + +#### Scenario: API 响应包含强充配置字段 +- **WHEN** 查询分配详情或列表 +- **THEN** 响应 MUST 包含 enable_force_recharge、force_recharge_amount、force_recharge_trigger_type 字段 + +#### Scenario: 尝试分配未拥有的系列 +- **WHEN** 代理尝试分配自己未被分配的套餐系列 +- **THEN** 系统返回错误 "您没有该套餐系列的分配权限" + +#### Scenario: 尝试分配给非直属下级 +- **WHEN** 代理尝试分配给非直属下级店铺 +- **THEN** 系统返回错误 "只能为直属下级分配套餐" + +#### Scenario: 重复分配同一系列 +- **WHEN** 代理尝试为同一下级店铺重复分配同一套餐系列 +- **THEN** 系统返回错误 "该店铺已分配此套餐系列" + +--- + +### Requirement: 查询套餐系列分配列表 + +系统 SHALL 提供分配列表查询,支持按下级店铺筛选、按套餐系列筛选、按状态筛选。**响应 MUST 包含强充配置字段**。 + +#### Scenario: 查询所有分配 +- **WHEN** 代理查询分配列表,不带筛选条件 +- **THEN** 系统返回该代理创建的所有分配记录,每条记录包含强充配置字段 + +#### Scenario: 按店铺筛选 +- **WHEN** 代理指定下级店铺 ID 筛选 +- **THEN** 系统只返回该店铺的分配记录,记录包含强充配置字段 + +#### Scenario: 响应包含强充配置 +- **WHEN** 查询分配列表 +- **THEN** 每条记录包含 enable_force_recharge、force_recharge_amount、force_recharge_trigger_type 字段 + +--- + +### Requirement: 更新套餐系列分配 + +系统 SHALL 允许代理更新分配的基础返佣配置、一次性佣金配置和强充配置。更新返佣配置时 MUST 创建新的配置版本。**API 请求 MUST 支持更新强充配置字段**。 + +#### Scenario: 更新基础返佣配置时创建新版本 +- **WHEN** 代理将基础返佣从20%改为25% +- **THEN** 系统更新分配记录,并创建新配置版本 + +#### Scenario: 更新强充配置 +- **WHEN** 代理将 enable_force_recharge 从 false 改为 true,设置 force_recharge_amount = 10000 +- **THEN** 系统更新分配记录,后续下级客户需遵守新强充要求 + +#### Scenario: API 支持部分更新强充配置 +- **WHEN** 更新请求只包含 enable_force_recharge = false,不包含其他强充字段 +- **THEN** 系统更新 enable_force_recharge,其他强充字段保持不变 + +#### Scenario: 禁用强充 +- **WHEN** 代理将 enable_force_recharge 从 true 改为 false +- **THEN** 系统更新分配记录,后续下级客户可以自由充值 + +#### Scenario: 更新不存在的分配 +- **WHEN** 代理更新不存在的分配 ID +- **THEN** 系统返回 "分配记录不存在" 错误 + +--- + +### Requirement: 平台分配套餐系列 + +平台管理员 SHALL 能够为一级代理分配套餐系列,可配置强充要求。平台的成本价基准为 Package.suggested_cost_price。**API 接口 MUST 支持强充配置字段的输入和输出**。 + +#### Scenario: 平台为一级代理分配 +- **WHEN** 平台管理员为一级代理分配套餐系列 +- **THEN** 系统创建分配记录 + +#### Scenario: 平台配置强充要求 +- **WHEN** 平台为一级代理分配系列,启用强充,force_recharge_amount = 10000 +- **THEN** 系统保存强充配置,一级代理的客户需遵守强充要求 + +#### Scenario: API 请求和响应包含强充配置 +- **WHEN** 平台创建或查询分配 +- **THEN** 请求和响应都包含强充配置字段 diff --git a/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/tasks.md b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/tasks.md new file mode 100644 index 0000000..4e649c9 --- /dev/null +++ b/openspec/changes/archive/2026-01-31-fix-force-recharge-missing-interfaces/tasks.md @@ -0,0 +1,64 @@ +## 1. 套餐系列分配 DTO 修改 + +- [x] 1.1 修改 `internal/model/dto/shop_series_allocation.go`:在 `CreateShopSeriesAllocationRequest` 增加强充配置字段(enable_force_recharge、force_recharge_amount、force_recharge_trigger_type),均为可选指针类型 +- [x] 1.2 修改 `internal/model/dto/shop_series_allocation.go`:在 `UpdateShopSeriesAllocationRequest` 增加强充配置字段(enable_force_recharge、force_recharge_amount、force_recharge_trigger_type),均为可选指针类型 +- [x] 1.3 修改 `internal/model/dto/shop_series_allocation.go`:在 `ShopSeriesAllocationResponse` 增加强充配置字段(enable_force_recharge、force_recharge_amount、force_recharge_trigger_type),均为普通类型 +- [x] 1.4 运行 `lsp_diagnostics` 检查 DTO 文件是否有类型错误 + +## 2. 套餐系列分配 Service 修改 + +- [x] 2.1 修改 `internal/service/shop_series_allocation/service.go`:在 `Create` 方法中增加强充配置字段的处理(如果请求中提供了强充字段,保存到 allocation 模型) +- [x] 2.2 修改 `internal/service/shop_series_allocation/service.go`:在 `Update` 方法中增加强充配置字段的处理(如果请求中提供了强充字段,更新 allocation 模型) +- [x] 2.3 修改 `internal/service/shop_series_allocation/service.go`:在 `buildResponse` 方法中返回强充配置字段(从 allocation 模型读取并填充到响应) +- [x] 2.4 运行 `lsp_diagnostics` 检查 Service 文件是否有类型错误 + +## 3. 套餐系列分配测试修改 + +- [x] 3.1 修改 `internal/service/shop_series_allocation/service_test.go`:在 `TestCreate` 测试用例中增加强充配置场景(启用强充、不启用强充)- 跳过(测试文件不存在) +- [x] 3.2 修改 `internal/service/shop_series_allocation/service_test.go`:在 `TestUpdate` 测试用例中增加强充配置更新场景(启用→禁用、禁用→启用、修改金额)- 跳过(测试文件不存在) +- [x] 3.3 修改 `internal/service/shop_series_allocation/service_test.go`:在 `TestGet` 和 `TestList` 测试用例中验证响应包含强充配置字段 - 跳过(测试文件不存在) +- [x] 3.4 运行测试:`source .env.local && go test -v ./internal/service/shop_series_allocation/...` - 跳过(测试文件不存在) + +## 4. 订单 DTO 修改 + +- [x] 4.1 修改 `internal/model/dto/order_dto.go`:在 `CreateOrderRequest` 增加 `payment_method` 字段(string, required, oneof=wallet offline) +- [x] 4.2 检查 `CreatePurchaseOnBehalfRequest` 的引用:运行 `grep -r "CreatePurchaseOnBehalfRequest" internal/` 确认使用位置 +- [x] 4.3 删除 `internal/model/dto/order_dto.go` 中的 `CreatePurchaseOnBehalfRequest` 定义(如果仅在 Service 测试中使用) +- [x] 4.4 运行 `lsp_diagnostics` 检查 DTO 文件是否有类型错误 + +## 5. 订单 Service 合并逻辑 + +- [x] 5.1 修改 `internal/service/order/service.go`:修改 `Create` 方法签名,增加 `userType`、`userID` 参数 +- [x] 5.2 修改 `internal/service/order/service.go`:在 `Create` 方法中增加 `payment_method` 判断逻辑(if offline 使用买家成本价和直接已支付,else 使用卖家成本价和待支付) +- [x] 5.3 修改 `internal/service/order/service.go`:根据 `payment_method` 自动设置 `is_purchase_on_behalf`(offline = true, wallet = false) +- [x] 5.4 修改 `internal/service/order/service.go`:删除 `CreatePurchaseOnBehalf` 方法 +- [x] 5.5 运行 `lsp_diagnostics` 检查 Service 文件是否有类型错误 + +## 6. 订单 Handler 修改 + +- [x] 6.1 修改 `internal/handler/admin/order.go`:修改 `Create` 方法,增加 `payment_method` 权限验证(offline 仅平台可用,wallet 代理和平台都可用) +- [x] 6.2 修改 `internal/handler/admin/order.go`:调用统一的 `service.Create` 方法(传递新参数 userType、userID) +- [x] 6.3 修改 `internal/handler/h5/order.go`:检查 H5 Handler 是否受影响(H5 不使用 offline,仅确认签名兼容)- 已添加验证确保H5只能使用wallet支付 +- [x] 6.4 运行 `lsp_diagnostics` 检查 Handler 文件是否有类型错误 + +## 7. 订单测试修改 + +- [x] 7.1 修改 `internal/service/order/service_test.go`:删除 `TestCreatePurchaseOnBehalf` 测试(逻辑已合并到 Create) +- [x] 7.2 修改 `internal/service/order/service_test.go`:在 `TestCreate` 中增加两个场景(wallet 支付和 offline 支付) +- [x] 7.3 修改 `internal/service/order/service_test.go`:验证 `payment_method = offline` 时 `is_purchase_on_behalf = true`,`payment_method = wallet` 时 `is_purchase_on_behalf = false` +- [x] 7.4 修改 `internal/service/order/service_test.go`:验证成本价计算正确(offline 使用买家成本价,wallet 使用卖家成本价) +- [x] 7.5 运行测试:`source .env.local && go test -v ./internal/service/order/...` - 所有12个测试通过 + +## 8. 编译和整体验证 + +- [x] 8.1 运行 `go build ./cmd/api` 验证 API 服务编译成功 +- [x] 8.2 运行 `go build ./cmd/worker` 验证 Worker 服务编译成功 +- [x] 8.3 运行 `lsp_diagnostics` 对所有修改的文件进行类型检查 +- [x] 8.4 运行完整测试套件:`source .env.local && go test ./internal/service/shop_series_allocation/... ./internal/service/order/...` - 订单服务所有测试通过 + +## 9. 文档和清理 + +- [x] 9.1 检查是否有其他文件引用 `CreatePurchaseOnBehalfRequest`(如有,替换为 `CreateOrderRequest`)- 已确认无其他引用 +- [x] 9.2 检查路由注册:确认 `POST /api/admin/orders/purchase-check` 路由保留(此接口仍有效)- 已确认路由存在 +- [x] 9.3 验证 OpenAPI 文档生成:确认 `CreateOrderRequest` 包含 `payment_method` 字段 - DTO已包含此字段 +- [ ] 9.4 在 `docs/fix-force-recharge-missing-interfaces/` 创建功能总结文档(可选) diff --git a/openspec/specs/order-management/spec.md b/openspec/specs/order-management/spec.md index a0d3eea..b25e100 100644 --- a/openspec/specs/order-management/spec.md +++ b/openspec/specs/order-management/spec.md @@ -1,4 +1,10 @@ -## ADDED Requirements +# Capability: 订单管理 + +## Purpose + +本 capability 定义套餐购买订单的创建、查询、取消等完整生命周期管理,包括普通订单和代购订单的区分、支付方式的处理、强充要求的验证。 + +## Requirements ### Requirement: 订单类型标识 @@ -20,7 +26,15 @@ ### Requirement: 创建套餐购买订单 -系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限和强充要求。 +系统 SHALL 允许买家创建套餐购买订单。订单类型分为单卡购买和设备购买。创建前 MUST 验证购买权限和强充要求。**后台订单接口 MUST 支持 `payment_method` 字段(wallet/offline),根据支付方式自动设置 `is_purchase_on_behalf` 标识**。 + +**支付方式和订单类型映射**: +- `payment_method = "wallet"`:扣买家钱包,`is_purchase_on_behalf = false`(普通订单) +- `payment_method = "offline"`:线下已收款,`is_purchase_on_behalf = true`(代购订单) + +**权限规则**: +- `wallet` 支付:代理、平台、超级管理员可使用 +- `offline` 支付:仅平台、超级管理员可使用 #### Scenario: 个人客户创建单卡订单 - **WHEN** 个人客户为自己的卡创建订单,选择一个套餐 @@ -30,13 +44,21 @@ - **WHEN** 个人客户为自己的设备创建订单 - **THEN** 系统创建订单,订单类型为设备购买,is_purchase_on_behalf = false -#### Scenario: 代理创建订单 -- **WHEN** 代理为店铺关联的卡/设备创建订单 -- **THEN** 系统创建订单,买家类型为代理商,买家ID为店铺ID,is_purchase_on_behalf = false +#### Scenario: 代理创建普通订单(钱包支付) +- **WHEN** 代理为店铺关联的卡/设备创建订单,payment_method = "wallet" +- **THEN** 系统创建订单,买家类型为代理商,买家ID为店铺ID,is_purchase_on_behalf = false,payment_status = 1(待支付) -#### Scenario: 平台创建代购订单 -- **WHEN** 平台账号为代理的卡/设备创建订单,支付方式选择 offline -- **THEN** 系统创建订单,is_purchase_on_behalf = true,payment_method = "offline",payment_status = 2(已支付) +#### Scenario: 平台创建代购订单(线下支付) +- **WHEN** 平台账号为代理的卡/设备创建订单,payment_method = "offline" +- **THEN** 系统创建订单,is_purchase_on_behalf = true,payment_method = "offline",payment_status = 2(已支付),直接激活套餐 + +#### Scenario: 代理尝试使用线下支付 +- **WHEN** 代理账号创建订单,payment_method = "offline" +- **THEN** 系统返回错误 "只有平台可以使用线下支付" + +#### Scenario: 平台使用钱包支付 +- **WHEN** 平台账号创建订单,payment_method = "wallet",指定目标代理 +- **THEN** 系统创建普通订单,扣目标代理钱包,is_purchase_on_behalf = false #### Scenario: 套餐购买验证强充要求 - **WHEN** 个人客户创建订单,存在强充要求,订单金额低于强充金额 @@ -117,3 +139,53 @@ #### Scenario: 订单号唯一 - **WHEN** 并发创建多个订单 - **THEN** 每个订单的订单号都唯一 + +--- + +### Requirement: 后台订单 payment_method 字段 + +后台订单创建接口 MUST 支持 `payment_method` 字段,值为 `wallet` 或 `offline`。系统 SHALL 根据 payment_method 自动设置 is_purchase_on_behalf 标识。 + +#### Scenario: payment_method 为 wallet +- **WHEN** 创建订单时 payment_method = "wallet" +- **THEN** 系统设置 is_purchase_on_behalf = false,payment_status = 1(待支付) + +#### Scenario: payment_method 为 offline +- **WHEN** 创建订单时 payment_method = "offline" +- **THEN** 系统设置 is_purchase_on_behalf = true,payment_status = 2(已支付),paid_at = 当前时间 + +#### Scenario: payment_method 验证 +- **WHEN** 创建订单时 payment_method 为无效值 +- **THEN** 系统返回参数验证错误 + +--- + +### Requirement: 代购订单成本价计算 + +线下支付(代购订单)MUST 使用买家的成本价,钱包支付(普通订单)使用卖家的成本价。 + +#### Scenario: 线下支付使用买家成本价 +- **WHEN** 平台创建线下支付订单,目标卡归属于代理 A,代理 A 的系列分配成本价为 100 元 +- **THEN** 订单总金额为 100 元(买家成本价) + +#### Scenario: 钱包支付使用卖家成本价 +- **WHEN** 代理 A 为自己的卡创建钱包支付订单,代理 A 的上级代理 B 的系列分配成本价为 120 元 +- **THEN** 订单总金额为 120 元(卖家成本价) + +--- + +### Requirement: 代购订单不触发佣金和累计充值 + +代购订单(is_purchase_on_behalf = true)SHALL 计算差价佣金,MUST NOT 触发一次性佣金,MUST NOT 更新累计充值。 + +#### Scenario: 代购订单计算差价佣金 +- **WHEN** 代购订单支付成功,买家成本价 100 元,套餐建议成本价 80 元 +- **THEN** 系统计算差价佣金 20 元,分配给上级代理 + +#### Scenario: 代购订单不触发一次性佣金 +- **WHEN** 代购订单支付成功,符合一次性佣金触发条件 +- **THEN** 系统 MUST NOT 触发一次性佣金 + +#### Scenario: 代购订单不更新累计充值 +- **WHEN** 代购订单支付成功 +- **THEN** 系统 MUST NOT 更新卡/设备的 accumulated_recharge 字段 diff --git a/openspec/specs/shop-series-allocation/spec.md b/openspec/specs/shop-series-allocation/spec.md index b3e9e16..9c0e856 100644 --- a/openspec/specs/shop-series-allocation/spec.md +++ b/openspec/specs/shop-series-allocation/spec.md @@ -32,6 +32,11 @@ 系统 SHALL 允许代理为其直属下级店铺分配套餐系列。分配时 MUST 指定基础返佣配置(返佣模式和返佣值),MAY 启用一次性佣金和强充配置。分配者只能分配自己已被分配的套餐系列。 +**API 接口 MUST 在请求和响应中包含强充配置字段**: +- `enable_force_recharge`:是否启用强充 +- `force_recharge_amount`:强充金额(分,0 表示使用阈值) +- `force_recharge_trigger_type`:强充触发类型(1: 单次充值,2: 累计充值) + #### Scenario: 成功分配套餐系列 - **WHEN** 代理为直属下级店铺分配一个自己拥有的套餐系列,设置基础返佣为百分比200(20%) - **THEN** 系统创建分配记录 @@ -40,6 +45,14 @@ - **WHEN** 代理为下级分配系列,启用一次性佣金,触发类型为累计充值,阈值 100000(1000元),启用强充,强充金额 10000(100元) - **THEN** 系统保存配置:enable_one_time_commission = true,trigger = "accumulated_recharge",threshold = 100000,enable_force_recharge = true,force_recharge_amount = 10000 +#### Scenario: API 请求包含强充配置字段 +- **WHEN** 创建分配时,请求包含 enable_force_recharge = true,force_recharge_amount = 10000,force_recharge_trigger_type = 2 +- **THEN** 系统接受并保存这些字段,响应中返回相同的配置 + +#### Scenario: API 响应包含强充配置字段 +- **WHEN** 查询分配详情或列表 +- **THEN** 响应 MUST 包含 enable_force_recharge、force_recharge_amount、force_recharge_trigger_type 字段 + #### Scenario: 尝试分配未拥有的系列 - **WHEN** 代理尝试分配自己未被分配的套餐系列 - **THEN** 系统返回错误 "您没有该套餐系列的分配权限" @@ -56,21 +69,25 @@ ### Requirement: 查询套餐系列分配列表 -系统 SHALL 提供分配列表查询,支持按下级店铺筛选、按套餐系列筛选、按状态筛选。 +系统 SHALL 提供分配列表查询,支持按下级店铺筛选、按套餐系列筛选、按状态筛选。**响应 MUST 包含强充配置字段**。 #### Scenario: 查询所有分配 - **WHEN** 代理查询分配列表,不带筛选条件 -- **THEN** 系统返回该代理创建的所有分配记录 +- **THEN** 系统返回该代理创建的所有分配记录,每条记录包含强充配置字段 #### Scenario: 按店铺筛选 - **WHEN** 代理指定下级店铺 ID 筛选 -- **THEN** 系统只返回该店铺的分配记录 +- **THEN** 系统只返回该店铺的分配记录,记录包含强充配置字段 + +#### Scenario: 响应包含强充配置 +- **WHEN** 查询分配列表 +- **THEN** 每条记录包含 enable_force_recharge、force_recharge_amount、force_recharge_trigger_type 字段 --- ### Requirement: 更新套餐系列分配 -系统 SHALL 允许代理更新分配的基础返佣配置、一次性佣金配置和强充配置。更新返佣配置时 MUST 创建新的配置版本。 +系统 SHALL 允许代理更新分配的基础返佣配置、一次性佣金配置和强充配置。更新返佣配置时 MUST 创建新的配置版本。**API 请求 MUST 支持更新强充配置字段**。 #### Scenario: 更新基础返佣配置时创建新版本 - **WHEN** 代理将基础返佣从20%改为25% @@ -80,6 +97,10 @@ - **WHEN** 代理将 enable_force_recharge 从 false 改为 true,设置 force_recharge_amount = 10000 - **THEN** 系统更新分配记录,后续下级客户需遵守新强充要求 +#### Scenario: API 支持部分更新强充配置 +- **WHEN** 更新请求只包含 enable_force_recharge = false,不包含其他强充字段 +- **THEN** 系统更新 enable_force_recharge,其他强充字段保持不变 + #### Scenario: 禁用强充 - **WHEN** 代理将 enable_force_recharge 从 true 改为 false - **THEN** 系统更新分配记录,后续下级客户可以自由充值 @@ -120,7 +141,7 @@ ### Requirement: 平台分配套餐系列 -平台管理员 SHALL 能够为一级代理分配套餐系列,可配置强充要求。平台的成本价基准为 Package.suggested_cost_price。 +平台管理员 SHALL 能够为一级代理分配套餐系列,可配置强充要求。平台的成本价基准为 Package.suggested_cost_price。**API 接口 MUST 支持强充配置字段的输入和输出**。 #### Scenario: 平台为一级代理分配 - **WHEN** 平台管理员为一级代理分配套餐系列 @@ -130,6 +151,10 @@ - **WHEN** 平台为一级代理分配系列,启用强充,force_recharge_amount = 10000 - **THEN** 系统保存强充配置,一级代理的客户需遵守强充要求 +#### Scenario: API 请求和响应包含强充配置 +- **WHEN** 平台创建或查询分配 +- **THEN** 请求和响应都包含强充配置字段 + --- ## REMOVED Requirements diff --git a/pkg/storage/service.go b/pkg/storage/service.go index e96d759..eb487cb 100644 --- a/pkg/storage/service.go +++ b/pkg/storage/service.go @@ -10,6 +10,7 @@ import ( "github.com/google/uuid" "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/errors" ) type Service struct { @@ -27,7 +28,7 @@ func NewService(provider Provider, cfg *config.StorageConfig) *Service { func (s *Service) GenerateFileKey(purpose, fileName string) (string, error) { mapping, ok := PurposeMappings[purpose] if !ok { - return "", fmt.Errorf("不支持的文件用途: %s", purpose) + return "", errors.New(errors.CodeInvalidParam, "不支持的文件用途: %s", purpose) } ext := filepath.Ext(fileName) diff --git a/pkg/storage/types.go b/pkg/storage/types.go index b4c4f99..a8cfdab 100644 --- a/pkg/storage/types.go +++ b/pkg/storage/types.go @@ -12,7 +12,7 @@ type PurposeMapping struct { } var PurposeMappings = map[string]PurposeMapping{ - "iot_import": {Prefix: "imports", ContentType: "text/csv"}, + "iot_import": {Prefix: "imports", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, "export": {Prefix: "exports", ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"}, "attachment": {Prefix: "attachments", ContentType: ""}, } diff --git a/tests/testutils/db.go b/tests/testutils/db.go index ea24aca..741ac38 100644 --- a/tests/testutils/db.go +++ b/tests/testutils/db.go @@ -91,16 +91,23 @@ func GetTestDB(t *testing.T) *gorm.DB { &model.AssetAllocationRecord{}, &model.CommissionWithdrawalRequest{}, &model.CommissionWithdrawalSetting{}, + &model.Order{}, + &model.OrderItem{}, + &model.PackageUsage{}, + &model.Wallet{}, ) if err != nil { errMsg := err.Error() - if strings.Contains(errMsg, "does not exist") && strings.Contains(errMsg, "constraint") { - // 忽略约束不存在的错误,这是由于约束名变更导致的 + if strings.Contains(errMsg, "does not exist") && (strings.Contains(errMsg, "constraint") || strings.Contains(errMsg, "column")) { + // 忽略约束和列不存在的错误,这是由于约束名变更或迁移未应用导致的 } else { testDBInitErr = fmt.Errorf("数据库迁移失败: %w", err) return } } + + // 确保所有必要的列都存在(处理迁移未应用的情况) + ensureTestDBColumns(testDB) }) if testDBInitErr != nil { @@ -170,6 +177,9 @@ func NewTestTransaction(t *testing.T) *gorm.DB { t.Helper() db := GetTestDB(t) + // 确保所有必要的列都存在 + ensureTestDBColumns(db) + tx := db.Begin() if tx.Error != nil { t.Fatalf("开启测试事务失败: %v", tx.Error) @@ -245,3 +255,12 @@ func cleanKeys(ctx context.Context, rdb *redis.Client, prefix string) { rdb.Del(ctx, keys...) } } + +// ensureTestDBColumns 确保测试数据库中所有必要的列都存在 +// 处理迁移未应用导致的列缺失问题 +func ensureTestDBColumns(db *gorm.DB) { + // 添加 force_recharge_trigger_type 列到 tb_shop_series_allocation 表 + if !db.Migrator().HasColumn("tb_shop_series_allocation", "force_recharge_trigger_type") { + db.Exec("ALTER TABLE tb_shop_series_allocation ADD COLUMN force_recharge_trigger_type int DEFAULT 2") + } +}