Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展 Confidence: medium Scope-risk: broad Directive: 后续修改需保持审计事件与业务事务边界一致 Tested: git diff --cached --check Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
345 lines
14 KiB
Go
345 lines
14 KiB
Go
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
|
|
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
|
|
wechat OnlinePaymentPort
|
|
alipay OnlinePaymentPort
|
|
audit PaymentAuditWriter
|
|
}
|
|
|
|
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入两个渠道 Adapter。
|
|
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
|
|
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, 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.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, 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.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, "仅代理账号可以查询在线支付方式")
|
|
}
|
|
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 s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
|
|
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建代理充值支付记录",
|
|
Payment: payment, Recharge: record, AfterData: map[string]any{"status": payment.Status},
|
|
})
|
|
})
|
|
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.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) 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
|
|
}
|