feat: C端订单支付拆分 — 创建订单与发起支付分离
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m10s

- CreateOrder 改造:普通下单只建单(无支付参数),强充场景保持原有微信支付一步完成
- 新增 POST /orders/:id/pay 统一支付入口,支持 wallet(钱包)和 wechat(微信 JSAPI)
- 移除 POST /orders/:id/wallet-pay,合并至统一支付接口
- app_type 改为可选字段(强充场景必传,普通下单无需传)
- CreateOrderResponse.pay_config 改为 omitempty(普通下单不返回支付参数)
This commit is contained in:
2026-03-31 12:43:34 +08:00
parent 121462c00f
commit dc4f3cb7ba
6 changed files with 276 additions and 62 deletions

View File

@@ -34,6 +34,12 @@ 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
@@ -50,6 +56,7 @@ type Service struct {
personalDeviceStore *postgres.PersonalCustomerDeviceStore
openIDStore *postgres.PersonalCustomerOpenIDStore
wechatConfigService WechatConfigServiceInterface
orderPaymentService OrderWalletPayServiceInterface
packageSeriesStore *postgres.PackageSeriesStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
iotCardStore *postgres.IotCardStore
@@ -69,6 +76,7 @@ func New(
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
openIDStore *postgres.PersonalCustomerOpenIDStore,
wechatConfigService WechatConfigServiceInterface,
orderPaymentService OrderWalletPayServiceInterface,
packageSeriesStore *postgres.PackageSeriesStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
iotCardStore *postgres.IotCardStore,
@@ -86,6 +94,7 @@ func New(
personalDeviceStore: personalDeviceStore,
openIDStore: openIDStore,
wechatConfigService: wechatConfigService,
orderPaymentService: orderPaymentService,
packageSeriesStore: packageSeriesStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
iotCardStore: iotCardStore,
@@ -97,6 +106,8 @@ func New(
}
// 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)
@@ -105,17 +116,17 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
return nil, errors.New(errors.CodeInternalError, "Redis 服务未配置")
}
skipPermissionCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := s.assetService.Resolve(skipPermissionCtx, strings.TrimSpace(req.Identifier))
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(skipPermissionCtx, customerID, assetInfo.VirtualNo); err != nil {
if err := s.checkAssetOwnership(skipCtx, customerID, assetInfo.VirtualNo); err != nil {
return nil, err
}
validationResult, err := s.validatePurchase(skipPermissionCtx, assetInfo, req.PackageIDs)
validationResult, err := s.validatePurchase(skipCtx, assetInfo, req.PackageIDs)
if err != nil {
return nil, err
}
@@ -125,21 +136,14 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
return nil, errors.New(errors.CodeNeedRealname)
}
activeConfig, appID, err := s.resolveWechatConfig(skipPermissionCtx, req.AppType)
if err != nil {
return nil, err
}
// 先判断是否需要强充,避免普通下单时不必要地校验微信配置
forceRecharge := s.checkForceRechargeRequirement(skipCtx, validationResult)
openID, err := s.resolveCustomerOpenID(skipPermissionCtx, customerID, appID)
if err != nil {
return nil, err
}
businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req)
businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req.PackageIDs)
redisKey := constants.RedisClientPurchaseIdempotencyKey(businessKey)
lockKey := constants.RedisClientPurchaseLockKey(assetInfo.AssetType, assetInfo.AssetID)
lockAcquired, err := s.redis.SetNX(skipPermissionCtx, lockKey, time.Now().String(), clientPurchaseLockTTL).Result()
lockAcquired, err := s.redis.SetNX(skipCtx, lockKey, time.Now().String(), clientPurchaseLockTTL).Result()
if err != nil {
s.logger.Warn("获取客户端购买分布式锁失败,继续尝试幂等标记",
zap.Error(err),
@@ -150,16 +154,16 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
claimed, err := s.redis.SetNX(skipPermissionCtx, redisKey, "processing", clientPurchaseIdempotencyTTL).Result()
claimed, err := s.redis.SetNX(skipCtx, redisKey, "processing", clientPurchaseIdempotencyTTL).Result()
if err != nil {
if lockAcquired {
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
_ = s.redis.Del(skipCtx, lockKey).Err()
}
return nil, errors.Wrap(errors.CodeInternalError, err, "设置客户端购买幂等标记失败")
}
if !claimed {
if lockAcquired {
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
_ = s.redis.Del(skipCtx, lockKey).Err()
}
return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
@@ -167,24 +171,31 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
created := false
defer func() {
if lockAcquired {
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
_ = s.redis.Del(skipCtx, lockKey).Err()
}
if !created {
_ = s.redis.Del(skipPermissionCtx, redisKey).Err()
_ = s.redis.Del(skipCtx, redisKey).Err()
}
}()
paymentProvider, err := s.newPaymentProvider(activeConfig, appID, req.AppType)
if err != nil {
return nil, err
}
forceRecharge := s.checkForceRechargeRequirement(skipPermissionCtx, validationResult)
if forceRecharge.NeedForceRecharge {
return s.createForceRechargeOrder(skipPermissionCtx, customerID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentProvider, &created)
// 强充场景需要微信支付,此时 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(skipPermissionCtx, customerID, openID, validationResult, activeConfig, redisKey, paymentProvider, &created)
return s.createPackageOrder(skipCtx, customerID, validationResult, redisKey, &created)
}
func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error {
@@ -269,14 +280,11 @@ func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, ap
func (s *Service) createPackageOrder(
ctx context.Context,
customerID uint,
openID string,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
redisKey string,
paymentProvider PaymentProvider,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
order, err := s.buildPendingOrder(customerID, validationResult, activeConfig)
order, err := s.buildPendingOrder(customerID, validationResult)
if err != nil {
return nil, err
}
@@ -291,17 +299,6 @@ func (s *Service) createPackageOrder(
}
s.markClientPurchaseCreated(ctx, redisKey, order.OrderNo)
description := "套餐购买"
if len(items) > 0 && items[0] != nil && items[0].PackageName != "" {
description = items[0].PackageName
}
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
if err != nil {
return nil, err
}
*created = true
return &dto.ClientCreateOrderResponse{
@@ -313,7 +310,6 @@ func (s *Service) createPackageOrder(
PaymentStatus: order.PaymentStatus,
CreatedAt: formatClientServiceTime(order.CreatedAt),
},
PayConfig: buildClientPayConfigFromResult(paymentResult),
}, nil
}
@@ -393,7 +389,7 @@ func (s *Service) createForceRechargeOrder(
}, nil
}
func (s *Service) buildPendingOrder(customerID uint, result *purchase_validation.PurchaseValidationResult, activeConfig *model.WechatConfig) (*model.Order, error) {
func (s *Service) buildPendingOrder(customerID uint, result *purchase_validation.PurchaseValidationResult) (*model.Order, error) {
orderType := resolveOrderType(result)
if orderType == "" {
return nil, errors.New(errors.CodeInvalidParam)
@@ -411,14 +407,12 @@ func (s *Service) buildPendingOrder(customerID uint, result *purchase_validation
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 {
@@ -648,17 +642,17 @@ func extractPackageIDs(packages []*model.Package) []uint {
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)
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(packageIDs))
for _, packageID := range packageIDs {
parts = append(parts, strconv.FormatUint(uint64(packageID), 10))
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:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, req.AppType, strings.Join(parts, ","))
return fmt.Sprintf("%d:%s:%d:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, strings.Join(parts, ","))
}
func rechargeStatusToClientStatus(status int) int {
@@ -810,6 +804,77 @@ func (s *Service) GetOrderDetail(ctx context.Context, customerID uint, orderID u
}, 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":