This commit is contained in:
190
internal/application/agentrecharge/confirm_online_payment.go
Normal file
190
internal/application/agentrecharge/confirm_online_payment.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
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"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// ConfirmOnlinePaymentCommand 描述验签或主动查单后得到的第三方收款事实。
|
||||
type ConfirmOnlinePaymentCommand struct {
|
||||
PaymentNo string
|
||||
PaymentMethod string
|
||||
ConfigID uint
|
||||
MerchantIdentity string
|
||||
ThirdPartyTradeNo string
|
||||
Amount int64
|
||||
PaidAt time.Time
|
||||
RequestID string
|
||||
CorrelationID string
|
||||
}
|
||||
|
||||
// PaymentConfirmedEvent 是第三方收款事实提交后的代理充值入账事件。
|
||||
type PaymentConfirmedEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
RechargeID uint `json:"recharge_id"`
|
||||
RechargeNo string `json:"recharge_no"`
|
||||
PaymentID uint `json:"payment_id"`
|
||||
PaymentNo string `json:"payment_no"`
|
||||
ShopID uint `json:"shop_id"`
|
||||
WalletID uint `json:"wallet_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
ThirdPartyTradeNo string `json:"third_party_trade_no"`
|
||||
PaidAt time.Time `json:"paid_at"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
CorrelationID string `json:"correlation_id,omitempty"`
|
||||
}
|
||||
|
||||
// PaymentConfirmedEventWriter 在支付确认事务内追加可靠入账事件。
|
||||
type PaymentConfirmedEventWriter interface {
|
||||
Append(ctx context.Context, tx *gorm.DB, event PaymentConfirmedEvent) error
|
||||
}
|
||||
|
||||
// ConfirmOnlinePaymentResult 返回支付确认是否属于幂等重放。
|
||||
type ConfirmOnlinePaymentResult struct {
|
||||
RechargeID uint
|
||||
AlreadyConfirmed bool
|
||||
}
|
||||
|
||||
// ConfirmOnlinePaymentService 统一处理回调和主动查单得到的代理充值支付事实。
|
||||
type ConfirmOnlinePaymentService struct {
|
||||
db *gorm.DB
|
||||
eventWriter PaymentConfirmedEventWriter
|
||||
}
|
||||
|
||||
// NewConfirmOnlinePaymentService 创建代理充值支付确认用例。
|
||||
func NewConfirmOnlinePaymentService(db *gorm.DB, eventWriter PaymentConfirmedEventWriter) *ConfirmOnlinePaymentService {
|
||||
return &ConfirmOnlinePaymentService{db: db, eventWriter: eventWriter}
|
||||
}
|
||||
|
||||
// Execute 在一个短事务中校验并固化支付事实和可靠入账事件。
|
||||
func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command ConfirmOnlinePaymentCommand) (*ConfirmOnlinePaymentResult, error) {
|
||||
if s == nil || s.db == nil || s.eventWriter == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "代理充值支付确认能力未配置")
|
||||
}
|
||||
command.PaymentNo = strings.TrimSpace(command.PaymentNo)
|
||||
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() {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整")
|
||||
}
|
||||
|
||||
result := &ConfirmOnlinePaymentResult{}
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
payment, recharge, err := lockPaymentConfirmationFacts(ctx, tx, command.PaymentNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result.RechargeID = recharge.ID
|
||||
if alreadyConfirmed {
|
||||
result.AlreadyConfirmed = true
|
||||
return nil
|
||||
}
|
||||
if err := ensureTradeNoAvailable(ctx, tx, payment, command); err != nil {
|
||||
return err
|
||||
}
|
||||
paidAt := command.PaidAt.UTC()
|
||||
paymentUpdate := tx.WithContext(ctx).Model(&model.Payment{}).
|
||||
Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}).
|
||||
Updates(map[string]any{"status": model.PaymentRecordStatusPaid, "third_party_trade_no": command.ThirdPartyTradeNo, "paid_at": paidAt})
|
||||
if paymentUpdate.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "更新代理充值支付单失败")
|
||||
}
|
||||
if paymentUpdate.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "代理充值支付单状态已变化")
|
||||
}
|
||||
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})
|
||||
if rechargeUpdate.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "更新代理充值单支付状态失败")
|
||||
}
|
||||
if rechargeUpdate.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "代理充值单状态已变化")
|
||||
}
|
||||
event := PaymentConfirmedEvent{
|
||||
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,
|
||||
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
|
||||
}
|
||||
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理充值支付确认事件失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func lockPaymentConfirmationFacts(ctx context.Context, tx *gorm.DB, paymentNo string) (*model.Payment, *model.AgentRechargeRecord, error) {
|
||||
var payment model.Payment
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_no = ?", paymentNo).First(&payment).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, errors.New(errors.CodeNotFound, "代理充值支付单不存在")
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理充值支付单失败")
|
||||
}
|
||||
var recharge model.AgentRechargeRecord
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", payment.OrderID).First(&recharge).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil, errors.New(errors.CodeConflict, "支付单关联的代理充值单不存在")
|
||||
}
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理充值单失败")
|
||||
}
|
||||
return &payment, &recharge, nil
|
||||
}
|
||||
|
||||
func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRechargeRecord, command ConfirmOnlinePaymentCommand) domain.PaymentConfirmationFacts {
|
||||
paymentConfigID, rechargeConfigID, rechargeChannel := uint(0), uint(0), ""
|
||||
if payment.PaymentConfigID != nil {
|
||||
paymentConfigID = *payment.PaymentConfigID
|
||||
}
|
||||
if recharge.PaymentConfigID != nil {
|
||||
rechargeConfigID = *recharge.PaymentConfigID
|
||||
}
|
||||
if recharge.PaymentChannel != nil {
|
||||
rechargeChannel = *recharge.PaymentChannel
|
||||
}
|
||||
return domain.PaymentConfirmationFacts{
|
||||
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,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func ensureTradeNoAvailable(ctx context.Context, tx *gorm.DB, payment *model.Payment, command ConfirmOnlinePaymentCommand) error {
|
||||
var count int64
|
||||
if err := tx.WithContext(ctx).Unscoped().Model(&model.Payment{}).
|
||||
Where("id <> ? AND payment_method = ? AND third_party_trade_no = ?", payment.ID, command.PaymentMethod, command.ThirdPartyTradeNo).
|
||||
Count(&count).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "校验第三方交易号唯一性失败")
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeConflict, "第三方交易号已被其他支付单使用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
331
internal/application/agentrecharge/online_creation.go
Normal file
331
internal/application/agentrecharge/online_creation.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
wechat OnlinePaymentPort
|
||||
alipay OnlinePaymentPort
|
||||
}
|
||||
|
||||
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入两个渠道 Adapter。
|
||||
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort) *OnlineCreationService {
|
||||
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay}
|
||||
}
|
||||
|
||||
// Execute 以短事务建单,事务外预下单,再条件保存付款内容或关闭失败订单。
|
||||
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
|
||||
if s == nil || s.db == nil || s.wechat == nil || s.alipay == 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, config, adapter, err := s.loadCreationFacts(ctx, command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config)
|
||||
if err != nil {
|
||||
if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found {
|
||||
return replay, replayErr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
paymentResult, err := adapter.PreCreate(ctx, OnlinePaymentRequest{
|
||||
PaymentNo: result.Payment.PaymentNo, Description: "代理主钱包充值", Amount: command.Amount,
|
||||
ExpireAt: *result.Payment.ExpireAt, 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, "仅代理账号可以查询在线支付方式")
|
||||
}
|
||||
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
|
||||
}
|
||||
return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败")
|
||||
}
|
||||
if s.wechat.Available(&config) {
|
||||
result.Methods = append(result.Methods, constants.RechargeMethodWechat)
|
||||
}
|
||||
if s.alipay.Available(&config) {
|
||||
result.Methods = append(result.Methods, constants.RechargeMethodAlipay)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OnlineCreationService) loadCreationFacts(
|
||||
ctx context.Context,
|
||||
command CreateOnlineCommand,
|
||||
) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, 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, 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, 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, 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)
|
||||
if adapter == nil || !adapter.Available(&config) {
|
||||
return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig)
|
||||
}
|
||||
return &account, &shop, &wallet, &config, adapter, nil
|
||||
}
|
||||
|
||||
func (s *OnlineCreationService) createLocalFacts(
|
||||
ctx context.Context,
|
||||
command CreateOnlineCommand,
|
||||
fingerprint string,
|
||||
account *model.Account,
|
||||
shop *model.Shop,
|
||||
wallet *model.AgentWallet,
|
||||
config *model.WechatConfig,
|
||||
) (*CreateOnlineResult, error) {
|
||||
rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405"))
|
||||
if err != nil {
|
||||
return 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 := command.PaymentMethod, 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,
|
||||
}
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
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 nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
|
||||
}
|
||||
return &CreateOnlineResult{Recharge: record, Payment: payment}, nil
|
||||
}
|
||||
|
||||
func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string {
|
||||
if config == nil {
|
||||
return ""
|
||||
}
|
||||
if paymentMethod == constants.RechargeMethodWechat {
|
||||
return config.WxMchID
|
||||
}
|
||||
if paymentMethod == constants.RechargeMethodAlipay {
|
||||
return config.AliAppID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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.CanCloseAfterPreCreateFailure(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, "关闭失败充值单失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *OnlineCreationService) adapter(paymentMethod string) OnlinePaymentPort {
|
||||
if paymentMethod == constants.RechargeMethodWechat {
|
||||
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
|
||||
}
|
||||
48
internal/application/agentrecharge/online_payment.go
Normal file
48
internal/application/agentrecharge/online_payment.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// OnlinePaymentStatePending 表示渠道仍在等待付款。
|
||||
OnlinePaymentStatePending = "pending"
|
||||
// OnlinePaymentStatePaid 表示渠道已确认收款。
|
||||
OnlinePaymentStatePaid = "paid"
|
||||
// OnlinePaymentStateClosed 表示渠道订单已明确关闭。
|
||||
OnlinePaymentStateClosed = "closed"
|
||||
// OnlinePaymentStateUnknown 表示渠道状态暂时无法确定。
|
||||
OnlinePaymentStateUnknown = "unknown"
|
||||
)
|
||||
|
||||
// OnlinePaymentRequest 描述扫码预下单所需的最小事实。
|
||||
type OnlinePaymentRequest struct {
|
||||
PaymentNo string
|
||||
Description string
|
||||
Amount int64
|
||||
ExpireAt time.Time
|
||||
Config *model.WechatConfig
|
||||
}
|
||||
|
||||
// OnlinePaymentResult 描述渠道返回的扫码付款内容。
|
||||
type OnlinePaymentResult struct {
|
||||
QRContent string
|
||||
}
|
||||
|
||||
// OnlinePaymentQueryResult 描述统一后的渠道支付状态。
|
||||
type OnlinePaymentQueryResult struct {
|
||||
State string
|
||||
ThirdPartyTradeNo string
|
||||
Amount int64
|
||||
PaidAt *time.Time
|
||||
}
|
||||
|
||||
// OnlinePaymentPort 定义代理扫码充值需要的最小渠道能力。
|
||||
type OnlinePaymentPort interface {
|
||||
Available(config *model.WechatConfig) bool
|
||||
PreCreate(ctx context.Context, request OnlinePaymentRequest) (OnlinePaymentResult, error)
|
||||
Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (OnlinePaymentQueryResult, error)
|
||||
}
|
||||
189
internal/application/agentrecharge/recover_online_payment.go
Normal file
189
internal/application/agentrecharge/recover_online_payment.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package agentrecharge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RecoverOnlinePaymentService 批量收敛长期待预下单或待支付的代理在线充值。
|
||||
type RecoverOnlinePaymentService struct {
|
||||
db *gorm.DB
|
||||
wechat OnlinePaymentPort
|
||||
alipay OnlinePaymentPort
|
||||
confirm *ConfirmOnlinePaymentService
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
|
||||
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService) *RecoverOnlinePaymentService {
|
||||
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, 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.confirm == nil {
|
||||
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
|
||||
}
|
||||
now := s.now().UTC()
|
||||
var payments []model.Payment
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("order_type = ? AND status = ? AND created_at <= ?", model.PaymentOrderTypeAgentRecharge, model.PaymentRecordStatusPending, now.Add(-constants.AgentRechargeRecoveryMinimumAge)).
|
||||
Order("created_at ASC, id ASC").Limit(constants.AgentRechargeRecoveryBatchSize).
|
||||
Find(&payments).Error; err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "扫描待恢复代理充值支付单失败")
|
||||
}
|
||||
if len(payments) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
recharges, configs, err := s.loadRecoveryFacts(ctx, payments)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
processed := 0
|
||||
var firstErr error
|
||||
for index := range payments {
|
||||
payment := &payments[index]
|
||||
recharge := recharges[payment.OrderID]
|
||||
config := recoveryConfig(payment, configs)
|
||||
if recharge == nil || config == nil {
|
||||
if firstErr == nil {
|
||||
firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := s.recoverOne(ctx, payment, recharge, config, now); err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
continue
|
||||
}
|
||||
processed++
|
||||
}
|
||||
return processed, firstErr
|
||||
}
|
||||
|
||||
func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig, now time.Time) error {
|
||||
adapter := s.adapter(payment.PaymentMethod)
|
||||
if adapter == nil {
|
||||
return errors.New(errors.CodeNoPaymentConfig, "代理充值创建时支付配置不可用")
|
||||
}
|
||||
if payment.QRContent == "" {
|
||||
if !adapter.Available(config) {
|
||||
return errors.New(errors.CodeNoPaymentConfig, "代理充值预下单配置不可用")
|
||||
}
|
||||
expireAt := now.Add(30 * time.Minute)
|
||||
if payment.ExpireAt != nil && payment.ExpireAt.After(now) {
|
||||
expireAt = *payment.ExpireAt
|
||||
}
|
||||
result, err := adapter.PreCreate(ctx, OnlinePaymentRequest{
|
||||
PaymentNo: payment.PaymentNo, Description: "代理主钱包充值", Amount: payment.Amount,
|
||||
ExpireAt: expireAt, Config: config,
|
||||
})
|
||||
if err != nil {
|
||||
// 恢复阶段不能仅凭预下单错误推断未收款,保留本地状态等待下次查单。
|
||||
return nil
|
||||
}
|
||||
if result.QRContent == "" {
|
||||
return nil
|
||||
}
|
||||
update := s.db.WithContext(ctx).Model(&model.Payment{}).
|
||||
Where("id = ? AND status = ? AND qr_content = ''", payment.ID, model.PaymentRecordStatusPending).
|
||||
Update("qr_content", result.QRContent)
|
||||
if update.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, update.Error, "恢复代理充值扫码付款内容失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
queryResult, err := adapter.Query(ctx, payment.PaymentNo, config)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch queryResult.State {
|
||||
case OnlinePaymentStatePaid:
|
||||
if queryResult.PaidAt == nil {
|
||||
return errors.New(errors.CodeConflict, "支付渠道成功结果缺少支付时间")
|
||||
}
|
||||
_, err = s.confirm.Execute(ctx, ConfirmOnlinePaymentCommand{
|
||||
PaymentNo: payment.PaymentNo, PaymentMethod: payment.PaymentMethod, ConfigID: config.ID,
|
||||
MerchantIdentity: paymentMerchantIdentity(payment.PaymentMethod, config),
|
||||
ThirdPartyTradeNo: queryResult.ThirdPartyTradeNo, Amount: queryResult.Amount, PaidAt: *queryResult.PaidAt,
|
||||
CorrelationID: payment.PaymentNo,
|
||||
})
|
||||
return err
|
||||
case OnlinePaymentStateClosed:
|
||||
return s.closePending(ctx, payment.ID, recharge.ID)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, payments []model.Payment) (map[uint]*model.AgentRechargeRecord, map[uint]*model.WechatConfig, error) {
|
||||
rechargeIDs := make([]uint, 0, len(payments))
|
||||
configIDs := make([]uint, 0, len(payments))
|
||||
for index := range payments {
|
||||
rechargeIDs = append(rechargeIDs, payments[index].OrderID)
|
||||
if payments[index].PaymentConfigID != nil {
|
||||
configIDs = append(configIDs, *payments[index].PaymentConfigID)
|
||||
}
|
||||
}
|
||||
var rechargeRows []model.AgentRechargeRecord
|
||||
if err := s.db.WithContext(ctx).Where("id IN ?", rechargeIDs).Find(&rechargeRows).Error; err != nil {
|
||||
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询待恢复代理充值单失败")
|
||||
}
|
||||
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, "批量查询代理充值创建配置失败")
|
||||
}
|
||||
}
|
||||
recharges := make(map[uint]*model.AgentRechargeRecord, len(rechargeRows))
|
||||
for index := range rechargeRows {
|
||||
recharges[rechargeRows[index].ID] = &rechargeRows[index]
|
||||
}
|
||||
configs := make(map[uint]*model.WechatConfig, len(configRows))
|
||||
for index := range configRows {
|
||||
configs[configRows[index].ID] = &configRows[index]
|
||||
}
|
||||
return recharges, configs, nil
|
||||
}
|
||||
|
||||
func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig {
|
||||
if payment.PaymentConfigID == nil {
|
||||
return nil
|
||||
}
|
||||
return configs[*payment.PaymentConfigID]
|
||||
}
|
||||
|
||||
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, paymentID, rechargeID uint) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
paymentUpdate := tx.Model(&model.Payment{}).
|
||||
Where("id = ? AND status = ?", paymentID, model.PaymentRecordStatusPending).
|
||||
Update("status", model.PaymentRecordStatusFailed)
|
||||
if paymentUpdate.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "关闭失效代理充值支付单失败")
|
||||
}
|
||||
rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}).
|
||||
Where("id = ? AND status = ?", rechargeID, constants.RechargeStatusPending).
|
||||
Update("status", constants.RechargeStatusClosed)
|
||||
if rechargeUpdate.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "关闭失效代理充值单失败")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *RecoverOnlinePaymentService) adapter(paymentMethod string) OnlinePaymentPort {
|
||||
if paymentMethod == constants.RechargeMethodWechat {
|
||||
return s.wechat
|
||||
}
|
||||
if paymentMethod == constants.RechargeMethodAlipay {
|
||||
return s.alipay
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user