Files
junhong_cmp_fiber/internal/service/order/service.go
2026-06-29 12:29:21 +09:00

3032 lines
105 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package order
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/internal/service/packageprice"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/payment"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// WechatConfigServiceInterface 支付配置服务接口
// 用于查询当前生效的支付配置
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
}
type Service struct {
db *gorm.DB
redis *redis.Client
orderStore *postgres.OrderStore
orderItemStore *postgres.OrderItemStore
agentWalletStore *postgres.AgentWalletStore
assetWalletStore *postgres.AssetWalletStore
paymentStore *postgres.PaymentStore
purchaseValidationService *purchase_validation.Service
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
packageSeriesStore *postgres.PackageSeriesStore
packageUsageStore *postgres.PackageUsageStore
packageStore *postgres.PackageStore
wechatConfigService WechatConfigServiceInterface
wechatPayment wechat.PaymentServiceInterface
paymentLoader payment.PaymentConfigLoader
queueClient *queue.Client
logger *zap.Logger
resumeCallback packagepkg.ResumeCallback
assetIdentifierStore *postgres.AssetIdentifierStore
personalCustomerStore *postgres.PersonalCustomerStore
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
}
func New(
db *gorm.DB,
redisClient *redis.Client,
orderStore *postgres.OrderStore,
orderItemStore *postgres.OrderItemStore,
agentWalletStore *postgres.AgentWalletStore,
assetWalletStore *postgres.AssetWalletStore,
paymentStore *postgres.PaymentStore,
purchaseValidationService *purchase_validation.Service,
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
packageSeriesStore *postgres.PackageSeriesStore,
packageUsageStore *postgres.PackageUsageStore,
packageStore *postgres.PackageStore,
wechatConfigService WechatConfigServiceInterface,
wechatPayment wechat.PaymentServiceInterface,
paymentLoader payment.PaymentConfigLoader,
queueClient *queue.Client,
logger *zap.Logger,
assetIdentifierStore *postgres.AssetIdentifierStore,
personalCustomerStore *postgres.PersonalCustomerStore,
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore,
) *Service {
return &Service{
db: db,
redis: redisClient,
orderStore: orderStore,
orderItemStore: orderItemStore,
agentWalletStore: agentWalletStore,
assetWalletStore: assetWalletStore,
paymentStore: paymentStore,
purchaseValidationService: purchaseValidationService,
shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
packageSeriesStore: packageSeriesStore,
packageUsageStore: packageUsageStore,
packageStore: packageStore,
wechatConfigService: wechatConfigService,
wechatPayment: wechatPayment,
paymentLoader: paymentLoader,
queueClient: queueClient,
logger: logger,
assetIdentifierStore: assetIdentifierStore,
personalCustomerStore: personalCustomerStore,
personalCustomerPhoneStore: personalCustomerPhoneStore,
}
}
// SetResumeCallback 设置复机回调,支付成功激活套餐后触发自动复机
func (s *Service) SetResumeCallback(callback packagepkg.ResumeCallback) {
s.resumeCallback = callback
}
// 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) {
resolvedCard, resolvedDevice, resolveErr := s.resolveAssetByIdentifier(ctx, req.Identifier)
if resolveErr != nil {
return nil, resolveErr
}
var orderType string
var iotCardID *uint
var deviceID *uint
var resourceShopID *uint
if resolvedCard != nil {
orderType = model.OrderTypeSingleCard
iotCardID = &resolvedCard.ID
resourceShopID = resolvedCard.ShopID
} else {
orderType = model.OrderTypeDevice
deviceID = &resolvedDevice.ID
resourceShopID = resolvedDevice.ShopID
}
var validationResult *purchase_validation.PurchaseValidationResult
var err error
operatorUserType := middleware.GetUserTypeFromContext(ctx)
validationCtx := ctx
if req.PaymentMethod == model.PaymentMethodOffline && (operatorUserType == constants.UserTypeSuperAdmin || operatorUserType == constants.UserTypePlatform) {
validationCtx = purchase_validation.WithAllowGiftPackages(ctx)
}
if req.PaymentMethod == model.PaymentMethodOffline {
if resolvedCard != nil {
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(validationCtx, resolvedCard.ID, req.PackageIDs)
} else {
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(validationCtx, resolvedDevice.ID, req.PackageIDs)
}
} else {
if resolvedCard != nil {
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(validationCtx, resolvedCard.ID, req.PackageIDs)
} else {
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(validationCtx, resolvedDevice.ID, req.PackageIDs)
}
}
if err != nil {
return nil, rewriteAdminAgentPackageOffShelfError(err, resourceShopID, req.PaymentMethod != model.PaymentMethodOffline)
}
if err := validatePackageTypeMixFromPackages(validationResult.Packages); err != nil {
return nil, err
}
containsGift := packageprice.ContainsGiftPackage(validationResult.Packages)
if containsGift {
if operatorUserType != constants.UserTypeSuperAdmin && operatorUserType != constants.UserTypePlatform {
return nil, errors.New(errors.CodeForbidden, "仅平台侧可发放赠送套餐")
}
if req.PaymentMethod != model.PaymentMethodOffline {
return nil, errors.New(errors.CodeInvalidParam, "赠送套餐仅支持后台线下发放")
}
if err := packageprice.ValidateGiftAdminOrder(validationResult.Packages); err != nil {
return nil, err
}
}
if req.PaymentMethod == model.PaymentMethodOffline && len(req.PaymentVoucherKey) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下支付必须上传支付凭证")
}
carrierType, carrierID := resolveAdminCarrierInfoFromVars(orderType, iotCardID, deviceID)
existingOrderID, err := s.checkOrderIdempotency(ctx, buyerType, buyerID, 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)
// offline 订单不检查强充:平台直接操作,不涉及消费者支付门槛,不产生一次性佣金触发条件
if req.PaymentMethod != model.PaymentMethodOffline {
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求")
}
}
userID := middleware.GetUserIDFromContext(ctx)
operatorAccountID, operatorAccountType, operatorAccountName := s.buildAccountOperatorSnapshot(ctx, userID, operatorUserType)
// 提取资源所属店铺ID
var seriesID *uint
if validationResult.Card != nil {
seriesID = validationResult.Card.SeriesID
} else if validationResult.Device != nil {
seriesID = validationResult.Device.SeriesID
}
// 先按零售价构建明细价格快照,后续在代购/成本价场景覆盖
// offline 场景先使用平台零售价,避免在平台代购场景下提前依赖代理分配记录
sellerShopIDValue := uint(0)
if req.PaymentMethod != model.PaymentMethodOffline && resourceShopID != nil {
sellerShopIDValue = *resourceShopID
}
itemUnitPriceMap, retailTotalAmount, err := s.buildRetailPriceMap(ctx, validationResult.Packages, sellerShopIDValue)
if err != nil {
return nil, rewriteAdminAgentPackageOffShelfError(err, resourceShopID, sellerShopIDValue > 0)
}
// 初始化订单字段
orderBuyerType := buyerType
orderBuyerID := buyerID
totalAmount := retailTotalAmount
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
var itemCostPriceMap map[uint]int64 // 各套餐成本单价映射,用于 OrderItem.CostPrice 字段
// 根据支付方式分别处理
if req.PaymentMethod == model.PaymentMethodOffline {
// ==== 场景 1offline线下支付====
if containsGift {
orderBuyerType = model.BuyerTypePersonal
orderBuyerID = 0
paymentMethod = model.PaymentMethodOffline
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = false
sellerShopID = nil
sellerCostPrice = 0
operatorID = nil
operatorType = constants.OwnerTypePlatform
actualPaidAmountVal := int64(0)
actualPaidAmount = &actualPaidAmountVal
purchaseRole = model.PurchaseRoleSelfPurchase
} else {
isPlatformOwned := resourceShopID == nil || *resourceShopID == 0
if isPlatformOwned {
// ==== 子场景 1.1:平台自营(资源未分配给代理商)====
// 平台直接销售给终端用户,无代理商参与,使用零售价,无需查询分配配置
orderBuyerType = model.BuyerTypePersonal
orderBuyerID = 0
paymentMethod = model.PaymentMethodOffline
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = false
sellerCostPrice = 0
operatorID = nil
operatorType = constants.OwnerTypePlatform
purchaseRole = model.PurchaseRoleSelfPurchase
actualPaidAmount = nil
} else {
// ==== 子场景 1.2:平台代购(资源属于代理商,平台代为购买)====
purchaseBuyerID, buyerCostPriceMap, buyerTotalCost, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = purchaseBuyerID
itemCostPriceMap = buyerCostPriceMap
paymentMethod = model.PaymentMethodOffline
paymentStatus = model.PaymentStatusPaid
paidAt = purchasePaidAt
isPurchaseOnBehalf = true
sellerCostPrice = buyerTotalCost
// 设置操作者信息(平台代购)
operatorID = nil
operatorType = constants.OwnerTypePlatform
purchaseRole = model.PurchaseRolePurchasedByPlatform
actualPaidAmount = &buyerTotalCost
}
}
} else if req.PaymentMethod == model.PaymentMethodWallet {
// ==== 场景 2代理钱包支付wallet====
if resourceShopID == nil || *resourceShopID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "资源未分配给代理商,无法使用代理钱包支付")
}
if buyerType == model.BuyerTypeAgent {
operatorShopID := buyerID
if *resourceShopID == operatorShopID {
// ==== 子场景 2.1:代理自购 ====
operatorCostPriceMap, operatorTotalCost, err := s.buildCostPriceMap(ctx, validationResult.Packages, operatorShopID)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = operatorShopID
itemCostPriceMap = operatorCostPriceMap
paymentMethod = model.PaymentMethodWallet
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = false
operatorID = &operatorShopID
operatorType = "agent"
actualPaidAmountVal := operatorTotalCost
actualPaidAmount = &actualPaidAmountVal
purchaseRole = model.PurchaseRoleSelfPurchase
sellerCostPrice = operatorTotalCost
} else {
// ==== 子场景 2.2:代理代购(给下级购买)====
// 获取买家成本价(买家的价格映射用于 CostPrice 快照,总价不再使用)
buyerCostPriceMap, _, err := s.buildCostPriceMap(ctx, validationResult.Packages, *resourceShopID)
if err != nil {
return nil, err
}
// 获取操作者成本价(操作方实际扣款金额和佣金基准)
_, operatorTotalCost, err := s.buildCostPriceMap(ctx, validationResult.Packages, operatorShopID)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = *resourceShopID
itemCostPriceMap = buyerCostPriceMap
paymentMethod = model.PaymentMethodWallet
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = true
operatorID = &operatorShopID
operatorType = "agent"
actualPaidAmountVal := operatorTotalCost
actualPaidAmount = &actualPaidAmountVal
purchaseRole = model.PurchaseRolePurchaseForSubordinate
// 代理代购下级时,佣金差额基准应是操作方(代理自己)的成本价,而非下级的成本价
sellerCostPrice = operatorTotalCost
}
} else {
// ==== 子场景 2.3:平台/超管代扣目标代理钱包 ====
targetShopID := *resourceShopID
targetCostPriceMap, targetTotalCost, err := s.buildCostPriceMap(ctx, validationResult.Packages, targetShopID)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = targetShopID
itemCostPriceMap = targetCostPriceMap
paymentMethod = model.PaymentMethodWallet
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = false
operatorID = nil
operatorType = constants.OwnerTypePlatform
actualPaidAmountVal := targetTotalCost
actualPaidAmount = &actualPaidAmountVal
purchaseRole = model.PurchaseRoleSelfPurchase
sellerCostPrice = targetTotalCost
}
} else {
// 兜底检查后台不支持其他支付方式DTO 验证已拒绝,此为防御性编程)
return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
}
if paymentStatus == model.PaymentStatusPaid && actualPaidAmount == nil {
actualPaidAmountSnapshot := totalAmount
actualPaidAmount = &actualPaidAmountSnapshot
}
// 查询当前生效的支付配置
var paymentConfigID *uint
if req.PaymentMethod == model.PaymentMethodWechat || req.PaymentMethod == model.PaymentMethodAlipay {
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
s.logger.Warn("查询生效支付配置失败", zap.Error(err))
}
if activeConfig != nil {
paymentConfigID = &activeConfig.ID
}
}
// 从资产获取当前 generation用于订单快照
var assetGeneration int
if validationResult.Card != nil {
assetGeneration = validationResult.Card.Generation
} else if validationResult.Device != nil {
assetGeneration = validationResult.Device.Generation
}
if assetGeneration == 0 {
assetGeneration = 1
}
order := &model.Order{
BaseModel: model.BaseModel{
Creator: userID,
Updater: userID,
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: orderType,
Source: constants.OrderSourceAdmin,
Generation: assetGeneration,
BuyerType: orderBuyerType,
BuyerID: orderBuyerID,
IotCardID: iotCardID,
DeviceID: deviceID,
AssetIdentifier: req.Identifier,
TotalAmount: totalAmount,
PaymentMethod: paymentMethod,
PaymentStatus: paymentStatus,
PaidAt: paidAt,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
SeriesID: seriesID,
SellerShopID: sellerShopID,
SellerCostPrice: sellerCostPrice,
IsPurchaseOnBehalf: isPurchaseOnBehalf,
OperatorAccountID: operatorAccountID,
OperatorAccountType: operatorAccountType,
OperatorAccountName: operatorAccountName,
OperatorID: operatorID,
OperatorType: operatorType,
ActualPaidAmount: actualPaidAmount,
PurchaseRole: purchaseRole,
PaymentConfigID: paymentConfigID,
}
// 线下支付订单写入支付凭证 file_key 列表
if req.PaymentMethod == model.PaymentMethodOffline {
order.PaymentVoucherKey = model.StringJSONBArray(req.PaymentVoucherKey)
}
if orderBuyerType == model.BuyerTypePersonal {
order.BuyerPhone, order.BuyerNickname = s.fetchBuyerSnapshot(ctx, orderBuyerID)
}
items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap, itemCostPriceMap)
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, orderType, carrierType, carrierID, req.PackageIDs)
// 根据支付方式选择创建订单的方式
if req.PaymentMethod == model.PaymentMethodOffline {
// 平台代购:创建订单并立即激活套餐
if err := s.createOrderWithActivation(ctx, order, items); err != nil {
return nil, err
}
if !containsGift {
s.enqueueCommissionCalculation(ctx, order.ID)
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(ctx, order, items), nil
} else if req.PaymentMethod == model.PaymentMethodWallet {
// 钱包支付:创建订单、扣款、激活套餐(在事务中完成)
operatorShopID := orderBuyerID
if operatorID != nil {
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(ctx, order, items), nil
} else {
// 不应该到这里DTO 验证已拒绝其他支付方式)
return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
}
}
func rewriteAdminAgentPackageOffShelfError(err error, resourceShopID *uint, shouldRewrite bool) error {
if err == nil || !shouldRewrite || resourceShopID == nil || *resourceShopID == 0 {
return err
}
appErr, ok := err.(*errors.AppError)
if !ok {
return err
}
if appErr.Code == errors.CodeInvalidParam && appErr.Message == "套餐已下架" {
return errors.New(errors.CodeInvalidParam, "该代理已经下架该套餐,让代理上架该套餐后购买")
}
return err
}
// 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)
operatorUserType := middleware.GetUserTypeFromContext(ctx)
operatorAccountID, operatorAccountType, operatorAccountName := s.buildAccountOperatorSnapshot(ctx, userID, operatorUserType)
// 提取资源所属店铺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
}
// 先按零售价构建明细价格快照,后续在代购/成本价场景覆盖
sellerShopIDValue := uint(0)
if resourceShopID != nil {
sellerShopIDValue = *resourceShopID
}
itemUnitPriceMap, retailTotalAmount, err := s.buildRetailPriceMap(ctx, validationResult.Packages, sellerShopIDValue)
if err != nil {
return nil, err
}
// 初始化订单字段
orderBuyerType := buyerType
orderBuyerID := buyerID
totalAmount := retailTotalAmount
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
var expiresAt *time.Time // 待支付订单设置过期时间,立即支付的订单为 nil
var itemCostPriceMap map[uint]int64 // 各套餐成本单价映射,用于 OrderItem.CostPrice 字段
// 场景判断offline平台代购、wallet代理钱包支付、其他待支付
if req.PaymentMethod == model.PaymentMethodOffline {
// ==== 场景 1平台代购offline====
purchaseBuyerID, buyerCostPriceMap, buyerTotalCost, purchasePaidAt, err := s.resolvePurchaseOnBehalfInfo(ctx, validationResult)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = purchaseBuyerID
itemCostPriceMap = buyerCostPriceMap
paymentMethod = model.PaymentMethodOffline
paymentStatus = model.PaymentStatusPaid
paidAt = purchasePaidAt
isPurchaseOnBehalf = true
sellerCostPrice = buyerTotalCost
// 设置操作者信息(平台代购)
operatorID = nil
operatorType = constants.OwnerTypePlatform
purchaseRole = model.PurchaseRolePurchasedByPlatform
actualPaidAmount = &buyerTotalCost
} 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为空")
}
if *resourceShopID == operatorShopID {
// ==== 子场景 2.1:代理自购 ====
operatorCostPriceMap, operatorTotalCost, err := s.buildCostPriceMap(ctx, validationResult.Packages, operatorShopID)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = operatorShopID
itemCostPriceMap = operatorCostPriceMap
paymentMethod = model.PaymentMethodWallet
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = false
operatorID = &operatorShopID
operatorType = "agent"
actualPaidAmountVal := operatorTotalCost
actualPaidAmount = &actualPaidAmountVal
purchaseRole = model.PurchaseRoleSelfPurchase
sellerCostPrice = operatorTotalCost
} else {
// ==== 子场景 2.2:代理代购(给下级购买)====
// 获取买家成本价(价格映射用于 CostPrice 快照,总价不再使用)
buyerCostPriceMap, _, err := s.buildCostPriceMap(ctx, validationResult.Packages, *resourceShopID)
if err != nil {
return nil, err
}
// 获取操作者成本价(操作方实际扣款金额和佣金基准)
_, operatorTotalCost, err := s.buildCostPriceMap(ctx, validationResult.Packages, operatorShopID)
if err != nil {
return nil, err
}
orderBuyerType = model.BuyerTypeAgent
orderBuyerID = *resourceShopID
itemCostPriceMap = buyerCostPriceMap
paymentMethod = model.PaymentMethodWallet
paymentStatus = model.PaymentStatusPaid
paidAt = &now
isPurchaseOnBehalf = true
operatorID = &operatorShopID
operatorType = "agent"
actualPaidAmountVal := operatorTotalCost
actualPaidAmount = &actualPaidAmountVal
purchaseRole = model.PurchaseRolePurchaseForSubordinate
// 代理代购下级时,佣金差额基准应是操作方(代理自己)的成本价,而非下级的成本价
sellerCostPrice = operatorTotalCost
}
}
if paymentStatus == model.PaymentStatusPaid && actualPaidAmount == nil {
actualPaidAmountSnapshot := totalAmount
actualPaidAmount = &actualPaidAmountSnapshot
}
// 查询当前生效的支付配置
var h5PaymentConfigID *uint
if req.PaymentMethod == model.PaymentMethodWechat || req.PaymentMethod == model.PaymentMethodAlipay {
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
s.logger.Warn("查询生效支付配置失败", zap.Error(err))
}
if activeConfig != nil {
h5PaymentConfigID = &activeConfig.ID
}
}
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,
AssetIdentifier: buildOrderAssetIdentifier(validationResult),
TotalAmount: totalAmount,
PaymentMethod: paymentMethod,
PaymentStatus: paymentStatus,
PaidAt: paidAt,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
SeriesID: seriesID,
SellerShopID: sellerShopID,
SellerCostPrice: sellerCostPrice,
IsPurchaseOnBehalf: isPurchaseOnBehalf,
OperatorAccountID: operatorAccountID,
OperatorAccountType: operatorAccountType,
OperatorAccountName: operatorAccountName,
OperatorID: operatorID,
OperatorType: operatorType,
ActualPaidAmount: actualPaidAmount,
PurchaseRole: purchaseRole,
ExpiresAt: expiresAt,
PaymentConfigID: h5PaymentConfigID,
}
items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap, itemCostPriceMap)
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(ctx, 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(ctx, order, items), nil
} else {
// 其他支付方式创建待支付订单H5 端支持 wechat/alipay
// 待支付订单设置过期时间,超过 30 分钟未支付则自动取消
expireTime := now.Add(constants.OrderExpireTimeout)
order.ExpiresAt = &expireTime
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, err
}
s.markOrderCreated(ctx, idempotencyKey, order.ID)
return s.buildOrderResponse(ctx, order, items), nil
}
}
func (s *Service) resolvePurchaseOnBehalfInfo(ctx context.Context, result *purchase_validation.PurchaseValidationResult) (uint, map[uint]int64, int64, *time.Time, error) {
var resourceShopID *uint
var seriesID *uint
if result.Card != nil {
resourceShopID = result.Card.ShopID
seriesID = result.Card.SeriesID
} else if result.Device != nil {
resourceShopID = result.Device.ShopID
seriesID = result.Device.SeriesID
}
if resourceShopID == nil || *resourceShopID == 0 {
return 0, nil, 0, nil, errors.New(errors.CodeInvalidParam, "资源未分配给代理商,无法代购")
}
if seriesID == nil || *seriesID == 0 {
return 0, nil, 0, nil, errors.New(errors.CodeInvalidParam, "资源未关联套餐系列")
}
if len(result.Packages) == 0 {
return 0, nil, 0, nil, errors.New(errors.CodeInvalidParam, "订单中没有套餐")
}
buyerCostPriceMap, buyerTotalCost, err := s.buildCostPriceMap(ctx, result.Packages, *resourceShopID)
if err != nil {
return 0, nil, 0, nil, err
}
now := time.Now()
return *resourceShopID, buyerCostPriceMap, buyerTotalCost, &now, nil
}
// buildOrderItems 构建订单明细列表
// retailPriceMap: 零售单价映射UnitPricecostPriceMap: 成本单价映射CostPrice无成本价时传 nil
func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package, retailPriceMap map[uint]int64, costPriceMap map[uint]int64) []*model.OrderItem {
items := make([]*model.OrderItem, 0, len(packages))
for _, pkg := range packages {
if pkg == nil {
continue
}
unitPrice := packageprice.PackageEffectiveRetailPrice(pkg)
if price, ok := retailPriceMap[pkg.ID]; ok {
unitPrice = price
}
var costPrice int64
if costPriceMap != nil {
costPrice = costPriceMap[pkg.ID]
}
item := &model.OrderItem{
BaseModel: model.BaseModel{
Creator: operatorID,
Updater: operatorID,
},
PackageID: pkg.ID,
PackageName: pkg.PackageName,
PackageType: &pkg.PackageType,
Quantity: 1,
UnitPrice: unitPrice,
CostPrice: costPrice,
Amount: unitPrice,
PackagePriceConfigStatus: pkg.PriceConfigStatus,
PackageIsGift: pkg.IsGift,
}
items = append(items, item)
}
return items
}
// buildRetailPriceMap 构建订单明细零售价映射和总价(分)
// sellerShopID > 0 时取代理分配零售价,=0 时取套餐建议零售价
func (s *Service) buildRetailPriceMap(ctx context.Context, packages []*model.Package, sellerShopID uint) (map[uint]int64, int64, error) {
priceMap := make(map[uint]int64, len(packages))
var total int64
for _, pkg := range packages {
if pkg == nil {
continue
}
price, err := s.purchaseValidationService.GetPurchasePrice(ctx, pkg, sellerShopID)
if err != nil {
return nil, 0, err
}
priceMap[pkg.ID] = price
total += price
}
return priceMap, total, nil
}
// buildCostPriceMap 构建订单明细成本价映射和总价(分)
func (s *Service) buildCostPriceMap(ctx context.Context, packages []*model.Package, shopID uint) (map[uint]int64, int64, error) {
priceMap := make(map[uint]int64, len(packages))
var total int64
for _, pkg := range packages {
if pkg == nil {
continue
}
costPrice, err := s.getCostPrice(ctx, shopID, pkg.ID)
if err != nil {
return nil, 0, err
}
priceMap[pkg.ID] = costPrice
total += costPrice
}
return priceMap, total, nil
}
func (s *Service) fetchBuyerSnapshot(ctx context.Context, customerID uint) (phone, nickname *string) {
if customerID == 0 {
return nil, nil
}
customer, err := s.personalCustomerStore.GetByID(ctx, customerID)
if err != nil {
s.logger.Warn("fetchBuyerSnapshot: 查询个人客户失败", zap.Uint("customer_id", customerID), zap.Error(err))
} else if customer != nil && customer.Nickname != "" {
nickname = &customer.Nickname
}
phoneRecord, err := s.personalCustomerPhoneStore.GetPrimaryPhone(ctx, customerID)
if err != nil {
s.logger.Warn("fetchBuyerSnapshot: 查询买家手机号失败", zap.Uint("customer_id", customerID), zap.Error(err))
} else if phoneRecord != nil && phoneRecord.Phone != "" {
phone = &phoneRecord.Phone
}
return phone, nickname
}
func (s *Service) buildAccountOperatorSnapshot(ctx context.Context, userID uint, userType int) (*uint, string, string) {
if userID == 0 {
return nil, "", ""
}
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&account).Error; err != nil {
s.logger.Warn("buildAccountOperatorSnapshot: 查询账号失败", zap.Uint("user_id", userID), zap.Error(err))
id := userID
return &id, orderOperatorAccountTypeFromUserType(userType), ""
}
id := account.ID
return &id, orderOperatorAccountTypeFromUserType(account.UserType), account.Username
}
func (s *Service) buildPersonalCustomerOperatorSnapshot(ctx context.Context, customerID uint, nickname *string) (*uint, string, string) {
if customerID == 0 {
return nil, "", ""
}
name := ""
if nickname != nil {
name = strings.TrimSpace(*nickname)
}
if name == "" && s.personalCustomerStore != nil {
customer, err := s.personalCustomerStore.GetByID(ctx, customerID)
if err != nil {
s.logger.Warn("buildPersonalCustomerOperatorSnapshot: 查询个人客户失败", zap.Uint("customer_id", customerID), zap.Error(err))
} else if customer != nil {
name = strings.TrimSpace(customer.Nickname)
}
}
id := customerID
return &id, model.OperatorAccountTypePersonalCustomer, name
}
func orderOperatorAccountTypeFromUserType(userType int) string {
switch userType {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
return model.OperatorAccountTypePlatform
case constants.UserTypeAgent:
return model.OperatorAccountTypeAgent
case constants.UserTypeEnterprise:
return model.OperatorAccountTypeEnterprise
default:
return ""
}
}
// getCostPrice 查询店铺对套餐的成本价
// shopID: 店铺ID
// packageID: 套餐ID
// 返回成本价(分),如果查询失败返回错误
func (s *Service) getCostPrice(ctx context.Context, shopID uint, packageID uint) (int64, error) {
allocation, err := s.shopPackageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeInvalidParam, "店铺没有该套餐的分配配置")
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐成本价失败")
}
return allocation.CostPrice, nil
}
// createWalletTransaction 创建钱包流水记录
// ctx: 上下文
// tx: 事务对象
// wallet: 扣款前的钱包快照
// order: 订单快照
// amount: 扣款金额(正数)
// purchaseRole: 订单角色
// relatedShopID: 关联店铺ID代购场景填充下级店铺ID
func (s *Service) createWalletTransaction(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, order *model.Order, amount int64, purchaseRole string, relatedShopID *uint) error {
var subtype *string
remark := "购买套餐"
if purchaseRole == "" {
purchaseRole = model.PurchaseRoleSelfPurchase
}
// 根据订单角色确定交易子类型和备注
switch purchaseRole {
case model.PurchaseRoleSelfPurchase:
subtypeVal := constants.WalletTransactionSubtypeSelfPurchase
subtype = &subtypeVal
case model.PurchaseRolePurchaseForSubordinate:
subtypeVal := constants.WalletTransactionSubtypePurchaseForSubordinate
subtype = &subtypeVal
// 查询下级店铺名称,填充到备注
if relatedShopID != nil {
var shop model.Shop
if err := tx.Where("id = ?", *relatedShopID).First(&shop).Error; err == nil {
remark = fmt.Sprintf("为下级代理【%s】购买套餐", shop.ShopName)
} else {
remark = "为下级代理购买套餐"
}
}
}
userID := middleware.GetUserIDFromContext(ctx)
assetType, assetID, assetIdentifier := buildAgentWalletTransactionAssetSnapshot(order)
// 创建钱包流水记录
transaction := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: wallet.ShopID,
UserID: userID,
TransactionType: constants.AgentTransactionTypeDeduct,
TransactionSubtype: subtype,
Amount: -amount, // 扣款为负数
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance - amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: strPtr(constants.ReferenceTypeOrder),
ReferenceID: &order.ID,
RelatedShopID: relatedShopID,
AssetType: assetType,
AssetID: assetID,
AssetIdentifier: assetIdentifier,
Remark: &remark,
Creator: userID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := tx.Create(transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包流水失败")
}
return nil
}
// buildAgentWalletTransactionAssetSnapshot 从订单中生成代理钱包扣款流水的资产快照。
func buildAgentWalletTransactionAssetSnapshot(order *model.Order) (string, uint, string) {
if order == nil {
return "", 0, ""
}
switch order.OrderType {
case model.OrderTypeSingleCard:
if order.IotCardID == nil {
return constants.AssetTypeIotCard, 0, order.AssetIdentifier
}
return constants.AssetTypeIotCard, *order.IotCardID, order.AssetIdentifier
case model.OrderTypeDevice:
if order.DeviceID == nil {
return constants.AssetTypeDevice, 0, order.AssetIdentifier
}
return constants.AssetTypeDevice, *order.DeviceID, order.AssetIdentifier
default:
return "", 0, order.AssetIdentifier
}
}
// buildOrderAssetIdentifier 生成订单资产标识快照。
// 后台按标识下单时保留请求标识按ID下单时用卡ICCID或设备IMEI/虚拟号兜底。
func buildOrderAssetIdentifier(result *purchase_validation.PurchaseValidationResult) string {
if result == nil {
return ""
}
if result.Card != nil {
return result.Card.ICCID
}
if result.Device != nil {
return firstNonEmpty(result.Device.IMEI, result.Device.VirtualNo)
}
return ""
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
// strPtr 字符串指针辅助函数
func strPtr(s string) *string {
return &s
}
// createOrderWithWalletPayment 使用钱包支付创建订单并完成支付
// 包含余额检查、扣款、创建流水、激活套餐等操作,在事务中执行
// ctx: 上下文
// order: 订单对象
// items: 订单明细列表
// operatorShopID: 操作者店铺ID扣款的店铺
// buyerShopID: 买家店铺ID代购场景下级店铺ID
func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model.Order, items []*model.OrderItem, operatorShopID uint, buyerShopID uint) error {
if order.ActualPaidAmount == nil {
return errors.New(errors.CodeInternalError, "实际支付金额不能为空")
}
actualAmount := *order.ActualPaidAmount
// 事务内:加锁读取钱包 + 余额检查 + 创建订单 + 扣款 + 创建流水 + 激活套餐
// 使用 FOR UPDATE 悲观锁替代乐观锁,避免并发请求因 version 过期导致误报余额不足
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 加锁读取钱包,并发请求排队等待,不会冲突
var wallet model.AgentWallet
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND wallet_type = ?", operatorShopID, constants.AgentWalletTypeMain).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
// 2. 余额检查(加锁后读到的是最新余额,结果准确)
if wallet.Balance < actualAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
// 3. 创建订单
if err := tx.Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
// 4. 创建订单明细
for _, item := range items {
item.OrderID = order.ID
}
if err := tx.CreateInBatches(items, 100).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
}
// 5. 扣减钱包余额FOR UPDATE 已保证串行,无需 version 条件)
if err := tx.Model(&wallet).Updates(map[string]any{
"balance": gorm.Expr("balance - ?", actualAmount),
"version": gorm.Expr("version + 1"),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "扣减钱包余额失败")
}
// 6. 创建钱包流水
var relatedShopID *uint
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
relatedShopID = &buyerShopID
}
if err := s.createWalletTransaction(ctx, tx, &wallet, order, actualAmount, order.PurchaseRole, relatedShopID); err != nil {
return err
}
// 7. 激活套餐
if err := s.activatePackage(ctx, tx, order); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
// 3. 事务外:所有已支付且适用差价佣金的订单都进入佣金计算
if order.CommissionStatus == model.CommissionStatusPending {
s.enqueueCommissionCalculation(ctx, order.ID)
}
return nil
}
func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Order, items []*model.OrderItem) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
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) {
order, items, err := s.orderStore.GetByIDWithItems(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, err
}
return s.buildOrderResponse(ctx, order, items), nil
}
func (s *Service) List(ctx context.Context, req *dto.OrderListRequest, buyerType string, buyerID uint) (*dto.OrderListResponse, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
opts := &store.QueryOptions{
Page: page,
PageSize: pageSize,
}
filters := map[string]any{}
// 仅代理用户需要限定买家范围;平台/超管不加限制,可查看所有订单
if buyerType != "" {
filters["buyer_type"] = buyerType
}
if buyerID != 0 {
filters["buyer_id"] = buyerID
}
if req.PaymentStatus != nil {
filters["payment_status"] = *req.PaymentStatus
}
if req.PaymentMethod != "" {
filters["payment_method"] = req.PaymentMethod
}
if req.OrderType != "" {
filters["order_type"] = req.OrderType
}
if req.OrderNo != "" {
filters["order_no"] = req.OrderNo
}
if req.PurchaseRole != "" {
filters["purchase_role"] = req.PurchaseRole
}
if req.SellerShopID != nil {
filters["seller_shop_id"] = *req.SellerShopID
}
if req.StartTime != nil {
filters["start_time"] = req.StartTime
}
if req.EndTime != nil {
filters["end_time"] = req.EndTime
}
if req.Identifier != "" {
resolvedIotCardID, resolvedDeviceID, err := s.resolveOrderListAssetIdentifier(ctx, req.Identifier)
if err != nil {
return nil, err
}
// 保留快照标识匹配,并叠加资产 ID 匹配(由 Store 层统一按 OR 组合)
filters["identifier"] = req.Identifier
if resolvedIotCardID != nil {
filters["iot_card_id"] = *resolvedIotCardID
}
if resolvedDeviceID != nil {
filters["device_id"] = *resolvedDeviceID
}
}
if req.BuyerPhone != "" {
filters["buyer_phone"] = req.BuyerPhone
}
orders, total, err := s.orderStore.List(ctx, opts, filters)
if err != nil {
return nil, err
}
var orderIDs []uint
for _, o := range orders {
orderIDs = append(orderIDs, o.ID)
}
itemsMap := make(map[uint][]*model.OrderItem)
if len(orderIDs) > 0 {
allItems, err := s.orderItemStore.ListByOrderIDs(ctx, orderIDs)
if err != nil {
return nil, err
}
for _, item := range allItems {
itemsMap[item.OrderID] = append(itemsMap[item.OrderID], item)
}
}
var list []*dto.OrderResponse
for _, o := range orders {
list = append(list, s.buildOrderResponse(ctx, o, itemsMap[o.ID]))
}
return &dto.OrderListResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// resolveOrderListAssetIdentifier 将订单列表的统一资产标识解析为卡或设备 ID
// 支持 ICCID、VirtualNo、IMEI、SN、MSISDN与资产解析接口的识别口径保持一致。
func (s *Service) resolveOrderListAssetIdentifier(ctx context.Context, identifier string) (*uint, *uint, error) {
if identifier == "" {
return nil, nil, nil
}
if s.assetIdentifierStore != nil {
regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier)
if regErr != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, regErr, "查询资产标识符失败")
}
if regRecord != nil {
switch regRecord.AssetType {
case model.AssetTypeIotCard:
card, cardErr := s.iotCardStore.GetByID(ctx, regRecord.AssetID)
if cardErr == nil && card != nil {
cardID := card.ID
return &cardID, nil, nil
}
if cardErr != nil && cardErr != gorm.ErrRecordNotFound {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, cardErr, "查询物联网卡失败")
}
case model.AssetTypeDevice:
device, deviceErr := s.deviceStore.GetByID(ctx, regRecord.AssetID)
if deviceErr == nil && device != nil {
deviceID := device.ID
return nil, &deviceID, nil
}
if deviceErr != nil && deviceErr != gorm.ErrRecordNotFound {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, deviceErr, "查询设备失败")
}
}
}
}
device, deviceErr := s.deviceStore.GetByIdentifier(ctx, identifier)
if deviceErr == nil && device != nil {
deviceID := device.ID
return nil, &deviceID, nil
}
if deviceErr != nil && deviceErr != gorm.ErrRecordNotFound {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, deviceErr, "查询设备失败")
}
var card model.IotCard
query := s.db.WithContext(ctx).Model(&model.IotCard{})
query = middleware.ApplyShopFilter(ctx, query)
conditions := []string{"virtual_no = ?", "msisdn = ?", "iccid = ?"}
args := []any{identifier, identifier, identifier}
if len(identifier) == 19 {
conditions = append(conditions, "iccid_19 = ?")
args = append(args, identifier)
} else if len(identifier) == 20 {
conditions = append(conditions, "iccid_20 = ?")
args = append(args, identifier)
}
if err := query.Where(strings.Join(conditions, " OR "), args...).First(&card).Error; err == nil {
cardID := card.ID
return &cardID, nil, nil
} else if err != gorm.ErrRecordNotFound {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询物联网卡失败")
}
return nil, nil, nil
}
func (s *Service) Cancel(ctx context.Context, id uint, buyerType string, buyerID uint) error {
order, err := s.orderStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "订单不存在")
}
return err
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单")
}
return s.cancelOrder(ctx, order)
}
// CancelExpiredOrders 批量取消已超时的待支付订单
// 返回已取消的订单数量和错误
func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) {
startTime := time.Now()
orders, err := s.orderStore.FindExpiredOrders(ctx, constants.OrderExpireBatchSize)
if err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询超时订单失败")
}
if len(orders) == 0 {
return 0, nil
}
cancelledCount := 0
for _, order := range orders {
if err := s.cancelOrder(ctx, order); err != nil {
s.logger.Error("自动取消超时订单失败",
zap.Uint("order_id", order.ID),
zap.String("order_no", order.OrderNo),
zap.Error(err),
)
continue
}
cancelledCount++
}
s.logger.Info("批量取消超时订单完成",
zap.Int("total", len(orders)),
zap.Int("cancelled", cancelledCount),
zap.Int("failed", len(orders)-cancelledCount),
zap.Duration("duration", time.Since(startTime)),
)
return cancelledCount, nil
}
// cancelOrder 内部取消订单逻辑(共用于手动取消和自动超时取消)
// 在事务中执行:更新订单状态为已取消、清除过期时间、解冻钱包余额(如有)
func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 使用条件更新确保幂等性:只有待支付的订单才能取消
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
Updates(map[string]any{
"payment_status": model.PaymentStatusCancelled,
"expires_at": nil,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单状态失败")
}
if result.RowsAffected == 0 {
// 订单已被处理(幂等),直接返回
return nil
}
// 检查是否需要解冻钱包余额(混合支付场景)
// 当前系统中钱包支付订单是立即支付的,不会进入待支付状态
// 此处为预留逻辑,支持未来混合支付场景的钱包解冻
if order.PaymentMethod == model.PaymentMethodWallet {
if err := s.unfreezeWalletForCancel(ctx, tx, order); err != nil {
s.logger.Error("取消订单时解冻钱包失败",
zap.Uint("order_id", order.ID),
zap.Error(err),
)
return err
}
}
return nil
})
}
// unfreezeWalletForCancel 取消订单时解冻钱包余额
// 根据买家类型和订单金额确定解冻金额和目标钱包
func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, order *model.Order) error {
if order.BuyerType == model.BuyerTypeAgent {
// 代理商钱包(店铺钱包)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, order.BuyerID)
if err != nil {
return errors.Wrap(errors.CodeWalletNotFound, err, "查询代理钱包失败")
}
return s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, order.TotalAmount)
} else if order.BuyerType == model.BuyerTypePersonal {
// 个人客户钱包(卡/设备钱包)
var resourceType string
var resourceID uint
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
resourceType = "iot_card"
resourceID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
resourceType = "device"
resourceID = *order.DeviceID
} else {
return errors.New(errors.CodeInternalError, "无法确定钱包归属")
}
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
if err != nil {
return errors.Wrap(errors.CodeWalletNotFound, err, "查询资产钱包失败")
}
// 资产钱包解冻:直接减少冻结余额
result := tx.Model(&model.AssetWallet{}).
Where("id = ? AND frozen_balance >= ?", wallet.ID, order.TotalAmount).
Updates(map[string]any{
"frozen_balance": gorm.Expr("frozen_balance - ?", order.TotalAmount),
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "冻结余额不足,无法解冻")
}
return nil
}
return nil
}
func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string) error {
payment := &model.Payment{
PaymentNo: order.OrderNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: paymentMethod,
Amount: order.TotalAmount,
Status: model.PaymentRecordStatusPaid,
}
return tx.Create(payment).Error
}
func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error {
order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "订单不存在")
}
return err
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.IsPurchaseOnBehalf {
return errors.New(errors.CodeInvalidStatus, "代购订单无需支付")
}
existingPayment, _ := s.paymentStore.GetByOrderIDAndStatus(ctx, orderID, model.PaymentRecordStatusPaid)
if existingPayment != nil {
return nil
}
var resourceType string
var resourceID uint
if buyerType == model.BuyerTypePersonal {
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
resourceType = "iot_card"
resourceID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
resourceType = "device"
resourceID = *order.DeviceID
} else {
return errors.New(errors.CodeInvalidParam, "无法确定钱包归属")
}
} else if buyerType == model.BuyerTypeAgent {
resourceType = "shop"
resourceID = buyerID
} else {
return errors.New(errors.CodeInvalidParam, "不支持的买家类型")
}
// 根据资源类型选择对应的钱包系统
now := time.Now()
actualPaidAmount := order.TotalAmount
shouldEnqueueCommission := false
if resourceType == "shop" {
// 代理钱包系统(店铺)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return err
}
if wallet.Balance < order.TotalAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
Updates(map[string]any{
"payment_status": model.PaymentStatusPaid,
"payment_method": model.PaymentMethodWallet,
"paid_at": now,
"expires_at": nil, // 支付成功,清除过期时间
"actual_paid_amount": actualPaidAmount,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
}
if result.RowsAffected == 0 {
var currentOrder model.Order
if err := tx.First(&currentOrder, orderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
}
switch currentOrder.PaymentStatus {
case model.PaymentStatusPaid:
return nil
case model.PaymentStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
case model.PaymentStatusRefunded:
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")
default:
return errors.New(errors.CodeInvalidStatus, "订单状态异常")
}
}
actualPaidAmountSnapshot := actualPaidAmount
order.ActualPaidAmount = &actualPaidAmountSnapshot
shouldEnqueueCommission = true
walletResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", order.TotalAmount),
"version": gorm.Expr("version + 1"),
})
if walletResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败")
}
if walletResult.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
if err := s.createWalletTransaction(ctx, tx, wallet, order, order.TotalAmount, order.PurchaseRole, nil); err != nil {
return err
}
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet); err != nil {
return err
}
return s.activatePackage(ctx, tx, order)
})
} else {
// 资产钱包系统iot_card 或 device
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return err
}
if wallet.Balance < order.TotalAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
Updates(map[string]any{
"payment_status": model.PaymentStatusPaid,
"payment_method": model.PaymentMethodWallet,
"paid_at": now,
"expires_at": nil, // 支付成功,清除过期时间
"actual_paid_amount": actualPaidAmount,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
}
if result.RowsAffected == 0 {
var currentOrder model.Order
if err := tx.First(&currentOrder, orderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
}
switch currentOrder.PaymentStatus {
case model.PaymentStatusPaid:
return nil
case model.PaymentStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
case model.PaymentStatusRefunded:
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")
default:
return errors.New(errors.CodeInvalidStatus, "订单状态异常")
}
}
actualPaidAmountSnapshot := actualPaidAmount
order.ActualPaidAmount = &actualPaidAmountSnapshot
// 扣款前记录余额快照,用于写入流水
balanceBefore := wallet.Balance
walletResult := tx.Model(&model.AssetWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", order.TotalAmount),
"version": gorm.Expr("version + 1"),
})
if walletResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败")
}
if walletResult.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
// 扣款成功后补写扣款流水,填补流水表中扣款记录缺失的问题
deductTx := &model.AssetWalletTransaction{
AssetWalletID: wallet.ID,
ResourceType: resourceType,
ResourceID: resourceID,
UserID: buyerID,
TransactionType: "deduct",
Amount: -order.TotalAmount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceBefore - order.TotalAmount,
Status: 1,
ReferenceType: strPtr("order"),
ReferenceNo: &order.OrderNo,
Remark: strPtr("钱包支付套餐"),
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := tx.Create(deductTx).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建扣款流水失败")
}
if err := s.createWalletPaymentRecord(tx, order, model.PaymentByWallet); err != nil {
return err
}
return s.activatePackage(ctx, tx, order)
})
}
if err != nil {
return err
}
if shouldEnqueueCommission && order.CommissionStatus == model.CommissionStatusPending {
s.enqueueCommissionCalculation(ctx, orderID)
}
return nil
}
func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, paymentMethod string, actualPaidAmount int64) error {
order, err := s.orderStore.GetByOrderNo(ctx, orderNo)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "订单不存在")
}
return err
}
now := time.Now()
shouldResumeAfterPayment := false
shouldEnqueueCommission := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
Updates(map[string]any{
"payment_status": model.PaymentStatusPaid,
"payment_method": paymentMethod,
"paid_at": now,
"expires_at": nil, // 支付成功,清除过期时间
"actual_paid_amount": actualPaidAmount,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
}
if result.RowsAffected == 0 {
var currentOrder model.Order
if err := tx.First(&currentOrder, order.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
}
switch currentOrder.PaymentStatus {
case model.PaymentStatusPaid:
return nil
case model.PaymentStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
case model.PaymentStatusRefunded:
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")
default:
return errors.New(errors.CodeInvalidStatus, "订单状态异常")
}
}
// 回调更新成功后,同步内存对象,确保激活套餐时写入 paid_amount 快照
actualPaidAmountSnapshot := actualPaidAmount
order.ActualPaidAmount = &actualPaidAmountSnapshot
shouldResumeAfterPayment = true
shouldEnqueueCommission = true
return s.activatePackage(ctx, tx, order)
})
if err != nil {
return err
}
if shouldResumeAfterPayment {
s.tryResumeAfterPayment(ctx, order)
}
if shouldEnqueueCommission && order.CommissionStatus == model.CommissionStatusPending {
s.enqueueCommissionCalculation(ctx, order.ID)
}
return nil
}
// HandlePaymentRecordCallback 通过支付单处理套餐订单第三方支付回调。
func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo string, paymentMethod string, thirdPartyTradeNo string, actualPaidAmount int64) error {
payment, err := s.paymentStore.GetByPaymentNo(ctx, paymentNo)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "支付记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询支付记录失败")
}
if payment.OrderType != model.PaymentOrderTypePackage {
return errors.New(errors.CodeInvalidParam, "支付记录类型不匹配")
}
order, err := s.orderStore.GetByID(ctx, payment.OrderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "订单不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
}
now := time.Now()
shouldResumeAfterPayment := false
shouldEnqueueCommission := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentUpdates := map[string]any{
"status": model.PaymentRecordStatusPaid,
"paid_at": now,
}
if thirdPartyTradeNo != "" {
paymentUpdates["third_party_trade_no"] = thirdPartyTradeNo
}
paymentResult := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Updates(paymentUpdates)
if paymentResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, paymentResult.Error, "更新支付记录失败")
}
if paymentResult.RowsAffected == 0 {
var currentPayment model.Payment
if err := tx.First(&currentPayment, payment.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询支付记录失败")
}
if currentPayment.Status == model.PaymentRecordStatusPaid {
return nil
}
return errors.New(errors.CodeInvalidStatus, "支付记录状态不允许处理")
}
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
Updates(map[string]any{
"payment_status": model.PaymentStatusPaid,
"payment_method": paymentMethod,
"paid_at": now,
"expires_at": nil,
"actual_paid_amount": actualPaidAmount,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新订单支付状态失败")
}
if result.RowsAffected == 0 {
var currentOrder model.Order
if err := tx.First(&currentOrder, order.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
}
switch currentOrder.PaymentStatus {
case model.PaymentStatusPaid:
return nil
case model.PaymentStatusCancelled:
return errors.New(errors.CodeInvalidStatus, "订单已取消,无法支付")
case model.PaymentStatusRefunded:
return errors.New(errors.CodeInvalidStatus, "订单已退款,无法支付")
default:
return errors.New(errors.CodeInvalidStatus, "订单状态异常")
}
}
actualPaidAmountSnapshot := actualPaidAmount
order.ActualPaidAmount = &actualPaidAmountSnapshot
shouldResumeAfterPayment = true
shouldEnqueueCommission = true
return s.activatePackage(ctx, tx, order)
})
if err != nil {
return err
}
if shouldResumeAfterPayment {
s.tryResumeAfterPayment(ctx, order)
}
if shouldEnqueueCommission && order.CommissionStatus == model.CommissionStatusPending {
s.enqueueCommissionCalculation(ctx, order.ID)
}
return nil
}
// tryResumeAfterPayment 支付成功后检查是否应触发自动复机
// 仅当本次支付直接激活了主套餐status=1非排队时才调用复机避免虚流量已超但套餐排队时错误复机
func (s *Service) tryResumeAfterPayment(ctx context.Context, order *model.Order) {
if s.resumeCallback == nil {
return
}
var resumeCarrierType string
var resumeCarrierID uint
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
resumeCarrierType = "iot_card"
resumeCarrierID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
resumeCarrierType = "device"
resumeCarrierID = *order.DeviceID
}
if resumeCarrierID == 0 {
return
}
var activatedCount int64
s.db.WithContext(ctx).Model(&model.PackageUsage{}).
Where("order_id = ? AND status = ? AND master_usage_id IS NULL", order.ID, constants.PackageUsageStatusActive).
Count(&activatedCount)
if activatedCount == 0 {
return
}
go func(ct string, cid uint) {
if err := s.resumeCallback.ResumeCardIfStopped(context.Background(), ct, cid); err != nil {
s.logger.Error("支付后自动复机失败",
zap.String("carrier_type", ct),
zap.Uint("carrier_id", cid),
zap.Uint("order_id", order.ID),
zap.Error(err))
}
}(resumeCarrierType, resumeCarrierID)
}
func (s *Service) activatePackage(ctx context.Context, tx *gorm.DB, order *model.Order) error {
var items []*model.OrderItem
if err := tx.Where("order_id = ?", order.ID).Find(&items).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
}
// 任务 8.1: 检查混买限制 - 禁止同订单混买正式套餐和加油包
if err := s.validatePackageTypeMix(tx, items); err != nil {
return err
}
// 确定载体类型和ID
carrierType := "iot_card"
var carrierID uint
if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil {
carrierID = *order.IotCardID
} else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil {
carrierType = "device"
carrierID = *order.DeviceID
} else {
return errors.New(errors.CodeInvalidParam, "无效的订单类型或缺少载体ID")
}
now := time.Now()
for _, item := range items {
// 检查是否已存在使用记录
var existingUsage model.PackageUsage
err := tx.Where("order_id = ? AND package_id = ?", order.ID, item.PackageID).
First(&existingUsage).Error
if err == nil {
s.logger.Warn("套餐使用记录已存在,跳过创建",
zap.Uint("order_id", order.ID),
zap.Uint("package_id", item.PackageID))
continue
}
if err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "检查套餐使用记录失败")
}
// 查询套餐信息
var pkg model.Package
if err := tx.First(&pkg, item.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
// 根据套餐类型分别处理
if pkg.PackageType == "formal" {
// 主套餐处理逻辑(任务 8.2-8.4
if err := s.activateMainPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
return err
}
} else if pkg.PackageType == "addon" {
// 加油包处理逻辑(任务 8.5-8.7
if err := s.activateAddonPackage(ctx, tx, order, &pkg, carrierType, carrierID, now); err != nil {
return err
}
}
}
return nil
}
func (s *Service) lockPackageCarrier(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
switch carrierType {
case constants.AssetWalletResourceTypeIotCard, "card":
var card model.IotCard
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&card, carrierID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定卡失败")
}
case constants.AssetWalletResourceTypeDevice:
var device model.Device
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&device, carrierID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定设备失败")
}
default:
return errors.New(errors.CodeInvalidParam, "无效的套餐载体类型")
}
return nil
}
// validatePackageTypeMix 任务 8.1: 检查混买限制
func (s *Service) validatePackageTypeMix(tx *gorm.DB, items []*model.OrderItem) error {
hasFormal := false
hasAddon := false
for _, item := range items {
var pkg model.Package
if err := tx.First(&pkg, item.PackageID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
if pkg.PackageType == constants.PackageTypeFormal {
hasFormal = true
} else if pkg.PackageType == constants.PackageTypeAddon {
hasAddon = true
}
if hasFormal && hasAddon {
return errors.New(errors.CodeInvalidParam, "不允许在同一订单中同时购买正式套餐和加油包")
}
}
return nil
}
// validatePackageTypeMixFromPackages 基于已加载的套餐列表检查混买限制(无需 DB 查询)
func validatePackageTypeMixFromPackages(packages []*model.Package) error {
hasFormal := false
hasAddon := false
for _, pkg := range packages {
if pkg.PackageType == constants.PackageTypeFormal {
hasFormal = true
} else if pkg.PackageType == constants.PackageTypeAddon {
hasAddon = true
}
if hasFormal && hasAddon {
return errors.New(errors.CodeInvalidParam, "不允许在同一订单中同时购买正式套餐和加油包")
}
}
return nil
}
// resolveCarrierInfo 从请求中提取载体类型和ID
func resolveCarrierInfo(req *dto.CreateOrderRequest) (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
}
// resolveAdminCarrierInfoFromVars 从已解析的订单类型和资产ID中提取载体类型和ID
func resolveAdminCarrierInfoFromVars(orderType string, iotCardID *uint, deviceID *uint) (carrierType string, carrierID uint) {
if orderType == model.OrderTypeSingleCard && iotCardID != nil {
return "iot_card", *iotCardID
}
if orderType == model.OrderTypeDevice && deviceID != nil {
return "device", *deviceID
}
return "", 0
}
// resolveAssetByIdentifier 通过标识符ICCID 或 VirtualNo解析资产
// 优先查注册表,注册表命中则直接返回对应卡或设备
func (s *Service) resolveAssetByIdentifier(ctx context.Context, identifier string) (*model.IotCard, *model.Device, error) {
if s.assetIdentifierStore != nil {
regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier)
if regErr == nil && regRecord != nil {
switch regRecord.AssetType {
case model.AssetTypeIotCard:
card, cardErr := s.iotCardStore.GetByID(ctx, regRecord.AssetID)
if cardErr == nil && card != nil {
return card, nil, nil
}
case model.AssetTypeDevice:
device, devErr := s.deviceStore.GetByID(ctx, regRecord.AssetID)
if devErr == nil && device != nil {
return nil, device, nil
}
}
}
}
if card, cardErr := s.iotCardStore.GetByIdentifier(ctx, identifier); cardErr == nil && card != nil {
return card, nil, nil
}
if device, deviceErr := s.deviceStore.GetByIdentifier(ctx, identifier); deviceErr == nil && device != nil {
return nil, device, nil
}
return nil, nil, errors.New(errors.CodeNotFound, "未找到对应资产,请使用 ICCID 或虚拟号")
}
// 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 {
sortedIDs := make([]uint, len(packageIDs))
copy(sortedIDs, packageIDs)
sort.Slice(sortedIDs, func(i, j int) bool { return sortedIDs[i] < sortedIDs[j] })
idStrs := make([]string, len(sortedIDs))
for i, id := range sortedIDs {
idStrs[i] = strconv.FormatUint(uint64(id), 10)
}
return fmt.Sprintf("%s:%d:%s:%s:%d:%s",
buyerType, buyerID, orderType, carrierType, carrierID, strings.Join(idStrs, ","))
}
const (
orderIdempotencyTTL = 3 * time.Minute
orderCreateLockTTL = 10 * time.Second
)
// checkOrderIdempotency 检查订单是否已创建Redis SETNX + 分布式锁)
// 返回已存在的 orderID>0 表示重复请求),或 0 表示可以创续创建
func (s *Service) checkOrderIdempotency(ctx context.Context, buyerType string, buyerID uint, orderType string, carrierType string, carrierID uint, packageIDs []uint) (uint, error) {
idempotencyKey := buildOrderIdempotencyKey(buyerType, buyerID, orderType, carrierType, carrierID, packageIDs)
redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey)
// 第 1 层Redis 快速检测,如果 key 已存在说明已创建或正在创建
val, err := s.redis.Get(ctx, redisKey).Result()
if err == nil && val != "" {
orderID, _ := strconv.ParseUint(val, 10, 64)
if orderID > 0 {
s.logger.Info("订单幂等性命中,返回已有订单",
zap.Uint("order_id", uint(orderID)),
zap.String("idempotency_key", idempotencyKey))
return uint(orderID), nil
}
}
// 第 2 层:分布式锁,防止并发创建
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
locked, err := s.redis.SetNX(ctx, lockKey, time.Now().String(), orderCreateLockTTL).Result()
if err != nil {
s.logger.Warn("获取订单创建分布式锁失败,继续执行",
zap.Error(err),
zap.String("lock_key", lockKey))
return 0, nil
}
if !locked {
return 0, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
// 第 3 层:加锁后二次检测,防止锁等待期间已被处理
val, err = s.redis.Get(ctx, redisKey).Result()
if err == nil && val != "" {
orderID, _ := strconv.ParseUint(val, 10, 64)
if orderID > 0 {
s.logger.Info("订单幂等性二次检测命中",
zap.Uint("order_id", uint(orderID)),
zap.String("idempotency_key", idempotencyKey))
return uint(orderID), nil
}
}
return 0, nil
}
// markOrderCreated 订单创建成功后标记 Redis 并释放分布式锁
func (s *Service) markOrderCreated(ctx context.Context, idempotencyKey string, orderID uint) {
redisKey := constants.RedisOrderIdempotencyKey(idempotencyKey)
if err := s.redis.Set(ctx, redisKey, strconv.FormatUint(uint64(orderID), 10), orderIdempotencyTTL).Err(); err != nil {
s.logger.Warn("设置订单幂等性标记失败",
zap.Error(err),
zap.Uint("order_id", orderID))
}
}
// activateMainPackage 任务 8.2-8.4: 主套餐激活逻辑
func (s *Service) activateMainPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
if err := s.lockPackageCarrier(ctx, tx, carrierType, carrierID); err != nil {
return err
}
hasCurrentMain, err := packagepkg.HasCurrentMainPackageForQueue(tx.WithContext(ctx), carrierType, carrierID, now)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
}
var status int
var priority int
var activatedAt time.Time
var expiresAt time.Time
var nextResetAt *time.Time
var pendingRealnameActivation bool
if hasCurrentMain {
// 当前仍有在用主套餐时,新主套餐进入待生效队列,后续由过期/退款等流程接续激活。
status = constants.PackageUsageStatusPending
var maxPriority int
tx.Model(&model.PackageUsage{}).
Where(carrierType+"_id = ?", carrierID).
Select("COALESCE(MAX(priority), 0)").
Scan(&maxPriority)
priority = maxPriority + 1
} else {
status = constants.PackageUsageStatusActive
priority = 1
activatedAt = now
// 使用工具函数计算过期时间
expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
// 计算下次重置时间(基于套餐周期类型)
nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt)
// REALNAME-02: 后台囤货场景三维决策(卡类型 + 购买路径 + expiry_base + 实名状态)
// 查询卡类型(行业卡永远直接激活,不等实名)
// 同时查询载体当前实名状态:已实名则跳过 pending直接激活
var cardCategory string
var currentlyRealnamed bool
if carrierType == "iot_card" {
var card model.IotCard
if err := tx.Select("card_category", "real_name_status").First(&card, carrierID).Error; err == nil {
cardCategory = card.CardCategory
currentlyRealnamed = card.RealNameStatus == constants.RealNameStatusVerified
}
// err != nil 时 currentlyRealnamed 保守默认 false不阻断购买流程
} else if carrierType == "device" {
// 子查询统计该设备绑定的已实名卡数量GORM 自动注入 deleted_at IS NULL
var count int64
subQuery := tx.Model(&model.DeviceSimBinding{}).
Select("iot_card_id").
Where("device_id = ? AND bind_status = 1", carrierID)
if err := tx.Model(&model.IotCard{}).
Where("id IN (?) AND real_name_status = 1", subQuery).
Count(&count).Error; err == nil {
currentlyRealnamed = count > 0
}
// err != nil 时保守默认 false不阻断购买流程
}
// 判断是否 C 端购买C 端购买前已做实名前置检查,直接激活)
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
if cardCategory != "industry" && !isCEndPurchase {
// 后台囤货路径:按 expiry_base 决定是否等实名
if pkg.ExpiryBase != "from_purchase" {
if currentlyRealnamed {
s.logger.Info("购买时载体已实名,直接激活套餐",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
} else {
// from_activation默认未实名等实名后激活
status = constants.PackageUsageStatusPending
pendingRealnameActivation = true
activatedAt = time.Time{}
expiresAt = time.Time{}
nextResetAt = nil
}
}
// from_purchase立即激活status 已为 Active保持不变
}
// 行业卡或 C 端购买直接激活status 已为 Active保持不变
}
// 创建套餐使用记录
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
retailAmount := order.TotalAmount
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Creator,
},
OrderID: order.ID,
OrderNo: order.OrderNo,
PackageID: pkg.ID,
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: status,
Priority: priority,
DataResetCycle: pkg.DataResetCycle,
PendingRealnameActivation: pendingRealnameActivation,
PaidAmount: order.ActualPaidAmount,
RetailAmount: &retailAmount,
PackagePriceConfigStatus: pkg.PriceConfigStatus,
PackageIsGift: pkg.IsGift,
}
if carrierType == "iot_card" {
usage.IotCardID = carrierID
} else {
usage.DeviceID = carrierID
}
if status == constants.PackageUsageStatusActive {
usage.ActivatedAt = &activatedAt
usage.ExpiresAt = &expiresAt
usage.NextResetAt = nextResetAt
}
// 创建套餐使用记录(两步处理零值问题)
if err := tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建主套餐使用记录失败")
}
// 明确更新零值字段
if err := tx.Model(usage).Updates(map[string]interface{}{
"status": usage.Status,
"pending_realname_activation": usage.PendingRealnameActivation,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新主套餐状态失败")
}
return nil
}
// activateAddonPackage 任务 8.5-8.7: 加油包激活逻辑
func (s *Service) activateAddonPackage(ctx context.Context, tx *gorm.DB, order *model.Order, pkg *model.Package, carrierType string, carrierID uint, now time.Time) error {
// 任务 8.5-8.6: 检查是否有可挂靠主套餐(待生效、未过期的生效中或已用完)
mainPackage, err := packagepkg.FindAttachableMainPackageForAddon(tx, carrierType, carrierID, now)
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInvalidParam, "必须有主套餐才能购买加油包")
}
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐失败")
}
// 任务 8.7: 创建加油包,绑定到主套餐
// 查询当前最大优先级(加油包优先级低于主套餐)
var maxPriority int
tx.Model(&model.PackageUsage{}).
Where(carrierType+"_id = ?", carrierID).
Select("COALESCE(MAX(priority), 0)").
Scan(&maxPriority)
priority := maxPriority + 1
// 加油包立即生效
status := constants.PackageUsageStatusActive
activatedAt := now
// 计算过期时间(根据 has_independent_expiry
var expiresAt time.Time
// 注意has_independent_expiry 字段在 Package 模型中,暂时使用默认行为
// 默认加油包跟随主套餐过期
if mainPackage.ExpiresAt != nil {
expiresAt = *mainPackage.ExpiresAt
} else {
expiresAt = now
}
virtualTotalMBSnapshot, displayGainRatioSnapshot, enableVirtualDataSnapshot := model.BuildPackageUsageSnapshotValues(pkg)
addonRetailAmount := order.TotalAmount
usage := &model.PackageUsage{
BaseModel: model.BaseModel{
Creator: order.Creator,
Updater: order.Creator,
},
OrderID: order.ID,
OrderNo: order.OrderNo,
PackageID: pkg.ID,
PackageName: pkg.PackageName,
UsageType: order.OrderType,
DataLimitMB: pkg.RealDataMB,
VirtualTotalMBSnapshot: virtualTotalMBSnapshot,
DisplayGainRatioSnapshot: displayGainRatioSnapshot,
EnableVirtualDataSnapshot: enableVirtualDataSnapshot,
Status: status,
Priority: priority,
MasterUsageID: &mainPackage.ID,
ActivatedAt: &activatedAt,
ExpiresAt: &expiresAt,
DataResetCycle: pkg.DataResetCycle,
PaidAmount: order.ActualPaidAmount,
RetailAmount: &addonRetailAmount,
PackagePriceConfigStatus: pkg.PriceConfigStatus,
PackageIsGift: pkg.IsGift,
}
if carrierType == "iot_card" {
usage.IotCardID = carrierID
} else {
usage.DeviceID = carrierID
}
// 创建加油包使用记录(加油包 status=1不需要处理零值
if err := tx.Create(usage).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建加油包使用记录失败")
}
return nil
}
func (s *Service) enqueueCommissionCalculation(ctx context.Context, orderID uint) {
if s.queueClient == nil {
s.logger.Warn("队列客户端未初始化,跳过佣金计算任务入队", zap.Uint("order_id", orderID))
return
}
// 直接传 map由 EnqueueTask 内部统一序列化一次(传 []byte 会导致 sonic.Marshal 二次 base64 编码)
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeCommission, map[string]any{"order_id": orderID}); err != nil {
s.logger.Error("佣金计算任务入队失败",
zap.Uint("order_id", orderID),
zap.Error(err),
zap.String("task_type", constants.TaskTypeCommission))
return
}
s.logger.Info("佣金计算任务已入队",
zap.Uint("order_id", orderID),
zap.String("task_type", constants.TaskTypeCommission))
}
func (s *Service) buildOrderResponse(ctx context.Context, order *model.Order, items []*model.OrderItem) *dto.OrderResponse {
var itemResponses []*dto.OrderItemResponse
for _, item := range items {
itemResponses = append(itemResponses, &dto.OrderItemResponse{
ID: item.ID,
PackageID: item.PackageID,
PackageName: item.PackageName,
PackageType: item.PackageType,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
Amount: item.Amount,
})
}
statusText := ""
switch order.PaymentStatus {
case model.PaymentStatusPending:
statusText = "待支付"
case model.PaymentStatusPaid:
statusText = "已支付"
case model.PaymentStatusCancelled:
statusText = "已取消"
case model.PaymentStatusRefunded:
statusText = "已退款"
}
operatorID, operatorType, operatorName := s.resolveOrderOperatorView(ctx, order)
sellerShopName := s.resolveSellerShopName(ctx, order.SellerShopID)
// 生成派生字段
isPurchasedByParent := order.PurchaseRole == model.PurchaseRolePurchasedByParent
purchaseRemark := ""
switch order.PurchaseRole {
case model.PurchaseRolePurchasedByParent:
if operatorName != "" {
purchaseRemark = fmt.Sprintf("由上级代理【%s】购买", operatorName)
} else {
purchaseRemark = "由上级代理购买"
}
case model.PurchaseRolePurchasedByPlatform:
purchaseRemark = "由平台代购"
case model.PurchaseRolePurchaseForSubordinate:
if operatorName != "" {
purchaseRemark = fmt.Sprintf("由【%s】为下级购买", operatorName)
}
}
assetType := ""
if order.OrderType == model.OrderTypeSingleCard {
assetType = "card"
} else if order.OrderType == model.OrderTypeDevice {
assetType = "device"
}
actualPaidAmount := order.ActualPaidAmount
if actualPaidAmount == nil && order.PaymentStatus == model.PaymentStatusPaid {
actualPaidAmountSnapshot := order.TotalAmount
actualPaidAmount = &actualPaidAmountSnapshot
}
commissionResult := s.resolveOrderCommissionResult(ctx, order)
return &dto.OrderResponse{
ID: order.ID,
OrderNo: order.OrderNo,
OrderType: order.OrderType,
BuyerType: order.BuyerType,
BuyerID: order.BuyerID,
BuyerPhone: order.BuyerPhone,
BuyerNickname: order.BuyerNickname,
IotCardID: order.IotCardID,
DeviceID: order.DeviceID,
TotalAmount: order.TotalAmount,
PaymentMethod: order.PaymentMethod,
PaymentStatus: order.PaymentStatus,
PaymentStatusText: statusText,
PaidAt: order.PaidAt,
PaymentVoucherKey: []string(order.PaymentVoucherKey),
IsPurchaseOnBehalf: order.IsPurchaseOnBehalf,
CommissionStatus: order.CommissionStatus,
CommissionStatusName: constants.GetOrderCommissionStatusName(order.CommissionStatus),
CommissionResult: commissionResult,
CommissionResultName: constants.GetOrderCommissionResultName(commissionResult),
CommissionConfigVersion: order.CommissionConfigVersion,
SellerShopID: order.SellerShopID,
SellerShopName: sellerShopName,
OperatorID: operatorID,
OperatorType: operatorType,
OperatorName: operatorName,
ActualPaidAmount: actualPaidAmount,
PurchaseRole: order.PurchaseRole,
IsPurchasedByParent: isPurchasedByParent,
PurchaseRemark: purchaseRemark,
Items: itemResponses,
CreatedAt: order.CreatedAt,
UpdatedAt: order.UpdatedAt,
ExpiresAt: order.ExpiresAt,
IsExpired: order.ExpiresAt != nil && order.PaymentStatus == model.PaymentStatusPending && time.Now().After(*order.ExpiresAt),
AssetIdentifier: order.AssetIdentifier,
AssetType: assetType,
}
}
func (s *Service) resolveOrderOperatorView(ctx context.Context, order *model.Order) (*uint, string, string) {
if order.OperatorAccountID != nil || order.OperatorAccountType != "" || order.OperatorAccountName != "" {
return order.OperatorAccountID, order.OperatorAccountType, order.OperatorAccountName
}
if order.Source == constants.OrderSourceClient && order.BuyerType == model.BuyerTypePersonal {
operatorID, operatorType, operatorName := s.buildPersonalCustomerOperatorSnapshot(ctx, order.BuyerID, order.BuyerNickname)
if operatorID != nil || operatorType != "" || operatorName != "" {
return operatorID, operatorType, operatorName
}
}
if order.Creator > 0 {
operatorID, operatorType, operatorName := s.buildAccountOperatorSnapshot(ctx, order.Creator, 0)
if operatorID != nil || operatorType != "" || operatorName != "" {
return operatorID, operatorType, operatorName
}
}
if order.OperatorType == model.OperatorAccountTypeAgent && order.OperatorID != nil {
return nil, model.OperatorAccountTypeAgent, s.resolveLegacyOperatorShopName(ctx, *order.OperatorID)
}
if order.OperatorType == model.OperatorAccountTypePlatform {
return nil, model.OperatorAccountTypePlatform, "平台"
}
return nil, "", ""
}
func (s *Service) resolveLegacyOperatorShopName(ctx context.Context, shopID uint) string {
if shopID == 0 {
return ""
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ?", shopID).First(&shop).Error; err != nil {
return ""
}
return shop.ShopName
}
func (s *Service) resolveSellerShopName(ctx context.Context, sellerShopID *uint) string {
if sellerShopID == nil || *sellerShopID == 0 {
return ""
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ?", *sellerShopID).First(&shop).Error; err != nil {
return ""
}
return shop.ShopName
}
func (s *Service) resolveOrderCommissionResult(ctx context.Context, order *model.Order) int {
if order.CommissionResult != model.CommissionResultUnknown {
return order.CommissionResult
}
if order.CommissionStatus == model.CommissionStatusPending {
return model.CommissionResultUnknown
}
var pendingReviewCount int64
if err := s.db.WithContext(ctx).Model(&model.CommissionRecord{}).
Where("order_id = ? AND status = ?", order.ID, constants.CommissionStatusPendingReview).
Count(&pendingReviewCount).Error; err == nil && pendingReviewCount > 0 {
return model.CommissionResultChainBroken
}
var releasedCount int64
if err := s.db.WithContext(ctx).Model(&model.CommissionRecord{}).
Where("order_id = ? AND status = ? AND amount > 0", order.ID, constants.CommissionStatusReleased).
Count(&releasedCount).Error; err == nil && releasedCount > 0 {
return model.CommissionResultHasAmount
}
if order.CommissionStatus == model.CommissionStatusCompleted {
return model.CommissionResultNoAmount
}
return model.CommissionResultUnknown
}
// WechatPayJSAPI 发起微信 JSAPI 支付
// 通过 order.PaymentConfigID 动态加载支付配置,支持多商户场景
func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.WechatPayJSAPIResponse, error) {
order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单失败")
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
// 按 payment_config_id 动态加载支付配置
if order.PaymentConfigID == nil {
s.logger.Error("订单缺少支付配置", zap.Uint("order_id", orderID))
return nil, errors.New(errors.CodeWechatConfigNotFound, "订单缺少支付配置")
}
paymentSvc, err := s.paymentLoader.LoadConfig(ctx, *order.PaymentConfigID)
if err != nil {
return nil, err
}
items, err := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单明细失败")
}
description := "套餐购买"
if len(items) > 0 {
description = items[0].PackageName
}
result, err := paymentSvc.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
if err != nil {
s.logger.Error("创建 JSAPI 支付失败",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.Error(err),
)
return nil, err
}
s.logger.Info("创建 JSAPI 支付成功",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.String("prepay_id", result.PrepayID),
)
payConfig, _ := result.PayConfig.(map[string]interface{})
return &dto.WechatPayJSAPIResponse{
PrepayID: result.PrepayID,
PayConfig: payConfig,
}, nil
}
// WechatPayH5 发起微信 H5 支付
// 通过 order.PaymentConfigID 动态加载支付配置,支持多商户场景
func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.WechatH5SceneInfo, buyerType string, buyerID uint) (*dto.WechatPayH5Response, error) {
order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单失败")
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
// 按 payment_config_id 动态加载支付配置
if order.PaymentConfigID == nil {
s.logger.Error("订单缺少支付配置", zap.Uint("order_id", orderID))
return nil, errors.New(errors.CodeWechatConfigNotFound, "订单缺少支付配置")
}
paymentSvc, err := s.paymentLoader.LoadConfig(ctx, *order.PaymentConfigID)
if err != nil {
return nil, err
}
items, err := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单明细失败")
}
description := "套餐购买"
if len(items) > 0 {
description = items[0].PackageName
}
h5SceneInfo := &wechat.H5SceneInfo{
PayerClientIP: sceneInfo.PayerClientIP,
H5Type: sceneInfo.H5Info.Type,
}
result, err := paymentSvc.CreateH5Order(ctx, order.OrderNo, description, int(order.TotalAmount), h5SceneInfo)
if err != nil {
s.logger.Error("创建 H5 支付失败",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.Error(err),
)
return nil, err
}
s.logger.Info("创建 H5 支付成功",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.String("h5_url", result.H5URL),
)
return &dto.WechatPayH5Response{
H5URL: result.H5URL,
}, nil
}
type ForceRechargeRequirement struct {
NeedForceRecharge bool
ForceRechargeAmount int64
TriggerType string
}
// checkForceRechargeRequirement 检查强充要求
// 从 PackageSeries 获取一次性佣金配置,使用 per-series 追踪判断是否需要强充
func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement {
defaultResult := &ForceRechargeRequirement{NeedForceRecharge: false}
// 1. 获取 seriesID 和卖家店铺 ID
var seriesID *uint
var firstCommissionPaid bool
var sellerShopID uint
if result.Card != nil {
seriesID = result.Card.SeriesID
if seriesID != nil {
firstCommissionPaid = result.Card.IsFirstRechargeTriggeredBySeries(*seriesID)
}
if result.Card.ShopID != nil {
sellerShopID = *result.Card.ShopID
}
} else if result.Device != nil {
seriesID = result.Device.SeriesID
if seriesID != nil {
firstCommissionPaid = result.Device.IsFirstRechargeTriggeredBySeries(*seriesID)
}
if result.Device.ShopID != nil {
sellerShopID = *result.Device.ShopID
}
}
if seriesID == nil {
return defaultResult
}
// 2. 从 PackageSeries 获取一次性佣金配置
series, err := s.packageSeriesStore.GetByID(ctx, *seriesID)
if err != nil {
s.logger.Warn("查询套餐系列失败", zap.Uint("series_id", *seriesID), zap.Error(err))
return defaultResult
}
config, err := series.GetOneTimeCommissionConfig()
if err != nil || config == nil || !config.Enable {
return defaultResult
}
// 3. 如果该系列的一次性佣金已发放,无需强充
if firstCommissionPaid {
return defaultResult
}
// 4. 根据触发类型判断是否需要强充
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: config.Threshold,
TriggerType: model.OneTimeCommissionTriggerFirstRecharge,
}
}
// 5. 累计充值模式,检查平台是否启用强充
if config.EnableForceRecharge {
forceAmount := config.ForceAmount
if forceAmount == 0 {
forceAmount = config.Threshold
}
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: forceAmount,
TriggerType: model.OneTimeCommissionTriggerAccumulatedRecharge,
}
}
// 6. 平台未设强充,查询卖家代理的 ShopSeriesAllocation判断代理是否自设强充
// 仅在累计充值模式且平台未启用时,代理强充配置才生效
if sellerShopID > 0 {
agentAllocation, allocErr := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, sellerShopID, *seriesID)
if allocErr == nil && agentAllocation.EnableForceRecharge {
agentForceAmount := agentAllocation.ForceRechargeAmount
if agentForceAmount == 0 {
agentForceAmount = config.Threshold
}
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: agentForceAmount,
TriggerType: model.OneTimeCommissionTriggerAccumulatedRecharge,
}
}
}
return defaultResult
}
func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRequest) (*dto.PurchaseCheckResponse, error) {
var validationResult *purchase_validation.PurchaseValidationResult
var err error
if req.OrderType == model.OrderTypeSingleCard {
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, req.ResourceID, req.PackageIDs)
} else if req.OrderType == model.OrderTypeDevice {
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, req.ResourceID, req.PackageIDs)
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
}
if err != nil {
return nil, err
}
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
response := &dto.PurchaseCheckResponse{
TotalPackageAmount: validationResult.TotalPrice,
NeedForceRecharge: forceRechargeCheck.NeedForceRecharge,
ForceRechargeAmount: forceRechargeCheck.ForceRechargeAmount,
ActualPayment: validationResult.TotalPrice,
WalletCredit: validationResult.TotalPrice,
}
if forceRechargeCheck.NeedForceRecharge {
if validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
response.ActualPayment = forceRechargeCheck.ForceRechargeAmount
response.WalletCredit = forceRechargeCheck.ForceRechargeAmount
response.Message = "首次购买需满足最低充值要求"
}
}
return response, nil
}
func (s *Service) FuiouPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.FuiouPayJSAPIResponse, error) {
return s.fuiouPreCreate(ctx, orderID, openID, buyerType, buyerID, "JSAPI", false)
}
func (s *Service) FuiouPayMiniApp(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.FuiouPayJSAPIResponse, error) {
return s.fuiouPreCreate(ctx, orderID, openID, buyerType, buyerID, "LETPAY", true)
}
func (s *Service) fuiouPreCreate(
ctx context.Context,
orderID uint,
openID string,
buyerType string,
buyerID uint,
tradeType string,
useMiniappAppID bool,
) (*dto.FuiouPayJSAPIResponse, error) {
if strings.TrimSpace(openID) == "" {
return nil, errors.New(errors.CodeInvalidParam, "openID 不能为空")
}
order, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单失败")
}
if order.BuyerType != buyerType || order.BuyerID != buyerID {
return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "查询生效支付配置失败")
}
if activeConfig == nil || activeConfig.ProviderType != model.ProviderTypeFuiou {
return nil, errors.New(errors.CodeFuiouPayFailed, "未找到生效的富友支付配置")
}
subAppID := activeConfig.OaAppID
if useMiniappAppID {
subAppID = activeConfig.MiniappAppID
}
if strings.TrimSpace(activeConfig.FyInsCd) == "" ||
strings.TrimSpace(activeConfig.FyMchntCd) == "" ||
strings.TrimSpace(activeConfig.FyTermID) == "" ||
strings.TrimSpace(activeConfig.FyPrivateKey) == "" ||
strings.TrimSpace(activeConfig.FyPublicKey) == "" ||
strings.TrimSpace(activeConfig.FyAPIURL) == "" ||
strings.TrimSpace(activeConfig.FyNotifyURL) == "" ||
strings.TrimSpace(subAppID) == "" {
return nil, errors.New(errors.CodeFuiouPayFailed, "富友支付配置不完整")
}
client, err := fuiou.NewClient(
activeConfig.FyInsCd,
activeConfig.FyMchntCd,
activeConfig.FyTermID,
activeConfig.FyAPIURL,
activeConfig.FyNotifyURL,
activeConfig.FyPrivateKey,
activeConfig.FyPublicKey,
s.logger,
)
if err != nil {
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友支付客户端失败")
}
items, err := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询订单明细失败")
}
description := "套餐购买"
if len(items) > 0 {
description = items[0].PackageName
}
termIP := "127.0.0.1"
if ip := middleware.GetIPFromContext(ctx); ip != nil && strings.TrimSpace(*ip) != "" {
termIP = *ip
}
resp, err := client.WxPreCreate(
order.OrderNo,
strconv.FormatInt(order.TotalAmount, 10),
description,
termIP,
tradeType,
subAppID,
openID,
)
if err != nil {
s.logger.Error("富友预下单失败",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.String("trade_type", tradeType),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
}
s.logger.Info("富友预下单成功",
zap.Uint("order_id", orderID),
zap.String("order_no", order.OrderNo),
zap.String("trade_type", tradeType),
)
return &dto.FuiouPayJSAPIResponse{
AppID: resp.SdkAppid,
TimeStamp: resp.SdkTimestamp,
NonceStr: resp.SdkNoncestr,
Package: resp.SdkPackage,
SignType: resp.SdkSigntype,
PaySign: resp.SdkPaysign,
}, nil
}