Files
junhong_cmp_fiber/internal/application/agentrecharge/online_creation.go
break 98c145fe70
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m55s
实现支付商户池与微信授权配置
新增收款商户、商户池轮询、微信授权配置独立管理;三类新支付
(C端套餐购买、C端资产钱包充值、代理在线预存款充值)无条件
经商户池选择并冻结路由,无旧综合配置回退。merchant_id 为空
历史支付继续按 payment_config_id 双读。凭证版本化加载与
ID+版本缓存保证轮换一致性。删除商户池新支付创建开关及全部
引用。
2026-09-09 18:13:04 +08:00

397 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agentrecharge
import (
"context"
cryptorand "crypto/rand"
stderrors "errors"
"fmt"
"math/big"
"strings"
"time"
"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"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/idempotency"
)
const onlineCreationOperation = "agent-recharge.create-online"
// CreateOnlineCommand 描述从认证上下文构造的在线充值命令。
type CreateOnlineCommand struct {
AccountID uint
UserType int
CurrentShopID uint
Amount int64
PaymentMethod string
RequestID string
PayerClientIP string
}
// CreateOnlineResult 返回在线充值单、支付单及支付链接。
type CreateOnlineResult struct {
Recharge *model.AgentRechargeRecord
Payment *model.Payment
}
// AvailablePaymentMethodsResult 返回当前可用渠道及在线金额边界。
type AvailablePaymentMethodsResult struct {
Methods []string
MinAmount int64
MaxAmount int64
}
// OnlineCreationService 创建代理在线扫码充值单。
type OnlineCreationService struct {
db *gorm.DB
runtime *merchantpayment.RuntimeLoader
wechat OnlinePaymentPort
alipay OnlinePaymentPort
fuiou OnlinePaymentPort
audit PaymentAuditWriter
}
// 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.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)
command.RequestID = strings.TrimSpace(command.RequestID)
if err := domain.ValidateOnlineCreation(command.UserType, command.Amount, command.PaymentMethod); err != nil {
return nil, err
}
if command.AccountID == 0 || command.CurrentShopID == 0 || len(command.RequestID) > 64 ||
!idempotency.ValidateScope(onlineCreationScope(command.AccountID), command.RequestID) {
return nil, apperrors.New(apperrors.CodeInvalidParam)
}
fingerprint, err := idempotency.Fingerprint(struct {
Amount int64 `json:"amount"`
PaymentMethod string `json:"payment_method"`
}{command.Amount, command.PaymentMethod})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败")
}
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
return replay, err
}
account, shop, wallet, err := s.loadCreationFacts(ctx, command)
if err != nil {
return nil, err
}
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
}
return nil, err
}
paymentResult, err := adapter.CreatePaymentURL(ctx, OnlinePaymentRequest{
PaymentID: result.Payment.ID, PaymentNo: result.Payment.PaymentNo, CorrelationID: result.Payment.PaymentNo,
Description: "代理主钱包充值", Amount: command.Amount,
ExpireAt: *result.Payment.ExpireAt, PayerClientIP: command.PayerClientIP, Config: config,
})
if err != nil {
if !isUnknownPaymentResult(err) {
if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, closeErr, "支付链接生成失败且关闭本地订单失败")
}
}
return nil, err
}
if strings.TrimSpace(paymentResult.QRContent) == "" {
if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil {
return nil, closeErr
}
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "支付渠道未返回支付链接")
}
update := s.db.WithContext(ctx).Model(&model.Payment{}).
Where("id = ? AND status = ? AND qr_content = ''", result.Payment.ID, model.PaymentRecordStatusPending).
Update("qr_content", paymentResult.QRContent)
if update.Error != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, update.Error, "保存支付链接失败")
}
if update.RowsAffected != 1 {
return nil, apperrors.New(apperrors.CodeConflict, "在线充值支付链接已变化")
}
result.Payment.QRContent = paymentResult.QRContent
return result, nil
}
// AvailablePaymentMethods 按固定顺序返回已有可用商户池且凭证完整的在线支付方式。
func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) {
result := AvailablePaymentMethodsResult{
Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount,
}
if userType != constants.UserTypeAgent {
return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式")
}
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, nil
}
func (s *OnlineCreationService) loadCreationFacts(
ctx context.Context,
command CreateOnlineCommand,
) (*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, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败")
}
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, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在")
}
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, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用")
}
return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败")
}
return &account, &shop, &wallet, nil
}
func (s *OnlineCreationService) createLocalFacts(
ctx context.Context,
command CreateOnlineCommand,
fingerprint string,
account *model.Account,
shop *model.Shop,
wallet *model.AgentWallet,
) (*CreateOnlineResult, *model.WechatConfig, OnlinePaymentPort, error) {
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
if err != nil {
return nil, nil, nil, err
}
paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli()))
if err != nil {
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
}
payment.OrderID = record.ID
if err := tx.Create(payment).Error; err != nil {
return err
}
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建代理充值支付记录",
Payment: payment, Recharge: record, AfterData: map[string]any{"status": payment.Status},
})
})
if err != nil {
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}, config, adapter, nil
}
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {
if config == nil {
return ""
}
if paymentMethod == constants.RechargeMethodWechat {
if config.ProviderType == model.ProviderTypeFuiou {
return config.FyMchntCd
}
return config.WxMchID
}
if paymentMethod == constants.RechargeMethodAlipay {
return config.AliAppID
}
return ""
}
// paymentChannel 返回实际支付渠道:富友配置下微信业务方式落库为 fuiou其余与业务方式一致。
func paymentChannel(paymentMethod string, config *model.WechatConfig) string {
if paymentMethod == constants.RechargeMethodWechat && config != nil && config.ProviderType == model.ProviderTypeFuiou {
return model.ProviderTypeFuiou
}
return paymentMethod
}
func (s *OnlineCreationService) loadReplay(
ctx context.Context,
command CreateOnlineCommand,
fingerprint idempotency.FingerprintValue,
) (*CreateOnlineResult, bool, error) {
var record model.AgentRechargeRecord
err := s.db.WithContext(ctx).Where("user_id = ? AND request_id = ?", command.AccountID, command.RequestID).First(&record).Error
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取在线充值幂等记录失败")
}
existingFingerprint := ""
if record.RequestFingerprint != nil {
existingFingerprint = *record.RequestFingerprint
}
if existingFingerprint != fingerprint.Value {
return nil, true, apperrors.New(apperrors.CodeConflict, "同一请求标识对应的充值内容不一致")
}
var payment model.Payment
if err := s.db.WithContext(ctx).Where("order_id = ? AND order_type = ?", record.ID, model.PaymentOrderTypeAgentRecharge).First(&payment).Error; err != nil {
return nil, true, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取在线充值支付单失败")
}
if payment.QRContent == "" {
if record.Status == constants.RechargeStatusClosed || payment.Status == model.PaymentRecordStatusFailed {
return nil, true, apperrors.New(apperrors.CodeInvalidStatus, "原在线充值请求支付链接生成失败")
}
return nil, true, apperrors.New(apperrors.CodeConflict, "在线充值请求正在处理中,请稍后重试")
}
return &CreateOnlineResult{Recharge: &record, Payment: &payment}, true, nil
}
func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result *CreateOnlineResult) error {
if result == nil || result.Recharge == nil || result.Payment == nil || !domain.CanCloseAfterPaymentURLFailure(result.Recharge.Status) {
return nil
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentUpdate := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", result.Payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if paymentUpdate.Error != nil {
return apperrors.Wrap(apperrors.CodeDatabaseError, paymentUpdate.Error, "关闭失败支付单失败")
}
rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", result.Recharge.ID, constants.RechargeStatusPending).
Update("status", constants.RechargeStatusClosed)
if rechargeUpdate.Error != nil {
return apperrors.Wrap(apperrors.CodeDatabaseError, rechargeUpdate.Error, "关闭失败充值单失败")
}
afterPayment := *result.Payment
afterPayment.Status = model.PaymentRecordStatusFailed
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付链接生成失败,关闭支付记录",
Payment: &afterPayment, Recharge: result.Recharge,
BeforeData: map[string]any{"status": result.Payment.Status}, AfterData: map[string]any{"status": afterPayment.Status},
RechargeBeforeData: map[string]any{"status": result.Recharge.Status}, RechargeAfterData: map[string]any{"status": constants.RechargeStatusClosed},
})
})
}
func (s *OnlineCreationService) adapter(paymentMethod string, config *model.WechatConfig) OnlinePaymentPort {
if paymentMethod == constants.RechargeMethodWechat {
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return s.fuiou
}
return s.wechat
}
if paymentMethod == constants.RechargeMethodAlipay {
return s.alipay
}
return nil
}
func onlineCreationScope(accountID uint) idempotency.Scope {
return idempotency.Scope{Subject: fmt.Sprintf("account:%d", accountID), Operation: onlineCreationOperation}
}
func newBusinessNo(prefix, timestamp string) (string, error) {
random, err := cryptorand.Int(cryptorand.Reader, big.NewInt(1000000))
if err != nil {
return "", apperrors.Wrap(apperrors.CodeInternalError, err, "生成业务单号失败")
}
return fmt.Sprintf("%s%s%06d", prefix, timestamp, random.Int64()), nil
}
func isUnknownPaymentResult(err error) bool {
var appErr *apperrors.AppError
return stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeTimeout
}