Files
junhong_cmp_fiber/internal/service/client_order/service.go

1024 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package 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/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)
}
// OrderWalletPayServiceInterface 订单钱包支付服务接口。
// 用于将钱包扣款、套餐激活、佣金计算等核心逻辑委托给 B 端 order.Service 处理。
type OrderWalletPayServiceInterface interface {
WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) 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
personalCustomerStore *postgres.PersonalCustomerStore
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
wechatConfigService WechatConfigServiceInterface
orderPaymentService OrderWalletPayServiceInterface
packageSeriesStore *postgres.PackageSeriesStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
db *gorm.DB
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,
personalCustomerStore *postgres.PersonalCustomerStore,
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore,
wechatConfigService WechatConfigServiceInterface,
orderPaymentService OrderWalletPayServiceInterface,
packageSeriesStore *postgres.PackageSeriesStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
db *gorm.DB,
redisClient *redis.Client,
logger *zap.Logger,
) *Service {
return &Service{
assetService: assetService,
purchaseValidationService: purchaseValidationService,
orderStore: orderStore,
rechargeRecordStore: rechargeRecordStore,
walletStore: walletStore,
personalDeviceStore: personalDeviceStore,
openIDStore: openIDStore,
personalCustomerStore: personalCustomerStore,
personalCustomerPhoneStore: personalCustomerPhoneStore,
wechatConfigService: wechatConfigService,
orderPaymentService: orderPaymentService,
packageSeriesStore: packageSeriesStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
db: db,
redis: redisClient,
logger: logger,
}
}
// CreateOrder 创建客户端订单。
// 普通套餐下单:仅创建待支付订单,不发起支付,需后续调用 POST /orders/:id/pay 支付。
// 强充场景:检测到需要强充时,直接创建充值单并发起微信支付(一步完成),此时 app_type 必传。
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 服务未配置")
}
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier))
if err != nil {
return nil, err
}
if err := s.checkAssetOwnership(skipCtx, customerID, assetInfo.VirtualNo); err != nil {
return nil, err
}
validationResult, err := s.validatePurchase(skipCtx, assetInfo, req.PackageIDs)
if err != nil {
return nil, err
}
// 暂不做实名认证拦截,由网关侧处理
// REALNAME-03: 普通卡需要实名,行业卡不需要,待后续业务明确后按卡类型分支启用
// if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
// return nil, errors.New(errors.CodeNeedRealname)
// }
// 先判断是否需要强充,避免普通下单时不必要地校验微信配置
forceRecharge := s.checkForceRechargeRequirement(skipCtx, validationResult)
businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req.PackageIDs)
redisKey := constants.RedisClientPurchaseIdempotencyKey(businessKey)
lockKey := constants.RedisClientPurchaseLockKey(assetInfo.AssetType, assetInfo.AssetID)
lockAcquired, err := s.redis.SetNX(skipCtx, 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(skipCtx, redisKey, "processing", clientPurchaseIdempotencyTTL).Result()
if err != nil {
if lockAcquired {
_ = s.redis.Del(skipCtx, lockKey).Err()
}
return nil, errors.Wrap(errors.CodeInternalError, err, "设置客户端购买幂等标记失败")
}
if !claimed {
if lockAcquired {
_ = s.redis.Del(skipCtx, lockKey).Err()
}
return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
created := false
defer func() {
if lockAcquired {
_ = s.redis.Del(skipCtx, lockKey).Err()
}
if !created {
_ = s.redis.Del(skipCtx, redisKey).Err()
}
}()
if forceRecharge.NeedForceRecharge {
// 强充场景需要微信支付,此时 app_type 必须传入
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
if err != nil {
return nil, err
}
openID, err := s.resolveCustomerOpenID(skipCtx, customerID, appID)
if err != nil {
return nil, err
}
paymentProvider, err := s.newPaymentProvider(activeConfig, appID, req.AppType)
if err != nil {
return nil, err
}
return s.createForceRechargeOrder(skipCtx, customerID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentProvider, &created)
}
return s.createPackageOrder(skipCtx, customerID, validationResult, redisKey, &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) createPackageOrder(
ctx context.Context,
customerID uint,
validationResult *purchase_validation.PurchaseValidationResult,
redisKey string,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
// 查询卖家成本价,用于差价佣金链式计算的起点
// 平台直销sellerShopID == 0时成本价为 0CalculateCostDiffCommission 会直接跳过
var sellerCostPrice int64
sellerShopID := resolveSellerShopID(validationResult)
if sellerShopID > 0 && len(validationResult.Packages) > 0 {
costPrice, err := s.purchaseValidationService.GetCostPrice(ctx, validationResult.Packages[0], sellerShopID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询卖家成本价失败")
}
sellerCostPrice = costPrice
}
order, err := s.buildPendingOrder(ctx, customerID, validationResult, sellerCostPrice)
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
return &dto.ClientCreateOrderResponse{
OrderType: "package",
Order: &dto.ClientOrderInfo{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: order.PaymentStatus,
PaymentStatusName: constants.GetOrderPaymentStatusName(order.PaymentStatus),
CreatedAt: formatClientServiceTime(order.CreatedAt),
},
}, nil
}
func (s *Service) createForceRechargeOrder(
ctx context.Context,
customerID uint,
openID string,
assetInfo *dto.AssetResolveResponse,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
forceRecharge *ForceRechargeRequirement,
redisKey string,
paymentProvider PaymentProvider,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
resourceType, resourceID, err := resolveWalletResource(validationResult)
if err != nil {
return nil, err
}
wallet, err := s.getOrCreateWallet(ctx, resourceType, resourceID, resolveSellerShopID(validationResult))
if err != nil {
return nil, 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, "创建充值记录失败")
}
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount))
if err != nil {
return nil, err
}
// 微信支付创建成功后才标记幂等 key确保支付失败时 defer 能正确清除 key
s.markClientPurchaseCreated(ctx, redisKey, recharge.RechargeNo)
*created = true
clientStatus := rechargeStatusToClientStatus(recharge.Status)
return &dto.ClientCreateOrderResponse{
OrderType: "recharge",
Recharge: &dto.ClientRechargeInfo{
RechargeID: recharge.ID,
RechargeNo: recharge.RechargeNo,
Amount: recharge.Amount,
Status: clientStatus,
StatusName: clientRechargeStatusName(clientStatus),
AutoPurchaseStatus: recharge.AutoPurchaseStatus,
},
PayConfig: buildClientPayConfigFromResult(paymentResult),
LinkedPackageInfo: buildLinkedPackageInfo(validationResult, forceRecharge),
}, 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) buildPendingOrder(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult, sellerCostPrice int64) (*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,
PaymentStatus: model.PaymentStatusPending,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
Source: constants.OrderSourceClient,
Generation: resolveGeneration(result),
ExpiresAt: &expiresAt,
SellerCostPrice: sellerCostPrice,
}
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
}
order.BuyerPhone, order.BuyerNickname = s.fetchBuyerSnapshot(ctx, customerID)
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,
PackageType: &pkg.PackageType,
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,
}
}
// getOrCreateWallet 获取或自动创建资产钱包
// 兜底机制:当钱包不存在时自动创建,避免因钱包未提前创建导致业务中断
func (s *Service) getOrCreateWallet(ctx context.Context, resourceType string, resourceID uint, shopIDTag uint) (*model.AssetWallet, error) {
wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
if err == nil {
return wallet, nil
}
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
}
// 钱包不存在,自动创建空钱包
s.logger.Info("资产钱包不存在,自动创建",
zap.String("resource_type", resourceType),
zap.Uint("resource_id", resourceID),
zap.Uint("shop_id_tag", shopIDTag),
)
newWallet := &model.AssetWallet{
ResourceType: resourceType,
ResourceID: resourceID,
Balance: 0,
FrozenBalance: 0,
Currency: "CNY",
Status: constants.AssetWalletStatusNormal,
Version: 0,
ShopIDTag: shopIDTag,
}
if createErr := s.walletStore.Create(ctx, newWallet); createErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, createErr, "自动创建资产钱包失败")
}
// 重新查询获取完整数据(含数据库生成的字段)
wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
}
return wallet, nil
}
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 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, packageIDs []uint) string {
sorted := make([]uint, len(packageIDs))
copy(sorted, packageIDs)
slices.Sort(sorted)
parts := make([]string, 0, len(sorted))
for _, id := range sorted {
parts = append(parts, strconv.FormatUint(uint64(id), 10))
}
return fmt.Sprintf("%d:%s:%d:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, strings.Join(parts, ","))
}
func rechargeStatusToClientStatus(status int) int {
switch status {
case 1:
return 0
case 2, 3:
return 1
default:
return 2
}
}
func clientRechargeStatusName(clientStatus int) string {
switch clientStatus {
case 0:
return "待支付"
case 1:
return "已支付"
default:
return "已关闭"
}
}
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))
}
// ListOrders 查询 C 端订单列表。
func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.ClientOrderListRequest) ([]dto.ClientOrderListItem, int64, error) {
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier))
if err != nil {
return nil, 0, err
}
if err := s.checkAssetOwnership(skipCtx, customerID, assetInfo.VirtualNo); err != nil {
return nil, 0, err
}
generation, err := s.getAssetGeneration(skipCtx, assetInfo.AssetType, assetInfo.AssetID)
if err != nil {
return nil, 0, err
}
query := s.db.WithContext(skipCtx).
Model(&model.Order{}).
Where("generation = ?", generation)
if assetInfo.AssetType == constants.ResourceTypeDevice {
query = query.Where("order_type = ? AND device_id = ?", model.OrderTypeDevice, assetInfo.AssetID)
} else {
query = query.Where("order_type = ? AND iot_card_id = ?", model.OrderTypeSingleCard, assetInfo.AssetID)
}
if req.PaymentStatus != nil {
query = query.Where("payment_status = ?", *req.PaymentStatus)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询订单总数失败")
}
var orders []*model.Order
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&orders).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询订单列表失败")
}
orderIDs := make([]uint, 0, len(orders))
for _, order := range orders {
if order != nil {
orderIDs = append(orderIDs, order.ID)
}
}
itemMap, err := s.loadOrderItemMap(skipCtx, orderIDs)
if err != nil {
return nil, 0, err
}
list := make([]dto.ClientOrderListItem, 0, len(orders))
for _, order := range orders {
if order == nil {
continue
}
packageNames := make([]string, 0)
for _, item := range itemMap[order.ID] {
if item != nil && item.PackageName != "" {
packageNames = append(packageNames, item.PackageName)
}
}
list = append(list, dto.ClientOrderListItem{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: order.PaymentStatus,
PaymentStatusName: constants.GetOrderPaymentStatusName(order.PaymentStatus),
CreatedAt: formatClientServiceTime(order.CreatedAt),
PackageNames: packageNames,
})
}
return list, total, nil
}
// GetOrderDetail 查询 C 端订单详情。
func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID uint) (*dto.ClientOrderDetailResponse, error) {
order, items, err := s.orderStore.GetByIDWithItems(ctx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单详情失败")
}
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
virtualNo, err := s.getOrderVirtualNo(skipCtx, order)
if err != nil {
return nil, err
}
if err := s.checkAssetOwnership(skipCtx, customerID, virtualNo); err != nil {
return nil, err
}
packages := make([]dto.ClientOrderPackageItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
packages = append(packages, dto.ClientOrderPackageItem{
PackageID: item.PackageID,
PackageName: item.PackageName,
Price: item.UnitPrice,
Quantity: item.Quantity,
})
}
return &dto.ClientOrderDetailResponse{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: order.PaymentStatus,
PaymentStatusName: constants.GetOrderPaymentStatusName(order.PaymentStatus),
PaymentMethod: order.PaymentMethod,
CreatedAt: formatClientServiceTime(order.CreatedAt),
PaidAt: formatClientServiceTimePtr(order.PaidAt),
CompletedAt: nil,
Packages: packages,
}, nil
}
// PayOrder D4 对待支付的 C 端订单发起支付。
// 支持 wallet钱包扣款和 wechat微信 JSAPI两种支付方式。
// 先校验订单归属和状态,再按 payment_method 路由到对应支付逻辑:
// - wallet委托 B 端 WalletPay乐观锁扣款 + 激活套餐 + 佣金入队)
// - wechat查微信配置 + OpenID → 预下单 → 更新 payment_config_id → 返回 JS 支付参数
func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, req *dto.ClientPayOrderRequest) (*dto.ClientPayOrderResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
// 跳过 GORM 店铺过滤C 端订单查询不依赖 seller_shop_id 过滤
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
order, err := s.orderStore.GetByID(skipCtx, orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "订单不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单失败")
}
if order.BuyerType != model.BuyerTypePersonal || order.BuyerID != customerID {
return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
}
if order.PaymentStatus != model.PaymentStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
}
switch req.PaymentMethod {
case model.PaymentMethodWallet:
if err := s.orderPaymentService.WalletPay(skipCtx, orderID, model.BuyerTypePersonal, customerID); err != nil {
return nil, err
}
return &dto.ClientPayOrderResponse{PaymentMethod: model.PaymentMethodWallet}, nil
case model.PaymentMethodWechat:
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
if err != nil {
return nil, err
}
openID, err := s.resolveCustomerOpenID(skipCtx, customerID, appID)
if err != nil {
return nil, err
}
paymentProvider, err := s.newPaymentProvider(activeConfig, appID, req.AppType)
if err != nil {
return nil, err
}
// 记录本次支付使用的微信配置,供支付回调验签使用
if err := s.db.WithContext(skipCtx).Model(&model.Order{}).
Where("id = ?", orderID).
Update("payment_config_id", activeConfig.ID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新订单支付配置失败")
}
paymentResult, err := paymentProvider.CreateJSAPIPayment(skipCtx, order.OrderNo, "套餐购买", openID, int(order.TotalAmount))
if err != nil {
return nil, err
}
return &dto.ClientPayOrderResponse{
PaymentMethod: model.PaymentMethodWechat,
PayConfig: buildClientPayConfigFromResult(paymentResult),
}, nil
default:
return nil, errors.New(errors.CodeInvalidParam, "不支持的支付方式")
}
}
func (s *Service) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":
card, err := s.iotCardStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
}
return card.Generation, nil
case constants.ResourceTypeDevice:
device, err := s.deviceStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
}
return device.Generation, nil
default:
return 0, errors.New(errors.CodeInvalidParam)
}
}
func (s *Service) loadOrderItemMap(ctx context.Context, orderIDs []uint) (map[uint][]*model.OrderItem, error) {
result := make(map[uint][]*model.OrderItem)
if len(orderIDs) == 0 {
return result, nil
}
var items []*model.OrderItem
if err := s.db.WithContext(ctx).Where("order_id IN ?", orderIDs).Order("id ASC").Find(&items).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
}
for _, item := range items {
if item != nil {
result[item.OrderID] = append(result[item.OrderID], item)
}
}
return result, nil
}
func (s *Service) getOrderVirtualNo(ctx context.Context, order *model.Order) (string, error) {
if order == nil {
return "", errors.New(errors.CodeNotFound, "订单不存在")
}
switch order.OrderType {
case model.OrderTypeSingleCard:
if order.IotCardID == nil || *order.IotCardID == 0 {
return "", errors.New(errors.CodeInvalidParam)
}
card, err := s.iotCardStore.GetByID(ctx, *order.IotCardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
}
return card.VirtualNo, nil
case model.OrderTypeDevice:
if order.DeviceID == nil || *order.DeviceID == 0 {
return "", errors.New(errors.CodeInvalidParam)
}
device, err := s.deviceStore.GetByID(ctx, *order.DeviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
}
return device.VirtualNo, nil
default:
return "", errors.New(errors.CodeInvalidParam)
}
}
func formatClientServiceTimePtr(t *time.Time) *string {
if t == nil || t.IsZero() {
return nil
}
formatted := formatClientServiceTime(*t)
return &formatted
}