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 }