feat: 业务逻辑补全 — 佣金待审记录、C端订单重构、支付抽象、富友支付、卡设备状态联动
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

F-1: 佣金链断裂时创建 status=99 零额待审记录,新增修正接口 POST /commission-records/:id/resolve
F-4: C端订单查询从 Handler 迁移至 Service 层,移除 SkipPermissionCtx
J-1: 富友支付 JSAPI/MiniApp 预下单实现,回调补全签名验证
J-2: 平台钱包代购支持(buyerType 为空时使用资产所属代理钱包)
J-3: 套餐激活后自动更新卡/设备 status=3,最后套餐过期后更新 status=4
支付抽象: 引入 PaymentProvider 接口 + 微信/富友适配器,CreateOrder 支持多支付渠道
修复: 设备/IoT卡响应 DTO 移除 omitempty,空值字段返回 null 而非省略
This commit is contained in:
2026-03-28 16:57:39 +08:00
parent 65e461eff7
commit 623a622298
26 changed files with 970 additions and 447 deletions

View File

@@ -0,0 +1,146 @@
package client_order
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
)
// PaymentProvider C 端支付提供者接口。
// 抽象不同支付渠道(微信、富友等)的预下单能力。
type PaymentProvider interface {
// CreateJSAPIPayment 创建 JSAPI 预下单,返回前端调起支付的参数。
CreateJSAPIPayment(ctx context.Context, orderNo, description, openID string, amount int) (*PaymentResult, error)
}
// PaymentResult 预下单结果,前端用这些参数调起支付。
type PaymentResult struct {
AppID string
TimeStamp string
NonceStr string
Package string
SignType string
PaySign string
}
// wechatPaymentProvider 微信支付适配器。
type wechatPaymentProvider struct {
paymentService *wechat.PaymentService
}
// CreateJSAPIPayment 创建微信 JSAPI 预下单。
func (w *wechatPaymentProvider) CreateJSAPIPayment(ctx context.Context, orderNo, description, openID string, amount int) (*PaymentResult, error) {
result, err := w.paymentService.CreateJSAPIOrder(ctx, orderNo, description, openID, amount)
if err != nil {
return nil, err
}
return extractPaymentResult(result.PayConfig), nil
}
// fuiouPaymentProvider 富友支付适配器。
type fuiouPaymentProvider struct {
client *fuiou.Client
appID string
}
// CreateJSAPIPayment 创建富友 JSAPI 预下单。
func (f *fuiouPaymentProvider) CreateJSAPIPayment(ctx context.Context, orderNo, description, openID string, amount int) (*PaymentResult, error) {
amountStr := strconv.Itoa(amount)
resp, err := f.client.WxPreCreate(orderNo, amountStr, description, "", "JSAPI", f.appID, openID)
if err != nil {
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
}
return &PaymentResult{
AppID: resp.SdkAppid,
TimeStamp: resp.SdkTimestamp,
NonceStr: resp.SdkNoncestr,
Package: resp.SdkPackage,
SignType: resp.SdkSigntype,
PaySign: resp.SdkPaysign,
}, nil
}
// newPaymentProvider 根据支付配置创建支付提供者。
func (s *Service) newPaymentProvider(config *model.WechatConfig, appID string) (PaymentProvider, error) {
switch config.ProviderType {
case model.ProviderTypeFuiou:
client, err := fuiou.NewClient(
config.FyInsCd,
config.FyMchntCd,
config.FyTermID,
config.FyAPIURL,
config.FyNotifyURL,
config.FyPrivateKey,
config.FyPublicKey,
s.logger,
)
if err != nil {
return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友客户端失败")
}
return &fuiouPaymentProvider{client: client, appID: appID}, nil
default:
cache := wechat.NewRedisCache(s.redis)
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, s.logger)
if err != nil {
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "创建微信支付应用失败")
}
return &wechatPaymentProvider{paymentService: wechat.NewPaymentService(paymentApp, s.logger)}, nil
}
}
// buildClientPayConfigFromResult 将预下单结果转换为客户端支付参数。
func buildClientPayConfigFromResult(result *PaymentResult) *dto.ClientPayConfig {
if result == nil {
result = &PaymentResult{}
}
return &dto.ClientPayConfig{
AppID: result.AppID,
Timestamp: result.TimeStamp,
NonceStr: result.NonceStr,
PackageVal: result.Package,
SignType: result.SignType,
PaySign: result.PaySign,
}
}
// extractPaymentResult 从微信 SDK 的 map 配置中提取统一支付参数。
func extractPaymentResult(payConfig any) *PaymentResult {
configMap, _ := payConfig.(map[string]any)
if configMap == nil {
configMap = map[string]any{}
}
return &PaymentResult{
AppID: payFirstNonEmpty(payStringFromAny(configMap["appId"])),
TimeStamp: payFirstNonEmpty(payStringFromAny(configMap["timeStamp"]), payStringFromAny(configMap["timestamp"])),
NonceStr: payStringFromAny(configMap["nonceStr"]),
Package: payStringFromAny(configMap["package"]),
SignType: payStringFromAny(configMap["signType"]),
PaySign: payStringFromAny(configMap["paySign"]),
}
}
func payStringFromAny(value any) string {
if value == nil {
return ""
}
return fmt.Sprint(value)
}
func payFirstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -17,7 +17,6 @@ import (
"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"
@@ -53,6 +52,9 @@ type Service struct {
wechatConfigService WechatConfigServiceInterface
packageSeriesStore *postgres.PackageSeriesStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
db *gorm.DB
redis *redis.Client
logger *zap.Logger
}
@@ -69,6 +71,9 @@ func New(
wechatConfigService WechatConfigServiceInterface,
packageSeriesStore *postgres.PackageSeriesStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
db *gorm.DB,
redisClient *redis.Client,
logger *zap.Logger,
) *Service {
@@ -83,6 +88,9 @@ func New(
wechatConfigService: wechatConfigService,
packageSeriesStore: packageSeriesStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
db: db,
redis: redisClient,
logger: logger,
}
@@ -166,17 +174,17 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
}
}()
paymentService, err := s.newPaymentService(activeConfig, appID)
paymentProvider, err := s.newPaymentProvider(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.createForceRechargeOrder(skipPermissionCtx, customerID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentProvider, &created)
}
return s.createPackageOrder(skipPermissionCtx, customerID, appID, openID, validationResult, activeConfig, redisKey, paymentService, &created)
return s.createPackageOrder(skipPermissionCtx, customerID, openID, validationResult, activeConfig, redisKey, paymentProvider, &created)
}
func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error {
@@ -258,24 +266,14 @@ func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, ap
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,
paymentProvider PaymentProvider,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
order, err := s.buildPendingOrder(customerID, validationResult, activeConfig)
@@ -300,7 +298,7 @@ func (s *Service) createPackageOrder(
description = items[0].PackageName
}
payResult, err := paymentService.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
if err != nil {
return nil, err
}
@@ -314,21 +312,20 @@ func (s *Service) createPackageOrder(
PaymentStatus: order.PaymentStatus,
CreatedAt: formatClientServiceTime(order.CreatedAt),
},
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
PayConfig: buildClientPayConfigFromResult(paymentResult),
}, 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,
paymentProvider PaymentProvider,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
resourceType, resourceID, err := resolveWalletResource(validationResult)
@@ -378,7 +375,7 @@ func (s *Service) createForceRechargeOrder(
s.markClientPurchaseCreated(ctx, redisKey, recharge.RechargeNo)
*created = true
payResult, err := paymentService.CreateJSAPIOrder(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount))
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount))
if err != nil {
return nil, err
}
@@ -392,7 +389,7 @@ func (s *Service) createForceRechargeOrder(
Status: rechargeStatusToClientStatus(recharge.Status),
AutoPurchaseStatus: recharge.AutoPurchaseStatus,
},
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
PayConfig: buildClientPayConfigFromResult(paymentResult),
LinkedPackageInfo: buildLinkedPackageInfo(validationResult, forceRecharge),
}, nil
}
@@ -561,22 +558,6 @@ func buildLinkedPackageInfo(result *purchase_validation.PurchaseValidationResult
}
}
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
@@ -663,18 +644,217 @@ 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)
}
// 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{})
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
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)
}
}
return ""
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,
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,
PaymentMethod: order.PaymentMethod,
CreatedAt: formatClientServiceTime(order.CreatedAt),
PaidAt: formatClientServiceTimePtr(order.PaidAt),
CompletedAt: nil,
Packages: packages,
}, nil
}
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
}