新增收款商户、商户池轮询、微信授权配置独立管理;三类新支付 (C端套餐购买、C端资产钱包充值、代理在线预存款充值)无条件 经商户池选择并冻结路由,无旧综合配置回退。merchant_id 为空 历史支付继续按 payment_config_id 双读。凭证版本化加载与 ID+版本缓存保证轮换一致性。删除商户池新支付创建开关及全部 引用。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
721
internal/application/merchantpayment/management.go
Normal file
721
internal/application/merchantpayment/management.go
Normal 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}
|
||||
}
|
||||
386
internal/application/merchantpayment/routing.go
Normal file
386
internal/application/merchantpayment/routing.go
Normal 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(¤t, 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(¤t)); err == nil {
|
||||
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
|
||||
}
|
||||
}
|
||||
return ¤t, 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(¤t).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(¤t)); err == nil {
|
||||
_ = l.redis.Set(ctx, key, text, time.Hour).Err()
|
||||
}
|
||||
}
|
||||
return ¤t, 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "支付配置身份与创建记录不一致")
|
||||
|
||||
218
internal/handler/admin/payment_merchant.go
Normal file
218
internal/handler/admin/payment_merchant.go
Normal 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)
|
||||
}
|
||||
@@ -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) == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("富友回调:预解析失败",
|
||||
|
||||
124
internal/model/dto/payment_merchant_dto.go
Normal file
124
internal/model/dto/payment_merchant_dto.go
Normal 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:"更新时间"`
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
94
internal/model/payment_merchant.go
Normal file
94
internal/model/payment_merchant.go
Normal 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" }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
35
internal/routes/payment_merchant.go
Normal file
35
internal/routes/payment_merchant.go
Normal 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})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}).
|
||||
|
||||
99
internal/service/refund/payment_merchant.go
Normal file
99
internal/service/refund/payment_merchant.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user