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

@@ -1597,7 +1597,7 @@ components:
DtoClientCreateOrderRequest:
properties:
app_type:
description: 应用类型 (official_account:公众号, miniapp:小程序)
description: 应用类型(强充必传)(official_account:公众号, miniapp:小程序)
type: string
identifier:
description: 资产标识符SN/IMEI/虚拟号/ICCID/MSISDN
@@ -1614,7 +1614,6 @@ components:
required:
- identifier
- package_ids
- app_type
type: object
DtoClientCreateOrderResponse:
properties:
@@ -1849,6 +1848,25 @@ components:
description: 时间戳
type: string
type: object
DtoClientPayOrderParams:
properties:
app_type:
description: 应用类型(微信支付必传)(official_account:公众号, miniapp:小程序)
type: string
payment_method:
description: 支付方式 (wallet:钱包, wechat:微信)
type: string
required:
- payment_method
type: object
DtoClientPayOrderResponse:
properties:
pay_config:
$ref: '#/components/schemas/DtoClientPayConfig'
payment_method:
description: 支付方式
type: string
type: object
DtoClientRechargeCheckResponse:
properties:
force_recharge_amount:
@@ -22879,6 +22897,78 @@ paths:
summary: 订单详情
tags:
- 个人客户 - 订单
/api/c/v1/orders/{id}/pay:
post:
parameters:
- description: ID
in: path
name: id
required: true
schema:
description: ID
minimum: 0
type: integer
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DtoClientPayOrderParams'
responses:
"200":
content:
application/json:
schema:
properties:
code:
description: 响应码
example: 0
type: integer
data:
$ref: '#/components/schemas/DtoClientPayOrderResponse'
msg:
description: 响应消息
example: success
type: string
timestamp:
description: 时间戳
format: date-time
type: string
required:
- code
- msg
- data
- timestamp
type: object
description: 成功
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 请求参数错误
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 未认证或认证已过期
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 无权访问
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
description: 服务器内部错误
security:
- BearerAuth: []
summary: 订单支付
tags:
- 个人客户 - 订单
/api/c/v1/orders/create:
post:
requestBody:

View File

@@ -35,6 +35,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
personalCustomerDeviceStore,
personalCustomerOpenIDStore,
svc.WechatConfig,
svc.Order,
packageSeriesStore,
shopSeriesAllocationStore,
iotCardStore,

View File

@@ -14,7 +14,7 @@ import (
)
// ClientOrderHandler C 端订单处理器
// 提供 D1~D3 下单、列表、详情接口。
// 提供 D1~D4 下单、列表、详情、支付接口。
type ClientOrderHandler struct {
clientOrderService *clientorder.Service
logger *zap.Logger
@@ -83,6 +83,32 @@ func (h *ClientOrderHandler) ListOrders(c *fiber.Ctx) error {
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
}
// PayOrder D4 订单支付。
// POST /api/c/v1/orders/:id/pay
func (h *ClientOrderHandler) PayOrder(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
orderID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || orderID == 0 {
return errors.New(errors.CodeInvalidParam)
}
var req dto.ClientPayOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resp, err := h.clientOrderService.PayOrder(c.UserContext(), customerID, uint(orderID), &req)
if err != nil {
return err
}
return response.Success(c, resp)
}
// GetOrderDetail D3 订单详情。
// GET /api/c/v1/orders/:id
func (h *ClientOrderHandler) GetOrderDetail(c *fiber.Ctx) error {

View File

@@ -8,15 +8,16 @@ package dto
type ClientCreateOrderRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,dive,gt=0" required:"true" description:"套餐ID列表"`
AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"`
// app_type 仅强充场景需要(服务端检测到强充时会直接发起微信支付);普通下单时可不传
AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(强充必传)(official_account:公众号, miniapp:小程序)"`
}
// ClientCreateOrderResponse D1 客户端创建订单响应
type ClientCreateOrderResponse struct {
OrderType string `json:"order_type" description:"订单类型 (package:套餐订单, recharge:充值订单)"`
Order *ClientOrderInfo `json:"order,omitempty" description:"套餐订单信息"`
Order *ClientOrderInfo `json:"order,omitempty" description:"套餐订单信息(普通下单时返回,无支付参数,需单独调支付接口)"`
Recharge *ClientRechargeInfo `json:"recharge,omitempty" description:"充值订单信息"`
PayConfig *ClientPayConfig `json:"pay_config" description:"支付配置"`
PayConfig *ClientPayConfig `json:"pay_config,omitempty" description:"微信支付配置(仅强充场景返回)"`
LinkedPackageInfo *LinkedPackageInfo `json:"linked_package_info,omitempty" description:"关联套餐信息"`
}
@@ -38,7 +39,7 @@ type ClientRechargeInfo struct {
AutoPurchaseStatus string `json:"auto_purchase_status" description:"自动购包状态"`
}
// ClientPayConfig D1 支付配置
// ClientPayConfig 微信支付配置
type ClientPayConfig struct {
AppID string `json:"app_id" description:"应用ID"`
Timestamp string `json:"timestamp" description:"时间戳"`
@@ -111,3 +112,26 @@ type ClientOrderPackageItem struct {
Price int64 `json:"price" description:"单价(分)"`
Quantity int `json:"quantity" description:"数量"`
}
// ========================================
// D4 订单支付
// ========================================
// ClientPayOrderRequest D4 订单支付请求
type ClientPayOrderRequest struct {
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet wechat" required:"true" description:"支付方式 (wallet:钱包, wechat:微信)"`
// AppType 仅 payment_method=wechat 时必填
AppType string `json:"app_type" validate:"omitempty,oneof=official_account miniapp" description:"应用类型(微信支付必传)(official_account:公众号, miniapp:小程序)"`
}
// ClientPayOrderResponse D4 订单支付响应
type ClientPayOrderResponse struct {
PaymentMethod string `json:"payment_method" description:"支付方式"`
PayConfig *ClientPayConfig `json:"pay_config,omitempty" description:"微信支付配置payment_method=wechat 时返回)"`
}
// ClientPayOrderParams D4 订单支付文档参数(路径参数 + 请求体,仅供文档生成器使用)
type ClientPayOrderParams struct {
IDReq
ClientPayOrderRequest
}

View File

@@ -214,6 +214,14 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator,
Output: &dto.ClientOrderDetailResponse{},
})
Register(authGroup, doc, basePath, "POST", "/orders/:id/pay", handlers.ClientOrder.PayOrder, RouteSpec{
Summary: "订单支付",
Tags: []string{"个人客户 - 订单"},
Auth: true,
Input: &dto.ClientPayOrderParams{},
Output: &dto.ClientPayOrderResponse{},
})
Register(authGroup, doc, basePath, "GET", "/exchange/pending", handlers.ClientExchange.GetPending, RouteSpec{
Summary: "查询待处理换货单",
Tags: []string{"个人客户 - 换货"},

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":