Files
junhong_cmp_fiber/internal/handler/app/client_wallet.go
break ba435dd6a6
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
关于绑定的问题
2026-06-22 12:08:41 +09:00

880 lines
27 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 app
import (
"context"
"fmt"
"math/rand"
"strconv"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"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"
customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/alipay"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ClientWalletHandler C 端钱包处理器
// 提供 C1~C5 钱包详情、流水、充值前校验、充值下单、充值记录接口
type ClientWalletHandler struct {
assetService *asset.Service
customerBinding *customerBinding.Service
walletStore *postgres.AssetWalletStore
transactionStore *postgres.AssetWalletTransactionStore
rechargeOrderStore *postgres.RechargeOrderStore
paymentStore *postgres.PaymentStore
rechargeService *rechargeSvc.Service
openIDStore *postgres.PersonalCustomerOpenIDStore
wechatConfigService *wechatConfigSvc.Service
redis *redis.Client
logger *zap.Logger
db *gorm.DB
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
}
// NewClientWalletHandler 创建 C 端钱包处理器
func NewClientWalletHandler(
assetService *asset.Service,
binding *customerBinding.Service,
walletStore *postgres.AssetWalletStore,
transactionStore *postgres.AssetWalletTransactionStore,
rechargeOrderStore *postgres.RechargeOrderStore,
paymentStore *postgres.PaymentStore,
rechargeService *rechargeSvc.Service,
openIDStore *postgres.PersonalCustomerOpenIDStore,
wechatConfigService *wechatConfigSvc.Service,
redisClient *redis.Client,
logger *zap.Logger,
db *gorm.DB,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
) *ClientWalletHandler {
return &ClientWalletHandler{
assetService: assetService,
customerBinding: binding,
walletStore: walletStore,
transactionStore: transactionStore,
rechargeOrderStore: rechargeOrderStore,
paymentStore: paymentStore,
rechargeService: rechargeService,
openIDStore: openIDStore,
wechatConfigService: wechatConfigService,
redis: redisClient,
logger: logger,
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
}
}
type resolvedWalletAssetContext struct {
CustomerID uint
Identifier string
Asset *dto.AssetResolveResponse
Generation int
ResourceType string
SkipPermissionCtx context.Context
}
// GetWalletDetail C1 钱包详情
// GET /api/c/v1/wallet/detail
func (h *ClientWalletHandler) GetWalletDetail(c *fiber.Ctx) error {
var req dto.WalletDetailRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
wallet, err := h.getOrCreateWallet(resolved)
if err != nil {
return err
}
resp := &dto.WalletDetailResponse{
WalletID: wallet.ID,
ResourceType: wallet.ResourceType,
ResourceID: wallet.ResourceID,
Balance: wallet.Balance,
FrozenBalance: wallet.FrozenBalance,
UpdatedAt: wallet.UpdatedAt.Format(time.RFC3339),
}
return response.Success(c, resp)
}
// GetWalletTransactions C2 钱包流水列表
// GET /api/c/v1/wallet/transactions
func (h *ClientWalletHandler) GetWalletTransactions(c *fiber.Ctx) error {
var req dto.WalletTransactionListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 {
req.PageSize = constants.DefaultPageSize
}
if req.PageSize > constants.MaxPageSize {
req.PageSize = constants.MaxPageSize
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return response.SuccessWithPagination(c, []dto.WalletTransactionItem{}, 0, req.Page, req.PageSize)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
var txType *string
if strings.TrimSpace(req.TransactionType) != "" {
v := strings.TrimSpace(req.TransactionType)
txType = &v
}
startTime, err := parseOptionalTime(req.StartTime)
if err != nil {
return errors.New(errors.CodeInvalidParam)
}
endTime, err := parseOptionalTime(req.EndTime)
if err != nil {
return errors.New(errors.CodeInvalidParam)
}
if startTime != nil && endTime != nil && endTime.Before(*startTime) {
return errors.New(errors.CodeInvalidParam)
}
offset := (req.Page - 1) * req.PageSize
list, err := h.transactionStore.ListByResourceIDWithFilter(
resolved.SkipPermissionCtx,
wallet.ResourceType,
wallet.ResourceID,
txType,
startTime,
endTime,
offset,
req.PageSize,
)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水失败")
}
total, err := h.transactionStore.CountByResourceIDWithFilter(
resolved.SkipPermissionCtx,
wallet.ResourceType,
wallet.ResourceID,
txType,
startTime,
endTime,
)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水总数失败")
}
items := make([]dto.WalletTransactionItem, 0, len(list))
for _, tx := range list {
if tx == nil {
continue
}
remark := ""
if tx.Remark != nil {
remark = *tx.Remark
}
items = append(items, dto.WalletTransactionItem{
TransactionID: tx.ID,
Type: tx.TransactionType,
Amount: tx.Amount,
BalanceAfter: tx.BalanceAfter,
CreatedAt: tx.CreatedAt.Format(time.RFC3339),
Remark: remark,
})
}
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
}
// GetRechargeCheck C3 充值前校验
// GET /api/c/v1/wallet/recharge-check
func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
var req dto.ClientRechargeCheckRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
// before_order 策略:必须先实名才能充值
if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 {
return errors.New(errors.CodeNeedRealname)
}
check, err := h.rechargeService.GetRechargeCheck(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
if err != nil {
return err
}
resp := &dto.ClientRechargeCheckResponse{
NeedForceRecharge: check.NeedForceRecharge,
ForceRechargeAmount: check.ForceRechargeAmount,
TriggerType: check.TriggerType,
MinAmount: check.MinAmount,
MaxAmount: check.MaxAmount,
Message: check.Message,
}
return response.Success(c, resp)
}
// CreateRecharge C4 创建充值订单
// POST /api/c/v1/wallet/recharge
func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
var req dto.ClientCreateRechargeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
// before_order 策略:必须先实名才能充值
if resolved.Asset.RealnamePolicy == constants.RealnamePolicyBeforeOrder && resolved.Asset.RealNameStatus != 1 {
return errors.New(errors.CodeNeedRealname)
}
wallet, err := h.getOrCreateWallet(resolved)
if err != nil {
return err
}
config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx)
if err != nil {
return err
}
if config == nil {
return errors.New(errors.CodeWechatConfigUnavailable)
}
switch req.PaymentMethod {
case constants.RechargeMethodAlipay:
return h.createAlipayRecharge(c, resolved, config, wallet, req)
default:
// 微信充值默认app_type 必填
return h.createWechatRecharge(c, resolved, config, wallet, req)
}
}
// createWechatRecharge 微信充值分支
func (h *ClientWalletHandler) createWechatRecharge(
c *fiber.Ctx,
resolved *resolvedWalletAssetContext,
config *model.WechatConfig,
wallet *model.AssetWallet,
req dto.ClientCreateRechargeRequest,
) error {
appID, err := pickAppIDByType(config, req.AppType)
if err != nil {
return err
}
openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID)
if err != nil {
return err
}
rechargeNo := generateClientRechargeNo()
paymentNo := generateClientPaymentNo()
// 先初始化生效支付通道并创建预支付订单,确认支付通道可用
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
payConfig, err := h.createClientRechargePayConfig(
resolved.SkipPermissionCtx,
config,
appID,
req.AppType,
paymentNo,
"资产钱包充值",
openID,
int(req.Amount),
)
if err != nil {
return err
}
// 支付通道确认可用后,再创建充值订单和支付记录
rechargeOrder := &model.RechargeOrder{
RechargeOrderNo: rechargeNo,
UserID: resolved.CustomerID,
AssetWalletID: wallet.ID,
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
Amount: req.Amount,
Status: model.RechargeOrderStatusPending,
PaymentConfigID: &config.ID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
if err := h.rechargeOrderStore.Create(resolved.SkipPermissionCtx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
}
if err := h.paymentStore.Create(resolved.SkipPermissionCtx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return response.Success(c, &dto.ClientRechargeResponse{
Recharge: dto.ClientRechargeResult{
RechargeID: rechargeOrder.ID,
RechargeNo: rechargeOrder.RechargeOrderNo,
Amount: rechargeOrder.Amount,
Status: rechargeOrder.Status,
},
PayConfig: &payConfig,
})
}
// createAlipayRecharge 支付宝充值分支。
// WAP URL 为本地签名生成,事务内先写 DB再生成链接链接失败则标记 payment failed。
func (h *ClientWalletHandler) createAlipayRecharge(
c *fiber.Ctx,
resolved *resolvedWalletAssetContext,
config *model.WechatConfig,
wallet *model.AssetWallet,
req dto.ClientCreateRechargeRequest,
) error {
rechargeNo := generateClientRechargeNo()
paymentNo := generateClientPaymentNo()
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
rechargeOrder := &model.RechargeOrder{
RechargeOrderNo: rechargeNo,
UserID: resolved.CustomerID,
AssetWalletID: wallet.ID,
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
Amount: req.Amount,
Status: model.RechargeOrderStatusPending,
PaymentConfigID: &config.ID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByAlipay,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
ExpireAt: &expireAt,
}
// WAP URL 是本地签名,不需要先调第三方,在事务内创建充值单和支付单
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
return h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment)
}); err != nil {
return err
}
wapURL, err := alipay.BuildWapPayURL(resolved.SkipPermissionCtx, config, payment, "资产钱包充值")
if err != nil {
if updateErr := h.paymentStore.UpdateStatus(resolved.SkipPermissionCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
h.logger.Warn("标记支付宝支付单 failed 失败",
zap.String("payment_no", paymentNo),
zap.Error(updateErr),
)
}
return err
}
h.logger.Info("支付宝充值支付单已创建",
zap.String("payment_no", paymentNo),
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", req.Amount),
zap.Time("expire_at", expireAt),
zap.Uint("config_id", config.ID),
)
expireStr := expireAt.Format(time.RFC3339)
return response.Success(c, &dto.ClientRechargeResponse{
Recharge: dto.ClientRechargeResult{
RechargeID: rechargeOrder.ID,
RechargeNo: rechargeOrder.RechargeOrderNo,
Amount: rechargeOrder.Amount,
Status: rechargeOrder.Status,
},
PaymentLink: &dto.ClientPaymentLink{
PaymentNo: paymentNo,
QRLink: wapURL,
CopyLink: wapURL,
PayExpireAt: expireStr,
},
})
}
// GetRechargeList C5 充值记录列表
// GET /api/c/v1/wallet/recharges
func (h *ClientWalletHandler) GetRechargeList(c *fiber.Ctx) error {
var req dto.ClientRechargeListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 {
req.PageSize = constants.DefaultPageSize
}
if req.PageSize > constants.MaxPageSize {
req.PageSize = constants.MaxPageSize
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
query := h.db.WithContext(resolved.SkipPermissionCtx).
Model(&model.RechargeOrder{}).
Where("resource_type = ? AND resource_id = ? AND generation = ?", resolved.ResourceType, resolved.Asset.AssetID, resolved.Generation)
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单总数失败")
}
var orders []*model.RechargeOrder
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&orders).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
}
items := make([]dto.ClientRechargeListItem, 0, len(orders))
for _, order := range orders {
if order == nil {
continue
}
items = append(items, dto.ClientRechargeListItem{
RechargeID: order.ID,
RechargeNo: order.RechargeOrderNo,
Amount: order.Amount,
Status: order.Status,
PaymentMethod: "", // 支付方式在 Payment 表中,列表查询不返回
CreatedAt: order.CreatedAt.Format(time.RFC3339),
AutoPurchaseStatus: order.AutoPurchaseStatus,
})
}
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
}
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedWalletAssetContext, error) {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
identifier = strings.TrimSpace(identifier)
if identifier == "" {
identifier = strings.TrimSpace(c.Query("identifier"))
}
if identifier == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
if err != nil {
return nil, err
}
owned, ownErr := h.customerBinding.OwnsAsset(skipPermissionCtx, customerID, assetInfo.AssetType, assetInfo.AssetID)
if ownErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
}
if !owned {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
resourceType, mapErr := mapAssetTypeToWalletResource(assetInfo.AssetType)
if mapErr != nil {
return nil, mapErr
}
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
if genErr != nil {
return nil, genErr
}
return &resolvedWalletAssetContext{
CustomerID: customerID,
Identifier: identifier,
Asset: assetInfo,
Generation: generation,
ResourceType: resourceType,
SkipPermissionCtx: skipPermissionCtx,
}, nil
}
func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":
card, err := h.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 "device":
device, err := h.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 (h *ClientWalletHandler) getOrCreateWallet(resolved *resolvedWalletAssetContext) (*model.AssetWallet, error) {
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
if err == nil {
return wallet, nil
}
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
shopIDTag := uint(0)
if resolved.Asset.ShopID != nil {
shopIDTag = *resolved.Asset.ShopID
}
newWallet := &model.AssetWallet{
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
Balance: 0,
FrozenBalance: 0,
Currency: "CNY",
Status: constants.AssetWalletStatusNormal,
Version: 0,
ShopIDTag: shopIDTag,
}
if createErr := h.walletStore.Create(resolved.SkipPermissionCtx, newWallet); createErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, createErr, "创建钱包失败")
}
wallet, err = h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
return wallet, nil
}
func (h *ClientWalletHandler) findOpenIDByCustomerAndAppID(ctx context.Context, customerID uint, appID string) (string, error) {
list, err := h.openIDStore.ListByCustomerID(ctx, customerID)
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败")
}
for _, item := range list {
if item == nil {
continue
}
if item.AppID == appID && strings.TrimSpace(item.OpenID) != "" {
return item.OpenID, nil
}
}
return "", errors.New(errors.CodeOpenIDNotFound)
}
func mapAssetTypeToWalletResource(assetType string) (string, error) {
switch assetType {
case "card":
return constants.AssetWalletResourceTypeIotCard, nil
case "device":
return constants.AssetWalletResourceTypeDevice, nil
default:
return "", errors.New(errors.CodeInvalidParam)
}
}
func parseOptionalTime(value string) (*time.Time, error) {
v := strings.TrimSpace(value)
if v == "" {
return nil, nil
}
layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"}
for _, layout := range layouts {
t, err := time.Parse(layout, v)
if err == nil {
return &t, nil
}
}
return nil, fmt.Errorf("invalid time format")
}
func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) {
switch appType {
case "official_account":
if strings.TrimSpace(config.OaAppID) == "" {
return "", errors.New(errors.CodeWechatConfigUnavailable)
}
return config.OaAppID, nil
case "miniapp":
if strings.TrimSpace(config.MiniappAppID) == "" {
return "", errors.New(errors.CodeWechatConfigUnavailable)
}
return config.MiniappAppID, nil
default:
return "", errors.New(errors.CodeInvalidParam)
}
}
// createClientRechargePayConfig 根据当前生效支付配置创建充值预支付订单。
// provider_type 必须显式匹配,避免富友等渠道误落到微信直连。
func (h *ClientWalletHandler) createClientRechargePayConfig(
ctx context.Context,
config *model.WechatConfig,
appID, appType, orderNo, description, openID string,
amount int,
) (dto.ClientRechargePayConfig, error) {
switch config.ProviderType {
case model.ProviderTypeWechatV2:
// 微信 v2仅需 APIv2Key无需证书序列号
svc, err := wechat.NewPaymentV2ServiceFromConfig(config, appID, h.logger)
if err != nil {
return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信 v2 支付实例失败")
}
result, err := svc.CreateJSAPIOrder(ctx, orderNo, description, openID, amount)
if err != nil {
return dto.ClientRechargePayConfig{}, err
}
return buildClientRechargePayConfig(appID, result), nil
case model.ProviderTypeWechat:
// 微信 v3PowerWeChat需要证书序列号和 API v3 Key
cache := wechat.NewRedisCache(h.redis)
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger)
if err != nil {
return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败")
}
result, err := wechat.NewPaymentService(paymentApp, h.logger).CreateJSAPIOrder(ctx, orderNo, description, openID, amount)
if err != nil {
return dto.ClientRechargePayConfig{}, err
}
return buildClientRechargePayConfig(appID, result), nil
case model.ProviderTypeFuiou:
return h.createFuiouRechargePayConfig(ctx, config, appID, appType, orderNo, description, openID, amount)
default:
return dto.ClientRechargePayConfig{}, errors.New(errors.CodeWechatConfigUnavailable, "不支持的支付渠道类型")
}
}
// createFuiouRechargePayConfig 使用富友支付配置创建充值预支付订单。
func (h *ClientWalletHandler) createFuiouRechargePayConfig(
ctx context.Context,
config *model.WechatConfig,
appID, appType, orderNo, description, openID string,
amount int,
) (dto.ClientRechargePayConfig, error) {
if strings.TrimSpace(config.FyInsCd) == "" ||
strings.TrimSpace(config.FyMchntCd) == "" ||
strings.TrimSpace(config.FyTermID) == "" ||
strings.TrimSpace(config.FyPrivateKey) == "" ||
strings.TrimSpace(config.FyPublicKey) == "" ||
strings.TrimSpace(config.FyAPIURL) == "" ||
strings.TrimSpace(config.FyNotifyURL) == "" ||
strings.TrimSpace(appID) == "" {
return dto.ClientRechargePayConfig{}, errors.New(errors.CodeFuiouPayFailed, "富友支付配置不完整")
}
client, err := fuiou.NewClient(
config.FyInsCd,
config.FyMchntCd,
config.FyTermID,
config.FyAPIURL,
config.FyNotifyURL,
config.FyPrivateKey,
config.FyPublicKey,
h.logger,
)
if err != nil {
return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeFuiouPayFailed, err, "创建富友支付客户端失败")
}
tradeType := "JSAPI"
if appType == "miniapp" {
tradeType = "LETPAY"
}
termIP := fuiou.GetServerIP()
if ip, ok := ctx.Value(constants.ContextKeyIP).(string); ok && strings.TrimSpace(ip) != "" {
termIP = ip
}
resp, err := client.WxPreCreate(orderNo, strconv.Itoa(amount), description, termIP, tradeType, appID, openID)
if err != nil {
h.logger.Error("富友充值预下单失败",
zap.String("payment_no", orderNo),
zap.String("trade_type", tradeType),
zap.Error(err),
)
return dto.ClientRechargePayConfig{}, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
}
return dto.ClientRechargePayConfig{
AppID: resp.SdkAppid,
Timestamp: resp.SdkTimestamp,
NonceStr: resp.SdkNoncestr,
PackageVal: resp.SdkPackage,
SignType: resp.SdkSigntype,
PaySign: resp.SdkPaysign,
}, nil
}
func generateClientRechargeNo() string {
timestamp := time.Now().Format("20060102150405")
randomNum := rand.Intn(1000000)
return fmt.Sprintf("%s%s%06d", constants.AssetRechargeOrderPrefix, timestamp, randomNum)
}
func generateClientPaymentNo() string {
return fmt.Sprintf("PAY%d%06d", time.Now().UnixNano()/1e6, rand.Intn(1000000))
}
func buildClientRechargePayConfig(appID string, result *wechat.JSAPIPayResult) dto.ClientRechargePayConfig {
resp := dto.ClientRechargePayConfig{AppID: appID}
if result == nil || result.PayConfig == nil {
return resp
}
if cfg, ok := result.PayConfig.(map[string]any); ok {
resp.Timestamp = getStringFromAnyMap(cfg, "timeStamp", "timestamp")
resp.NonceStr = getStringFromAnyMap(cfg, "nonceStr", "nonce_str")
resp.PackageVal = getStringFromAnyMap(cfg, "package")
resp.SignType = getStringFromAnyMap(cfg, "signType", "sign_type")
resp.PaySign = getStringFromAnyMap(cfg, "paySign", "pay_sign")
if appIDVal := getStringFromAnyMap(cfg, "appId", "app_id"); appIDVal != "" {
resp.AppID = appIDVal
}
return resp
}
if cfg, ok := result.PayConfig.(map[string]string); ok {
resp.Timestamp = cfg["timeStamp"]
if resp.Timestamp == "" {
resp.Timestamp = cfg["timestamp"]
}
resp.NonceStr = cfg["nonceStr"]
if resp.NonceStr == "" {
resp.NonceStr = cfg["nonce_str"]
}
resp.PackageVal = cfg["package"]
resp.SignType = cfg["signType"]
if resp.SignType == "" {
resp.SignType = cfg["sign_type"]
}
resp.PaySign = cfg["paySign"]
if resp.PaySign == "" {
resp.PaySign = cfg["pay_sign"]
}
if cfg["appId"] != "" {
resp.AppID = cfg["appId"]
} else if cfg["app_id"] != "" {
resp.AppID = cfg["app_id"]
}
}
return resp
}
func getStringFromAnyMap(m map[string]any, keys ...string) string {
for _, key := range keys {
val, ok := m[key]
if !ok || val == nil {
continue
}
switch v := val.(type) {
case string:
if v != "" {
return v
}
case fmt.Stringer:
text := v.String()
if text != "" {
return text
}
default:
text := fmt.Sprintf("%v", v)
if text != "" && text != "<nil>" {
return text
}
}
}
return ""
}