All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
- 受控配置新增代理在线自充允许范围(仅微信/仅支付宝/同时支持),读侧与创建侧取允许范围与可用商户池交集,两侧失败关闭 - 新增允许范围查询与修改端点,读限代理与平台账号、写限超级管理员,复用受控配置写服务留痕 - tb_agent_recharge_record 新增交易流水号、线下收款方式三列快照与其他凭证列(成对迁移 000213) - 线下申请校验启用的收款方式字典项与必填交易流水号,交易流水号独立于在线渠道交易号、不参与去重 - 扩展 offline_recharge_approval 场景可映射字段白名单与字典引用保护 - 新增付款凭证识别能力与交易流水号预填接口,识别不落库、日志不记录载荷
418 lines
18 KiB
Go
418 lines
18 KiB
Go
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
|
||
policy *OnlinePaymentMethodPolicy
|
||
}
|
||
|
||
// SetPaymentMethodPolicy 注入代理在线自充允许范围策略。
|
||
func (s *OnlineCreationService) SetPaymentMethodPolicy(policy *OnlinePaymentMethodPolicy) {
|
||
s.policy = policy
|
||
}
|
||
|
||
// 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 || s.policy == 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, "生成在线充值请求指纹失败")
|
||
}
|
||
// 幂等回放先于允许范围门禁:同一 request_id 的重试属于既有单,不是新单,
|
||
// 不因允许范围变更被拒绝;允许范围只拦截会真正新建充值单与支付单的路径。
|
||
if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found {
|
||
return replay, err
|
||
}
|
||
if err := s.policy.IsAllowed(ctx, command.PaymentMethod); err != nil {
|
||
return nil, 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 s == nil || s.db == nil || s.runtime == nil {
|
||
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
|
||
}
|
||
if userType != constants.UserTypeAgent && userType != constants.UserTypePlatform {
|
||
return result, apperrors.New(apperrors.CodeForbidden, "仅代理或平台账号可以查询在线支付方式")
|
||
}
|
||
if s.policy == nil {
|
||
return result, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值允许范围策略未配置")
|
||
}
|
||
allowed, err := s.policy.AllowedMethods(ctx)
|
||
if err != nil {
|
||
return result, err
|
||
}
|
||
for _, method := range allowed {
|
||
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
|
||
}
|