All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m59s
3015 lines
103 KiB
Go
3015 lines
103 KiB
Go
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"
|
||
)
|
||
|
||
// 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 && strings.TrimSpace(req.PaymentVoucherKey) == "" {
|
||
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
|
||
|
||
// 根据支付方式分别处理
|
||
if req.PaymentMethod == model.PaymentMethodOffline {
|
||
// ==== 场景 1:offline(线下支付)====
|
||
if containsGift {
|
||
orderBuyerType = model.BuyerTypePersonal
|
||
orderBuyerID = 0
|
||
totalAmount = 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
|
||
itemUnitPriceMap = buyerCostPriceMap
|
||
totalAmount = buyerTotalCost
|
||
paymentMethod = model.PaymentMethodOffline
|
||
paymentStatus = model.PaymentStatusPaid
|
||
paidAt = purchasePaidAt
|
||
isPurchaseOnBehalf = true
|
||
sellerCostPrice = buyerTotalCost
|
||
|
||
// 设置操作者信息(平台代购)
|
||
operatorID = nil
|
||
operatorType = constants.OwnerTypePlatform
|
||
purchaseRole = model.PurchaseRolePurchasedByPlatform
|
||
actualPaidAmount = nil
|
||
}
|
||
}
|
||
|
||
} 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
|
||
itemUnitPriceMap = operatorCostPriceMap
|
||
totalAmount = operatorTotalCost
|
||
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:代理代购(给下级购买)====
|
||
// 获取买家成本价
|
||
buyerCostPriceMap, buyerTotalCost, 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
|
||
itemUnitPriceMap = buyerCostPriceMap
|
||
totalAmount = buyerTotalCost
|
||
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
|
||
itemUnitPriceMap = targetCostPriceMap
|
||
totalAmount = targetTotalCost
|
||
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 = strings.TrimSpace(req.PaymentVoucherKey)
|
||
}
|
||
|
||
if orderBuyerType == model.BuyerTypePersonal {
|
||
order.BuyerPhone, order.BuyerNickname = s.fetchBuyerSnapshot(ctx, orderBuyerID)
|
||
}
|
||
|
||
items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap)
|
||
|
||
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
|
||
|
||
// 场景判断: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
|
||
itemUnitPriceMap = buyerCostPriceMap
|
||
totalAmount = buyerTotalCost
|
||
paymentMethod = model.PaymentMethodOffline
|
||
paymentStatus = model.PaymentStatusPaid
|
||
paidAt = purchasePaidAt
|
||
isPurchaseOnBehalf = true
|
||
sellerCostPrice = buyerTotalCost
|
||
|
||
// 设置操作者信息(平台代购)
|
||
operatorID = nil
|
||
operatorType = constants.OwnerTypePlatform
|
||
purchaseRole = model.PurchaseRolePurchasedByPlatform
|
||
actualPaidAmount = nil
|
||
|
||
} else if req.PaymentMethod == model.PaymentMethodWallet {
|
||
// ==== 场景 2:代理钱包支付(wallet)====
|
||
// 只有代理账号可以使用钱包支付
|
||
if buyerType != model.BuyerTypeAgent {
|
||
return nil, errors.New(errors.CodeInvalidParam, "只有代理账号可以使用钱包支付")
|
||
}
|
||
operatorShopID := buyerID
|
||
|
||
// 判断资源是否属于操作者
|
||
if resourceShopID == nil {
|
||
return nil, errors.New(errors.CodeInternalError, "资源店铺ID为空")
|
||
}
|
||
|
||
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
|
||
itemUnitPriceMap = operatorCostPriceMap
|
||
totalAmount = operatorTotalCost
|
||
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:代理代购(给下级购买)====
|
||
// 获取买家成本价
|
||
buyerCostPriceMap, buyerTotalCost, 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
|
||
itemUnitPriceMap = buyerCostPriceMap
|
||
totalAmount = buyerTotalCost
|
||
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)
|
||
|
||
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
|
||
}
|
||
|
||
func (s *Service) buildOrderItems(operatorID uint, packages []*model.Package, unitPriceMap 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 := unitPriceMap[pkg.ID]; ok {
|
||
unitPrice = price
|
||
}
|
||
|
||
item := &model.OrderItem{
|
||
BaseModel: model.BaseModel{
|
||
Creator: operatorID,
|
||
Updater: operatorID,
|
||
},
|
||
PackageID: pkg.ID,
|
||
PackageName: pkg.PackageName,
|
||
PackageType: &pkg.PackageType,
|
||
Quantity: 1,
|
||
UnitPrice: unitPrice,
|
||
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
|
||
|
||
// 1. 事务外:检查钱包余额(快速失败)
|
||
wallet, err := s.agentWalletStore.GetMainWallet(ctx, operatorShopID)
|
||
if err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
|
||
}
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||
}
|
||
if wallet.Balance < actualAmount {
|
||
return errors.New(errors.CodeInsufficientBalance, "余额不足")
|
||
}
|
||
|
||
// 2. 事务内:创建订单 + 扣款 + 创建流水 + 激活套餐
|
||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
// 2.1 创建订单
|
||
if err := tx.Create(order).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
|
||
}
|
||
|
||
// 2.2 创建订单明细
|
||
for _, item := range items {
|
||
item.OrderID = order.ID
|
||
}
|
||
if err := tx.CreateInBatches(items, 100).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
|
||
}
|
||
|
||
// 2.3 扣减钱包余额(乐观锁)
|
||
result := tx.Model(&model.AgentWallet{}).
|
||
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, actualAmount, wallet.Version).
|
||
Updates(map[string]any{
|
||
"balance": gorm.Expr("balance - ?", actualAmount),
|
||
"version": gorm.Expr("version + 1"),
|
||
})
|
||
if result.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减钱包余额失败")
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
|
||
}
|
||
|
||
// 2.4 创建钱包流水
|
||
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
|
||
}
|
||
|
||
// 2.5 激活套餐
|
||
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(¤tOrder, 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(¤tOrder, 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(¤tOrder, 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(¤tPayment, 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(¤tOrder, 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
|
||
}
|
||
|
||
// 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 {
|
||
// 检查是否有生效中主套餐
|
||
var activeMainPackage model.PackageUsage
|
||
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
|
||
Where("master_usage_id IS NULL").
|
||
Where(carrierType+"_id = ?", carrierID).
|
||
Order("priority ASC").
|
||
First(&activeMainPackage).Error
|
||
|
||
hasActiveMain := err == nil
|
||
|
||
var status int
|
||
var priority int
|
||
var activatedAt time.Time
|
||
var expiresAt time.Time
|
||
var nextResetAt *time.Time
|
||
var pendingRealnameActivation bool
|
||
|
||
if hasActiveMain {
|
||
// 任务 8.3: 有生效中主套餐,新套餐排队
|
||
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 {
|
||
// 任务 8.4: 无生效中主套餐,立即激活
|
||
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 + 实名状态)
|
||
// 注意:仅在 else 分支(立即激活)时才需要判断是否切换为等实名
|
||
if !hasActiveMain {
|
||
// 查询卡类型(行业卡永远直接激活,不等实名)
|
||
// 同时查询载体当前实名状态:已实名则跳过 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)
|
||
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,
|
||
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)
|
||
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,
|
||
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: 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
|
||
}
|