实现支付商户池与微信授权配置
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m55s

新增收款商户、商户池轮询、微信授权配置独立管理;三类新支付
(C端套餐购买、C端资产钱包充值、代理在线预存款充值)无条件
经商户池选择并冻结路由,无旧综合配置回退。merchant_id 为空
历史支付继续按 payment_config_id 双读。凭证版本化加载与
ID+版本缓存保证轮换一致性。删除商户池新支付创建开关及全部
引用。
This commit is contained in:
2026-09-09 18:13:04 +08:00
parent ff25586dc9
commit 98c145fe70
39 changed files with 2718 additions and 415 deletions

View File

@@ -9,6 +9,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -81,7 +82,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
command.MerchantIdentity = strings.TrimSpace(command.MerchantIdentity)
command.ThirdPartyTradeNo = strings.TrimSpace(command.ThirdPartyTradeNo)
if command.PaymentNo == "" || command.ConfigID == 0 || command.PaidAt.IsZero() {
if command.PaymentNo == "" || command.PaidAt.IsZero() {
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
}
@@ -91,6 +92,9 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
if err != nil {
return err
}
if payment.MerchantID == nil && command.ConfigID == 0 {
return errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
}
alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command))
if err != nil {
return err
@@ -114,6 +118,10 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
if paymentUpdate.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理充值支付单状态已变化")
}
if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, paidAt); err != nil {
return err
}
rechargeUpdate := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status IN ?", recharge.ID, []int{constants.RechargeStatusPending, constants.RechargeStatusClosed}).
Updates(map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt})
@@ -127,7 +135,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
EventID: "agent-recharge:" + strconv.FormatUint(uint64(recharge.ID), 10) + ":payment-confirmed",
RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, PaymentID: payment.ID, PaymentNo: payment.PaymentNo,
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaymentMethod: payment.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
ParentEventID: command.ParentEventID,
}
@@ -186,7 +194,9 @@ func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRech
OrderType: payment.OrderType, ExpectedOrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: payment.PaymentMethod, RechargePaymentMethod: recharge.PaymentMethod, RechargePaymentChannel: rechargeChannel,
PaymentConfigID: paymentConfigID, RechargePaymentConfigID: rechargeConfigID, ConfirmedConfigID: command.ConfigID,
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
FrozenMerchant: payment.MerchantID != nil, FrozenMerchantPaymentMethod: payment.MerchantPaymentMethodSnapshot,
FrozenMerchantProviderType: payment.MerchantProviderTypeSnapshot,
MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity,
PaymentAmount: payment.Amount, RechargeAmount: recharge.Amount, ConfirmedAmount: command.Amount,
PaymentOrderID: payment.OrderID, RechargeID: recharge.ID, PaymentState: domain.PaymentState(payment.Status),
RechargeStatus: recharge.Status, StoredTradeNo: payment.ThirdPartyTradeNo, ConfirmedTradeNo: command.ThirdPartyTradeNo,

View File

@@ -11,6 +11,7 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -46,21 +47,22 @@ type AvailablePaymentMethodsResult struct {
// OnlineCreationService 创建代理在线扫码充值单。
type OnlineCreationService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
audit PaymentAuditWriter
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
audit PaymentAuditWriter
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入三个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入运行时路由和三个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit}
}
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
@@ -82,11 +84,11 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
return replay, err
}
account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command)
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
result, config, adapter, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet)
if err != nil {
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
return replay, replayErr
@@ -125,7 +127,7 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin
return result, nil
}
// AvailablePaymentMethods 按固定顺序返回配置完整的在线支付方式。
// AvailablePaymentMethods 按固定顺序返回已有可用商户池且凭证完整的在线支付方式。
func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) {
result := AvailablePaymentMethodsResult{
Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount,
@@ -133,18 +135,38 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
if userType != constants.UserTypeAgent {
return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return result, nil
for _, method := range []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay} {
var merchants []model.PaymentMerchant
err := s.db.WithContext(ctx).
Model(&model.PaymentMerchant{}).
Joins("JOIN tb_payment_merchant_pool_member AS member ON member.merchant_id = tb_payment_merchant.id AND member.deleted_at IS NULL").
Joins("JOIN tb_payment_merchant_pool AS pool ON pool.id = member.pool_id AND pool.deleted_at IS NULL").
Where("pool.payment_method = ? AND pool.status = ? AND tb_payment_merchant.payment_method = ? AND tb_payment_merchant.status = ?", method, model.PaymentMerchantStatusEnabled, method, model.PaymentMerchantStatusEnabled).
Order("member.sort_order ASC").Find(&merchants).Error
if err != nil {
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询代理在线支付商户池失败")
}
for index := range merchants {
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
merchant := &merchants[index]
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
var authErr error
authorization, authErr = s.runtime.LoadAuthorization(ctx)
if authErr != nil {
continue
}
}
config, configErr := merchantpayment.MerchantConfig(merchant, authorization)
if configErr != nil {
continue
}
adapter := s.adapter(method, config)
if adapter != nil && adapter.Available(config) {
result.Methods = append(result.Methods, method)
break
}
}
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
if s.wechat.Available(&config) || s.fuiou.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
}
if s.alipay.Available(&config) {
result.Methods = append(result.Methods, constants.RechargeMethodAlipay)
}
return result, nil
}
@@ -152,40 +174,29 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use
func (s *OnlineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOnlineCommand,
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) {
) (*model.Account, *model.Shop, *model.AgentWallet, error) {
var account model.Account
if err := s.db.WithContext(ctx).Where("id = ? AND user_type = ? AND status = ?", command.AccountID, constants.UserTypeAgent, constants.StatusEnabled).First(&account).Error; err != nil || account.ShopID == nil || *account.ShopID != command.CurrentShopID {
if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
}
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值")
}
var shop model.Shop
if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.CurrentShopID, constants.StatusEnabled).First(&shop).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败")
}
var wallet model.AgentWallet
if err := s.db.WithContext(ctx).Where("shop_id = ? AND wallet_type = ? AND status = ?", command.CurrentShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).First(&wallet).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
return nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
}
var config model.WechatConfig
if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
}
adapter := s.adapter(command.PaymentMethod, &config)
if adapter == nil || !adapter.Available(&config) {
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
}
return &account, &shop, &wallet, &config, adapter, nil
return &account, &shop, &wallet, nil
}
func (s *OnlineCreationService) createLocalFacts(
@@ -195,36 +206,58 @@ func (s *OnlineCreationService) createLocalFacts(
account *model.Account,
shop *model.Shop,
wallet *model.AgentWallet,
config *model.WechatConfig,
) (*CreateOnlineResult, error) {
) (*CreateOnlineResult, *model.WechatConfig, OnlinePaymentPort, error) {
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
if err != nil {
return nil, err
return nil, nil, nil, err
}
paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli()))
if err != nil {
return nil, err
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = model.DefaultAliPayExpireMinutes
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
record := &model.AgentRechargeRecord{
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending,
RequestID: &requestID, RequestFingerprint: &fingerprint,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
payment := &model.Payment{
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: command.PaymentMethod, MerchantIdentity: paymentMerchantIdentity(command.PaymentMethod, config),
Amount: command.Amount, Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID, ExpireAt: &expireAt,
return nil, nil, nil, err
}
var record *model.AgentRechargeRecord
var payment *model.Payment
var config *model.WechatConfig
var adapter OnlinePaymentPort
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, err := s.runtime.SelectForNewPaymentWithTx(ctx, tx, command.PaymentMethod, time.Now())
if err != nil {
return err
}
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
if route.Merchant.ProviderType == model.ProviderTypeWechat || route.Merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.runtime.LoadAuthorization(ctx)
if err != nil {
return err
}
}
config, err = merchantpayment.MerchantConfig(route.Merchant, authorization)
if err != nil {
return err
}
adapter = s.adapter(command.PaymentMethod, config)
if adapter == nil || !adapter.Available(config) {
return apperrors.New(apperrors.CodeNoPaymentConfig)
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = model.DefaultAliPayExpireMinutes
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
channel, requestID := paymentChannel(command.PaymentMethod, config), command.RequestID
record = &model.AgentRechargeRecord{
UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo,
Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel,
Status: constants.RechargeStatusPending, RequestID: &requestID, RequestFingerprint: &fingerprint,
ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag,
}
payment = &model.Payment{
PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge,
PaymentMethod: command.PaymentMethod, Amount: command.Amount,
Status: model.PaymentRecordStatusPending, ExpireAt: &expireAt,
}
merchantpayment.FreezeRoute(payment, route)
if err := tx.Create(record).Error; err != nil {
return err
}
@@ -238,9 +271,13 @@ func (s *OnlineCreationService) createLocalFacts(
})
})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) {
return nil, nil, nil, err
}
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
}
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
return &CreateOnlineResult{Recharge: record, Payment: payment}, config, adapter, nil
}
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {

View File

@@ -6,6 +6,7 @@ import (
"gorm.io/gorm"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -14,6 +15,7 @@ import (
// RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。
type RecoverOnlinePaymentService struct {
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
@@ -23,13 +25,13 @@ type RecoverOnlinePaymentService struct {
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
func NewRecoverOnlinePaymentService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now}
}
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil {
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
}
now := s.now().UTC()
@@ -52,7 +54,13 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er
for index := range payments {
payment := &payments[index]
recharge := recharges[payment.OrderID]
config := recoveryConfig(payment, configs)
config, configErr := s.recoveryConfig(ctx, payment, configs)
if configErr != nil {
if firstErr == nil {
firstErr = configErr
}
continue
}
if recharge == nil || config == nil {
if firstErr == nil {
firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置")
@@ -141,7 +149,7 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
configIDs := make([]uint, 0, len(payments))
for index := range payments {
rechargeIDs = append(rechargeIDs, payments[index].OrderID)
if payments[index].PaymentConfigID != nil {
if payments[index].MerchantID == nil && payments[index].PaymentConfigID != nil {
configIDs = append(configIDs, *payments[index].PaymentConfigID)
}
}
@@ -151,8 +159,10 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
}
var configRows []model.WechatConfig
if len(configIDs) > 0 {
if err := s.db.WithContext(ctx).Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值创建配置失败")
// merchant_id 为空仅为留存期内历史支付;独立 Change 删除旧路径前,
// 必须按其 payment_config_id 读取,包括已软删除的原始配置。
if err := s.db.WithContext(ctx).Unscoped().Where("id IN ?", configIDs).Find(&configRows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值历史配置失败")
}
}
recharges := make(map[uint]*model.AgentRechargeRecord, len(rechargeRows))
@@ -166,11 +176,27 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay
return recharges, configs, nil
}
func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig {
if payment.PaymentConfigID == nil {
return nil
// recoveryConfig 对冻结商户支付单按当前凭证版本加载;历史支付单保持 payment_config_id 路径。
func (s *RecoverOnlinePaymentService) recoveryConfig(ctx context.Context, payment *model.Payment, configs map[uint]*model.WechatConfig) (*model.WechatConfig, error) {
if payment.MerchantID != nil {
merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return nil, err
}
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.runtime.LoadAuthorization(ctx)
if err != nil {
return nil, err
}
}
return merchantpayment.MerchantConfig(merchant, authorization)
}
return configs[*payment.PaymentConfigID]
if payment.PaymentConfigID == nil {
return nil, nil
}
return configs[*payment.PaymentConfigID], nil
}
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error {

View File

@@ -0,0 +1,721 @@
// Package merchantpayment provides merchant pool payment routing use cases.
package merchantpayment
import (
"context"
"reflect"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/bytedance/sonic"
)
// ManagementService 负责商户、商户池与授权配置写入。
type ManagementService struct {
db *gorm.DB
audit systemconfigapp.AuditWriter
}
// NewManagementService 创建商户配置用例。
func NewManagementService(db *gorm.DB, audit systemconfigapp.AuditWriter) *ManagementService {
return &ManagementService{db: db, audit: audit}
}
func requireManager(ctx context.Context) error {
kind := middleware.GetUserTypeFromContext(ctx)
if kind != constants.UserTypeSuperAdmin && kind != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限访问支付商户配置")
}
return nil
}
func normalizePage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 {
size = 20
}
if size > 100 {
size = 100
}
return page, size
}
func validPaymentMethod(method string) bool {
return method == "wechat" || method == "alipay"
}
func validateMerchantConfiguration(paymentMethod, providerType, merchantIdentity string, credentials model.JSONB) error {
paymentMethod, providerType, merchantIdentity = strings.TrimSpace(paymentMethod), strings.TrimSpace(providerType), strings.TrimSpace(merchantIdentity)
if !validPaymentMethod(paymentMethod) || merchantIdentity == "" || len(credentials) == 0 {
return errors.New(errors.CodeInvalidParam, "支付商户配置不完整")
}
var config model.WechatConfig
raw, err := sonic.Marshal(credentials)
if err != nil || sonic.Unmarshal(raw, &config) != nil {
return errors.New(errors.CodeInvalidParam, "支付商户凭证格式无效")
}
switch paymentMethod {
case "wechat":
switch providerType {
case model.ProviderTypeWechat:
if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV3Key) == "" || strings.TrimSpace(config.WxCertContent) == "" || strings.TrimSpace(config.WxKeyContent) == "" || strings.TrimSpace(config.WxSerialNo) == "" || strings.TrimSpace(config.WxNotifyURL) == "" {
return errors.New(errors.CodeInvalidParam, "微信直连商户凭证不完整或身份不一致")
}
case model.ProviderTypeWechatV2:
if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV2Key) == "" || strings.TrimSpace(config.WxNotifyURL) == "" {
return errors.New(errors.CodeInvalidParam, "微信 v2 商户凭证不完整或身份不一致")
}
case model.ProviderTypeFuiou:
if config.FyMchntCd != merchantIdentity || strings.TrimSpace(config.FyInsCd) == "" || strings.TrimSpace(config.FyTermID) == "" || strings.TrimSpace(config.FyPrivateKey) == "" || strings.TrimSpace(config.FyPublicKey) == "" || strings.TrimSpace(config.FyAPIURL) == "" || strings.TrimSpace(config.FyNotifyURL) == "" {
return errors.New(errors.CodeInvalidParam, "富友商户凭证不完整或身份不一致")
}
default:
return errors.New(errors.CodeInvalidParam, "微信支付服务商类型无效")
}
case "alipay":
if providerType != "alipay" || config.AliAppID != merchantIdentity || strings.TrimSpace(config.AliPrivateKey) == "" || strings.TrimSpace(config.AliPublicKey) == "" || strings.TrimSpace(config.AliNotifyURL) == "" || strings.TrimSpace(config.AliReturnURL) == "" {
return errors.New(errors.CodeInvalidParam, "支付宝商户凭证不完整或身份不一致")
}
}
return nil
}
func validatePoolRequest(req dto.PaymentMerchantPoolRequest) error {
if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) {
return errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
}
switch req.Strategy {
case model.PaymentMerchantStrategyAmount:
if req.ThresholdAmount == nil || *req.ThresholdAmount <= 0 || req.ThresholdCount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil {
return errors.New(errors.CodeInvalidParam, "金额轮询策略参数不完整")
}
case model.PaymentMerchantStrategyCount:
if req.ThresholdCount == nil || *req.ThresholdCount <= 0 || req.ThresholdAmount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil {
return errors.New(errors.CodeInvalidParam, "笔数轮询策略参数不完整")
}
case model.PaymentMerchantStrategyTime:
if req.TimePeriodValue == nil || *req.TimePeriodValue < 1 || req.TimePeriodUnit == nil || !validTimeUnit(*req.TimePeriodUnit) || req.TimePeriodStartedAt == nil || req.ThresholdAmount != nil || req.ThresholdCount != nil || req.StatisticCycle != nil {
return errors.New(errors.CodeInvalidParam, "时间轮询策略参数不完整")
}
default:
return errors.New(errors.CodeInvalidParam, "不支持的商户池轮询策略")
}
return nil
}
func validStatisticCycle(value string) bool {
return value == "round" || value == "day" || value == "month"
}
func validTimeUnit(value string) bool { return value == "minute" || value == "hour" || value == "day" }
func (s *ManagementService) writeAudit(ctx context.Context, tx *gorm.DB, operation, description, key, name string, id uint, identity, before, after map[string]any) error {
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "支付商户管理审计接缝未配置")
}
resourceID := strconv.FormatUint(uint64(id), 10)
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: key, Module: "payment_merchant", ResourceID: &resourceID, DisplayName: name,
Identity: identity, BeforeData: before, AfterData: after, Result: constants.AuditResultSuccess,
})
}
func merchantAuditIdentity(m *model.PaymentMerchant) map[string]any {
return map[string]any{"id": m.ID, "name": m.Name, "payment_method": m.PaymentMethod, "provider_type": m.ProviderType, "merchant_identity": m.MerchantIdentity, "status": m.Status, "credential_version": m.CredentialVersion}
}
func poolAuditIdentity(p *model.PaymentMerchantPool) map[string]any {
return map[string]any{"id": p.ID, "name": p.Name, "payment_method": p.PaymentMethod, "strategy": p.Strategy, "status": p.Status, "routing_epoch": p.RoutingEpoch}
}
func authorizationAuditIdentity(a *model.WechatAuthorization) map[string]any {
return map[string]any{"id": a.ID, "status": a.Status, "credential_version": a.CredentialVersion, "oa_app_id": a.OaAppID, "miniapp_app_id": a.MiniappAppID}
}
// CreateMerchant 创建独立管理的支付商户。
func (s *ManagementService) CreateMerchant(ctx context.Context, req dto.PaymentMerchantRequest) (*dto.PaymentMerchantResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if s == nil || s.db == nil || strings.TrimSpace(req.Name) == "" || len(req.Credentials) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "商户参数或凭证不完整")
}
if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) {
return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
}
m := &model.PaymentMerchant{Name: strings.TrimSpace(req.Name), PaymentMethod: strings.TrimSpace(req.PaymentMethod), ProviderType: strings.TrimSpace(req.ProviderType), MerchantIdentity: strings.TrimSpace(req.MerchantIdentity), Credentials: req.Credentials, CredentialVersion: 1, Remark: strings.TrimSpace(req.Remark), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}}
if req.Enabled {
m.Status = model.PaymentMerchantStatusEnabled
}
if m.MerchantIdentity == "" || m.ProviderType == "" {
return nil, errors.New(errors.CodeInvalidParam, "商户身份或服务商类型不能为空")
}
if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(m).Error; err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigCreate, "创建支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(m), nil, merchantAuditIdentity(m))
}); err != nil {
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付商户失败")
}
return merchantResponse(m), nil
}
// ListMerchants returns the privileged configuration projection.
// ListPools returns one page of merchant pools and their ordered members without per-pool member queries.
func (s *ManagementService) ListPools(ctx context.Context, req dto.PaymentMerchantPoolListRequest) ([]*dto.PaymentMerchantPoolResponse, int64, error) {
if err := requireManager(ctx); err != nil {
return nil, 0, err
}
page, size := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PaymentMerchantPool{})
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计商户池失败")
}
var pools []model.PaymentMerchantPool
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&pools).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
}
poolIDs := make([]uint, 0, len(pools))
for index := range pools {
poolIDs = append(poolIDs, pools[index].ID)
}
membersByPool := make(map[uint][]uint, len(pools))
if len(poolIDs) > 0 {
var members []model.PaymentMerchantPoolMember
if err := s.db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
}
for index := range members {
member := &members[index]
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
}
}
result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools))
for index := range pools {
pool := &pools[index]
result = append(result, &dto.PaymentMerchantPoolResponse{ID: pool.ID, Name: pool.Name, PaymentMethod: pool.PaymentMethod, Enabled: pool.Status == model.PaymentMerchantStatusEnabled, Strategy: pool.Strategy, ThresholdAmount: pool.ThresholdAmount, ThresholdCount: pool.ThresholdCount, StatisticCycle: pool.StatisticCycle, TimePeriodValue: pool.TimePeriodValue, TimePeriodUnit: pool.TimePeriodUnit, TimePeriodStartedAt: pool.TimePeriodStartedAt, RoutingEpoch: pool.RoutingEpoch, MemberIDs: membersByPool[pool.ID], Remark: pool.Remark})
}
return result, total, nil
}
// GetPool 查询一个商户池及其有序成员。
func (s *ManagementService) GetPool(ctx context.Context, id uint) (*dto.PaymentMerchantPoolResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var pool model.PaymentMerchantPool
if err := s.db.WithContext(ctx).First(&pool, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
}
return poolResponse(ctx, s.db, &pool)
}
// ListMerchants 分页查询特权商户配置。
func (s *ManagementService) ListMerchants(ctx context.Context, req dto.PaymentMerchantListRequest) ([]*dto.PaymentMerchantResponse, int64, error) {
if err := requireManager(ctx); err != nil {
return nil, 0, err
}
page, size := normalizePage(req.Page, req.PageSize)
query := s.db.WithContext(ctx).Model(&model.PaymentMerchant{})
if req.PaymentMethod != nil {
query = query.Where("payment_method = ?", strings.TrimSpace(*req.PaymentMethod))
}
if req.Enabled != nil {
status := model.PaymentMerchantStatusDisabled
if *req.Enabled {
status = model.PaymentMerchantStatusEnabled
}
query = query.Where("status = ?", status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
}
var rows []model.PaymentMerchant
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
}
result := make([]*dto.PaymentMerchantResponse, 0, len(rows))
for index := range rows {
result = append(result, merchantResponse(&rows[index]))
}
return result, total, nil
}
// GetMerchant 查询一个特权商户配置。
func (s *ManagementService) GetMerchant(ctx context.Context, id uint) (*dto.PaymentMerchantResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var m model.PaymentMerchant
if err := s.db.WithContext(ctx).First(&m, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败")
}
return merchantResponse(&m), nil
}
// UpdateMerchant 更新商户凭证和可变配置。
func (s *ManagementService) UpdateMerchant(ctx context.Context, id uint, req dto.PaymentMerchantUpdateRequest) (*dto.PaymentMerchantResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if req.PaymentMethod != nil && !validPaymentMethod(strings.TrimSpace(*req.PaymentMethod)) {
return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝")
}
var m model.PaymentMerchant
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, id).Error; err != nil {
return err
}
before := merchantAuditIdentity(&m)
previous := m
var refs int64
if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil {
return err
}
if refs > 0 && ((req.PaymentMethod != nil && *req.PaymentMethod != m.PaymentMethod) || (req.ProviderType != nil && *req.ProviderType != m.ProviderType) || (req.MerchantIdentity != nil && *req.MerchantIdentity != m.MerchantIdentity)) {
return errors.New(errors.CodeConflict, "已被支付单引用,不能修改收款身份")
}
if req.Name != nil {
m.Name = strings.TrimSpace(*req.Name)
}
if req.PaymentMethod != nil {
m.PaymentMethod = strings.TrimSpace(*req.PaymentMethod)
}
if req.ProviderType != nil {
m.ProviderType = strings.TrimSpace(*req.ProviderType)
}
if req.MerchantIdentity != nil {
m.MerchantIdentity = strings.TrimSpace(*req.MerchantIdentity)
}
if req.Remark != nil {
m.Remark = strings.TrimSpace(*req.Remark)
}
if req.Enabled != nil {
m.Status = model.PaymentMerchantStatusDisabled
if *req.Enabled {
m.Status = model.PaymentMerchantStatusEnabled
}
}
if req.Credentials != nil && !reflect.DeepEqual(m.Credentials, *req.Credentials) {
m.Credentials = *req.Credentials
}
if previous.Name != m.Name || previous.PaymentMethod != m.PaymentMethod || previous.ProviderType != m.ProviderType || previous.MerchantIdentity != m.MerchantIdentity || previous.Status != m.Status || !reflect.DeepEqual(previous.Credentials, m.Credentials) {
m.CredentialVersion++
}
if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil {
return err
}
if err := tx.Save(&m).Error; err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigUpdate, "更新支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(&m), before, merchantAuditIdentity(&m))
}); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
}
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新支付商户失败")
}
return merchantResponse(&m), nil
}
// DeleteMerchant 仅在未被引用且二次确认后删除商户。
func (s *ManagementService) DeleteMerchant(ctx context.Context, id uint, confirm bool) error {
if err := requireManager(ctx); err != nil {
return err
}
if !confirm {
return errors.New(errors.CodeInvalidParam, "删除商户必须二次确认")
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var refs, members int64
var merchant model.PaymentMerchant
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&merchant, id).Error; err != nil {
return err
}
before := merchantAuditIdentity(&merchant)
if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil {
return err
}
if refs > 0 {
return errors.New(errors.CodeConflict, "已被支付单引用的商户不能删除")
}
if err := tx.Model(&model.PaymentMerchantPoolMember{}).Where("merchant_id = ?", id).Count(&members).Error; err != nil {
return err
}
if members > 0 {
return errors.New(errors.CodeConflict, "商户仍属于商户池")
}
r := tx.Delete(&model.PaymentMerchant{}, id)
if r.Error != nil {
return r.Error
}
if r.RowsAffected == 0 {
return errors.New(errors.CodeNotFound, "支付商户不存在")
}
if err := s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDelete, "删除支付商户", "payment_merchant:"+strconv.FormatUint(uint64(merchant.ID), 10), merchant.Name, merchant.ID, before, before, nil); err != nil {
return err
}
return nil
})
}
// SavePool 创建或更新商户池,并原子替换有序成员。
func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.PaymentMerchantPoolRequest) (*dto.PaymentMerchantPoolResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if err := validatePoolRequest(req); err != nil {
return nil, err
}
if len(req.MemberIDs) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "商户池至少需要一个商户")
}
var pool model.PaymentMerchantPool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
creating := id == 0
var previousMemberIDs []uint
if !creating {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil {
return err
}
var previousMembers []model.PaymentMerchantPoolMember
if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&previousMembers).Error; err != nil {
return err
}
previousMemberIDs = make([]uint, 0, len(previousMembers))
for _, member := range previousMembers {
previousMemberIDs = append(previousMemberIDs, member.MerchantID)
}
} else {
pool.Creator = middleware.GetUserIDFromContext(ctx)
pool.RoutingEpoch = 1
}
before := poolAuditIdentity(&pool)
if err := validatePoolMembers(ctx, tx, req.PaymentMethod, req.MemberIDs); err != nil {
return err
}
if !creating && poolEpochChanged(&pool, &req, previousMemberIDs) {
pool.RoutingEpoch++
}
pool.Name, pool.PaymentMethod, pool.Strategy, pool.Remark = strings.TrimSpace(req.Name), strings.TrimSpace(req.PaymentMethod), strings.TrimSpace(req.Strategy), strings.TrimSpace(req.Remark)
pool.ThresholdAmount, pool.ThresholdCount, pool.StatisticCycle, pool.TimePeriodValue, pool.TimePeriodUnit, pool.TimePeriodStartedAt = req.ThresholdAmount, req.ThresholdCount, req.StatisticCycle, req.TimePeriodValue, req.TimePeriodUnit, req.TimePeriodStartedAt
if req.Enabled {
var others int64
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
return err
}
if others > 0 {
return errors.New(errors.CodeConflict, "该支付方式已有启用商户池")
}
}
pool.Status = model.PaymentMerchantStatusDisabled
if req.Enabled {
pool.Status = model.PaymentMerchantStatusEnabled
}
pool.Updater = middleware.GetUserIDFromContext(ctx)
if creating {
if err := tx.Create(&pool).Error; err != nil {
return err
}
} else if err := tx.Save(&pool).Error; err != nil {
return err
}
if err := tx.Where("pool_id = ?", pool.ID).Delete(&model.PaymentMerchantPoolMember{}).Error; err != nil {
return err
}
members := make([]model.PaymentMerchantPoolMember, 0, len(req.MemberIDs))
for i, merchantID := range req.MemberIDs {
members = append(members, model.PaymentMerchantPoolMember{PoolID: pool.ID, MerchantID: merchantID, SortOrder: int64(i), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}})
}
if err := tx.Create(&members).Error; err != nil {
return err
}
op := constants.AuditOperationPaymentConfigUpdate
summary := "更新商户池"
if creating {
op = constants.AuditOperationPaymentConfigCreate
summary = "创建商户池"
}
return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool))
})
if err != nil {
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存商户池失败")
}
return poolResponse(ctx, s.db, &pool)
}
// SetPoolEnabled enables or disables a pool after rechecking the active-pool and member invariants.
func (s *ManagementService) SetPoolEnabled(ctx context.Context, id uint, enabled bool) (*dto.PaymentMerchantPoolResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var pool model.PaymentMerchantPool
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil {
return err
}
before := poolAuditIdentity(&pool)
if enabled {
var others int64
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
return err
}
if others > 0 {
return errors.New(errors.CodeConflict, "该支付方式已有启用商户池")
}
var members []model.PaymentMerchantPoolMember
if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil {
return err
}
ids := make([]uint, 0, len(members))
for _, member := range members {
ids = append(ids, member.MerchantID)
}
if err := validatePoolMembers(ctx, tx, pool.PaymentMethod, ids); err != nil {
return err
}
pool.Status = model.PaymentMerchantStatusEnabled
} else {
pool.Status = model.PaymentMerchantStatusDisabled
}
pool.Updater = middleware.GetUserIDFromContext(ctx)
if err := tx.Save(&pool).Error; err != nil {
return err
}
op := constants.AuditOperationPaymentConfigDeactivate
summary := "停用商户池"
if enabled {
op = constants.AuditOperationPaymentConfigActivate
summary = "启用商户池"
}
return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool))
})
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "商户池不存在")
}
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新商户池状态失败")
}
return poolResponse(ctx, s.db, &pool)
}
func validatePoolMembers(ctx context.Context, tx *gorm.DB, method string, ids []uint) error {
seen := map[uint]struct{}{}
for _, id := range ids {
if id == 0 {
return errors.New(errors.CodeInvalidParam, "商户ID无效")
}
if _, ok := seen[id]; ok {
return errors.New(errors.CodeInvalidParam, "商户池成员不能重复")
}
seen[id] = struct{}{}
}
var merchants []model.PaymentMerchant
if err := tx.WithContext(ctx).Where("id IN ? AND payment_method = ? AND status = ?", ids, method, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil {
return err
}
if len(merchants) != len(ids) {
return errors.New(errors.CodeConflict, "商户池成员必须存在、启用且支付方式一致")
}
for index := range merchants {
merchant := &merchants[index]
if err := validateMerchantConfiguration(merchant.PaymentMethod, merchant.ProviderType, merchant.MerchantIdentity, merchant.Credentials); err != nil {
return err
}
}
return nil
}
func poolEpochChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool {
if pool.PaymentMethod != strings.TrimSpace(request.PaymentMethod) ||
pool.Strategy != request.Strategy ||
!sameString(pool.StatisticCycle, request.StatisticCycle) ||
!sameInt64(pool.TimePeriodValue, request.TimePeriodValue) ||
!sameString(pool.TimePeriodUnit, request.TimePeriodUnit) ||
!sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) {
return true
}
if sameMemberOrder(previousMemberIDs, request.MemberIDs) {
return false
}
return pool.StatisticCycle == nil || (*pool.StatisticCycle != "day" && *pool.StatisticCycle != "month") || !sameMemberSet(previousMemberIDs, request.MemberIDs)
}
func sameMemberOrder(left, right []uint) bool {
if len(left) != len(right) {
return false
}
for i := range left {
if left[i] != right[i] {
return false
}
}
return true
}
func sameMemberSet(left, right []uint) bool {
if len(left) != len(right) {
return false
}
seen := make(map[uint]struct{}, len(left))
for _, id := range left {
seen[id] = struct{}{}
}
for _, id := range right {
if _, ok := seen[id]; !ok {
return false
}
}
return true
}
func sameString(left, right *string) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func sameInt64(left, right *int64) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func sameTime(a, b *time.Time) bool {
if a == nil || b == nil {
return a == b
}
return a.Equal(*b)
}
func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse {
return &dto.PaymentMerchantResponse{ID: m.ID, Name: m.Name, PaymentMethod: m.PaymentMethod, ProviderType: m.ProviderType, MerchantIdentity: m.MerchantIdentity, Credentials: m.Credentials, CredentialVersion: m.CredentialVersion, Enabled: m.Status == model.PaymentMerchantStatusEnabled, Remark: m.Remark, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
}
func poolResponse(ctx context.Context, db *gorm.DB, p *model.PaymentMerchantPool) (*dto.PaymentMerchantPoolResponse, error) {
var rows []model.PaymentMerchantPoolMember
if err := db.WithContext(ctx).Where("pool_id = ?", p.ID).Order("sort_order ASC").Find(&rows).Error; err != nil {
return nil, err
}
ids := make([]uint, 0, len(rows))
for _, row := range rows {
ids = append(ids, row.MerchantID)
}
return &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: ids, Remark: p.Remark}, nil
}
// GetAuthorization 查询特权全局授权配置。
func (s *ManagementService) GetAuthorization(ctx context.Context) (*dto.WechatAuthorizationResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
var a model.WechatAuthorization
if err := s.db.WithContext(ctx).First(&a).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, nil
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权配置失败")
}
return authorizationResponse(&a), nil
}
// SaveAuthorization 创建或更新唯一启用的授权配置。
func (s *ManagementService) SaveAuthorization(ctx context.Context, req dto.WechatAuthorizationRequest) (*dto.WechatAuthorizationResponse, error) {
if err := requireManager(ctx); err != nil {
return nil, err
}
if req.Enabled && (strings.TrimSpace(req.OaAppID) == "" || strings.TrimSpace(req.OaAppSecret) == "" || strings.TrimSpace(req.MiniappAppID) == "" || strings.TrimSpace(req.MiniappAppSecret) == "") {
return nil, errors.New(errors.CodeInvalidParam, "启用微信授权配置时公众号和小程序凭证必须完整")
}
var authorization model.WechatAuthorization
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&authorization).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
previousStatus := authorization.Status
creating := err == gorm.ErrRecordNotFound
before := authorizationAuditIdentity(&authorization)
if creating {
authorization.Creator = middleware.GetUserIDFromContext(ctx)
authorization.CredentialVersion = 1
}
changed := authorization.OaAppID != req.OaAppID || authorization.OaAppSecret != req.OaAppSecret || authorization.OaToken != req.OaToken || authorization.OaAesKey != req.OaAesKey || authorization.OaOAuthRedirectURL != req.OaOAuthRedirectURL || authorization.MiniappAppID != req.MiniappAppID || authorization.MiniappAppSecret != req.MiniappAppSecret
authorization.OaAppID, authorization.OaAppSecret, authorization.OaToken, authorization.OaAesKey, authorization.OaOAuthRedirectURL, authorization.MiniappAppID, authorization.MiniappAppSecret = req.OaAppID, req.OaAppSecret, req.OaToken, req.OaAesKey, req.OaOAuthRedirectURL, req.MiniappAppID, req.MiniappAppSecret
authorization.Status = model.PaymentMerchantStatusDisabled
if req.Enabled {
authorization.Status = model.PaymentMerchantStatusEnabled
}
if !creating && (changed || previousStatus != authorization.Status) {
authorization.CredentialVersion++
}
authorization.Updater = middleware.GetUserIDFromContext(ctx)
if creating {
if err := tx.Create(&authorization).Error; err != nil {
return err
}
} else {
if err := tx.Save(&authorization).Error; err != nil {
return err
}
}
op := constants.AuditOperationPaymentConfigUpdate
summary := "更新微信授权配置"
if creating {
op = constants.AuditOperationPaymentConfigCreate
summary = "创建微信授权配置"
}
return s.writeAudit(ctx, tx, op, summary, "wechat_authorization:"+strconv.FormatUint(uint64(authorization.ID), 10), "微信授权配置", authorization.ID, authorizationAuditIdentity(&authorization), before, authorizationAuditIdentity(&authorization))
})
if err != nil {
if appErr, ok := err.(*errors.AppError); ok {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存微信授权配置失败")
}
return authorizationResponse(&authorization), nil
}
func authorizationResponse(a *model.WechatAuthorization) *dto.WechatAuthorizationResponse {
return &dto.WechatAuthorizationResponse{ID: a.ID, OaAppID: a.OaAppID, OaAppSecret: a.OaAppSecret, OaToken: a.OaToken, OaAesKey: a.OaAesKey, OaOAuthRedirectURL: a.OaOAuthRedirectURL, MiniappAppID: a.MiniappAppID, MiniappAppSecret: a.MiniappAppSecret, CredentialVersion: a.CredentialVersion, Enabled: a.Status == model.PaymentMerchantStatusEnabled, UpdatedAt: a.UpdatedAt}
}

View File

@@ -0,0 +1,386 @@
package merchantpayment
import (
"context"
"fmt"
"strings"
"time"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RouteSelection is the non-sensitive route frozen onto a new payment.
type RouteSelection struct {
Merchant *model.PaymentMerchant
Pool *model.PaymentMerchantPool
}
// RuntimeLoader loads current merchant and authorization credentials by version.
type RuntimeLoader struct {
db *gorm.DB
redis *redis.Client
}
// merchantCachePayload is used only for the internal versioned Redis cache and deliberately includes credentials.
// It must never be used for DTOs, logs, audits, or payment snapshots.
type merchantCachePayload struct {
ID uint `json:"id"`
Name string `json:"name"`
PaymentMethod string `json:"payment_method"`
ProviderType string `json:"provider_type"`
MerchantIdentity string `json:"merchant_identity"`
Credentials model.JSONB `json:"credentials"`
CredentialVersion int64 `json:"credential_version"`
Status int `json:"status"`
Remark string `json:"remark"`
}
func merchantCachePayloadFrom(merchant *model.PaymentMerchant) merchantCachePayload {
return merchantCachePayload{ID: merchant.ID, Name: merchant.Name, PaymentMethod: merchant.PaymentMethod, ProviderType: merchant.ProviderType, MerchantIdentity: merchant.MerchantIdentity, Credentials: merchant.Credentials, CredentialVersion: merchant.CredentialVersion, Status: merchant.Status, Remark: merchant.Remark}
}
func (p merchantCachePayload) merchant() *model.PaymentMerchant {
return &model.PaymentMerchant{Model: gorm.Model{ID: p.ID}, Name: p.Name, PaymentMethod: p.PaymentMethod, ProviderType: p.ProviderType, MerchantIdentity: p.MerchantIdentity, Credentials: p.Credentials, CredentialVersion: p.CredentialVersion, Status: p.Status, Remark: p.Remark}
}
// authorizationCachePayload is used only for the internal versioned Redis cache and deliberately includes secrets.
// It must never be used for DTOs, logs, audits, or payment snapshots.
type authorizationCachePayload struct {
ID uint `json:"id"`
OaAppID string `json:"oa_app_id"`
OaAppSecret string `json:"oa_app_secret"`
OaToken string `json:"oa_token"`
OaAesKey string `json:"oa_aes_key"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url"`
MiniappAppID string `json:"miniapp_app_id"`
MiniappAppSecret string `json:"miniapp_app_secret"`
CredentialVersion int64 `json:"credential_version"`
Status int `json:"status"`
}
func authorizationCachePayloadFrom(authorization *model.WechatAuthorization) authorizationCachePayload {
return authorizationCachePayload{ID: authorization.ID, OaAppID: authorization.OaAppID, OaAppSecret: authorization.OaAppSecret, OaToken: authorization.OaToken, OaAesKey: authorization.OaAesKey, OaOAuthRedirectURL: authorization.OaOAuthRedirectURL, MiniappAppID: authorization.MiniappAppID, MiniappAppSecret: authorization.MiniappAppSecret, CredentialVersion: authorization.CredentialVersion, Status: authorization.Status}
}
func (p authorizationCachePayload) authorization() *model.WechatAuthorization {
return &model.WechatAuthorization{Model: gorm.Model{ID: p.ID}, OaAppID: p.OaAppID, OaAppSecret: p.OaAppSecret, OaToken: p.OaToken, OaAesKey: p.OaAesKey, OaOAuthRedirectURL: p.OaOAuthRedirectURL, MiniappAppID: p.MiniappAppID, MiniappAppSecret: p.MiniappAppSecret, CredentialVersion: p.CredentialVersion, Status: p.Status}
}
func NewRuntimeLoader(db *gorm.DB, redis *redis.Client) *RuntimeLoader {
return &RuntimeLoader{db: db, redis: redis}
}
// LoadMerchant first reads the current version from the primary database, then uses only that version's cache entry.
// Disabled merchants remain loadable for frozen historical payments.
func (l *RuntimeLoader) LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error) {
if l == nil || l.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置")
}
return l.loadMerchant(ctx, l.db, id)
}
// loadMerchant 先从当前事务或主库读取版本,再仅命中该版本的缓存。
// 版本在凭证事务提交时递增,因此提交前遗留的旧缓存永远不会被新读取命中。
func (l *RuntimeLoader) loadMerchant(ctx context.Context, db *gorm.DB, id uint) (*model.PaymentMerchant, error) {
var current model.PaymentMerchant
if err := db.WithContext(ctx).First(&current, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "支付商户不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取支付商户失败")
}
key := fmt.Sprintf("payment:merchant:%d:%d", current.ID, current.CredentialVersion)
if l.redis != nil {
if text, err := l.redis.Get(ctx, key).Result(); err == nil {
var cached merchantCachePayload
if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion {
return cached.merchant(), nil
}
}
}
if l.redis != nil {
if text, err := sonic.MarshalString(merchantCachePayloadFrom(&current)); err == nil {
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
}
}
return &current, nil
}
// LoadAuthorization first reads the current enabled version and only then resolves its versioned cache entry.
func (l *RuntimeLoader) LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error) {
if l == nil || l.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "微信授权加载能力未配置")
}
var current model.WechatAuthorization
if err := l.db.WithContext(ctx).Where("status = ?", model.PaymentMerchantStatusEnabled).First(&current).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取微信授权配置失败")
}
key := fmt.Sprintf("payment:wechat-authorization:%d:%d", current.ID, current.CredentialVersion)
if l.redis != nil {
if text, err := l.redis.Get(ctx, key).Result(); err == nil {
var cached authorizationCachePayload
if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion {
return cached.authorization(), nil
}
}
}
if l.redis != nil {
if text, err := sonic.MarshalString(authorizationCachePayloadFrom(&current)); err == nil {
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
}
}
return &current, nil
}
// MerchantConfig adapts the merchant credential payload to existing channel constructors without persisting credentials in a payment snapshot.
func MerchantConfig(merchant *model.PaymentMerchant, authorization *model.WechatAuthorization) (*model.WechatConfig, error) {
if merchant == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "支付商户不存在")
}
if authorization == nil {
authorization = &model.WechatAuthorization{}
}
raw, err := sonic.Marshal(merchant.Credentials)
if err != nil {
return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效")
}
var cfg model.WechatConfig
if err := sonic.Unmarshal(raw, &cfg); err != nil {
return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效")
}
cfg.ID = merchant.ID
cfg.ProviderType = merchant.ProviderType
cfg.IsActive = true
if authorization != nil {
cfg.OaAppID = authorization.OaAppID
cfg.OaAppSecret = authorization.OaAppSecret
cfg.OaToken = authorization.OaToken
cfg.OaAesKey = authorization.OaAesKey
cfg.OaOAuthRedirectURL = authorization.OaOAuthRedirectURL
cfg.MiniappAppID = authorization.MiniappAppID
cfg.MiniappAppSecret = authorization.MiniappAppSecret
}
return &cfg, nil
}
// MerchantConfigWithAuthorization 在需要 AppID 的渠道实例前,按当前版本加载全局微信授权配置。
// 授权字段只进入内存中的渠道配置,绝不写入支付快照、普通 DTO、日志、审计或导出。
func (l *RuntimeLoader) MerchantConfigWithAuthorization(ctx context.Context, merchant *model.PaymentMerchant) (*model.WechatConfig, error) {
authorization, err := l.LoadAuthorization(ctx)
if err != nil {
return nil, err
}
return MerchantConfig(merchant, authorization)
}
// SelectForNewPayment atomically reads the active pool and chooses its current eligible member.
func (l *RuntimeLoader) SelectForNewPayment(ctx context.Context, paymentMethod string, now time.Time) (*RouteSelection, error) {
if l == nil || l.db == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
var out *RouteSelection
err := l.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var err error
out, err = l.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, now)
return err
})
if err != nil {
return nil, err
}
return out, nil
}
// SelectForNewPaymentWithTx chooses an eligible merchant while retaining the caller's business transaction.
func (l *RuntimeLoader) SelectForNewPaymentWithTx(ctx context.Context, tx *gorm.DB, paymentMethod string, now time.Time) (*RouteSelection, error) {
if l == nil || tx == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
var pool model.PaymentMerchantPool
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_method = ? AND status = ?", paymentMethod, model.PaymentMerchantStatusEnabled).First(&pool).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
return nil, err
}
var members []model.PaymentMerchantPoolMember
if err := tx.WithContext(ctx).Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil {
return nil, err
}
if len(members) == 0 {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
ids := make([]uint, 0, len(members))
for _, member := range members {
ids = append(ids, member.MerchantID)
}
var merchants []model.PaymentMerchant
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND payment_method = ? AND status = ?", ids, pool.PaymentMethod, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil {
return nil, err
}
byID := make(map[uint]*model.PaymentMerchant, len(merchants))
for i := range merchants {
byID[merchants[i].ID] = &merchants[i]
}
ordered := make([]*model.PaymentMerchant, 0, len(members))
for _, member := range members {
if merchant := byID[member.MerchantID]; merchant != nil {
ordered = append(ordered, merchant)
}
}
chosen, err := chooseMerchant(ctx, tx, &pool, ordered, now)
if err != nil {
return nil, err
}
// 新支付在冻结前也按“商户 ID + 当前版本”读取缓存;事务锁保证本次
// 选择与凭证版本属于同一提交边界,避免新建支付误用旧版本缓存。
chosen, err = l.loadMerchant(ctx, tx, chosen.ID)
if err != nil {
return nil, err
}
return &RouteSelection{Merchant: chosen, Pool: &pool}, nil
}
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
if len(merchants) == 0 {
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
}
if pool.Strategy == model.PaymentMerchantStrategyTime {
return chooseTimedMerchant(pool, merchants, now)
}
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
return nil, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
}
if pool.StatisticCycle == nil {
return nil, errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
}
query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch)
if start, limited := routingWindowStart(*pool.StatisticCycle, now); limited {
query = query.Where("paid_at >= ?", start)
}
var rows []model.PaymentMerchantRoutingSuccess
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
amounts := make(map[uint]int64, len(merchants))
counts := make(map[uint]int64, len(merchants))
for _, row := range rows {
amounts[row.MerchantID] += row.Amount
counts[row.MerchantID]++
}
for _, merchant := range merchants {
if pool.Strategy == model.PaymentMerchantStrategyAmount {
if pool.ThresholdAmount == nil {
return nil, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
}
if amounts[merchant.ID] < *pool.ThresholdAmount {
return merchant, nil
}
continue
}
if pool.ThresholdCount == nil {
return nil, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
}
if counts[merchant.ID] < *pool.ThresholdCount {
return merchant, nil
}
}
if *pool.StatisticCycle != "round" {
return nil, errors.New(errors.CodeNoPaymentConfig, "当前统计周期内暂无可用商户")
}
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
return nil, err
}
return merchants[0], nil
}
func chooseTimedMerchant(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
if pool.TimePeriodStartedAt == nil || pool.TimePeriodValue == nil || pool.TimePeriodUnit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询配置不完整")
}
unit := time.Minute
switch *pool.TimePeriodUnit {
case "hour":
unit = time.Hour
case "day":
unit = 24 * time.Hour
case "minute":
default:
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询单位无效")
}
period := unit * time.Duration(*pool.TimePeriodValue)
if period <= 0 {
return nil, errors.New(errors.CodeInvalidStatus, "时间轮询周期无效")
}
slot := now.Sub(*pool.TimePeriodStartedAt) / period
if slot < 0 {
slot = 0
}
return merchants[int(slot%time.Duration(len(merchants)))], nil
}
func routingWindowStart(cycle string, now time.Time) (time.Time, bool) {
local := now.In(now.Location())
switch cycle {
case "day":
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()), true
case "month":
return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, local.Location()), true
default:
return time.Time{}, false
}
}
func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool) error {
next := pool.RoutingEpoch + 1
result := tx.WithContext(ctx).Model(&model.PaymentMerchantPool{}).Where("id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch).Update("routing_epoch", next)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "商户池统计世代已变化")
}
pool.RoutingEpoch = next
return nil
}
// FreezeRoute writes only non-sensitive route facts onto the payment.
func FreezeRoute(payment *model.Payment, route *RouteSelection) {
if payment == nil || route == nil || route.Merchant == nil || route.Pool == nil {
return
}
payment.MerchantID = &route.Merchant.ID
payment.MerchantPoolID = &route.Pool.ID
payment.MerchantIdentity = route.Merchant.MerchantIdentity
payment.MerchantNameSnapshot = route.Merchant.Name
payment.MerchantPaymentMethodSnapshot = route.Merchant.PaymentMethod
payment.MerchantProviderTypeSnapshot = route.Merchant.ProviderType
payment.MerchantPoolNameSnapshot = route.Pool.Name
payment.RoutingStrategySnapshot = route.Pool.Strategy
epoch := route.Pool.RoutingEpoch
payment.RoutingEpoch = &epoch
}
// RecordFirstSuccess 在支付成功事务内写入支付不可变的路由事实。
func RecordFirstSuccess(ctx context.Context, tx *gorm.DB, payment *model.Payment, paidAt time.Time) error {
if payment == nil || payment.MerchantID == nil || payment.MerchantPoolID == nil || payment.RoutingEpoch == nil {
return nil
}
fact := model.PaymentMerchantRoutingSuccess{PaymentID: payment.ID, MerchantID: *payment.MerchantID, PoolID: *payment.MerchantPoolID, RoutingEpoch: *payment.RoutingEpoch, Amount: payment.Amount, PaidAt: paidAt}
if err := tx.WithContext(ctx).Create(&fact).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "写入商户池成功统计失败")
}
return nil
}

View File

@@ -1,6 +1,7 @@
package bootstrap
import (
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
roleApp "github.com/break/junhong_cmp_fiber/internal/application/role"
shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop"
@@ -86,7 +87,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
personalCustomerOpenIDStore,
personalCustomerStore,
personalCustomerPhoneStore,
svc.WechatConfig,
svc.Order,
packageSeriesStore,
shopSeriesAllocationStore,
@@ -111,6 +111,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
clientOrderService.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
clientOrderService.SetLegacyPaymentConfigService(svc.WechatConfig)
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
systemConfigAudit := deps.SystemConfigAudit
if systemConfigAudit == nil {
@@ -173,7 +174,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
return handler
}(),
ClientWallet: func() *app.ClientWalletHandler {
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
handler.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
return handler
@@ -284,7 +285,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
h.SetAssetService(svc.Asset)
return h
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
PaymentMerchant: admin.NewPaymentMerchantHandler(merchantPaymentApp.NewManagementService(deps.DB, systemConfigAudit)),
AgentRecharge: func() *admin.AgentRechargeHandler {
handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate)
handler.SetOnlineCreationService(svc.AgentRechargeOnline)

View File

@@ -9,6 +9,7 @@ import (
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
@@ -286,6 +287,7 @@ func initServices(s *stores, deps *Dependencies) *services {
paymentIntegration := integrationlog.NewRepository(deps.DB)
agentRechargeOnline := agentrechargeApp.NewOnlineCreationService(
deps.DB,
merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis),
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger),
@@ -313,6 +315,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
refundService.SetNotificationOutbox(walletOutbox)
refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis))
refundService.SetLifecycleAudit(auditWriter)
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))

View File

@@ -68,6 +68,7 @@ type Handlers struct {
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler
WechatConfig *admin.WechatConfigHandler
PaymentMerchant *admin.PaymentMerchantHandler
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler

View File

@@ -24,25 +24,28 @@ const (
// PaymentConfirmationFacts 是支付确认所需的渠道与本地权威事实。
type PaymentConfirmationFacts struct {
OrderType string
ExpectedOrderType string
PaymentMethod string
RechargePaymentMethod string
RechargePaymentChannel string
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
RechargeAmount int64
ConfirmedAmount int64
PaymentOrderID uint
RechargeID uint
PaymentState PaymentState
RechargeStatus int
StoredTradeNo string
ConfirmedTradeNo string
OrderType string
ExpectedOrderType string
PaymentMethod string
RechargePaymentMethod string
RechargePaymentChannel string
PaymentConfigID uint
RechargePaymentConfigID uint
ConfirmedConfigID uint
FrozenMerchant bool
FrozenMerchantPaymentMethod string
FrozenMerchantProviderType string
MerchantIdentity string
ConfirmedMerchantIdentity string
PaymentAmount int64
RechargeAmount int64
ConfirmedAmount int64
PaymentOrderID uint
RechargeID uint
PaymentState PaymentState
RechargeStatus int
StoredTradeNo string
ConfirmedTradeNo string
}
// ValidatePaymentConfirmation 校验支付确认不变量,并返回是否属于完全一致的重复确认。
@@ -60,7 +63,20 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) {
return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致")
}
identity := strings.TrimSpace(facts.MerchantIdentity)
if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
if facts.FrozenMerchant {
frozenMethod := strings.TrimSpace(facts.FrozenMerchantPaymentMethod)
providerType := strings.TrimSpace(facts.FrozenMerchantProviderType)
if identity == "" || frozenMethod != method || providerType == "" {
return false, errors.New(errors.CodeConflict, "冻结商户支付事实不一致")
}
expectedChannel := method
if method == constants.RechargeMethodWechat && providerType == model.ProviderTypeFuiou {
expectedChannel = model.ProviderTypeFuiou
}
if channel != expectedChannel {
return false, errors.New(errors.CodeConflict, "支付渠道与冻结商户不一致")
}
} else if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID ||
facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" ||
identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) {
return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致")

View File

@@ -0,0 +1,218 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// PaymentMerchantHandler 处理特权商户与商户池配置请求。
type PaymentMerchantHandler struct {
service *merchantpayment.ManagementService
}
// NewPaymentMerchantHandler 创建商户池管理处理器。
func NewPaymentMerchantHandler(service *merchantpayment.ManagementService) *PaymentMerchantHandler {
return &PaymentMerchantHandler{service: service}
}
func pathID(c *fiber.Ctx) (uint, error) {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || id == 0 {
return 0, errors.New(errors.CodeInvalidParam, "无效的路径ID")
}
return uint(id), nil
}
// CreateMerchant 创建支付商户。
// POST /api/admin/payment-merchants
func (h *PaymentMerchantHandler) CreateMerchant(c *fiber.Ctx) error {
var r dto.PaymentMerchantRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.CreateMerchant(c.UserContext(), r)
if err != nil {
return err
}
return response.Success(c, v)
}
// ListMerchants 分页查询支付商户。
// GET /api/admin/payment-merchants
func (h *PaymentMerchantHandler) ListMerchants(c *fiber.Ctx) error {
var r dto.PaymentMerchantListRequest
if err := c.QueryParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, n, err := h.service.ListMerchants(c.UserContext(), r)
if err != nil {
return err
}
return response.SuccessWithPagination(c, v, n, r.Page, r.PageSize)
}
// GetMerchant 查询支付商户详情。
// GET /api/admin/payment-merchants/:id
func (h *PaymentMerchantHandler) GetMerchant(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
v, err := h.service.GetMerchant(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, v)
}
// UpdateMerchant 更新支付商户。
// PUT /api/admin/payment-merchants/:id
func (h *PaymentMerchantHandler) UpdateMerchant(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var r dto.PaymentMerchantUpdateRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.UpdateMerchant(c.UserContext(), id, r)
if err != nil {
return err
}
return response.Success(c, v)
}
// DeleteMerchant 删除支付商户。
// DELETE /api/admin/payment-merchants/:id
func (h *PaymentMerchantHandler) DeleteMerchant(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var r dto.PaymentMerchantDeleteRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.DeleteMerchant(c.UserContext(), id, r.Confirm); err != nil {
return err
}
return response.Success(c, nil)
}
// CreatePool 创建商户池。
// POST /api/admin/payment-merchant-pools
func (h *PaymentMerchantHandler) CreatePool(c *fiber.Ctx) error {
var r dto.PaymentMerchantPoolRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.SavePool(c.UserContext(), 0, r)
if err != nil {
return err
}
return response.Success(c, v)
}
// UpdatePool 更新商户池。
// PUT /api/admin/payment-merchant-pools/:id
func (h *PaymentMerchantHandler) UpdatePool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
var r dto.PaymentMerchantPoolRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.SavePool(c.UserContext(), id, r)
if err != nil {
return err
}
return response.Success(c, v)
}
// EnablePool 启用商户池。
// POST /api/admin/payment-merchant-pools/:id/enable
func (h *PaymentMerchantHandler) EnablePool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.service.SetPoolEnabled(c.UserContext(), id, true)
if err != nil {
return err
}
return response.Success(c, result)
}
// DisablePool 停用商户池。
// POST /api/admin/payment-merchant-pools/:id/disable
func (h *PaymentMerchantHandler) DisablePool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
result, err := h.service.SetPoolEnabled(c.UserContext(), id, false)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetAuthorization 获取微信授权配置。
// GET /api/admin/wechat-authorizations
func (h *PaymentMerchantHandler) GetAuthorization(c *fiber.Ctx) error {
v, err := h.service.GetAuthorization(c.UserContext())
if err != nil {
return err
}
return response.Success(c, v)
}
// ListPools 分页查询商户池。
// GET /api/admin/payment-merchant-pools
func (h *PaymentMerchantHandler) ListPools(c *fiber.Ctx) error {
var r dto.PaymentMerchantPoolListRequest
if err := c.QueryParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, total, err := h.service.ListPools(c.UserContext(), r)
if err != nil {
return err
}
return response.SuccessWithPagination(c, v, total, r.Page, r.PageSize)
}
// GetPool 查询商户池详情。
// GET /api/admin/payment-merchant-pools/:id
func (h *PaymentMerchantHandler) GetPool(c *fiber.Ctx) error {
id, err := pathID(c)
if err != nil {
return err
}
v, err := h.service.GetPool(c.UserContext(), id)
if err != nil {
return err
}
return response.Success(c, v)
}
// SaveAuthorization 保存微信授权配置。
// PUT /api/admin/wechat-authorizations/current
func (h *PaymentMerchantHandler) SaveAuthorization(c *fiber.Ctx) error {
var r dto.WechatAuthorizationRequest
if err := c.BodyParser(&r); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
v, err := h.service.SaveAuthorization(c.UserContext(), r)
if err != nil {
return err
}
return response.Success(c, v)
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/middleware"
@@ -16,7 +17,7 @@ import (
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"
@@ -41,7 +42,7 @@ type ClientWalletHandler struct {
paymentStore *postgres.PaymentStore
rechargeService *rechargeSvc.Service
openIDStore *postgres.PersonalCustomerOpenIDStore
wechatConfigService *wechatConfigSvc.Service
merchantRuntime *merchantpayment.RuntimeLoader
redis *redis.Client
logger *zap.Logger
db *gorm.DB
@@ -73,7 +74,6 @@ func NewClientWalletHandler(
paymentStore *postgres.PaymentStore,
rechargeService *rechargeSvc.Service,
openIDStore *postgres.PersonalCustomerOpenIDStore,
wechatConfigService *wechatConfigSvc.Service,
redisClient *redis.Client,
logger *zap.Logger,
db *gorm.DB,
@@ -81,20 +81,20 @@ func NewClientWalletHandler(
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,
assetService: assetService,
customerBinding: binding,
walletStore: walletStore,
transactionStore: transactionStore,
rechargeOrderStore: rechargeOrderStore,
paymentStore: paymentStore,
rechargeService: rechargeService,
openIDStore: openIDStore,
merchantRuntime: merchantpayment.NewRuntimeLoader(db, redisClient),
redis: redisClient,
logger: logger,
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
}
}
@@ -321,19 +321,11 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
// }
// }
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)
return h.createAlipayRecharge(c, resolved, wallet, req)
case constants.RechargeMethodWechat:
return h.createWechatRecharge(c, resolved, config, wallet, req)
return h.createWechatRecharge(c, resolved, wallet, req)
default:
return errors.New(errors.CodePaymentMethodUnavailable)
}
@@ -343,15 +335,13 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
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)
authorization, appID, err := h.loadWechatAuthorization(resolved.SkipPermissionCtx, req.AppType)
if err != nil {
return err
}
openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID)
if err != nil {
return err
@@ -359,9 +349,47 @@ func (h *ClientWalletHandler) createWechatRecharge(
rechargeNo := generateClientRechargeNo()
paymentNo := generateClientPaymentNo()
rechargeOrder := &model.RechargeOrder{
RechargeOrderNo: rechargeNo,
UserID: resolved.CustomerID,
AssetWalletID: wallet.ID,
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
Amount: req.Amount,
Status: model.RechargeOrderStatusPending,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
}
var config *model.WechatConfig
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
route, selectedConfig, err := h.selectMerchantConfig(resolved.SkipPermissionCtx, tx, model.PaymentByWechat, authorization)
if err != nil {
return err
}
config = selectedConfig
merchantpayment.FreezeRoute(payment, route)
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
// 先初始化生效支付通道并创建预支付订单,确认支付通道可用
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
attempt, startedAt, err := h.startRechargePaymentAttempt(resolved.SkipPermissionCtx, config, paymentNo, rechargeNo, req.Amount)
if err != nil {
return err
@@ -380,49 +408,15 @@ func (h *ClientWalletHandler) createWechatRecharge(
if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "充值支付预下单结果未知"); completeErr != nil {
return completeErr
}
if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil {
return updateErr
}
return err
}
if err := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); 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,
}
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.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
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
return response.Success(c, &dto.ClientRechargeResponse{
Recharge: dto.ClientRechargeResult{
RechargeID: rechargeOrder.ID,
@@ -439,18 +433,11 @@ func (h *ClientWalletHandler) createWechatRecharge(
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,
@@ -459,24 +446,33 @@ func (h *ClientWalletHandler) createAlipayRecharge(
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,
PaymentNo: paymentNo,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByAlipay,
Amount: req.Amount,
Status: model.PaymentRecordStatusPending,
}
// WAP URL 是本地签名,不需要先调第三方,在事务内创建充值单和支付单
var config *model.WechatConfig
var expireAt time.Time
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
route, selectedConfig, err := h.selectMerchantConfig(resolved.SkipPermissionCtx, tx, model.PaymentByAlipay, nil)
if err != nil {
return err
}
expireMinutes := selectedConfig.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt = time.Now().Add(time.Duration(expireMinutes) * time.Minute)
config = selectedConfig
payment.ExpireAt = &expireAt
merchantpayment.FreezeRoute(payment, route)
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
@@ -505,7 +501,6 @@ func (h *ClientWalletHandler) createAlipayRecharge(
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)
@@ -738,7 +733,37 @@ func parseOptionalTime(value string) (*time.Time, error) {
return nil, fmt.Errorf("invalid time format")
}
func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) {
func (h *ClientWalletHandler) loadWechatAuthorization(ctx context.Context, appType string) (*model.WechatAuthorization, string, error) {
if h.merchantRuntime == nil {
return nil, "", errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
authorization, err := h.merchantRuntime.LoadAuthorization(ctx)
if err != nil {
return nil, "", err
}
appID, err := pickAppIDByType(authorization, appType)
if err != nil {
return nil, "", err
}
return authorization, appID, nil
}
func (h *ClientWalletHandler) selectMerchantConfig(ctx context.Context, tx *gorm.DB, paymentMethod string, authorization *model.WechatAuthorization) (*merchantpayment.RouteSelection, *model.WechatConfig, error) {
if h.merchantRuntime == nil {
return nil, nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
route, err := h.merchantRuntime.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, time.Now())
if err != nil {
return nil, nil, err
}
config, err := merchantpayment.MerchantConfig(route.Merchant, authorization)
if err != nil {
return nil, nil, err
}
return route, config, nil
}
func pickAppIDByType(config *model.WechatAuthorization, appType string) (string, error) {
switch appType {
case "official_account":
if strings.TrimSpace(config.OaAppID) == "" {

View File

@@ -54,7 +54,7 @@ func (h *ClientWechatHandler) GetJSSDKConfig(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam)
}
wechatConfig, err := h.wechatConfigService.GetActiveConfig(c.UserContext())
wechatConfig, err := h.wechatConfigService.GetAuthorizationConfig(c.UserContext())
if err != nil {
return err
}
@@ -82,7 +82,7 @@ func (h *ClientWechatHandler) GetJSSDKConfig(c *fiber.Ctx) error {
// GetAppID 获取当前生效的公众号 AppID
// GET /api/c/v1/wechat/appid
func (h *ClientWechatHandler) GetAppID(c *fiber.Ctx) error {
wechatConfig, err := h.wechatConfigService.GetActiveConfig(c.UserContext())
wechatConfig, err := h.wechatConfigService.GetAuthorizationConfig(c.UserContext())
if err != nil {
return err
}

View File

@@ -38,6 +38,7 @@ type AgentRechargeServiceInterface interface {
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error)
GetPaymentServiceForCallback(config *model.WechatConfig) (wechat.PaymentServiceInterface, error)
}
type PaymentHandler struct {
@@ -98,7 +99,7 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
body := c.Body()
ctx := c.UserContext()
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
// 预解析订单号(不验签),用于按支付单冻结商户或历史 payment_config_id 双读加载配置
orderNo, err := wechat.PeekOrderNo(body)
if err != nil {
h.logger.Error("微信回调:预解析订单号失败", zap.Error(err))
@@ -144,12 +145,13 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
}
case model.ProviderTypeWechat:
if h.wechatPayment == nil {
return errors.New(errors.CodeWechatCallbackInvalid, "微信 v3 支付未初始化")
paymentSvc, err := h.wechatConfigService.GetPaymentServiceForCallback(cfg)
if err != nil {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "构建微信 v3 回调验签实例失败")
}
var httpReq http.Request
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
_, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
_, err = paymentSvc.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
if result.TradeState != "SUCCESS" {
return h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
@@ -421,7 +423,7 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeInvalidParam, "订单号不能为空")
}
// 按 payment_config_id 加载创建支付单时所用的配置(支持已停用配置)
// 按支付单冻结商户或历史 payment_config_id 双读加载配置(支持已停用配置)
cfg, err := h.wechatConfigService.GetConfigForCallback(ctx, outTradeNo)
if err != nil || cfg == nil {
h.logger.Error("支付宝回调:加载支付配置失败",
@@ -494,13 +496,14 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeWechatCallbackInvalid, "支付通道校验失败")
}
// 校验 payment_config_id 与当前配置一致;旧数据为空时兼容并记录 warn
if payment.PaymentConfigID == nil {
// 商户支付单已由 GetConfigForCallback 按 merchant_id 绑定当前商户凭证;
// payment_config_id 只属于历史双读路径,不能用于否定新商户回调。
if payment.MerchantID == nil && payment.PaymentConfigID == nil {
h.logger.Warn("支付宝回调payment_config_id 为空,跳过配置 ID 校验(旧数据兼容)",
zap.String("out_trade_no", outTradeNo),
zap.Uint("config_id", cfg.ID),
)
} else if *payment.PaymentConfigID != cfg.ID {
} else if payment.MerchantID == nil && *payment.PaymentConfigID != cfg.ID {
h.logger.Error("支付宝回调payment_config_id 不匹配",
zap.String("out_trade_no", outTradeNo),
zap.Uint("payment_config_id", *payment.PaymentConfigID),
@@ -567,7 +570,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
ctx := c.UserContext()
c.Set("Content-Type", "text/plain; charset=utf-8")
// 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置
// 预解析订单号(不验签),用于按支付单冻结商户或历史 payment_config_id 双读加载配置
preNotify, err := fuiou.ParseNotify(body)
if err != nil {
h.logger.Error("富友回调:预解析失败",

View File

@@ -0,0 +1,124 @@
package dto
import (
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// PaymentMerchantRequest 是受控商户管理写入请求。
type PaymentMerchantRequest struct {
Name string `json:"name" validate:"required,min=1,max=100" description:"商户名称"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
ProviderType string `json:"provider_type" validate:"required,max=30" description:"服务商类型:微信仅支持 wechat、wechat_v2、fuiou支付宝为 alipay"`
MerchantIdentity string `json:"merchant_identity" validate:"required,max=100" description:"商户号或应用标识"`
Credentials model.JSONB `json:"credentials" description:"商户受控凭证,仅专用管理接口传输"`
Enabled bool `json:"enabled" description:"是否启用"`
Remark string `json:"remark" validate:"max=1000" description:"备注"`
}
// PaymentMerchantUpdateRequest 是受控商户管理更新请求。
type PaymentMerchantUpdateRequest struct {
Name *string `json:"name" description:"商户名称"`
PaymentMethod *string `json:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
ProviderType *string `json:"provider_type" description:"服务商类型:微信仅支持 wechat、wechat_v2、fuiou支付宝为 alipay"`
MerchantIdentity *string `json:"merchant_identity" description:"商户号或应用标识"`
Credentials *model.JSONB `json:"credentials" description:"商户受控凭证"`
Enabled *bool `json:"enabled" description:"是否启用"`
Remark *string `json:"remark" description:"备注"`
}
// PaymentMerchantDeleteRequest 是受控商户删除二次确认请求。
type PaymentMerchantDeleteRequest struct {
Confirm bool `json:"confirm" validate:"required" description:"确认删除必须为true"`
}
// PaymentMerchantListRequest 是支付商户分页查询条件。
type PaymentMerchantListRequest struct {
Page int `query:"page" description:"页码"`
PageSize int `query:"page_size" description:"每页数量"`
PaymentMethod *string `query:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
Enabled *bool `query:"enabled" description:"是否启用"`
}
// PaymentMerchantResponse 是受控商户管理响应。
type PaymentMerchantResponse struct {
ID uint `json:"id" description:"商户ID"`
Name string `json:"name" description:"商户名称"`
PaymentMethod string `json:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
ProviderType string `json:"provider_type" description:"服务商类型"`
MerchantIdentity string `json:"merchant_identity" description:"商户号或应用标识"`
Credentials model.JSONB `json:"credentials" description:"商户受控凭证,仅专用管理接口返回"`
CredentialVersion int64 `json:"credential_version" description:"凭证版本"`
Enabled bool `json:"enabled" description:"是否启用"`
Remark string `json:"remark" description:"备注"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}
// PaymentMerchantPoolRequest 是商户池创建或更新请求。
type PaymentMerchantPoolRequest struct {
Name string `json:"name" validate:"required,min=1,max=100" description:"商户池名称"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
Enabled bool `json:"enabled" description:"是否启用"`
Strategy string `json:"strategy" validate:"required,oneof=amount count time" enum:"amount,count,time" description:"轮询策略amount=金额阈值count=笔数阈值time=按时间段轮换"`
ThresholdAmount *int64 `json:"threshold_amount" description:"金额轮询阈值,单位分;仅 amount 策略"`
ThresholdCount *int64 `json:"threshold_count" description:"笔数轮询阈值;仅 count 策略"`
StatisticCycle *string `json:"statistic_cycle" enum:"round,day,month" description:"金额/笔数统计周期round=每轮day=自然日month=自然月"`
TimePeriodValue *int64 `json:"time_period_value" description:"时间轮询周期数值;仅 time 策略,最小 1"`
TimePeriodUnit *string `json:"time_period_unit" enum:"minute,hour,day" description:"时间轮询单位minute=分钟hour=小时day=天"`
TimePeriodStartedAt *time.Time `json:"time_period_started_at" description:"时间轮询起始时间;仅 time 策略"`
MemberIDs []uint `json:"member_ids" validate:"required,min=1" description:"有序商户ID列表"`
Remark string `json:"remark" description:"备注"`
}
// PaymentMerchantPoolListRequest 是商户池分页查询条件。
type PaymentMerchantPoolListRequest struct {
Page int `query:"page" description:"页码"`
PageSize int `query:"page_size" description:"每页数量"`
}
// PaymentMerchantPoolResponse 是商户池管理响应。
type PaymentMerchantPoolResponse struct {
ID uint `json:"id" description:"商户池ID"`
Name string `json:"name" description:"商户池名称"`
PaymentMethod string `json:"payment_method" enum:"wechat,alipay" description:"支付方式wechat=微信支付alipay=支付宝支付"`
Enabled bool `json:"enabled" description:"是否启用"`
Strategy string `json:"strategy" enum:"amount,count,time" description:"轮询策略amount=金额阈值count=笔数阈值time=按时间段轮换"`
ThresholdAmount *int64 `json:"threshold_amount,omitempty" description:"金额阈值,分"`
ThresholdCount *int64 `json:"threshold_count,omitempty" description:"笔数阈值"`
StatisticCycle *string `json:"statistic_cycle,omitempty" enum:"round,day,month" description:"统计周期round=每轮day=自然日month=自然月"`
TimePeriodValue *int64 `json:"time_period_value,omitempty" description:"时间周期数值"`
TimePeriodUnit *string `json:"time_period_unit,omitempty" enum:"minute,hour,day" description:"时间单位minute=分钟hour=小时day=天"`
TimePeriodStartedAt *time.Time `json:"time_period_started_at,omitempty" description:"时间轮询起始时间"`
RoutingEpoch int64 `json:"routing_epoch" description:"当前路由统计世代"`
MemberIDs []uint `json:"member_ids" description:"有序商户ID列表"`
Remark string `json:"remark" description:"备注"`
}
// WechatAuthorizationRequest 是微信授权配置管理写入请求。
type WechatAuthorizationRequest struct {
OaAppID string `json:"oa_app_id" description:"公众号AppID"`
OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret"`
OaToken string `json:"oa_token" description:"公众号Token"`
OaAesKey string `json:"oa_aes_key" description:"公众号AES密钥"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"公众号OAuth回调地址"`
MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"`
MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret"`
Enabled bool `json:"enabled" description:"是否启用"`
}
// WechatAuthorizationResponse 是微信授权配置管理响应。
type WechatAuthorizationResponse struct {
ID uint `json:"id" description:"授权配置ID"`
OaAppID string `json:"oa_app_id" description:"公众号AppID"`
OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret仅专用管理接口返回"`
OaToken string `json:"oa_token" description:"公众号Token仅专用管理接口返回"`
OaAesKey string `json:"oa_aes_key" description:"公众号AES密钥仅专用管理接口返回"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"公众号OAuth回调地址"`
MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"`
MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret仅专用管理接口返回"`
CredentialVersion int64 `json:"credential_version" description:"凭证版本"`
Enabled bool `json:"enabled" description:"是否启用"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
}

View File

@@ -8,23 +8,31 @@ import (
)
type Payment struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
PaymentNo string `gorm:"column:payment_no;type:varchar(40);uniqueIndex;not null" json:"payment_no"`
OrderID uint `gorm:"column:order_id;not null" json:"order_id"`
OrderType string `gorm:"column:order_type;type:varchar(30);not null" json:"order_type"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"`
MerchantIdentity string `gorm:"column:merchant_identity;type:varchar(100)" json:"-"`
Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"`
Status int `gorm:"column:status;type:smallint;not null;default:0" json:"status"`
ThirdPartyTradeNo string `gorm:"column:third_party_trade_no;type:varchar(100)" json:"third_party_trade_no,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id" json:"payment_config_id,omitempty"`
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500)" json:"payment_voucher_key,omitempty"`
QRContent string `gorm:"column:qr_content;type:text" json:"-"`
PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"`
ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
ID uint `gorm:"column:id;primaryKey" json:"id"`
PaymentNo string `gorm:"column:payment_no;type:varchar(40);uniqueIndex;not null" json:"payment_no"`
OrderID uint `gorm:"column:order_id;not null" json:"order_id"`
OrderType string `gorm:"column:order_type;type:varchar(30);not null" json:"order_type"`
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"`
MerchantIdentity string `gorm:"column:merchant_identity;type:varchar(100)" json:"-"`
MerchantID *uint `gorm:"column:merchant_id" json:"merchant_id,omitempty"`
MerchantPoolID *uint `gorm:"column:merchant_pool_id" json:"merchant_pool_id,omitempty"`
MerchantNameSnapshot string `gorm:"column:merchant_name_snapshot" json:"merchant_name_snapshot,omitempty"`
MerchantPaymentMethodSnapshot string `gorm:"column:merchant_payment_method_snapshot" json:"merchant_payment_method_snapshot,omitempty"`
MerchantProviderTypeSnapshot string `gorm:"column:merchant_provider_type_snapshot" json:"merchant_provider_type_snapshot,omitempty"`
MerchantPoolNameSnapshot string `gorm:"column:merchant_pool_name_snapshot" json:"merchant_pool_name_snapshot,omitempty"`
RoutingStrategySnapshot string `gorm:"column:routing_strategy_snapshot" json:"routing_strategy_snapshot,omitempty"`
RoutingEpoch *int64 `gorm:"column:routing_epoch" json:"routing_epoch,omitempty"`
Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"`
Status int `gorm:"column:status;type:smallint;not null;default:0" json:"status"`
ThirdPartyTradeNo string `gorm:"column:third_party_trade_no;type:varchar(100)" json:"third_party_trade_no,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id" json:"payment_config_id,omitempty"`
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500)" json:"payment_voucher_key,omitempty"`
QRContent string `gorm:"column:qr_content;type:text" json:"-"`
PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"`
ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
}
func (Payment) TableName() string {

View File

@@ -0,0 +1,94 @@
package model
import (
"time"
"gorm.io/gorm"
)
const (
PaymentMerchantStatusDisabled = 0
PaymentMerchantStatusEnabled = 1
PaymentMerchantStrategyAmount = "amount"
PaymentMerchantStrategyCount = "count"
PaymentMerchantStrategyTime = "time"
)
// PaymentMerchant 是实际收款商户,凭证只保存在受控字段。
type PaymentMerchant struct {
gorm.Model
BaseModel `gorm:"embedded"`
Name string `gorm:"column:name" json:"name"`
PaymentMethod string `gorm:"column:payment_method" json:"payment_method"`
ProviderType string `gorm:"column:provider_type" json:"provider_type"`
MerchantIdentity string `gorm:"column:merchant_identity" json:"merchant_identity"`
Credentials JSONB `gorm:"column:credentials;type:jsonb" json:"-"`
CredentialVersion int64 `gorm:"column:credential_version" json:"credential_version"`
Status int `gorm:"column:status" json:"status"`
Remark string `gorm:"column:remark" json:"remark"`
}
func (PaymentMerchant) TableName() string { return "tb_payment_merchant" }
// PaymentMerchantPool 是一种支付方式的商户轮询池。
type PaymentMerchantPool struct {
gorm.Model
BaseModel `gorm:"embedded"`
Name string `gorm:"column:name" json:"name"`
PaymentMethod string `gorm:"column:payment_method" json:"payment_method"`
Status int `gorm:"column:status" json:"status"`
Strategy string `gorm:"column:strategy" json:"strategy"`
ThresholdAmount *int64 `gorm:"column:threshold_amount" json:"threshold_amount,omitempty"`
ThresholdCount *int64 `gorm:"column:threshold_count" json:"threshold_count,omitempty"`
StatisticCycle *string `gorm:"column:statistic_cycle" json:"statistic_cycle,omitempty"`
TimePeriodValue *int64 `gorm:"column:time_period_value" json:"time_period_value,omitempty"`
TimePeriodUnit *string `gorm:"column:time_period_unit" json:"time_period_unit,omitempty"`
TimePeriodStartedAt *time.Time `gorm:"column:time_period_started_at" json:"time_period_started_at,omitempty"`
RoutingEpoch int64 `gorm:"column:routing_epoch" json:"routing_epoch"`
Remark string `gorm:"column:remark" json:"remark"`
}
func (PaymentMerchantPool) TableName() string { return "tb_payment_merchant_pool" }
// PaymentMerchantPoolMember 是商户池内按顺序参与轮询的商户。
type PaymentMerchantPoolMember struct {
gorm.Model
BaseModel `gorm:"embedded"`
PoolID uint `gorm:"column:pool_id" json:"pool_id"`
MerchantID uint `gorm:"column:merchant_id" json:"merchant_id"`
SortOrder int64 `gorm:"column:sort_order" json:"sort_order"`
}
func (PaymentMerchantPoolMember) TableName() string { return "tb_payment_merchant_pool_member" }
// WechatAuthorization 是 C 端微信 OAuth、小程序与支付 AppID 的全局授权配置。
type WechatAuthorization struct {
gorm.Model
BaseModel `gorm:"embedded"`
OaAppID string `gorm:"column:oa_app_id" json:"oa_app_id"`
OaAppSecret string `gorm:"column:oa_app_secret" json:"-"`
OaToken string `gorm:"column:oa_token" json:"-"`
OaAesKey string `gorm:"column:oa_aes_key" json:"-"`
OaOAuthRedirectURL string `gorm:"column:oa_oauth_redirect_url" json:"oa_oauth_redirect_url"`
MiniappAppID string `gorm:"column:miniapp_app_id" json:"miniapp_app_id"`
MiniappAppSecret string `gorm:"column:miniapp_app_secret" json:"-"`
CredentialVersion int64 `gorm:"column:credential_version" json:"credential_version"`
Status int `gorm:"column:status" json:"status"`
}
func (WechatAuthorization) TableName() string { return "tb_wechat_authorization" }
// PaymentMerchantRoutingSuccess 是支付首次成功的唯一统计事实。
type PaymentMerchantRoutingSuccess struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
PaymentID uint `gorm:"column:payment_id" json:"payment_id"`
MerchantID uint `gorm:"column:merchant_id" json:"merchant_id"`
PoolID uint `gorm:"column:pool_id" json:"pool_id"`
RoutingEpoch int64 `gorm:"column:routing_epoch" json:"routing_epoch"`
Amount int64 `gorm:"column:amount" json:"amount"`
PaidAt time.Time `gorm:"column:paid_at" json:"paid_at"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (PaymentMerchantRoutingSuccess) TableName() string { return "tb_payment_merchant_routing_success" }

View File

@@ -126,6 +126,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.WechatConfig != nil {
registerWechatConfigRoutes(authGroup, handlers.WechatConfig, doc, basePath)
}
if handlers.PaymentMerchant != nil {
registerPaymentMerchantRoutes(authGroup, handlers.PaymentMerchant, doc, basePath)
}
if handlers.AgentRecharge != nil {
registerAgentRechargeRoutes(authGroup, handlers.AgentRecharge, doc, basePath)
}

View File

@@ -0,0 +1,35 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerPaymentMerchantRoutes(router fiber.Router, handler *admin.PaymentMerchantHandler, doc *openapi.Generator, basePath string) {
group := router.Group("", func(c *fiber.Ctx) error {
kind := middleware.GetUserTypeFromContext(c.UserContext())
if kind != constants.UserTypeSuperAdmin && kind != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限访问支付商户配置")
}
return c.Next()
})
Register(group, doc, basePath, "GET", "/payment-merchants", handler.ListMerchants, RouteSpec{Summary: "查询支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.PaymentMerchantListRequest), Output: new(dto.PaymentMerchantResponse), Auth: true})
Register(group, doc, basePath, "POST", "/payment-merchants", handler.CreateMerchant, RouteSpec{Summary: "创建支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.PaymentMerchantRequest), Output: new(dto.PaymentMerchantResponse), Auth: true})
Register(group, doc, basePath, "GET", "/payment-merchants/:id", handler.GetMerchant, RouteSpec{Summary: "查询支付商户详情", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantResponse), Auth: true})
Register(group, doc, basePath, "PUT", "/payment-merchants/:id", handler.UpdateMerchant, RouteSpec{Summary: "更新支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantUpdateRequest), Output: new(dto.PaymentMerchantResponse), Auth: true})
Register(group, doc, basePath, "DELETE", "/payment-merchants/:id", handler.DeleteMerchant, RouteSpec{Summary: "删除支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantDeleteRequest), Output: nil, Auth: true})
Register(group, doc, basePath, "GET", "/payment-merchant-pools", handler.ListPools, RouteSpec{Summary: "查询商户池", Description: "仅超级管理员和平台用户可访问。支持分页,返回当前页商户池及其有序成员。", Tags: []string{"商户池管理"}, Input: new(dto.PaymentMerchantPoolListRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "GET", "/payment-merchant-pools/:id", handler.GetPool, RouteSpec{Summary: "查询商户池详情", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "POST", "/payment-merchant-pools", handler.CreatePool, RouteSpec{Summary: "创建商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.PaymentMerchantPoolRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "PUT", "/payment-merchant-pools/:id", handler.UpdatePool, RouteSpec{Summary: "更新商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantPoolRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "POST", "/payment-merchant-pools/:id/enable", handler.EnablePool, RouteSpec{Summary: "启用商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "POST", "/payment-merchant-pools/:id/disable", handler.DisablePool, RouteSpec{Summary: "停用商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true})
Register(group, doc, basePath, "GET", "/wechat-authorizations", handler.GetAuthorization, RouteSpec{Summary: "获取微信授权配置", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"微信授权配置"}, Output: new(dto.WechatAuthorizationResponse), Auth: true})
Register(group, doc, basePath, "PUT", "/wechat-authorizations/current", handler.SaveAuthorization, RouteSpec{Summary: "保存微信授权配置", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"微信授权配置"}, Input: new(dto.WechatAuthorizationRequest), Output: new(dto.WechatAuthorizationResponse), Auth: true})
}

View File

@@ -137,10 +137,12 @@ func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest,
ExpiresIn: assetTokenExpireSeconds,
}
if wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx); err == nil && wechatConfig != nil {
resp.OaAppID = wechatConfig.OaAppID
resp.MiniappAppID = wechatConfig.MiniappAppID
wechatAuthorization, err := s.wechatConfigService.GetAuthorizationConfig(ctx)
if err != nil {
return nil, err
}
resp.OaAppID = wechatAuthorization.OaAppID
resp.MiniappAppID = wechatAuthorization.MiniappAppID
return resp, nil
}
@@ -156,13 +158,10 @@ func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest,
return nil, err
}
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx)
if err != nil {
return nil, err
}
if wechatConfig == nil {
return nil, errors.New(errors.CodeWechatConfigUnavailable)
}
oaApp, err := wechat.NewOfficialAccountAppFromConfig(wechatConfig, s.wechatCache, s.logger)
if err != nil {
@@ -219,13 +218,10 @@ func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest
return nil, err
}
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx)
if err != nil {
return nil, err
}
if wechatConfig == nil {
return nil, errors.New(errors.CodeWechatConfigUnavailable)
}
miniService, err := wechat.NewMiniAppServiceFromConfig(wechatConfig, s.logger)
if err != nil {

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -33,9 +34,10 @@ const (
clientPurchaseLockTTL = 10 * time.Second
)
// WechatConfigServiceInterface 微信配置服务接口
// WechatConfigServiceInterface 只读取历史支付单的综合支付配置
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
GetAuthorizationConfig(ctx context.Context) (*model.WechatConfig, error)
GetByIDUnscoped(ctx context.Context, id uint) (*model.WechatConfig, error)
}
// OrderWalletPayServiceInterface 订单钱包支付服务接口。
@@ -70,7 +72,6 @@ type Service struct {
openIDStore *postgres.PersonalCustomerOpenIDStore
personalCustomerStore *postgres.PersonalCustomerStore
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore
wechatConfigService WechatConfigServiceInterface
orderPaymentService OrderWalletPayServiceInterface
packageSeriesStore *postgres.PackageSeriesStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
@@ -82,6 +83,8 @@ type Service struct {
paymentMethodPolicy PaymentMethodPolicy
auditWriter *audit.Writer
paymentIntegration *integrationlog.Repository
merchantRuntime *merchantpayment.RuntimeLoader
wechatConfigService WechatConfigServiceInterface
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
@@ -95,6 +98,11 @@ func (s *Service) SetPaymentAudit(writer *audit.Writer, integration *integration
s.paymentIntegration = integration
}
// SetLegacyPaymentConfigService 注入仅供 merchant_id 为空历史支付单使用的旧配置读取。
func (s *Service) SetLegacyPaymentConfigService(wechatConfigService WechatConfigServiceInterface) {
s.wechatConfigService = wechatConfigService
}
// New 创建客户端订单服务。
func New(
assetService *asset.Service,
@@ -107,7 +115,6 @@ func New(
openIDStore *postgres.PersonalCustomerOpenIDStore,
personalCustomerStore *postgres.PersonalCustomerStore,
personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore,
wechatConfigService WechatConfigServiceInterface,
orderPaymentService OrderWalletPayServiceInterface,
packageSeriesStore *postgres.PackageSeriesStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
@@ -128,7 +135,6 @@ func New(
openIDStore: openIDStore,
personalCustomerStore: personalCustomerStore,
personalCustomerPhoneStore: personalCustomerPhoneStore,
wechatConfigService: wechatConfigService,
orderPaymentService: orderPaymentService,
packageSeriesStore: packageSeriesStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
@@ -137,6 +143,7 @@ func New(
db: db,
redis: redisClient,
logger: logger,
merchantRuntime: merchantpayment.NewRuntimeLoader(db, redisClient),
}
}
@@ -301,18 +308,10 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
// }
if req.PaymentMethod == model.PaymentMethodAlipay {
// 支付宝强充:不需要 app_type / OpenID
activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败")
}
if activeConfig == nil {
return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置")
}
return s.createAlipayForceRechargeOrder(skipCtx, customerID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, &created)
return s.createAlipayForceRechargeOrder(skipCtx, customerID, assetInfo, validationResult, forceRecharge, redisKey, &created)
}
// 微信强充app_type 必须传入
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
authorization, appID, err := s.loadWechatAuthorization(skipCtx, req.AppType)
if err != nil {
return nil, err
}
@@ -320,11 +319,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
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.createForceRechargeOrder(skipCtx, customerID, openID, assetInfo, validationResult, authorization, appID, req.AppType, forceRecharge, redisKey, &created)
}
return s.createPackageOrder(skipCtx, customerID, validationResult, req.PaymentMethod, redisKey, &created)
@@ -362,31 +357,49 @@ func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetReso
}
}
func (s *Service) resolveWechatConfig(ctx context.Context, appType string) (*model.WechatConfig, string, error) {
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
func (s *Service) loadWechatAuthorization(ctx context.Context, appType string) (*model.WechatAuthorization, string, error) {
if s.merchantRuntime == nil {
return nil, "", errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
authorization, err := s.merchantRuntime.LoadAuthorization(ctx)
if err != nil {
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信配置失败")
return nil, "", err
}
if activeConfig == nil {
return nil, "", errors.New(errors.CodeWechatPayFailed, "未找到生效的微信支付配置")
}
switch appType {
case "official_account":
if activeConfig.OaAppID == "" {
return nil, "", errors.New(errors.CodeWechatPayFailed, "公众号支付配置不完整")
if strings.TrimSpace(authorization.OaAppID) == "" {
return nil, "", errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置")
}
return activeConfig, activeConfig.OaAppID, nil
return authorization, authorization.OaAppID, nil
case "miniapp":
if activeConfig.MiniappAppID == "" {
return nil, "", errors.New(errors.CodeWechatPayFailed, "小程序支付配置不完整")
if strings.TrimSpace(authorization.MiniappAppID) == "" {
return nil, "", errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置")
}
return activeConfig, activeConfig.MiniappAppID, nil
return authorization, authorization.MiniappAppID, nil
default:
return nil, "", errors.New(errors.CodeInvalidParam)
}
}
func (s *Service) selectMerchantConfig(ctx context.Context, tx *gorm.DB, paymentMethod string, authorization *model.WechatAuthorization) (*merchantpayment.RouteSelection, *model.WechatConfig, error) {
if s.merchantRuntime == nil {
return nil, nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
route, err := s.merchantRuntime.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, time.Now())
if err != nil {
return nil, nil, err
}
config, err := merchantpayment.MerchantConfig(route.Merchant, authorization)
if err != nil {
return nil, nil, err
}
return route, config, nil
}
func freezePaymentRoute(payment *model.Payment, route *merchantpayment.RouteSelection) {
merchantpayment.FreezeRoute(payment, route)
}
func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, appID string) (string, error) {
records, err := s.openIDStore.ListByCustomerID(ctx, customerID)
if err != nil {
@@ -405,6 +418,41 @@ func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, ap
return "", errors.New(errors.CodeNotFound, "未找到当前应用的微信授权信息")
}
// merchantConfigForPayment 按冻结商户或历史 payment_config_id 加载支付凭证。
// merchant_id 为空仅是历史支付保留期兼容:独立 Change 清理后删除此读取,
// 绝不按当前商户池或综合配置推断、回填该支付的商户。
func (s *Service) merchantConfigForPayment(ctx context.Context, payment *model.Payment) (*model.WechatConfig, error) {
if payment != nil && payment.MerchantID != nil {
if s.merchantRuntime == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置")
}
merchant, err := s.merchantRuntime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return nil, err
}
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
var authorization *model.WechatAuthorization
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, err = s.merchantRuntime.LoadAuthorization(ctx)
if err != nil {
return nil, err
}
}
return merchantpayment.MerchantConfig(merchant, authorization)
}
if payment == nil || payment.PaymentConfigID == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付宝支付单缺少支付配置")
}
if s.wechatConfigService == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "旧支付配置加载能力未配置")
}
config, err := s.wechatConfigService.GetByIDUnscoped(ctx, *payment.PaymentConfigID)
if err != nil {
return nil, errors.Wrap(errors.CodeNoPaymentConfig, err, "历史支付宝支付配置不可用")
}
return config, nil
}
func (s *Service) createPackageOrder(
ctx context.Context,
customerID uint,
@@ -465,10 +513,10 @@ func (s *Service) createForceRechargeOrder(
openID string,
assetInfo *dto.AssetResolveResponse,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
authorization *model.WechatAuthorization,
appID, appType string,
forceRecharge *ForceRechargeRequirement,
redisKey string,
paymentProvider PaymentProvider,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
resourceType, resourceID, err := resolveWalletResource(validationResult)
@@ -519,16 +567,27 @@ func (s *Service) createForceRechargeOrder(
paymentNo := generateClientPaymentNo()
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: forceRecharge.ForceRechargeAmount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &activeConfig.ID,
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByWechat,
Amount: forceRecharge.ForceRechargeAmount,
Status: model.PaymentRecordStatusPending,
}
var activeConfig *model.WechatConfig
var paymentProvider PaymentProvider
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, config, err := s.selectMerchantConfig(ctx, tx, model.PaymentByWechat, authorization)
if err != nil {
return err
}
paymentProvider, err = s.newPaymentProvider(config, appID, appType)
if err != nil {
return err
}
activeConfig = config
freezePaymentRoute(payment, route)
if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
@@ -967,26 +1026,22 @@ func (s *Service) getOrBuildAlipayPaymentLink(
amount int64,
subject string,
) (*dto.ClientPaymentLink, 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.CodeInvalidParam, "未找到生效的支付配置")
}
// 查找最新的 alipay 待支付单
existing, _ := s.paymentStore.FindLatestPendingByOrderAndMethod(
ctx, orderID, orderType, model.PaymentByAlipay,
)
var payment *model.Payment
var activeConfig *model.WechatConfig
created := false
now := time.Now()
if existing != nil && existing.ExpireAt != nil && existing.ExpireAt.After(now) {
// 复用未过期的支付单
payment = existing
var err error
activeConfig, err = s.merchantConfigForPayment(ctx, payment)
if err != nil {
return nil, err
}
} else {
// 过期或不存在,标记旧单 failed 并新建
if existing != nil {
if updateErr := s.markPaymentFailed(ctx, existing, "支付宝支付记录过期关闭"); updateErr != nil {
s.logger.Warn("标记过期支付宝支付单 failed 失败",
@@ -995,44 +1050,51 @@ func (s *Service) getOrBuildAlipayPaymentLink(
)
}
}
expireMinutes := activeConfig.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
newPayment := &model.Payment{
PaymentNo: generateClientPaymentNo(),
OrderID: orderID,
OrderType: orderType,
PaymentMethod: model.PaymentByAlipay,
Amount: amount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &activeConfig.ID,
ExpireAt: &expireAt,
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, config, err := s.selectMerchantConfig(ctx, tx, model.PaymentByAlipay, nil)
if err != nil {
return err
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute)
newPayment := &model.Payment{
PaymentNo: generateClientPaymentNo(),
OrderID: orderID,
OrderType: orderType,
PaymentMethod: model.PaymentByAlipay,
Amount: amount,
Status: model.PaymentRecordStatusPending,
ExpireAt: &expireAt,
}
freezePaymentRoute(newPayment, route)
if err := s.paymentStore.CreateWithTx(ctx, tx, newPayment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败")
}
return s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil)
if err := s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil); err != nil {
return err
}
payment = newPayment
activeConfig = config
return nil
}); err != nil {
return nil, err
}
payment = newPayment
created = true
s.logger.Info("创建支付宝支付单",
zap.String("payment_no", payment.PaymentNo),
zap.String("order_type", orderType),
zap.Uint("order_id", orderID),
zap.Int64("amount", amount),
zap.Time("expire_at", expireAt),
zap.Uint("config_id", activeConfig.ID),
zap.Time("expire_at", *payment.ExpireAt),
)
}
wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, subject)
if err != nil {
// 新建的 payment 生成链接失败,标记 failed
if existing == nil || payment.ID != existing.ID {
if created {
_ = s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录")
}
return nil, err
@@ -1056,7 +1118,6 @@ func (s *Service) createAlipayForceRechargeOrder(
customerID uint,
assetInfo *dto.AssetResolveResponse,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
forceRecharge *ForceRechargeRequirement,
redisKey string,
created *bool,
@@ -1105,27 +1166,31 @@ func (s *Service) createAlipayForceRechargeOrder(
LinkedCarrierType: assetInfo.AssetType,
LinkedCarrierID: &resourceID,
AutoPurchaseStatus: model.AutoPurchaseStatusPending,
PaymentConfigID: &activeConfig.ID,
}
expireMinutesForce := activeConfig.AliPayExpireMinutes
if expireMinutesForce <= 0 {
expireMinutesForce = 30
}
expireAt := time.Now().Add(time.Duration(expireMinutesForce) * time.Minute)
paymentNo := generateClientPaymentNo()
payment := &model.Payment{
PaymentNo: paymentNo,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByAlipay,
Amount: forceRecharge.ForceRechargeAmount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &activeConfig.ID,
ExpireAt: &expireAt,
PaymentNo: paymentNo,
OrderType: model.PaymentOrderTypeRecharge,
PaymentMethod: model.PaymentByAlipay,
Amount: forceRecharge.ForceRechargeAmount,
Status: model.PaymentRecordStatusPending,
}
// 事务创建充值单 + 支付单WAP URL 本地签名,不需要先调第三方)
var activeConfig *model.WechatConfig
var expireAt time.Time
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, config, err := s.selectMerchantConfig(ctx, tx, model.PaymentByAlipay, nil)
if err != nil {
return err
}
expireMinutes := config.AliPayExpireMinutes
if expireMinutes <= 0 {
expireMinutes = 30
}
expireAt = time.Now().Add(time.Duration(expireMinutes) * time.Minute)
activeConfig = config
payment.ExpireAt = &expireAt
freezePaymentRoute(payment, route)
if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
@@ -1154,7 +1219,6 @@ func (s *Service) createAlipayForceRechargeOrder(
zap.String("recharge_no", rechargeOrderNo),
zap.Int64("amount", forceRecharge.ForceRechargeAmount),
zap.Time("expire_at", expireAt),
zap.Uint("config_id", activeConfig.ID),
)
s.markClientPurchaseCreated(ctx, redisKey, rechargeOrderNo)
@@ -1432,7 +1496,7 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
return &dto.ClientPayOrderResponse{PaymentMethod: paymentMethod}, nil
case model.PaymentMethodWechat:
activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType)
authorization, appID, err := s.loadWechatAuthorization(skipCtx, req.AppType)
if err != nil {
return nil, err
}
@@ -1440,29 +1504,29 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
if err != nil {
return nil, err
}
paymentProvider, err := s.newPaymentProvider(activeConfig, appID, req.AppType)
if err != nil {
return nil, err
}
paymentNo := generateClientPaymentNo()
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: model.PaymentByWechat,
Amount: order.TotalAmount,
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &activeConfig.ID,
PaymentNo: paymentNo,
OrderID: order.ID,
OrderType: model.PaymentOrderTypePackage,
PaymentMethod: model.PaymentByWechat,
Amount: order.TotalAmount,
Status: model.PaymentRecordStatusPending,
}
var activeConfig *model.WechatConfig
var paymentProvider PaymentProvider
if err := s.db.WithContext(skipCtx).Transaction(func(tx *gorm.DB) error {
// 订单仍保留最近一次支付配置,兼容旧 ORD 回调。
if err := tx.Model(&model.Order{}).
Where("id = ?", orderID).
Update("payment_config_id", activeConfig.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新订单支付配置失败")
route, config, err := s.selectMerchantConfig(skipCtx, tx, model.PaymentByWechat, authorization)
if err != nil {
return err
}
paymentProvider, err = s.newPaymentProvider(config, appID, req.AppType)
if err != nil {
return err
}
activeConfig = config
freezePaymentRoute(payment, route)
if err := s.paymentStore.CreateWithTx(skipCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
@@ -1498,30 +1562,12 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
}, nil
case model.PaymentMethodAlipay:
// 支付宝支付:不需要 app_type / OpenID
activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败")
}
if activeConfig == nil {
return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置")
}
paymentLink, err := s.getOrBuildAlipayPaymentLink(
skipCtx, orderID, model.PaymentOrderTypePackage, order.TotalAmount, "套餐购买",
)
if err != nil {
return nil, err
}
// 更新订单支付配置 ID兼容回调配置回溯
if dbErr := s.db.WithContext(skipCtx).Model(&model.Order{}).
Where("id = ?", orderID).
Update("payment_config_id", activeConfig.ID).Error; dbErr != nil {
s.logger.Warn("更新订单支付配置 ID 失败",
zap.Uint("order_id", orderID),
zap.Error(dbErr),
)
}
return &dto.ClientPayOrderResponse{
PaymentMethod: paymentMethod,
PaymentLink: paymentLink,

View File

@@ -11,6 +11,7 @@ import (
"time"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
@@ -2018,8 +2019,10 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str
if thirdPartyTradeNo != "" {
paymentUpdates["third_party_trade_no"] = thirdPartyTradeNo
}
// 迟到首次成功必须允许 pending→paid 与 failed→paid支付创建时冻结的
// merchant_id/routing_epoch 仍决定统计归属,失败状态不表示已计入或已冲销。
paymentResult := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}).
Updates(paymentUpdates)
if paymentResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, paymentResult.Error, "更新支付记录失败")
@@ -2035,6 +2038,10 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str
return errors.New(errors.CodeInvalidStatus, "支付记录状态不允许处理")
}
if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, now); err != nil {
return err
}
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending).
Updates(map[string]any{

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"time"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -86,7 +87,8 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p
return nil
}
if payment.Status != model.PaymentRecordStatusPending {
// 迟到首次成功必须允许 pending 与 failed 状态进入确认;冻结路由决定统计归属。
if payment.Status != model.PaymentRecordStatusPending && payment.Status != model.PaymentRecordStatusFailed {
return errors.New(errors.CodeInvalidStatus, "支付状态不允许处理")
}
@@ -102,7 +104,11 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
oldPaymentStatus := model.PaymentRecordStatusPending
// 乐观锁允许 pending 或 failed 首次进入 paid重复成功由 RowsAffected/唯一事实保护。
oldPaymentStatus := payment.Status
if oldPaymentStatus != model.PaymentRecordStatusPending && oldPaymentStatus != model.PaymentRecordStatusFailed {
return errors.New(errors.CodeInvalidStatus, "支付状态不允许处理")
}
if err := s.paymentStore.UpdateStatusWithOptimisticLockDB(ctx, tx, payment.ID, &oldPaymentStatus, model.PaymentRecordStatusPaid, &now); err != nil {
if err == gorm.ErrRecordNotFound {
return nil
@@ -117,6 +123,10 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p
return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败")
}
if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, now); err != nil {
return err
}
oldRechargeStatus := model.RechargeOrderStatusPending
if err := s.rechargeOrderStore.UpdateStatusWithOptimisticLockDB(ctx, tx, rechargeOrder.ID, &oldRechargeStatus, model.RechargeOrderStatusPaid, &now); err != nil {
if err == gorm.ErrRecordNotFound {

View File

@@ -62,6 +62,9 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T
if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, &order); err != nil {
return err
}
if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil {
return err
}
if refund.Status == model.RefundStatusPending {
changed = true
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).

View File

@@ -0,0 +1,99 @@
package refund
import (
"context"
"strings"
"gorm.io/gorm"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// SetPaymentMerchantRuntime 注入冻结商户的当前凭证版本加载器。
func (s *Service) SetPaymentMerchantRuntime(runtime *merchantpayment.RuntimeLoader) {
s.paymentMerchantRuntime = runtime
}
// preparePaymentRefundCredentials 只为既有套餐订单退款流程装载和校验支付凭证;本 Change
// 不发起任何渠道退款请求。钱包支付和线下支付没有收款商户凭证,直接放行。
// merchant_id 为空仅是留存期内的历史线上支付,独立 Change 清理后才可删除按
// payment_config_id 读取的兼容路径,绝不能按当前商户池推断商户。
func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.DB, orderID uint) error {
var payment model.Payment
err := tx.WithContext(ctx).
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
Order("id DESC").
First(&payment).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联支付单失败")
}
// 钱包支付和线下支付没有收款商户凭证,不被本检查阻断。
if payment.PaymentMethod == model.PaymentByWallet || payment.PaymentMethod == model.PaymentByOffline {
return nil
}
if payment.MerchantID != nil {
if s.paymentMerchantRuntime == nil {
return errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置")
}
merchant, loadErr := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID)
if loadErr != nil {
return loadErr
}
return validateFrozenMerchantRefundCredentials(merchant)
}
if payment.PaymentConfigID == nil {
return errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
}
var legacy model.WechatConfig
if err := tx.WithContext(ctx).Unscoped().First(&legacy, *payment.PaymentConfigID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
}
return errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
}
return nil
}
// validateFrozenMerchantRefundCredentials 只判断既有渠道流程所需凭证是否完整。
// 当前系统没有任何渠道原路退款 Adapter因此返回前不调用微信、支付宝或富友。
func validateFrozenMerchantRefundCredentials(merchant *model.PaymentMerchant) error {
config, err := merchantpayment.MerchantConfig(merchant, nil)
if err != nil {
return err
}
switch config.ProviderType {
case model.ProviderTypeWechat:
if blank(config.WxMchID, config.WxAPIV3Key, config.WxCertContent, config.WxKeyContent, config.WxSerialNo, config.WxNotifyURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整")
}
case model.ProviderTypeWechatV2:
if blank(config.WxMchID, config.WxAPIV2Key, config.WxNotifyURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整")
}
case model.ProviderTypeFuiou:
if blank(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey, config.FyPublicKey, config.FyAPIURL, config.FyNotifyURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结富友商户退款凭证不完整")
}
case "alipay":
if blank(config.AliAppID, config.AliPrivateKey, config.AliPublicKey, config.AliNotifyURL, config.AliReturnURL) {
return errors.New(errors.CodeNoPaymentConfig, "冻结支付宝商户退款凭证不完整")
}
default:
return errors.New(errors.CodeNoPaymentConfig, "冻结商户不支持现有退款流程")
}
return nil
}
func blank(values ...string) bool {
for _, value := range values {
if strings.TrimSpace(value) == "" {
return true
}
}
return false
}

View File

@@ -15,6 +15,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
@@ -56,6 +57,7 @@ type Service struct {
notificationOutbox *outbox.Repository
auditWriter *audit.Writer
logger *zap.Logger
paymentMerchantRuntime *merchantpayment.RuntimeLoader
}
// New 创建退款业务服务实例
@@ -355,6 +357,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe
if orderResult.RowsAffected == 0 {
return errors.New(errors.CodeInvalidStatus, "订单状态已变更,无法执行退款审批")
}
if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil {
return err
}
if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil {
return err

View File

@@ -13,6 +13,7 @@ import (
"go.uber.org/zap"
"gorm.io/gorm"
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -22,6 +23,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
)
// Redis 缓存键
@@ -544,6 +546,28 @@ func (s *Service) GetActiveConfig(ctx context.Context) (*model.WechatConfig, err
return config, nil
}
// GetAuthorizationConfig 读取唯一启用且按凭证版本缓存的 C 端微信授权配置。
// 仅用于兼容现有微信 SDK 的配置结构,绝不回退到 tb_wechat_config。
func (s *Service) GetAuthorizationConfig(ctx context.Context) (*model.WechatConfig, error) {
if s == nil || s.store == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "微信授权加载能力未配置")
}
authorization, err := merchantpayment.NewRuntimeLoader(s.store.DB(), s.redis).LoadAuthorization(ctx)
if err != nil {
return nil, err
}
return &model.WechatConfig{
IsActive: true,
OaAppID: authorization.OaAppID,
OaAppSecret: authorization.OaAppSecret,
OaToken: authorization.OaToken,
OaAesKey: authorization.OaAesKey,
OaOAuthRedirectURL: authorization.OaOAuthRedirectURL,
MiniappAppID: authorization.MiniappAppID,
MiniappAppSecret: authorization.MiniappAppSecret,
}, nil
}
// GetActiveConfigForAPI 获取当前生效的支付配置API 响应,已脱敏)
func (s *Service) GetActiveConfigForAPI(ctx context.Context) (*dto.WechatConfigResponse, error) {
config, err := s.GetActiveConfig(ctx)
@@ -594,30 +618,74 @@ func (s *Service) mergeSensitiveField(target *string, newVal *string) {
}
}
// GetConfigForCallback 按订单号查找创建该订单时所用的支付配置(含已软删除/停用的记录)
// 回调验签必须使用创建订单时的密钥,否则签名不匹配。
// 若找不到 payment_config_id旧订单或数据缺失回退到当前激活配置。
// GetByIDUnscoped loads a historical comprehensive payment configuration, including soft-deleted rows.
func (s *Service) GetByIDUnscoped(ctx context.Context, id uint) (*model.WechatConfig, error) {
if s == nil || s.store == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "旧支付配置加载能力未配置")
}
return s.store.GetByIDUnscoped(ctx, id)
}
// GetConfigForCallback 按支付单冻结的商户或旧支付配置加载回调凭证。
// merchant_id 为空仅表示数据留存期内的历史支付:本 Change 只允许按其
// payment_config_id 读取,独立 Change 完成留存清理后才可删除这条旧路径。
// 禁止按当前启用池、当前综合配置推断或回填历史支付的实际商户。
func (s *Service) GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error) {
if s.paymentStore != nil {
payment, err := s.paymentStore.GetByPaymentNo(ctx, orderNo)
if err == nil && payment.MerchantID != nil {
loader := merchantpayment.NewRuntimeLoader(s.store.DB(), s.redis)
merchant, loadErr := loader.LoadMerchant(ctx, *payment.MerchantID)
if loadErr != nil {
return nil, loadErr
}
// 仅微信直连v3/v2商户需要全局授权配置中的 AppID富友商户不依赖该配置。
// 授权字段只注入内存中的渠道配置,绝不写入支付快照、日志或审计。
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
authorization, authErr := loader.LoadAuthorization(ctx)
if authErr != nil {
return nil, authErr
}
return merchantpayment.MerchantConfig(merchant, authorization)
}
return merchantpayment.MerchantConfig(merchant, nil)
}
if err != nil && err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付单路由失败")
}
if err == nil && payment.PaymentConfigID == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
}
}
configID, err := s.resolvePaymentConfigID(ctx, orderNo)
if err != nil {
s.logger.Warn("回调:查询订单 payment_config_id 失败,回退激活配置",
zap.String("order_no", orderNo),
zap.Error(err),
)
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史支付配置失败")
}
if configID != nil {
cfg, err := s.store.GetByIDUnscoped(ctx, *configID)
if err == nil {
return cfg, nil
}
s.logger.Warn("回调:按 config_id 加载配置失败,回退激活配置",
zap.Uint("config_id", *configID),
zap.Error(err),
)
if configID == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
}
cfg, loadErr := s.store.GetByIDUnscoped(ctx, *configID)
if loadErr != nil {
return nil, errors.Wrap(errors.CodeNoPaymentConfig, loadErr, "历史支付配置不可用")
}
return cfg, nil
}
return s.GetActiveConfig(ctx)
// GetPaymentServiceForCallback 使用已选定的支付配置构造回调验签实例。
// 回调验签只能使用支付单冻结商户的当前凭证,不能复用进程启动时的旧支付实例。
func (s *Service) GetPaymentServiceForCallback(config *model.WechatConfig) (wechat.PaymentServiceInterface, error) {
if s == nil || s.redis == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "微信支付回调加载能力未配置")
}
if config == nil || config.ProviderType != model.ProviderTypeWechat {
return nil, errors.New(errors.CodeWechatConfigUnavailable, "微信支付回调配置不可用")
}
app, err := wechat.NewPaymentAppFromConfig(config, config.OaAppID, wechat.NewRedisCache(s.redis), s.logger)
if err != nil {
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "构建微信支付回调验签实例失败")
}
return wechat.NewPaymentService(app, s.logger), nil
}
// resolvePaymentConfigID 按订单号前缀从对应表中取 payment_config_id