// Package client_order 提供 C 端订单下单服务。 package client_order import ( "context" "fmt" "math/rand" "slices" "strconv" "strings" "time" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" asset "github.com/break/junhong_cmp_fiber/internal/service/asset" "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" "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/wechat" "github.com/bytedance/sonic" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/datatypes" "gorm.io/gorm" ) const ( clientPurchaseIdempotencyTTL = 5 * time.Minute clientPurchaseLockTTL = 10 * time.Second ) // WechatConfigServiceInterface 微信配置服务接口。 type WechatConfigServiceInterface interface { GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) } // ForceRechargeRequirement 强充要求。 type ForceRechargeRequirement struct { NeedForceRecharge bool ForceRechargeAmount int64 } // Service 客户端订单服务。 type Service struct { assetService *asset.Service purchaseValidationService *purchase_validation.Service orderStore *postgres.OrderStore rechargeRecordStore *postgres.AssetRechargeStore walletStore *postgres.AssetWalletStore personalDeviceStore *postgres.PersonalCustomerDeviceStore openIDStore *postgres.PersonalCustomerOpenIDStore wechatConfigService WechatConfigServiceInterface packageSeriesStore *postgres.PackageSeriesStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore redis *redis.Client logger *zap.Logger } // New 创建客户端订单服务。 func New( assetService *asset.Service, purchaseValidationService *purchase_validation.Service, orderStore *postgres.OrderStore, rechargeRecordStore *postgres.AssetRechargeStore, walletStore *postgres.AssetWalletStore, personalDeviceStore *postgres.PersonalCustomerDeviceStore, openIDStore *postgres.PersonalCustomerOpenIDStore, wechatConfigService WechatConfigServiceInterface, packageSeriesStore *postgres.PackageSeriesStore, shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, redisClient *redis.Client, logger *zap.Logger, ) *Service { return &Service{ assetService: assetService, purchaseValidationService: purchaseValidationService, orderStore: orderStore, rechargeRecordStore: rechargeRecordStore, walletStore: walletStore, personalDeviceStore: personalDeviceStore, openIDStore: openIDStore, wechatConfigService: wechatConfigService, packageSeriesStore: packageSeriesStore, shopSeriesAllocationStore: shopSeriesAllocationStore, redis: redisClient, logger: logger, } } // CreateOrder 创建客户端订单。 func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (*dto.ClientCreateOrderResponse, error) { if req == nil { return nil, errors.New(errors.CodeInvalidParam) } if s.redis == nil { return nil, errors.New(errors.CodeInternalError, "Redis 服务未配置") } skipPermissionCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{}) assetInfo, err := s.assetService.Resolve(skipPermissionCtx, strings.TrimSpace(req.Identifier)) if err != nil { return nil, err } if err := s.checkAssetOwnership(skipPermissionCtx, customerID, assetInfo.VirtualNo); err != nil { return nil, err } validationResult, err := s.validatePurchase(skipPermissionCtx, assetInfo, req.PackageIDs) if err != nil { return nil, err } if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 { return nil, errors.New(errors.CodeNeedRealname) } activeConfig, appID, err := s.resolveWechatConfig(skipPermissionCtx, req.AppType) if err != nil { return nil, err } openID, err := s.resolveCustomerOpenID(skipPermissionCtx, customerID, appID) if err != nil { return nil, err } businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req) redisKey := constants.RedisClientPurchaseIdempotencyKey(businessKey) lockKey := constants.RedisClientPurchaseLockKey(assetInfo.AssetType, assetInfo.AssetID) lockAcquired, err := s.redis.SetNX(skipPermissionCtx, lockKey, time.Now().String(), clientPurchaseLockTTL).Result() if err != nil { s.logger.Warn("获取客户端购买分布式锁失败,继续尝试幂等标记", zap.Error(err), zap.String("lock_key", lockKey), ) } if err == nil && !lockAcquired { return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交") } claimed, err := s.redis.SetNX(skipPermissionCtx, redisKey, "processing", clientPurchaseIdempotencyTTL).Result() if err != nil { if lockAcquired { _ = s.redis.Del(skipPermissionCtx, lockKey).Err() } return nil, errors.Wrap(errors.CodeInternalError, err, "设置客户端购买幂等标记失败") } if !claimed { if lockAcquired { _ = s.redis.Del(skipPermissionCtx, lockKey).Err() } return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交") } created := false defer func() { if lockAcquired { _ = s.redis.Del(skipPermissionCtx, lockKey).Err() } if !created { _ = s.redis.Del(skipPermissionCtx, redisKey).Err() } }() paymentService, err := s.newPaymentService(activeConfig, appID) if err != nil { return nil, err } forceRecharge := s.checkForceRechargeRequirement(skipPermissionCtx, validationResult) if forceRecharge.NeedForceRecharge { return s.createForceRechargeOrder(skipPermissionCtx, customerID, appID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentService, &created) } return s.createPackageOrder(skipPermissionCtx, customerID, appID, openID, validationResult, activeConfig, redisKey, paymentService, &created) } func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error { owned, err := s.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, virtualNo) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败") } if owned { return nil } records, err := s.personalDeviceStore.GetByCustomerID(ctx, customerID) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败") } for _, record := range records { if record == nil { continue } if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo { return nil } } return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") } func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetResolveResponse, packageIDs []uint) (*purchase_validation.PurchaseValidationResult, error) { switch assetInfo.AssetType { case "card": return s.purchaseValidationService.ValidateCardPurchase(ctx, assetInfo.AssetID, packageIDs) case constants.ResourceTypeDevice: return s.purchaseValidationService.ValidateDevicePurchase(ctx, assetInfo.AssetID, packageIDs) default: return nil, errors.New(errors.CodeInvalidParam) } } func (s *Service) resolveWechatConfig(ctx context.Context, appType string) (*model.WechatConfig, string, error) { activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx) if err != nil { return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信配置失败") } if activeConfig == nil { return nil, "", errors.New(errors.CodeWechatPayFailed, "未找到生效的微信支付配置") } switch appType { case "official_account": if activeConfig.OaAppID == "" { return nil, "", errors.New(errors.CodeWechatPayFailed, "公众号支付配置不完整") } return activeConfig, activeConfig.OaAppID, nil case "miniapp": if activeConfig.MiniappAppID == "" { return nil, "", errors.New(errors.CodeWechatPayFailed, "小程序支付配置不完整") } return activeConfig, activeConfig.MiniappAppID, nil default: return nil, "", errors.New(errors.CodeInvalidParam) } } func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, appID string) (string, error) { records, err := s.openIDStore.ListByCustomerID(ctx, customerID) if err != nil { return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败") } for _, record := range records { if record == nil { continue } if record.AppID == appID && strings.TrimSpace(record.OpenID) != "" { return record.OpenID, nil } } return "", errors.New(errors.CodeNotFound, "未找到当前应用的微信授权信息") } func (s *Service) newPaymentService(wechatConfig *model.WechatConfig, appID string) (*wechat.PaymentService, error) { cache := wechat.NewRedisCache(s.redis) paymentApp, err := wechat.NewPaymentAppFromConfig(wechatConfig, appID, cache, s.logger) if err != nil { return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "创建微信支付应用失败") } return wechat.NewPaymentService(paymentApp, s.logger), nil } func (s *Service) createPackageOrder( ctx context.Context, customerID uint, appID string, openID string, validationResult *purchase_validation.PurchaseValidationResult, activeConfig *model.WechatConfig, redisKey string, paymentService *wechat.PaymentService, created *bool, ) (*dto.ClientCreateOrderResponse, error) { order, err := s.buildPendingOrder(customerID, validationResult, activeConfig) if err != nil { return nil, err } items, err := s.buildOrderItems(ctx, customerID, validationResult) if err != nil { return nil, err } if err := s.orderStore.Create(ctx, order, items); err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败") } s.markClientPurchaseCreated(ctx, redisKey, order.OrderNo) *created = true description := "套餐购买" if len(items) > 0 && items[0] != nil && items[0].PackageName != "" { description = items[0].PackageName } payResult, err := paymentService.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount)) if err != nil { return nil, err } return &dto.ClientCreateOrderResponse{ OrderType: "package", Order: &dto.ClientOrderInfo{ OrderID: order.ID, OrderNo: order.OrderNo, TotalAmount: order.TotalAmount, PaymentStatus: orderStatusToClientStatus(order.PaymentStatus), CreatedAt: formatClientServiceTime(order.CreatedAt), }, PayConfig: buildClientPayConfig(appID, payResult.PayConfig), }, nil } func (s *Service) createForceRechargeOrder( ctx context.Context, customerID uint, appID string, openID string, assetInfo *dto.AssetResolveResponse, validationResult *purchase_validation.PurchaseValidationResult, activeConfig *model.WechatConfig, forceRecharge *ForceRechargeRequirement, redisKey string, paymentService *wechat.PaymentService, created *bool, ) (*dto.ClientCreateOrderResponse, error) { resourceType, resourceID, err := resolveWalletResource(validationResult) if err != nil { return nil, err } wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeWalletNotFound, "钱包不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败") } linkedPackageIDs, err := sonic.Marshal(extractPackageIDs(validationResult.Packages)) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "序列化关联套餐失败") } carrierID := resourceID recharge := &model.AssetRechargeRecord{ UserID: customerID, AssetWalletID: wallet.ID, ResourceType: resourceType, ResourceID: resourceID, RechargeNo: generateClientRechargeNo(), Amount: forceRecharge.ForceRechargeAmount, PaymentMethod: model.PaymentMethodWechat, PaymentConfigID: &activeConfig.ID, Status: 1, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, OperatorType: "personal_customer", Generation: resolveGeneration(validationResult), LinkedPackageIDs: datatypes.JSON(linkedPackageIDs), LinkedOrderType: resolveOrderType(validationResult), LinkedCarrierType: assetInfo.AssetType, LinkedCarrierID: &carrierID, AutoPurchaseStatus: constants.AutoPurchaseStatusPending, } if err := s.rechargeRecordStore.Create(ctx, recharge); err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败") } s.markClientPurchaseCreated(ctx, redisKey, recharge.RechargeNo) *created = true payResult, err := paymentService.CreateJSAPIOrder(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount)) if err != nil { return nil, err } return &dto.ClientCreateOrderResponse{ OrderType: "recharge", Recharge: &dto.ClientRechargeInfo{ RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, Amount: recharge.Amount, Status: rechargeStatusToClientStatus(recharge.Status), AutoPurchaseStatus: recharge.AutoPurchaseStatus, }, PayConfig: buildClientPayConfig(appID, payResult.PayConfig), LinkedPackageInfo: buildLinkedPackageInfo(validationResult, forceRecharge), }, nil } func (s *Service) buildPendingOrder(customerID uint, result *purchase_validation.PurchaseValidationResult, activeConfig *model.WechatConfig) (*model.Order, error) { orderType := resolveOrderType(result) if orderType == "" { return nil, errors.New(errors.CodeInvalidParam) } now := time.Now() expiresAt := now.Add(constants.OrderExpireTimeout) order := &model.Order{ BaseModel: model.BaseModel{ Creator: customerID, Updater: customerID, }, OrderNo: s.orderStore.GenerateOrderNo(), OrderType: orderType, BuyerType: model.BuyerTypePersonal, BuyerID: customerID, TotalAmount: result.TotalPrice, PaymentMethod: model.PaymentMethodWechat, PaymentStatus: model.PaymentStatusPending, CommissionStatus: model.CommissionStatusPending, CommissionConfigVersion: 0, Source: constants.OrderSourceClient, Generation: resolveGeneration(result), ExpiresAt: &expiresAt, PaymentConfigID: &activeConfig.ID, } if result.Card != nil { order.IotCardID = &result.Card.ID order.SeriesID = result.Card.SeriesID order.SellerShopID = result.Card.ShopID } else if result.Device != nil { order.DeviceID = &result.Device.ID order.SeriesID = result.Device.SeriesID order.SellerShopID = result.Device.ShopID } return order, nil } func (s *Service) buildOrderItems(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult) ([]*model.OrderItem, error) { sellerShopID := resolveSellerShopID(result) items := make([]*model.OrderItem, 0, len(result.Packages)) for _, pkg := range result.Packages { if pkg == nil { continue } unitPrice, err := s.purchaseValidationService.GetPurchasePrice(ctx, pkg, sellerShopID) if err != nil { return nil, err } items = append(items, &model.OrderItem{ BaseModel: model.BaseModel{ Creator: customerID, Updater: customerID, }, PackageID: pkg.ID, PackageName: pkg.PackageName, Quantity: 1, UnitPrice: unitPrice, Amount: unitPrice, }) } return items, nil } func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement { defaultResult := &ForceRechargeRequirement{NeedForceRecharge: false} var seriesID *uint var sellerShopID uint if result.Card != nil { seriesID = result.Card.SeriesID if result.Card.ShopID != nil { sellerShopID = *result.Card.ShopID } } else if result.Device != nil { seriesID = result.Device.SeriesID if result.Device.ShopID != nil { sellerShopID = *result.Device.ShopID } } if seriesID == nil || *seriesID == 0 { return defaultResult } 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 } if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge { return &ForceRechargeRequirement{ NeedForceRecharge: true, ForceRechargeAmount: config.Threshold, } } if config.EnableForceRecharge { amount := config.ForceAmount if amount == 0 { amount = config.Threshold } return &ForceRechargeRequirement{ NeedForceRecharge: true, ForceRechargeAmount: amount, } } if sellerShopID > 0 { allocation, allocErr := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, sellerShopID, *seriesID) if allocErr == nil && allocation.EnableForceRecharge { amount := allocation.ForceRechargeAmount if amount == 0 { amount = config.Threshold } return &ForceRechargeRequirement{ NeedForceRecharge: true, ForceRechargeAmount: amount, } } } return defaultResult } func (s *Service) markClientPurchaseCreated(ctx context.Context, redisKey string, value string) { if err := s.redis.Set(ctx, redisKey, value, clientPurchaseIdempotencyTTL).Err(); err != nil { s.logger.Warn("设置客户端购买幂等标记失败", zap.String("redis_key", redisKey), zap.Error(err), ) } } func buildLinkedPackageInfo(result *purchase_validation.PurchaseValidationResult, forceRecharge *ForceRechargeRequirement) *dto.LinkedPackageInfo { packageNames := make([]string, 0, len(result.Packages)) for _, pkg := range result.Packages { if pkg == nil || pkg.PackageName == "" { continue } packageNames = append(packageNames, pkg.PackageName) } return &dto.LinkedPackageInfo{ PackageNames: packageNames, TotalPackageAmount: result.TotalPrice, ForceRechargeAmount: forceRecharge.ForceRechargeAmount, WalletCredit: forceRecharge.ForceRechargeAmount, } } func buildClientPayConfig(appID string, payConfig any) *dto.ClientPayConfig { configMap, _ := payConfig.(map[string]any) if configMap == nil { configMap = map[string]any{} } return &dto.ClientPayConfig{ AppID: firstNonEmpty(stringFromAny(configMap["appId"]), appID), Timestamp: firstNonEmpty(stringFromAny(configMap["timeStamp"]), stringFromAny(configMap["timestamp"])), NonceStr: stringFromAny(configMap["nonceStr"]), PackageVal: stringFromAny(configMap["package"]), SignType: stringFromAny(configMap["signType"]), PaySign: stringFromAny(configMap["paySign"]), } } func resolveWalletResource(result *purchase_validation.PurchaseValidationResult) (string, uint, error) { if result.Card != nil { return constants.AssetWalletResourceTypeIotCard, result.Card.ID, nil } if result.Device != nil { return constants.AssetWalletResourceTypeDevice, result.Device.ID, nil } return "", 0, errors.New(errors.CodeInvalidParam) } func resolveOrderType(result *purchase_validation.PurchaseValidationResult) string { if result.Card != nil { return model.OrderTypeSingleCard } if result.Device != nil { return model.OrderTypeDevice } return "" } func resolveGeneration(result *purchase_validation.PurchaseValidationResult) int { if result.Card != nil && result.Card.Generation > 0 { return result.Card.Generation } if result.Device != nil && result.Device.Generation > 0 { return result.Device.Generation } return 1 } func resolveSellerShopID(result *purchase_validation.PurchaseValidationResult) uint { if result.Card != nil && result.Card.ShopID != nil { return *result.Card.ShopID } if result.Device != nil && result.Device.ShopID != nil { return *result.Device.ShopID } return 0 } func packagesNeedRealname(packages []*model.Package) bool { for _, pkg := range packages { if pkg != nil && pkg.EnableRealnameActivation { return true } } return false } func extractPackageIDs(packages []*model.Package) []uint { ids := make([]uint, 0, len(packages)) for _, pkg := range packages { if pkg == nil { continue } ids = append(ids, pkg.ID) } return ids } func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolveResponse, req *dto.ClientCreateOrderRequest) string { packageIDs := make([]uint, 0, len(req.PackageIDs)) packageIDs = append(packageIDs, req.PackageIDs...) slices.Sort(packageIDs) parts := make([]string, 0, len(packageIDs)) for _, packageID := range packageIDs { parts = append(parts, strconv.FormatUint(uint64(packageID), 10)) } return fmt.Sprintf("%d:%s:%d:%s:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, req.AppType, strings.Join(parts, ",")) } func orderStatusToClientStatus(status int) int { switch status { case model.PaymentStatusPending: return 0 case model.PaymentStatusPaid: return 1 case model.PaymentStatusCancelled: return 2 default: return status } } func rechargeStatusToClientStatus(status int) int { switch status { case 1: return 0 case 2, 3: return 1 default: return 2 } } func formatClientServiceTime(t time.Time) string { if t.IsZero() { return "" } return t.Format(time.RFC3339) } func generateClientRechargeNo() string { return fmt.Sprintf("CRCH%d%06d", time.Now().UnixNano()/1e6, rand.Intn(1000000)) } func stringFromAny(value any) string { if value == nil { return "" } return fmt.Sprint(value) } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return value } } return "" }