diff --git a/README.md b/README.md index a71f569..d881ef0 100644 --- a/README.md +++ b/README.md @@ -719,6 +719,8 @@ KeyAuth:Token 缺失 | `jwt.secret_key` | `JUNHONG_JWT_SECRET_KEY` | | `logging.level` | `JUNHONG_LOGGING_LEVEL` | +三类新线上支付(C 端套餐购买、C 端资产钱包充值、代理在线预存款充值)始终从对应启用商户池选择并冻结商户路由;商户池、成员缺失或停用时明确返回“暂无可用商户”,绝不回退旧综合支付配置。`merchant_id` 为空只代表留存期内的历史支付,其回调、查单和既有退款路径仍按 `payment_config_id` 双读,直到独立 Change 删除旧读取路径。 + ### 必填配置 以下配置项必须通过环境变量设置(无默认值或需要覆盖): diff --git a/cmd/api/docs.go b/cmd/api/docs.go index a9474f8..8736a69 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -29,6 +29,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) + handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 09d570c..5ad70a6 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -38,6 +38,7 @@ func generateAdminDocs(outputPath string) error { handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) + handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 270837f..eeefce3 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -18,6 +18,7 @@ import ( approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/bootstrap" @@ -376,6 +377,7 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil), ) refundService.SetNotificationOutbox(outbox.NewRepository()) + refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient)) refundService.SetLifecycleAudit(auditWriter) if err := runtime.outboxConsumers.Register(commissionDelivery.EventRefundCommissionDeduct, commissionDelivery.NewRefundConsumer(refundService.ProcessCommissionDeduction, refundService.ProcessAssetPostProcessing)); err != nil { appLogger.Fatal("注册退款佣金回扣 Outbox 消费者失败", zap.Error(err)) @@ -448,6 +450,7 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti ) recovery := agentrechargeApp.NewRecoverOnlinePaymentService( runtime.db, + merchantpayment.NewRuntimeLoader(runtime.db, runtime.redisClient), paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger), paymentInfra.NewAlipayWapAdapter(integration, appLogger), paymentInfra.NewFuiouScanAdapter(integration, appLogger), diff --git a/docs/deployment/production-runbook.md b/docs/deployment/production-runbook.md index 0d1c50e..85c0165 100644 --- a/docs/deployment/production-runbook.md +++ b/docs/deployment/production-runbook.md @@ -74,6 +74,28 @@ DB_PASSWORD='<密码>' DB_NAME=<库名> DB_SSLMODE=<模式> \ 迁移失败时不启动新二进制;按失败迁移的事务状态决定处理,必要时恢复已确认可用的数据库备份。启动失败时覆盖回部署前备份的二进制,再恢复数据库备份(如迁移已改变数据库)。 +### 商户池支付路由发布与回滚 + +本节是维护者操作清单,不是已执行证据。迁移、生产发布、Redis 操作和富友真实渠道核验均由维护者执行;本轮未执行,不能以本地构建替代。 + +**前置条件** + +1. 留存维护者指定测试环境或本地验证证据,且不得记录密钥或完整报文中的敏感凭证。富友仅沿用现有实现;未进行外部渠道实测不构成开发、测试部署、任务完成、归档或发布前置。 +2. 确认本次商户池 Schema 迁移已完成可恢复备份及校验;停止服务后确认迁移锁影响、无长事务和可接受维护窗口。 +3. 上传支持 `merchant_id`/`payment_config_id` 双读的 API 与 Worker 二进制。商户池新支付没有运行时开关。 + +**发布后检查** + +1. 由维护者执行迁移并部署双读二进制。C 端套餐购买、C 端资产钱包充值、代理在线预存款充值的后续新支付立即经启用商户池创建并冻结 `merchant_id`、商户池与 `routing_epoch`。 +2. 无可用商户池、成员缺失或池停用必须稳定失败,不得回退旧综合支付配置或自动换商户;后台线下订单、后台钱包余额支付和员工线下代充值不经过商户池。 +3. 检查首次成功唯一累计,以及回调/查单/退款 A 对 `merchant_id` 新单和 `payment_config_id` 历史单的双读分流;应用、审计和集成日志不得包含凭证、私钥、Token、证书或完整敏感配置。 + +**回滚与记录** + +1. 不存在关闭商户池新支付创建的运行时开关。故障只能在仍支持双读的二进制上前向修复,不得恢复旧综合支付配置创建。 +2. 只要存在 `merchant_id` 非空支付、成功累计事实或新商户池配置,禁止部署不识别新路由的旧二进制,也禁止执行破坏这些事实的 down 迁移。 +3. 维护者记录二进制版本、时间、目标 PostgreSQL/Redis 的脱敏标识、备份校验、验证结果与全部阻塞原因。 + ### 零金额退款发布后核验 发布本次退款审批变更后,维护者应先等待既有重试处理稳定事件 `approval:26:approved`;若重试已耗尽,按受控运维流程重放同一事件,不得直接修改退款、订单或钱包数据。随后核验: diff --git a/docs/integrations/fuiou/README.md b/docs/integrations/fuiou/README.md index 1f37e9a..584eacc 100644 --- a/docs/integrations/fuiou/README.md +++ b/docs/integrations/fuiou/README.md @@ -9,7 +9,9 @@ ## 当前实际使用范围 -系统仅使用微信预下单 `POST /wxPreCreate` 与支付通知。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);未发现退款、撤销或查单能力。 +系统当前使用微信预下单 `POST /wxPreCreate` 与支付通知;代理在线充值恢复流程另有本地 `CommonQuery` 调用,用于主动查询支付订单状态。交易类型为 `JSAPI`(公众号)或 `LETPAY`(小程序);本地 `CommonQuery` 代码保持现有请求格式、签名算法、状态映射和恢复语义不变。该源码事实仅表示本地候选实现及后续双读配置来源改造接缝,不证明真实富友渠道契约,也不证明验签、状态解释或恢复核验已通过。 + +真实核验仍需隔离富友商户、明确接口权限、合法订单样本和允许的网络条件;在这些条件完成前,不得将查单、验签、状态映射或恢复结果作为上线证据。本 Change 不新增富友退款能力。 ## 配置、认证与传输 diff --git a/internal/application/agentrecharge/confirm_online_payment.go b/internal/application/agentrecharge/confirm_online_payment.go index 24c9c66..0c3af0f 100644 --- a/internal/application/agentrecharge/confirm_online_payment.go +++ b/internal/application/agentrecharge/confirm_online_payment.go @@ -9,6 +9,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + merchantpayment "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" @@ -81,7 +82,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi 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() { + if command.PaymentNo == "" || command.PaidAt.IsZero() { return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整") } @@ -91,6 +92,9 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi if err != nil { return err } + if payment.MerchantID == nil && command.ConfigID == 0 { + return errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整") + } alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command)) if err != nil { return err @@ -114,6 +118,10 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi if paymentUpdate.RowsAffected != 1 { return errors.New(errors.CodeConflict, "代理充值支付单状态已变化") } + + if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, paidAt); err != nil { + return err + } 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}) @@ -127,7 +135,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi 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, + PaymentMethod: payment.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo, PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID, ParentEventID: command.ParentEventID, } @@ -186,7 +194,9 @@ func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRech 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, + FrozenMerchant: payment.MerchantID != nil, FrozenMerchantPaymentMethod: payment.MerchantPaymentMethodSnapshot, + FrozenMerchantProviderType: payment.MerchantProviderTypeSnapshot, + 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, diff --git a/internal/application/agentrecharge/online_creation.go b/internal/application/agentrecharge/online_creation.go index 70d837b..8ad912c 100644 --- a/internal/application/agentrecharge/online_creation.go +++ b/internal/application/agentrecharge/online_creation.go @@ -11,6 +11,7 @@ import ( "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" @@ -46,21 +47,22 @@ type AvailablePaymentMethodsResult struct { // OnlineCreationService 创建代理在线扫码充值单。 type OnlineCreationService struct { - db *gorm.DB - wechat OnlinePaymentPort - alipay OnlinePaymentPort - fuiou OnlinePaymentPort - audit PaymentAuditWriter + db *gorm.DB + runtime *merchantpayment.RuntimeLoader + wechat OnlinePaymentPort + alipay OnlinePaymentPort + fuiou OnlinePaymentPort + audit PaymentAuditWriter } -// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入三个渠道 Adapter。 -func NewOnlineCreationService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService { - return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, audit: audit} +// 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.wechat == nil || s.alipay == nil || s.fuiou == nil || s.audit == nil { + 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) @@ -82,11 +84,11 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin 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) + account, shop, wallet, err := s.loadCreationFacts(ctx, command) if err != nil { return nil, err } - result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config) + 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 @@ -125,7 +127,7 @@ func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlin return result, nil } -// AvailablePaymentMethods 按固定顺序返回配置完整的在线支付方式。 +// AvailablePaymentMethods 按固定顺序返回已有可用商户池且凭证完整的在线支付方式。 func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) { result := AvailablePaymentMethodsResult{ Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount, @@ -133,18 +135,38 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use 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 + 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, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败") - } - if s.wechat.Available(&config) || s.fuiou.Available(&config) { - result.Methods = append(result.Methods, constants.RechargeMethodWechat) - } - if s.alipay.Available(&config) { - result.Methods = append(result.Methods, constants.RechargeMethodAlipay) } return result, nil } @@ -152,40 +174,29 @@ func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, use func (s *OnlineCreationService) loadCreationFacts( ctx context.Context, command CreateOnlineCommand, -) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) { +) (*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, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败") + return nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败") } - return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值") + 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, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在") + return nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在") } - return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败") + 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, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用") + return nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用") } - return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败") + return 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, &config) - if adapter == nil || !adapter.Available(&config) { - return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig) - } - return &account, &shop, &wallet, &config, adapter, nil + return &account, &shop, &wallet, nil } func (s *OnlineCreationService) createLocalFacts( @@ -195,36 +206,58 @@ func (s *OnlineCreationService) createLocalFacts( account *model.Account, shop *model.Shop, wallet *model.AgentWallet, - config *model.WechatConfig, -) (*CreateOnlineResult, error) { +) (*CreateOnlineResult, *model.WechatConfig, OnlinePaymentPort, error) { rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405")) if err != nil { - return nil, err + return nil, nil, 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 := 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, - 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, + 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 } @@ -238,9 +271,13 @@ func (s *OnlineCreationService) createLocalFacts( }) }) if err != nil { - return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败") + 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}, nil + return &CreateOnlineResult{Recharge: record, Payment: payment}, config, adapter, nil } func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string { diff --git a/internal/application/agentrecharge/recover_online_payment.go b/internal/application/agentrecharge/recover_online_payment.go index 1ba9a2f..7e40345 100644 --- a/internal/application/agentrecharge/recover_online_payment.go +++ b/internal/application/agentrecharge/recover_online_payment.go @@ -6,6 +6,7 @@ import ( "gorm.io/gorm" + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -14,6 +15,7 @@ import ( // RecoverOnlinePaymentService 批量收敛长期缺少支付链接或待支付的代理在线充值。 type RecoverOnlinePaymentService struct { db *gorm.DB + runtime *merchantpayment.RuntimeLoader wechat OnlinePaymentPort alipay OnlinePaymentPort fuiou OnlinePaymentPort @@ -23,13 +25,13 @@ type RecoverOnlinePaymentService struct { } // NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。 -func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService { - return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, now: time.Now} +func NewRecoverOnlinePaymentService(db *gorm.DB, runtime *merchantpayment.RuntimeLoader, wechat, alipay, fuiou OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService { + return &RecoverOnlinePaymentService{db: db, runtime: runtime, wechat: wechat, alipay: alipay, fuiou: fuiou, confirm: confirm, audit: audit, 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.fuiou == nil || s.confirm == nil || s.audit == nil { + if s == nil || s.db == nil || s.runtime == nil || s.wechat == nil || s.alipay == nil || s.fuiou == nil || s.confirm == nil || s.audit == nil { return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置") } now := s.now().UTC() @@ -52,7 +54,13 @@ func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, er for index := range payments { payment := &payments[index] recharge := recharges[payment.OrderID] - config := recoveryConfig(payment, configs) + config, configErr := s.recoveryConfig(ctx, payment, configs) + if configErr != nil { + if firstErr == nil { + firstErr = configErr + } + continue + } if recharge == nil || config == nil { if firstErr == nil { firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置") @@ -141,7 +149,7 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay configIDs := make([]uint, 0, len(payments)) for index := range payments { rechargeIDs = append(rechargeIDs, payments[index].OrderID) - if payments[index].PaymentConfigID != nil { + if payments[index].MerchantID == nil && payments[index].PaymentConfigID != nil { configIDs = append(configIDs, *payments[index].PaymentConfigID) } } @@ -151,8 +159,10 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay } 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, "批量查询代理充值创建配置失败") + // merchant_id 为空仅为留存期内历史支付;独立 Change 删除旧路径前, + // 必须按其 payment_config_id 读取,包括已软删除的原始配置。 + if err := s.db.WithContext(ctx).Unscoped().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)) @@ -166,11 +176,27 @@ func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, pay return recharges, configs, nil } -func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig { - if payment.PaymentConfigID == nil { - return nil +// recoveryConfig 对冻结商户支付单按当前凭证版本加载;历史支付单保持 payment_config_id 路径。 +func (s *RecoverOnlinePaymentService) recoveryConfig(ctx context.Context, payment *model.Payment, configs map[uint]*model.WechatConfig) (*model.WechatConfig, error) { + if payment.MerchantID != nil { + merchant, err := s.runtime.LoadMerchant(ctx, *payment.MerchantID) + if err != nil { + return nil, err + } + // 仅微信直连(v3/v2)商户需要全局授权配置中的 AppID;富友商户不依赖该配置。 + var authorization *model.WechatAuthorization + if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 { + authorization, err = s.runtime.LoadAuthorization(ctx) + if err != nil { + return nil, err + } + } + return merchantpayment.MerchantConfig(merchant, authorization) } - return configs[*payment.PaymentConfigID] + if payment.PaymentConfigID == nil { + return nil, nil + } + return configs[*payment.PaymentConfigID], nil } func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error { diff --git a/internal/application/merchantpayment/management.go b/internal/application/merchantpayment/management.go new file mode 100644 index 0000000..6f602bb --- /dev/null +++ b/internal/application/merchantpayment/management.go @@ -0,0 +1,721 @@ +// Package merchantpayment provides merchant pool payment routing use cases. +package merchantpayment + +import ( + "context" + "reflect" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/bytedance/sonic" +) + +// ManagementService 负责商户、商户池与授权配置写入。 +type ManagementService struct { + db *gorm.DB + audit systemconfigapp.AuditWriter +} + +// NewManagementService 创建商户配置用例。 +func NewManagementService(db *gorm.DB, audit systemconfigapp.AuditWriter) *ManagementService { + return &ManagementService{db: db, audit: audit} +} + +func requireManager(ctx context.Context) error { + kind := middleware.GetUserTypeFromContext(ctx) + if kind != constants.UserTypeSuperAdmin && kind != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "无权限访问支付商户配置") + } + return nil +} + +func normalizePage(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 { + size = 20 + } + if size > 100 { + size = 100 + } + return page, size +} + +func validPaymentMethod(method string) bool { + return method == "wechat" || method == "alipay" +} + +func validateMerchantConfiguration(paymentMethod, providerType, merchantIdentity string, credentials model.JSONB) error { + paymentMethod, providerType, merchantIdentity = strings.TrimSpace(paymentMethod), strings.TrimSpace(providerType), strings.TrimSpace(merchantIdentity) + if !validPaymentMethod(paymentMethod) || merchantIdentity == "" || len(credentials) == 0 { + return errors.New(errors.CodeInvalidParam, "支付商户配置不完整") + } + var config model.WechatConfig + raw, err := sonic.Marshal(credentials) + if err != nil || sonic.Unmarshal(raw, &config) != nil { + return errors.New(errors.CodeInvalidParam, "支付商户凭证格式无效") + } + switch paymentMethod { + case "wechat": + switch providerType { + case model.ProviderTypeWechat: + if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV3Key) == "" || strings.TrimSpace(config.WxCertContent) == "" || strings.TrimSpace(config.WxKeyContent) == "" || strings.TrimSpace(config.WxSerialNo) == "" || strings.TrimSpace(config.WxNotifyURL) == "" { + return errors.New(errors.CodeInvalidParam, "微信直连商户凭证不完整或身份不一致") + } + case model.ProviderTypeWechatV2: + if config.WxMchID != merchantIdentity || strings.TrimSpace(config.WxAPIV2Key) == "" || strings.TrimSpace(config.WxNotifyURL) == "" { + return errors.New(errors.CodeInvalidParam, "微信 v2 商户凭证不完整或身份不一致") + } + case model.ProviderTypeFuiou: + if config.FyMchntCd != merchantIdentity || strings.TrimSpace(config.FyInsCd) == "" || strings.TrimSpace(config.FyTermID) == "" || strings.TrimSpace(config.FyPrivateKey) == "" || strings.TrimSpace(config.FyPublicKey) == "" || strings.TrimSpace(config.FyAPIURL) == "" || strings.TrimSpace(config.FyNotifyURL) == "" { + return errors.New(errors.CodeInvalidParam, "富友商户凭证不完整或身份不一致") + } + default: + return errors.New(errors.CodeInvalidParam, "微信支付服务商类型无效") + } + case "alipay": + if providerType != "alipay" || config.AliAppID != merchantIdentity || strings.TrimSpace(config.AliPrivateKey) == "" || strings.TrimSpace(config.AliPublicKey) == "" || strings.TrimSpace(config.AliNotifyURL) == "" || strings.TrimSpace(config.AliReturnURL) == "" { + return errors.New(errors.CodeInvalidParam, "支付宝商户凭证不完整或身份不一致") + } + } + return nil +} + +func validatePoolRequest(req dto.PaymentMerchantPoolRequest) error { + if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) { + return errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝") + } + switch req.Strategy { + case model.PaymentMerchantStrategyAmount: + if req.ThresholdAmount == nil || *req.ThresholdAmount <= 0 || req.ThresholdCount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil { + return errors.New(errors.CodeInvalidParam, "金额轮询策略参数不完整") + } + case model.PaymentMerchantStrategyCount: + if req.ThresholdCount == nil || *req.ThresholdCount <= 0 || req.ThresholdAmount != nil || req.StatisticCycle == nil || !validStatisticCycle(*req.StatisticCycle) || req.TimePeriodValue != nil || req.TimePeriodUnit != nil || req.TimePeriodStartedAt != nil { + return errors.New(errors.CodeInvalidParam, "笔数轮询策略参数不完整") + } + case model.PaymentMerchantStrategyTime: + if req.TimePeriodValue == nil || *req.TimePeriodValue < 1 || req.TimePeriodUnit == nil || !validTimeUnit(*req.TimePeriodUnit) || req.TimePeriodStartedAt == nil || req.ThresholdAmount != nil || req.ThresholdCount != nil || req.StatisticCycle != nil { + return errors.New(errors.CodeInvalidParam, "时间轮询策略参数不完整") + } + default: + return errors.New(errors.CodeInvalidParam, "不支持的商户池轮询策略") + } + return nil +} + +func validStatisticCycle(value string) bool { + return value == "round" || value == "day" || value == "month" +} +func validTimeUnit(value string) bool { return value == "minute" || value == "hour" || value == "day" } + +func (s *ManagementService) writeAudit(ctx context.Context, tx *gorm.DB, operation, description, key, name string, id uint, identity, before, after map[string]any) error { + if s.audit == nil { + return errors.New(errors.CodeInvalidStatus, "支付商户管理审计接缝未配置") + } + resourceID := strconv.FormatUint(uint64(id), 10) + return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ + OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description, + ConfigKey: key, Module: "payment_merchant", ResourceID: &resourceID, DisplayName: name, + Identity: identity, BeforeData: before, AfterData: after, Result: constants.AuditResultSuccess, + }) +} + +func merchantAuditIdentity(m *model.PaymentMerchant) map[string]any { + return map[string]any{"id": m.ID, "name": m.Name, "payment_method": m.PaymentMethod, "provider_type": m.ProviderType, "merchant_identity": m.MerchantIdentity, "status": m.Status, "credential_version": m.CredentialVersion} +} + +func poolAuditIdentity(p *model.PaymentMerchantPool) map[string]any { + return map[string]any{"id": p.ID, "name": p.Name, "payment_method": p.PaymentMethod, "strategy": p.Strategy, "status": p.Status, "routing_epoch": p.RoutingEpoch} +} + +func authorizationAuditIdentity(a *model.WechatAuthorization) map[string]any { + return map[string]any{"id": a.ID, "status": a.Status, "credential_version": a.CredentialVersion, "oa_app_id": a.OaAppID, "miniapp_app_id": a.MiniappAppID} +} + +// CreateMerchant 创建独立管理的支付商户。 +func (s *ManagementService) CreateMerchant(ctx context.Context, req dto.PaymentMerchantRequest) (*dto.PaymentMerchantResponse, error) { + if err := requireManager(ctx); err != nil { + return nil, err + } + if s == nil || s.db == nil || strings.TrimSpace(req.Name) == "" || len(req.Credentials) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "商户参数或凭证不完整") + } + if !validPaymentMethod(strings.TrimSpace(req.PaymentMethod)) { + return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝") + } + m := &model.PaymentMerchant{Name: strings.TrimSpace(req.Name), PaymentMethod: strings.TrimSpace(req.PaymentMethod), ProviderType: strings.TrimSpace(req.ProviderType), MerchantIdentity: strings.TrimSpace(req.MerchantIdentity), Credentials: req.Credentials, CredentialVersion: 1, Remark: strings.TrimSpace(req.Remark), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}} + if req.Enabled { + m.Status = model.PaymentMerchantStatusEnabled + } + if m.MerchantIdentity == "" || m.ProviderType == "" { + return nil, errors.New(errors.CodeInvalidParam, "商户身份或服务商类型不能为空") + } + if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil { + return nil, err + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(m).Error; err != nil { + return err + } + return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigCreate, "创建支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(m), nil, merchantAuditIdentity(m)) + }); err != nil { + if appErr, ok := err.(*errors.AppError); ok { + return nil, appErr + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付商户失败") + } + return merchantResponse(m), nil +} + +// ListMerchants returns the privileged configuration projection. + +// ListPools returns one page of merchant pools and their ordered members without per-pool member queries. +func (s *ManagementService) ListPools(ctx context.Context, req dto.PaymentMerchantPoolListRequest) ([]*dto.PaymentMerchantPoolResponse, int64, error) { + if err := requireManager(ctx); err != nil { + return nil, 0, err + } + page, size := normalizePage(req.Page, req.PageSize) + query := s.db.WithContext(ctx).Model(&model.PaymentMerchantPool{}) + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计商户池失败") + } + var pools []model.PaymentMerchantPool + if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&pools).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败") + } + poolIDs := make([]uint, 0, len(pools)) + for index := range pools { + poolIDs = append(poolIDs, pools[index].ID) + } + membersByPool := make(map[uint][]uint, len(pools)) + if len(poolIDs) > 0 { + var members []model.PaymentMerchantPoolMember + if err := s.db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败") + } + for index := range members { + member := &members[index] + membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID) + } + } + result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools)) + for index := range pools { + pool := &pools[index] + result = append(result, &dto.PaymentMerchantPoolResponse{ID: pool.ID, Name: pool.Name, PaymentMethod: pool.PaymentMethod, Enabled: pool.Status == model.PaymentMerchantStatusEnabled, Strategy: pool.Strategy, ThresholdAmount: pool.ThresholdAmount, ThresholdCount: pool.ThresholdCount, StatisticCycle: pool.StatisticCycle, TimePeriodValue: pool.TimePeriodValue, TimePeriodUnit: pool.TimePeriodUnit, TimePeriodStartedAt: pool.TimePeriodStartedAt, RoutingEpoch: pool.RoutingEpoch, MemberIDs: membersByPool[pool.ID], Remark: pool.Remark}) + } + return result, total, nil +} + +// GetPool 查询一个商户池及其有序成员。 +func (s *ManagementService) GetPool(ctx context.Context, id uint) (*dto.PaymentMerchantPoolResponse, error) { + if err := requireManager(ctx); err != nil { + return nil, err + } + var pool model.PaymentMerchantPool + if err := s.db.WithContext(ctx).First(&pool, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "商户池不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败") + } + return poolResponse(ctx, s.db, &pool) +} + +// ListMerchants 分页查询特权商户配置。 +func (s *ManagementService) ListMerchants(ctx context.Context, req dto.PaymentMerchantListRequest) ([]*dto.PaymentMerchantResponse, int64, error) { + if err := requireManager(ctx); err != nil { + return nil, 0, err + } + page, size := normalizePage(req.Page, req.PageSize) + query := s.db.WithContext(ctx).Model(&model.PaymentMerchant{}) + if req.PaymentMethod != nil { + query = query.Where("payment_method = ?", strings.TrimSpace(*req.PaymentMethod)) + } + if req.Enabled != nil { + status := model.PaymentMerchantStatusDisabled + if *req.Enabled { + status = model.PaymentMerchantStatusEnabled + } + query = query.Where("status = ?", status) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败") + } + var rows []model.PaymentMerchant + if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败") + } + result := make([]*dto.PaymentMerchantResponse, 0, len(rows)) + for index := range rows { + result = append(result, merchantResponse(&rows[index])) + } + return result, total, nil +} + +// GetMerchant 查询一个特权商户配置。 +func (s *ManagementService) GetMerchant(ctx context.Context, id uint) (*dto.PaymentMerchantResponse, error) { + if err := requireManager(ctx); err != nil { + return nil, err + } + var m model.PaymentMerchant + if err := s.db.WithContext(ctx).First(&m, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "支付商户不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付商户失败") + } + return merchantResponse(&m), nil +} + +// UpdateMerchant 更新商户凭证和可变配置。 +func (s *ManagementService) UpdateMerchant(ctx context.Context, id uint, req dto.PaymentMerchantUpdateRequest) (*dto.PaymentMerchantResponse, error) { + if err := requireManager(ctx); err != nil { + return nil, err + } + if req.PaymentMethod != nil && !validPaymentMethod(strings.TrimSpace(*req.PaymentMethod)) { + return nil, errors.New(errors.CodeInvalidParam, "支付方式仅支持微信或支付宝") + } + var m model.PaymentMerchant + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&m, id).Error; err != nil { + return err + } + before := merchantAuditIdentity(&m) + previous := m + var refs int64 + if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil { + return err + } + if refs > 0 && ((req.PaymentMethod != nil && *req.PaymentMethod != m.PaymentMethod) || (req.ProviderType != nil && *req.ProviderType != m.ProviderType) || (req.MerchantIdentity != nil && *req.MerchantIdentity != m.MerchantIdentity)) { + return errors.New(errors.CodeConflict, "已被支付单引用,不能修改收款身份") + } + if req.Name != nil { + m.Name = strings.TrimSpace(*req.Name) + } + if req.PaymentMethod != nil { + m.PaymentMethod = strings.TrimSpace(*req.PaymentMethod) + } + if req.ProviderType != nil { + m.ProviderType = strings.TrimSpace(*req.ProviderType) + } + if req.MerchantIdentity != nil { + m.MerchantIdentity = strings.TrimSpace(*req.MerchantIdentity) + } + if req.Remark != nil { + m.Remark = strings.TrimSpace(*req.Remark) + } + if req.Enabled != nil { + m.Status = model.PaymentMerchantStatusDisabled + if *req.Enabled { + m.Status = model.PaymentMerchantStatusEnabled + } + } + if req.Credentials != nil && !reflect.DeepEqual(m.Credentials, *req.Credentials) { + m.Credentials = *req.Credentials + } + if previous.Name != m.Name || previous.PaymentMethod != m.PaymentMethod || previous.ProviderType != m.ProviderType || previous.MerchantIdentity != m.MerchantIdentity || previous.Status != m.Status || !reflect.DeepEqual(previous.Credentials, m.Credentials) { + m.CredentialVersion++ + } + if err := validateMerchantConfiguration(m.PaymentMethod, m.ProviderType, m.MerchantIdentity, m.Credentials); err != nil { + return err + } + if err := tx.Save(&m).Error; err != nil { + return err + } + return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigUpdate, "更新支付商户", "payment_merchant:"+strconv.FormatUint(uint64(m.ID), 10), m.Name, m.ID, merchantAuditIdentity(&m), before, merchantAuditIdentity(&m)) + }); err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "支付商户不存在") + } + if appErr, ok := err.(*errors.AppError); ok { + return nil, appErr + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新支付商户失败") + } + return merchantResponse(&m), nil +} + +// DeleteMerchant 仅在未被引用且二次确认后删除商户。 +func (s *ManagementService) DeleteMerchant(ctx context.Context, id uint, confirm bool) error { + if err := requireManager(ctx); err != nil { + return err + } + if !confirm { + return errors.New(errors.CodeInvalidParam, "删除商户必须二次确认") + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var refs, members int64 + var merchant model.PaymentMerchant + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&merchant, id).Error; err != nil { + return err + } + before := merchantAuditIdentity(&merchant) + if err := tx.Model(&model.Payment{}).Where("merchant_id = ?", id).Count(&refs).Error; err != nil { + return err + } + if refs > 0 { + return errors.New(errors.CodeConflict, "已被支付单引用的商户不能删除") + } + if err := tx.Model(&model.PaymentMerchantPoolMember{}).Where("merchant_id = ?", id).Count(&members).Error; err != nil { + return err + } + if members > 0 { + return errors.New(errors.CodeConflict, "商户仍属于商户池") + } + r := tx.Delete(&model.PaymentMerchant{}, id) + if r.Error != nil { + return r.Error + } + if r.RowsAffected == 0 { + return errors.New(errors.CodeNotFound, "支付商户不存在") + } + if err := s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDelete, "删除支付商户", "payment_merchant:"+strconv.FormatUint(uint64(merchant.ID), 10), merchant.Name, merchant.ID, before, before, nil); err != nil { + return err + } + return nil + }) +} + +// SavePool 创建或更新商户池,并原子替换有序成员。 +func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.PaymentMerchantPoolRequest) (*dto.PaymentMerchantPoolResponse, error) { + if err := requireManager(ctx); err != nil { + return nil, err + } + if err := validatePoolRequest(req); err != nil { + return nil, err + } + if len(req.MemberIDs) == 0 { + return nil, errors.New(errors.CodeInvalidParam, "商户池至少需要一个商户") + } + var pool model.PaymentMerchantPool + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + creating := id == 0 + var previousMemberIDs []uint + if !creating { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil { + return err + } + var previousMembers []model.PaymentMerchantPoolMember + if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&previousMembers).Error; err != nil { + return err + } + previousMemberIDs = make([]uint, 0, len(previousMembers)) + for _, member := range previousMembers { + previousMemberIDs = append(previousMemberIDs, member.MerchantID) + } + } else { + pool.Creator = middleware.GetUserIDFromContext(ctx) + pool.RoutingEpoch = 1 + } + before := poolAuditIdentity(&pool) + if err := validatePoolMembers(ctx, tx, req.PaymentMethod, req.MemberIDs); err != nil { + return err + } + if !creating && poolEpochChanged(&pool, &req, previousMemberIDs) { + pool.RoutingEpoch++ + } + pool.Name, pool.PaymentMethod, pool.Strategy, pool.Remark = strings.TrimSpace(req.Name), strings.TrimSpace(req.PaymentMethod), strings.TrimSpace(req.Strategy), strings.TrimSpace(req.Remark) + pool.ThresholdAmount, pool.ThresholdCount, pool.StatisticCycle, pool.TimePeriodValue, pool.TimePeriodUnit, pool.TimePeriodStartedAt = req.ThresholdAmount, req.ThresholdCount, req.StatisticCycle, req.TimePeriodValue, req.TimePeriodUnit, req.TimePeriodStartedAt + if req.Enabled { + var others int64 + if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil { + return err + } + if others > 0 { + return errors.New(errors.CodeConflict, "该支付方式已有启用商户池") + } + } + pool.Status = model.PaymentMerchantStatusDisabled + if req.Enabled { + pool.Status = model.PaymentMerchantStatusEnabled + } + pool.Updater = middleware.GetUserIDFromContext(ctx) + if creating { + if err := tx.Create(&pool).Error; err != nil { + return err + } + } else if err := tx.Save(&pool).Error; err != nil { + return err + } + if err := tx.Where("pool_id = ?", pool.ID).Delete(&model.PaymentMerchantPoolMember{}).Error; err != nil { + return err + } + members := make([]model.PaymentMerchantPoolMember, 0, len(req.MemberIDs)) + for i, merchantID := range req.MemberIDs { + members = append(members, model.PaymentMerchantPoolMember{PoolID: pool.ID, MerchantID: merchantID, SortOrder: int64(i), BaseModel: model.BaseModel{Creator: middleware.GetUserIDFromContext(ctx), Updater: middleware.GetUserIDFromContext(ctx)}}) + } + if err := tx.Create(&members).Error; err != nil { + return err + } + op := constants.AuditOperationPaymentConfigUpdate + summary := "更新商户池" + if creating { + op = constants.AuditOperationPaymentConfigCreate + summary = "创建商户池" + } + return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool)) + }) + if err != nil { + if appErr, ok := err.(*errors.AppError); ok { + return nil, appErr + } + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "商户池不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存商户池失败") + } + return poolResponse(ctx, s.db, &pool) +} + +// SetPoolEnabled enables or disables a pool after rechecking the active-pool and member invariants. +func (s *ManagementService) SetPoolEnabled(ctx context.Context, id uint, enabled bool) (*dto.PaymentMerchantPoolResponse, error) { + if err := requireManager(ctx); err != nil { + return nil, err + } + var pool model.PaymentMerchantPool + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&pool, id).Error; err != nil { + return err + } + before := poolAuditIdentity(&pool) + if enabled { + var others int64 + if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil { + return err + } + if others > 0 { + return errors.New(errors.CodeConflict, "该支付方式已有启用商户池") + } + var members []model.PaymentMerchantPoolMember + if err := tx.Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil { + return err + } + ids := make([]uint, 0, len(members)) + for _, member := range members { + ids = append(ids, member.MerchantID) + } + if err := validatePoolMembers(ctx, tx, pool.PaymentMethod, ids); err != nil { + return err + } + pool.Status = model.PaymentMerchantStatusEnabled + } else { + pool.Status = model.PaymentMerchantStatusDisabled + } + pool.Updater = middleware.GetUserIDFromContext(ctx) + if err := tx.Save(&pool).Error; err != nil { + return err + } + op := constants.AuditOperationPaymentConfigDeactivate + summary := "停用商户池" + if enabled { + op = constants.AuditOperationPaymentConfigActivate + summary = "启用商户池" + } + return s.writeAudit(ctx, tx, op, summary, "payment_merchant_pool:"+strconv.FormatUint(uint64(pool.ID), 10), pool.Name, pool.ID, poolAuditIdentity(&pool), before, poolAuditIdentity(&pool)) + }) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "商户池不存在") + } + if appErr, ok := err.(*errors.AppError); ok { + return nil, appErr + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新商户池状态失败") + } + return poolResponse(ctx, s.db, &pool) +} + +func validatePoolMembers(ctx context.Context, tx *gorm.DB, method string, ids []uint) error { + seen := map[uint]struct{}{} + + for _, id := range ids { + if id == 0 { + return errors.New(errors.CodeInvalidParam, "商户ID无效") + } + if _, ok := seen[id]; ok { + return errors.New(errors.CodeInvalidParam, "商户池成员不能重复") + } + seen[id] = struct{}{} + } + var merchants []model.PaymentMerchant + if err := tx.WithContext(ctx).Where("id IN ? AND payment_method = ? AND status = ?", ids, method, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil { + return err + } + if len(merchants) != len(ids) { + return errors.New(errors.CodeConflict, "商户池成员必须存在、启用且支付方式一致") + } + for index := range merchants { + merchant := &merchants[index] + if err := validateMerchantConfiguration(merchant.PaymentMethod, merchant.ProviderType, merchant.MerchantIdentity, merchant.Credentials); err != nil { + return err + } + } + return nil +} +func poolEpochChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool { + if pool.PaymentMethod != strings.TrimSpace(request.PaymentMethod) || + pool.Strategy != request.Strategy || + !sameString(pool.StatisticCycle, request.StatisticCycle) || + !sameInt64(pool.TimePeriodValue, request.TimePeriodValue) || + !sameString(pool.TimePeriodUnit, request.TimePeriodUnit) || + !sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) { + return true + } + if sameMemberOrder(previousMemberIDs, request.MemberIDs) { + return false + } + return pool.StatisticCycle == nil || (*pool.StatisticCycle != "day" && *pool.StatisticCycle != "month") || !sameMemberSet(previousMemberIDs, request.MemberIDs) +} + +func sameMemberOrder(left, right []uint) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func sameMemberSet(left, right []uint) bool { + if len(left) != len(right) { + return false + } + seen := make(map[uint]struct{}, len(left)) + for _, id := range left { + seen[id] = struct{}{} + } + for _, id := range right { + if _, ok := seen[id]; !ok { + return false + } + } + return true +} + +func sameString(left, right *string) bool { + if left == nil || right == nil { + return left == right + } + return *left == *right +} + +func sameInt64(left, right *int64) bool { + if left == nil || right == nil { + return left == right + } + return *left == *right +} + +func sameTime(a, b *time.Time) bool { + if a == nil || b == nil { + return a == b + } + return a.Equal(*b) +} +func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse { + return &dto.PaymentMerchantResponse{ID: m.ID, Name: m.Name, PaymentMethod: m.PaymentMethod, ProviderType: m.ProviderType, MerchantIdentity: m.MerchantIdentity, Credentials: m.Credentials, CredentialVersion: m.CredentialVersion, Enabled: m.Status == model.PaymentMerchantStatusEnabled, Remark: m.Remark, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt} +} +func poolResponse(ctx context.Context, db *gorm.DB, p *model.PaymentMerchantPool) (*dto.PaymentMerchantPoolResponse, error) { + var rows []model.PaymentMerchantPoolMember + if err := db.WithContext(ctx).Where("pool_id = ?", p.ID).Order("sort_order ASC").Find(&rows).Error; err != nil { + return nil, err + } + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.MerchantID) + } + return &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: ids, Remark: p.Remark}, nil +} + +// GetAuthorization 查询特权全局授权配置。 +func (s *ManagementService) GetAuthorization(ctx context.Context) (*dto.WechatAuthorizationResponse, error) { + if err := requireManager(ctx); err != nil { + return nil, err + } + var a model.WechatAuthorization + if err := s.db.WithContext(ctx).First(&a).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权配置失败") + } + return authorizationResponse(&a), nil +} + +// SaveAuthorization 创建或更新唯一启用的授权配置。 +func (s *ManagementService) SaveAuthorization(ctx context.Context, req dto.WechatAuthorizationRequest) (*dto.WechatAuthorizationResponse, error) { + if err := requireManager(ctx); err != nil { + return nil, err + } + if req.Enabled && (strings.TrimSpace(req.OaAppID) == "" || strings.TrimSpace(req.OaAppSecret) == "" || strings.TrimSpace(req.MiniappAppID) == "" || strings.TrimSpace(req.MiniappAppSecret) == "") { + return nil, errors.New(errors.CodeInvalidParam, "启用微信授权配置时公众号和小程序凭证必须完整") + } + var authorization model.WechatAuthorization + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&authorization).Error + if err != nil && err != gorm.ErrRecordNotFound { + return err + } + previousStatus := authorization.Status + creating := err == gorm.ErrRecordNotFound + before := authorizationAuditIdentity(&authorization) + if creating { + authorization.Creator = middleware.GetUserIDFromContext(ctx) + authorization.CredentialVersion = 1 + } + changed := authorization.OaAppID != req.OaAppID || authorization.OaAppSecret != req.OaAppSecret || authorization.OaToken != req.OaToken || authorization.OaAesKey != req.OaAesKey || authorization.OaOAuthRedirectURL != req.OaOAuthRedirectURL || authorization.MiniappAppID != req.MiniappAppID || authorization.MiniappAppSecret != req.MiniappAppSecret + authorization.OaAppID, authorization.OaAppSecret, authorization.OaToken, authorization.OaAesKey, authorization.OaOAuthRedirectURL, authorization.MiniappAppID, authorization.MiniappAppSecret = req.OaAppID, req.OaAppSecret, req.OaToken, req.OaAesKey, req.OaOAuthRedirectURL, req.MiniappAppID, req.MiniappAppSecret + authorization.Status = model.PaymentMerchantStatusDisabled + if req.Enabled { + authorization.Status = model.PaymentMerchantStatusEnabled + } + if !creating && (changed || previousStatus != authorization.Status) { + authorization.CredentialVersion++ + } + authorization.Updater = middleware.GetUserIDFromContext(ctx) + if creating { + if err := tx.Create(&authorization).Error; err != nil { + return err + } + } else { + if err := tx.Save(&authorization).Error; err != nil { + return err + } + } + op := constants.AuditOperationPaymentConfigUpdate + summary := "更新微信授权配置" + if creating { + op = constants.AuditOperationPaymentConfigCreate + summary = "创建微信授权配置" + } + return s.writeAudit(ctx, tx, op, summary, "wechat_authorization:"+strconv.FormatUint(uint64(authorization.ID), 10), "微信授权配置", authorization.ID, authorizationAuditIdentity(&authorization), before, authorizationAuditIdentity(&authorization)) + }) + if err != nil { + if appErr, ok := err.(*errors.AppError); ok { + return nil, appErr + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存微信授权配置失败") + } + return authorizationResponse(&authorization), nil +} +func authorizationResponse(a *model.WechatAuthorization) *dto.WechatAuthorizationResponse { + return &dto.WechatAuthorizationResponse{ID: a.ID, OaAppID: a.OaAppID, OaAppSecret: a.OaAppSecret, OaToken: a.OaToken, OaAesKey: a.OaAesKey, OaOAuthRedirectURL: a.OaOAuthRedirectURL, MiniappAppID: a.MiniappAppID, MiniappAppSecret: a.MiniappAppSecret, CredentialVersion: a.CredentialVersion, Enabled: a.Status == model.PaymentMerchantStatusEnabled, UpdatedAt: a.UpdatedAt} +} diff --git a/internal/application/merchantpayment/routing.go b/internal/application/merchantpayment/routing.go new file mode 100644 index 0000000..7c40ddc --- /dev/null +++ b/internal/application/merchantpayment/routing.go @@ -0,0 +1,386 @@ +package merchantpayment + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/bytedance/sonic" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// RouteSelection is the non-sensitive route frozen onto a new payment. +type RouteSelection struct { + Merchant *model.PaymentMerchant + Pool *model.PaymentMerchantPool +} + +// RuntimeLoader loads current merchant and authorization credentials by version. +type RuntimeLoader struct { + db *gorm.DB + redis *redis.Client +} + +// merchantCachePayload is used only for the internal versioned Redis cache and deliberately includes credentials. +// It must never be used for DTOs, logs, audits, or payment snapshots. +type merchantCachePayload struct { + ID uint `json:"id"` + Name string `json:"name"` + PaymentMethod string `json:"payment_method"` + ProviderType string `json:"provider_type"` + MerchantIdentity string `json:"merchant_identity"` + Credentials model.JSONB `json:"credentials"` + CredentialVersion int64 `json:"credential_version"` + Status int `json:"status"` + Remark string `json:"remark"` +} + +func merchantCachePayloadFrom(merchant *model.PaymentMerchant) merchantCachePayload { + return merchantCachePayload{ID: merchant.ID, Name: merchant.Name, PaymentMethod: merchant.PaymentMethod, ProviderType: merchant.ProviderType, MerchantIdentity: merchant.MerchantIdentity, Credentials: merchant.Credentials, CredentialVersion: merchant.CredentialVersion, Status: merchant.Status, Remark: merchant.Remark} +} + +func (p merchantCachePayload) merchant() *model.PaymentMerchant { + return &model.PaymentMerchant{Model: gorm.Model{ID: p.ID}, Name: p.Name, PaymentMethod: p.PaymentMethod, ProviderType: p.ProviderType, MerchantIdentity: p.MerchantIdentity, Credentials: p.Credentials, CredentialVersion: p.CredentialVersion, Status: p.Status, Remark: p.Remark} +} + +// authorizationCachePayload is used only for the internal versioned Redis cache and deliberately includes secrets. +// It must never be used for DTOs, logs, audits, or payment snapshots. +type authorizationCachePayload struct { + ID uint `json:"id"` + OaAppID string `json:"oa_app_id"` + OaAppSecret string `json:"oa_app_secret"` + OaToken string `json:"oa_token"` + OaAesKey string `json:"oa_aes_key"` + OaOAuthRedirectURL string `json:"oa_oauth_redirect_url"` + MiniappAppID string `json:"miniapp_app_id"` + MiniappAppSecret string `json:"miniapp_app_secret"` + CredentialVersion int64 `json:"credential_version"` + Status int `json:"status"` +} + +func authorizationCachePayloadFrom(authorization *model.WechatAuthorization) authorizationCachePayload { + return authorizationCachePayload{ID: authorization.ID, OaAppID: authorization.OaAppID, OaAppSecret: authorization.OaAppSecret, OaToken: authorization.OaToken, OaAesKey: authorization.OaAesKey, OaOAuthRedirectURL: authorization.OaOAuthRedirectURL, MiniappAppID: authorization.MiniappAppID, MiniappAppSecret: authorization.MiniappAppSecret, CredentialVersion: authorization.CredentialVersion, Status: authorization.Status} +} + +func (p authorizationCachePayload) authorization() *model.WechatAuthorization { + return &model.WechatAuthorization{Model: gorm.Model{ID: p.ID}, OaAppID: p.OaAppID, OaAppSecret: p.OaAppSecret, OaToken: p.OaToken, OaAesKey: p.OaAesKey, OaOAuthRedirectURL: p.OaOAuthRedirectURL, MiniappAppID: p.MiniappAppID, MiniappAppSecret: p.MiniappAppSecret, CredentialVersion: p.CredentialVersion, Status: p.Status} +} + +func NewRuntimeLoader(db *gorm.DB, redis *redis.Client) *RuntimeLoader { + return &RuntimeLoader{db: db, redis: redis} +} + +// LoadMerchant first reads the current version from the primary database, then uses only that version's cache entry. +// Disabled merchants remain loadable for frozen historical payments. +func (l *RuntimeLoader) LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error) { + if l == nil || l.db == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置") + } + return l.loadMerchant(ctx, l.db, id) +} + +// loadMerchant 先从当前事务或主库读取版本,再仅命中该版本的缓存。 +// 版本在凭证事务提交时递增,因此提交前遗留的旧缓存永远不会被新读取命中。 +func (l *RuntimeLoader) loadMerchant(ctx context.Context, db *gorm.DB, id uint) (*model.PaymentMerchant, error) { + var current model.PaymentMerchant + if err := db.WithContext(ctx).First(¤t, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "支付商户不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取支付商户失败") + } + key := fmt.Sprintf("payment:merchant:%d:%d", current.ID, current.CredentialVersion) + if l.redis != nil { + if text, err := l.redis.Get(ctx, key).Result(); err == nil { + var cached merchantCachePayload + if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion { + return cached.merchant(), nil + } + } + } + if l.redis != nil { + if text, err := sonic.MarshalString(merchantCachePayloadFrom(¤t)); err == nil { + _ = l.redis.Set(ctx, key, text, time.Hour).Err() + } + } + return ¤t, nil +} + +// LoadAuthorization first reads the current enabled version and only then resolves its versioned cache entry. +func (l *RuntimeLoader) LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error) { + if l == nil || l.db == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "微信授权加载能力未配置") + } + var current model.WechatAuthorization + if err := l.db.WithContext(ctx).Where("status = ?", model.PaymentMerchantStatusEnabled).First(¤t).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取微信授权配置失败") + } + key := fmt.Sprintf("payment:wechat-authorization:%d:%d", current.ID, current.CredentialVersion) + if l.redis != nil { + if text, err := l.redis.Get(ctx, key).Result(); err == nil { + var cached authorizationCachePayload + if sonic.UnmarshalString(text, &cached) == nil && cached.ID == current.ID && cached.CredentialVersion == current.CredentialVersion { + return cached.authorization(), nil + } + } + } + if l.redis != nil { + if text, err := sonic.MarshalString(authorizationCachePayloadFrom(¤t)); err == nil { + _ = l.redis.Set(ctx, key, text, time.Hour).Err() + } + } + return ¤t, nil +} + +// MerchantConfig adapts the merchant credential payload to existing channel constructors without persisting credentials in a payment snapshot. +func MerchantConfig(merchant *model.PaymentMerchant, authorization *model.WechatAuthorization) (*model.WechatConfig, error) { + if merchant == nil { + return nil, errors.New(errors.CodeNoPaymentConfig, "支付商户不存在") + } + if authorization == nil { + authorization = &model.WechatAuthorization{} + } + raw, err := sonic.Marshal(merchant.Credentials) + if err != nil { + return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效") + } + var cfg model.WechatConfig + if err := sonic.Unmarshal(raw, &cfg); err != nil { + return nil, errors.Wrap(errors.CodeInvalidParam, err, "支付商户凭证格式无效") + } + cfg.ID = merchant.ID + cfg.ProviderType = merchant.ProviderType + cfg.IsActive = true + if authorization != nil { + cfg.OaAppID = authorization.OaAppID + cfg.OaAppSecret = authorization.OaAppSecret + cfg.OaToken = authorization.OaToken + cfg.OaAesKey = authorization.OaAesKey + cfg.OaOAuthRedirectURL = authorization.OaOAuthRedirectURL + cfg.MiniappAppID = authorization.MiniappAppID + cfg.MiniappAppSecret = authorization.MiniappAppSecret + } + return &cfg, nil +} + +// MerchantConfigWithAuthorization 在需要 AppID 的渠道实例前,按当前版本加载全局微信授权配置。 +// 授权字段只进入内存中的渠道配置,绝不写入支付快照、普通 DTO、日志、审计或导出。 +func (l *RuntimeLoader) MerchantConfigWithAuthorization(ctx context.Context, merchant *model.PaymentMerchant) (*model.WechatConfig, error) { + authorization, err := l.LoadAuthorization(ctx) + if err != nil { + return nil, err + } + return MerchantConfig(merchant, authorization) +} + +// SelectForNewPayment atomically reads the active pool and chooses its current eligible member. +func (l *RuntimeLoader) SelectForNewPayment(ctx context.Context, paymentMethod string, now time.Time) (*RouteSelection, error) { + if l == nil || l.db == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置") + } + var out *RouteSelection + err := l.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var err error + out, err = l.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, now) + return err + }) + if err != nil { + return nil, err + } + return out, nil +} + +// SelectForNewPaymentWithTx chooses an eligible merchant while retaining the caller's business transaction. +func (l *RuntimeLoader) SelectForNewPaymentWithTx(ctx context.Context, tx *gorm.DB, paymentMethod string, now time.Time) (*RouteSelection, error) { + if l == nil || tx == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置") + } + var pool model.PaymentMerchantPool + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_method = ? AND status = ?", paymentMethod, model.PaymentMerchantStatusEnabled).First(&pool).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户") + } + return nil, err + } + var members []model.PaymentMerchantPoolMember + if err := tx.WithContext(ctx).Where("pool_id = ?", pool.ID).Order("sort_order ASC").Find(&members).Error; err != nil { + return nil, err + } + if len(members) == 0 { + return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户") + } + ids := make([]uint, 0, len(members)) + for _, member := range members { + ids = append(ids, member.MerchantID) + } + var merchants []model.PaymentMerchant + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND payment_method = ? AND status = ?", ids, pool.PaymentMethod, model.PaymentMerchantStatusEnabled).Find(&merchants).Error; err != nil { + return nil, err + } + byID := make(map[uint]*model.PaymentMerchant, len(merchants)) + for i := range merchants { + byID[merchants[i].ID] = &merchants[i] + } + ordered := make([]*model.PaymentMerchant, 0, len(members)) + for _, member := range members { + if merchant := byID[member.MerchantID]; merchant != nil { + ordered = append(ordered, merchant) + } + } + chosen, err := chooseMerchant(ctx, tx, &pool, ordered, now) + if err != nil { + return nil, err + } + // 新支付在冻结前也按“商户 ID + 当前版本”读取缓存;事务锁保证本次 + // 选择与凭证版本属于同一提交边界,避免新建支付误用旧版本缓存。 + chosen, err = l.loadMerchant(ctx, tx, chosen.ID) + if err != nil { + return nil, err + } + return &RouteSelection{Merchant: chosen, Pool: &pool}, nil +} + +func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) { + if len(merchants) == 0 { + return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户") + } + if pool.Strategy == model.PaymentMerchantStrategyTime { + return chooseTimedMerchant(pool, merchants, now) + } + if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount { + return nil, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效") + } + if pool.StatisticCycle == nil { + return nil, errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置") + } + + query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch) + if start, limited := routingWindowStart(*pool.StatisticCycle, now); limited { + query = query.Where("paid_at >= ?", start) + } + var rows []model.PaymentMerchantRoutingSuccess + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + amounts := make(map[uint]int64, len(merchants)) + counts := make(map[uint]int64, len(merchants)) + for _, row := range rows { + amounts[row.MerchantID] += row.Amount + counts[row.MerchantID]++ + } + for _, merchant := range merchants { + if pool.Strategy == model.PaymentMerchantStrategyAmount { + if pool.ThresholdAmount == nil { + return nil, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置") + } + if amounts[merchant.ID] < *pool.ThresholdAmount { + return merchant, nil + } + continue + } + if pool.ThresholdCount == nil { + return nil, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置") + } + if counts[merchant.ID] < *pool.ThresholdCount { + return merchant, nil + } + } + if *pool.StatisticCycle != "round" { + return nil, errors.New(errors.CodeNoPaymentConfig, "当前统计周期内暂无可用商户") + } + if err := advanceRoutingEpoch(ctx, tx, pool); err != nil { + return nil, err + } + return merchants[0], nil +} + +func chooseTimedMerchant(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) { + if pool.TimePeriodStartedAt == nil || pool.TimePeriodValue == nil || pool.TimePeriodUnit == nil { + return nil, errors.New(errors.CodeInvalidStatus, "时间轮询配置不完整") + } + unit := time.Minute + switch *pool.TimePeriodUnit { + case "hour": + unit = time.Hour + case "day": + unit = 24 * time.Hour + case "minute": + default: + return nil, errors.New(errors.CodeInvalidStatus, "时间轮询单位无效") + } + period := unit * time.Duration(*pool.TimePeriodValue) + if period <= 0 { + return nil, errors.New(errors.CodeInvalidStatus, "时间轮询周期无效") + } + slot := now.Sub(*pool.TimePeriodStartedAt) / period + if slot < 0 { + slot = 0 + } + return merchants[int(slot%time.Duration(len(merchants)))], nil +} + +func routingWindowStart(cycle string, now time.Time) (time.Time, bool) { + local := now.In(now.Location()) + switch cycle { + case "day": + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()), true + case "month": + return time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, local.Location()), true + default: + return time.Time{}, false + } +} + +func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool) error { + next := pool.RoutingEpoch + 1 + result := tx.WithContext(ctx).Model(&model.PaymentMerchantPool{}).Where("id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch).Update("routing_epoch", next) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "商户池统计世代已变化") + } + pool.RoutingEpoch = next + return nil +} + +// FreezeRoute writes only non-sensitive route facts onto the payment. +func FreezeRoute(payment *model.Payment, route *RouteSelection) { + if payment == nil || route == nil || route.Merchant == nil || route.Pool == nil { + return + } + payment.MerchantID = &route.Merchant.ID + payment.MerchantPoolID = &route.Pool.ID + payment.MerchantIdentity = route.Merchant.MerchantIdentity + payment.MerchantNameSnapshot = route.Merchant.Name + payment.MerchantPaymentMethodSnapshot = route.Merchant.PaymentMethod + payment.MerchantProviderTypeSnapshot = route.Merchant.ProviderType + payment.MerchantPoolNameSnapshot = route.Pool.Name + payment.RoutingStrategySnapshot = route.Pool.Strategy + epoch := route.Pool.RoutingEpoch + payment.RoutingEpoch = &epoch +} + +// RecordFirstSuccess 在支付成功事务内写入支付不可变的路由事实。 +func RecordFirstSuccess(ctx context.Context, tx *gorm.DB, payment *model.Payment, paidAt time.Time) error { + if payment == nil || payment.MerchantID == nil || payment.MerchantPoolID == nil || payment.RoutingEpoch == nil { + return nil + } + fact := model.PaymentMerchantRoutingSuccess{PaymentID: payment.ID, MerchantID: *payment.MerchantID, PoolID: *payment.MerchantPoolID, RoutingEpoch: *payment.RoutingEpoch, Amount: payment.Amount, PaidAt: paidAt} + if err := tx.WithContext(ctx).Create(&fact).Error; err != nil { + if strings.Contains(err.Error(), "duplicate key") { + return nil + } + return errors.Wrap(errors.CodeDatabaseError, err, "写入商户池成功统计失败") + } + return nil +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 7c31383..4c1a6d1 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -1,6 +1,7 @@ package bootstrap import ( + merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" roleApp "github.com/break/junhong_cmp_fiber/internal/application/role" shopApp "github.com/break/junhong_cmp_fiber/internal/application/shop" @@ -86,7 +87,6 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { personalCustomerOpenIDStore, personalCustomerStore, personalCustomerPhoneStore, - svc.WechatConfig, svc.Order, packageSeriesStore, shopSeriesAllocationStore, @@ -111,6 +111,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader) clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy) clientOrderService.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB)) + clientOrderService.SetLegacyPaymentConfigService(svc.WechatConfig) systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader) systemConfigAudit := deps.SystemConfigAudit if systemConfigAudit == nil { @@ -173,7 +174,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { return handler }(), ClientWallet: func() *app.ClientWalletHandler { - handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore) + handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore) handler.SetPaymentMethodPolicy(paymentMethodPolicy) handler.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB)) return handler @@ -284,7 +285,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { h.SetAssetService(svc.Asset) return h }(), - WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig), + WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig), + PaymentMerchant: admin.NewPaymentMerchantHandler(merchantPaymentApp.NewManagementService(deps.DB, systemConfigAudit)), AgentRecharge: func() *admin.AgentRechargeHandler { handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate) handler.SetOnlineCreationService(svc.AgentRechargeOnline) diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 5c2d527..07c139d 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -9,6 +9,7 @@ import ( approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange" + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval" @@ -286,6 +287,7 @@ func initServices(s *stores, deps *Dependencies) *services { paymentIntegration := integrationlog.NewRepository(deps.DB) agentRechargeOnline := agentrechargeApp.NewOnlineCreationService( deps.DB, + merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis), paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger), paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger), paymentInfra.NewFuiouScanAdapter(paymentIntegration, deps.Logger), @@ -313,6 +315,7 @@ func initServices(s *stores, deps *Dependencies) *services { ) refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil)) refundService.SetNotificationOutbox(walletOutbox) + refundService.SetPaymentMerchantRuntime(merchantpayment.NewRuntimeLoader(deps.DB, deps.Redis)) refundService.SetLifecycleAudit(auditWriter) exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger) exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository()))) diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index ac2caf2..bbb2bf2 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -68,6 +68,7 @@ type Handlers struct { AssetLifecycle *admin.AssetLifecycleHandler AssetWallet *admin.AssetWalletHandler WechatConfig *admin.WechatConfigHandler + PaymentMerchant *admin.PaymentMerchantHandler AgentRecharge *admin.AgentRechargeHandler Refund *admin.RefundHandler OrderPackageInvalidate *admin.OrderPackageInvalidateHandler diff --git a/internal/domain/agentrecharge/payment_confirmation.go b/internal/domain/agentrecharge/payment_confirmation.go index 22988a8..833d4ed 100644 --- a/internal/domain/agentrecharge/payment_confirmation.go +++ b/internal/domain/agentrecharge/payment_confirmation.go @@ -24,25 +24,28 @@ const ( // PaymentConfirmationFacts 是支付确认所需的渠道与本地权威事实。 type PaymentConfirmationFacts struct { - OrderType string - ExpectedOrderType string - PaymentMethod string - RechargePaymentMethod string - RechargePaymentChannel string - PaymentConfigID uint - RechargePaymentConfigID uint - ConfirmedConfigID uint - MerchantIdentity string - ConfirmedMerchantIdentity string - PaymentAmount int64 - RechargeAmount int64 - ConfirmedAmount int64 - PaymentOrderID uint - RechargeID uint - PaymentState PaymentState - RechargeStatus int - StoredTradeNo string - ConfirmedTradeNo string + OrderType string + ExpectedOrderType string + PaymentMethod string + RechargePaymentMethod string + RechargePaymentChannel string + PaymentConfigID uint + RechargePaymentConfigID uint + ConfirmedConfigID uint + FrozenMerchant bool + FrozenMerchantPaymentMethod string + FrozenMerchantProviderType string + MerchantIdentity string + ConfirmedMerchantIdentity string + PaymentAmount int64 + RechargeAmount int64 + ConfirmedAmount int64 + PaymentOrderID uint + RechargeID uint + PaymentState PaymentState + RechargeStatus int + StoredTradeNo string + ConfirmedTradeNo string } // ValidatePaymentConfirmation 校验支付确认不变量,并返回是否属于完全一致的重复确认。 @@ -60,7 +63,20 @@ func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) { return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致") } identity := strings.TrimSpace(facts.MerchantIdentity) - if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID || + if facts.FrozenMerchant { + frozenMethod := strings.TrimSpace(facts.FrozenMerchantPaymentMethod) + providerType := strings.TrimSpace(facts.FrozenMerchantProviderType) + if identity == "" || frozenMethod != method || providerType == "" { + return false, errors.New(errors.CodeConflict, "冻结商户支付事实不一致") + } + expectedChannel := method + if method == constants.RechargeMethodWechat && providerType == model.ProviderTypeFuiou { + expectedChannel = model.ProviderTypeFuiou + } + if channel != expectedChannel { + return false, errors.New(errors.CodeConflict, "支付渠道与冻结商户不一致") + } + } else if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID || facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" || identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) { return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致") diff --git a/internal/handler/admin/payment_merchant.go b/internal/handler/admin/payment_merchant.go new file mode 100644 index 0000000..4b4927b --- /dev/null +++ b/internal/handler/admin/payment_merchant.go @@ -0,0 +1,218 @@ +package admin + +import ( + "strconv" + + "github.com/gofiber/fiber/v2" + + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// PaymentMerchantHandler 处理特权商户与商户池配置请求。 +type PaymentMerchantHandler struct { + service *merchantpayment.ManagementService +} + +// NewPaymentMerchantHandler 创建商户池管理处理器。 +func NewPaymentMerchantHandler(service *merchantpayment.ManagementService) *PaymentMerchantHandler { + return &PaymentMerchantHandler{service: service} +} +func pathID(c *fiber.Ctx) (uint, error) { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return 0, errors.New(errors.CodeInvalidParam, "无效的路径ID") + } + return uint(id), nil +} + +// CreateMerchant 创建支付商户。 +// POST /api/admin/payment-merchants +func (h *PaymentMerchantHandler) CreateMerchant(c *fiber.Ctx) error { + var r dto.PaymentMerchantRequest + if err := c.BodyParser(&r); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + v, err := h.service.CreateMerchant(c.UserContext(), r) + if err != nil { + return err + } + return response.Success(c, v) +} + +// ListMerchants 分页查询支付商户。 +// GET /api/admin/payment-merchants +func (h *PaymentMerchantHandler) ListMerchants(c *fiber.Ctx) error { + var r dto.PaymentMerchantListRequest + if err := c.QueryParser(&r); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + v, n, err := h.service.ListMerchants(c.UserContext(), r) + if err != nil { + return err + } + return response.SuccessWithPagination(c, v, n, r.Page, r.PageSize) +} + +// GetMerchant 查询支付商户详情。 +// GET /api/admin/payment-merchants/:id +func (h *PaymentMerchantHandler) GetMerchant(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + v, err := h.service.GetMerchant(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, v) +} + +// UpdateMerchant 更新支付商户。 +// PUT /api/admin/payment-merchants/:id +func (h *PaymentMerchantHandler) UpdateMerchant(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + var r dto.PaymentMerchantUpdateRequest + if err := c.BodyParser(&r); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + v, err := h.service.UpdateMerchant(c.UserContext(), id, r) + if err != nil { + return err + } + return response.Success(c, v) +} + +// DeleteMerchant 删除支付商户。 +// DELETE /api/admin/payment-merchants/:id +func (h *PaymentMerchantHandler) DeleteMerchant(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + var r dto.PaymentMerchantDeleteRequest + if err := c.BodyParser(&r); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.service.DeleteMerchant(c.UserContext(), id, r.Confirm); err != nil { + return err + } + return response.Success(c, nil) +} + +// CreatePool 创建商户池。 +// POST /api/admin/payment-merchant-pools +func (h *PaymentMerchantHandler) CreatePool(c *fiber.Ctx) error { + var r dto.PaymentMerchantPoolRequest + if err := c.BodyParser(&r); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + v, err := h.service.SavePool(c.UserContext(), 0, r) + if err != nil { + return err + } + return response.Success(c, v) +} + +// UpdatePool 更新商户池。 +// PUT /api/admin/payment-merchant-pools/:id +func (h *PaymentMerchantHandler) UpdatePool(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + var r dto.PaymentMerchantPoolRequest + if err := c.BodyParser(&r); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + v, err := h.service.SavePool(c.UserContext(), id, r) + if err != nil { + return err + } + return response.Success(c, v) +} + +// EnablePool 启用商户池。 +// POST /api/admin/payment-merchant-pools/:id/enable +func (h *PaymentMerchantHandler) EnablePool(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + result, err := h.service.SetPoolEnabled(c.UserContext(), id, true) + if err != nil { + return err + } + return response.Success(c, result) +} + +// DisablePool 停用商户池。 +// POST /api/admin/payment-merchant-pools/:id/disable +func (h *PaymentMerchantHandler) DisablePool(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + result, err := h.service.SetPoolEnabled(c.UserContext(), id, false) + if err != nil { + return err + } + return response.Success(c, result) +} + +// GetAuthorization 获取微信授权配置。 +// GET /api/admin/wechat-authorizations +func (h *PaymentMerchantHandler) GetAuthorization(c *fiber.Ctx) error { + v, err := h.service.GetAuthorization(c.UserContext()) + if err != nil { + return err + } + return response.Success(c, v) +} + +// ListPools 分页查询商户池。 +// GET /api/admin/payment-merchant-pools +func (h *PaymentMerchantHandler) ListPools(c *fiber.Ctx) error { + var r dto.PaymentMerchantPoolListRequest + if err := c.QueryParser(&r); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + v, total, err := h.service.ListPools(c.UserContext(), r) + if err != nil { + return err + } + return response.SuccessWithPagination(c, v, total, r.Page, r.PageSize) +} + +// GetPool 查询商户池详情。 +// GET /api/admin/payment-merchant-pools/:id +func (h *PaymentMerchantHandler) GetPool(c *fiber.Ctx) error { + id, err := pathID(c) + if err != nil { + return err + } + v, err := h.service.GetPool(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, v) +} + +// SaveAuthorization 保存微信授权配置。 +// PUT /api/admin/wechat-authorizations/current +func (h *PaymentMerchantHandler) SaveAuthorization(c *fiber.Ctx) error { + var r dto.WechatAuthorizationRequest + if err := c.BodyParser(&r); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + v, err := h.service.SaveAuthorization(c.UserContext(), r) + if err != nil { + return err + } + return response.Success(c, v) +} diff --git a/internal/handler/app/client_wallet.go b/internal/handler/app/client_wallet.go index 7ae003f..6e97f42 100644 --- a/internal/handler/app/client_wallet.go +++ b/internal/handler/app/client_wallet.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/middleware" @@ -16,7 +17,7 @@ import ( asset "github.com/break/junhong_cmp_fiber/internal/service/asset" customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge" - wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/alipay" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -41,7 +42,7 @@ type ClientWalletHandler struct { paymentStore *postgres.PaymentStore rechargeService *rechargeSvc.Service openIDStore *postgres.PersonalCustomerOpenIDStore - wechatConfigService *wechatConfigSvc.Service + merchantRuntime *merchantpayment.RuntimeLoader redis *redis.Client logger *zap.Logger db *gorm.DB @@ -73,7 +74,6 @@ func NewClientWalletHandler( paymentStore *postgres.PaymentStore, rechargeService *rechargeSvc.Service, openIDStore *postgres.PersonalCustomerOpenIDStore, - wechatConfigService *wechatConfigSvc.Service, redisClient *redis.Client, logger *zap.Logger, db *gorm.DB, @@ -81,20 +81,20 @@ func NewClientWalletHandler( deviceStore *postgres.DeviceStore, ) *ClientWalletHandler { return &ClientWalletHandler{ - assetService: assetService, - customerBinding: binding, - walletStore: walletStore, - transactionStore: transactionStore, - rechargeOrderStore: rechargeOrderStore, - paymentStore: paymentStore, - rechargeService: rechargeService, - openIDStore: openIDStore, - wechatConfigService: wechatConfigService, - redis: redisClient, - logger: logger, - db: db, - iotCardStore: iotCardStore, - deviceStore: deviceStore, + assetService: assetService, + customerBinding: binding, + walletStore: walletStore, + transactionStore: transactionStore, + rechargeOrderStore: rechargeOrderStore, + paymentStore: paymentStore, + rechargeService: rechargeService, + openIDStore: openIDStore, + merchantRuntime: merchantpayment.NewRuntimeLoader(db, redisClient), + redis: redisClient, + logger: logger, + db: db, + iotCardStore: iotCardStore, + deviceStore: deviceStore, } } @@ -321,19 +321,11 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error { // } // } - config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx) - if err != nil { - return err - } - if config == nil { - return errors.New(errors.CodeWechatConfigUnavailable) - } - switch req.PaymentMethod { case constants.RechargeMethodAlipay: - return h.createAlipayRecharge(c, resolved, config, wallet, req) + return h.createAlipayRecharge(c, resolved, wallet, req) case constants.RechargeMethodWechat: - return h.createWechatRecharge(c, resolved, config, wallet, req) + return h.createWechatRecharge(c, resolved, wallet, req) default: return errors.New(errors.CodePaymentMethodUnavailable) } @@ -343,15 +335,13 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error { func (h *ClientWalletHandler) createWechatRecharge( c *fiber.Ctx, resolved *resolvedWalletAssetContext, - config *model.WechatConfig, wallet *model.AssetWallet, req dto.ClientCreateRechargeRequest, ) error { - appID, err := pickAppIDByType(config, req.AppType) + authorization, appID, err := h.loadWechatAuthorization(resolved.SkipPermissionCtx, req.AppType) if err != nil { return err } - openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID) if err != nil { return err @@ -359,9 +349,47 @@ func (h *ClientWalletHandler) createWechatRecharge( rechargeNo := generateClientRechargeNo() paymentNo := generateClientPaymentNo() + rechargeOrder := &model.RechargeOrder{ + RechargeOrderNo: rechargeNo, + UserID: resolved.CustomerID, + AssetWalletID: wallet.ID, + ResourceType: resolved.ResourceType, + ResourceID: resolved.Asset.AssetID, + Amount: req.Amount, + Status: model.RechargeOrderStatusPending, + ShopIDTag: wallet.ShopIDTag, + EnterpriseIDTag: wallet.EnterpriseIDTag, + OperatorType: constants.OperatorTypePersonalCustomer, + Generation: resolved.Generation, + } + payment := &model.Payment{ + PaymentNo: paymentNo, + OrderID: rechargeOrder.ID, + OrderType: model.PaymentOrderTypeRecharge, + PaymentMethod: model.PaymentByWechat, + Amount: req.Amount, + Status: model.PaymentRecordStatusPending, + } + var config *model.WechatConfig + if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error { + route, selectedConfig, err := h.selectMerchantConfig(resolved.SkipPermissionCtx, tx, model.PaymentByWechat, authorization) + if err != nil { + return err + } + config = selectedConfig + merchantpayment.FreezeRoute(payment, route) + if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") + } + payment.OrderID = rechargeOrder.ID + if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败") + } + return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder) + }); err != nil { + return err + } - // 先初始化生效支付通道并创建预支付订单,确认支付通道可用 - // 避免先写入充值记录后支付初始化失败,导致产生孤儿记录 attempt, startedAt, err := h.startRechargePaymentAttempt(resolved.SkipPermissionCtx, config, paymentNo, rechargeNo, req.Amount) if err != nil { return err @@ -380,49 +408,15 @@ func (h *ClientWalletHandler) createWechatRecharge( if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "充值支付预下单结果未知"); completeErr != nil { return completeErr } + if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil { + return updateErr + } return err } if err := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil { return err } - // 支付通道确认可用后,再创建充值订单和支付记录 - rechargeOrder := &model.RechargeOrder{ - RechargeOrderNo: rechargeNo, - UserID: resolved.CustomerID, - AssetWalletID: wallet.ID, - ResourceType: resolved.ResourceType, - ResourceID: resolved.Asset.AssetID, - Amount: req.Amount, - Status: model.RechargeOrderStatusPending, - PaymentConfigID: &config.ID, - ShopIDTag: wallet.ShopIDTag, - EnterpriseIDTag: wallet.EnterpriseIDTag, - OperatorType: constants.OperatorTypePersonalCustomer, - Generation: resolved.Generation, - } - payment := &model.Payment{ - PaymentNo: paymentNo, - OrderID: rechargeOrder.ID, - OrderType: model.PaymentOrderTypeRecharge, - PaymentMethod: model.PaymentByWechat, - Amount: req.Amount, - Status: model.PaymentRecordStatusPending, - PaymentConfigID: &config.ID, - } - if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error { - if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") - } - payment.OrderID = rechargeOrder.ID - if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败") - } - return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder) - }); err != nil { - return err - } - return response.Success(c, &dto.ClientRechargeResponse{ Recharge: dto.ClientRechargeResult{ RechargeID: rechargeOrder.ID, @@ -439,18 +433,11 @@ func (h *ClientWalletHandler) createWechatRecharge( func (h *ClientWalletHandler) createAlipayRecharge( c *fiber.Ctx, resolved *resolvedWalletAssetContext, - config *model.WechatConfig, wallet *model.AssetWallet, req dto.ClientCreateRechargeRequest, ) error { rechargeNo := generateClientRechargeNo() paymentNo := generateClientPaymentNo() - expireMinutes := config.AliPayExpireMinutes - if expireMinutes <= 0 { - expireMinutes = 30 - } - expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute) - rechargeOrder := &model.RechargeOrder{ RechargeOrderNo: rechargeNo, UserID: resolved.CustomerID, @@ -459,24 +446,33 @@ func (h *ClientWalletHandler) createAlipayRecharge( ResourceID: resolved.Asset.AssetID, Amount: req.Amount, Status: model.RechargeOrderStatusPending, - PaymentConfigID: &config.ID, ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, OperatorType: constants.OperatorTypePersonalCustomer, Generation: resolved.Generation, } payment := &model.Payment{ - PaymentNo: paymentNo, - OrderType: model.PaymentOrderTypeRecharge, - PaymentMethod: model.PaymentByAlipay, - Amount: req.Amount, - Status: model.PaymentRecordStatusPending, - PaymentConfigID: &config.ID, - ExpireAt: &expireAt, + PaymentNo: paymentNo, + OrderType: model.PaymentOrderTypeRecharge, + PaymentMethod: model.PaymentByAlipay, + Amount: req.Amount, + Status: model.PaymentRecordStatusPending, } - - // WAP URL 是本地签名,不需要先调第三方,在事务内创建充值单和支付单 + var config *model.WechatConfig + var expireAt time.Time if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error { + route, selectedConfig, err := h.selectMerchantConfig(resolved.SkipPermissionCtx, tx, model.PaymentByAlipay, nil) + if err != nil { + return err + } + expireMinutes := selectedConfig.AliPayExpireMinutes + if expireMinutes <= 0 { + expireMinutes = 30 + } + expireAt = time.Now().Add(time.Duration(expireMinutes) * time.Minute) + config = selectedConfig + payment.ExpireAt = &expireAt + merchantpayment.FreezeRoute(payment, route) if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") } @@ -505,7 +501,6 @@ func (h *ClientWalletHandler) createAlipayRecharge( zap.String("recharge_no", rechargeNo), zap.Int64("amount", req.Amount), zap.Time("expire_at", expireAt), - zap.Uint("config_id", config.ID), ) expireStr := expireAt.Format(time.RFC3339) @@ -738,7 +733,37 @@ func parseOptionalTime(value string) (*time.Time, error) { return nil, fmt.Errorf("invalid time format") } -func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) { +func (h *ClientWalletHandler) loadWechatAuthorization(ctx context.Context, appType string) (*model.WechatAuthorization, string, error) { + if h.merchantRuntime == nil { + return nil, "", errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置") + } + authorization, err := h.merchantRuntime.LoadAuthorization(ctx) + if err != nil { + return nil, "", err + } + appID, err := pickAppIDByType(authorization, appType) + if err != nil { + return nil, "", err + } + return authorization, appID, nil +} + +func (h *ClientWalletHandler) selectMerchantConfig(ctx context.Context, tx *gorm.DB, paymentMethod string, authorization *model.WechatAuthorization) (*merchantpayment.RouteSelection, *model.WechatConfig, error) { + if h.merchantRuntime == nil { + return nil, nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置") + } + route, err := h.merchantRuntime.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, time.Now()) + if err != nil { + return nil, nil, err + } + config, err := merchantpayment.MerchantConfig(route.Merchant, authorization) + if err != nil { + return nil, nil, err + } + return route, config, nil +} + +func pickAppIDByType(config *model.WechatAuthorization, appType string) (string, error) { switch appType { case "official_account": if strings.TrimSpace(config.OaAppID) == "" { diff --git a/internal/handler/app/client_wechat.go b/internal/handler/app/client_wechat.go index fcba7d4..5c997ce 100644 --- a/internal/handler/app/client_wechat.go +++ b/internal/handler/app/client_wechat.go @@ -54,7 +54,7 @@ func (h *ClientWechatHandler) GetJSSDKConfig(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam) } - wechatConfig, err := h.wechatConfigService.GetActiveConfig(c.UserContext()) + wechatConfig, err := h.wechatConfigService.GetAuthorizationConfig(c.UserContext()) if err != nil { return err } @@ -82,7 +82,7 @@ func (h *ClientWechatHandler) GetJSSDKConfig(c *fiber.Ctx) error { // GetAppID 获取当前生效的公众号 AppID // GET /api/c/v1/wechat/appid func (h *ClientWechatHandler) GetAppID(c *fiber.Ctx) error { - wechatConfig, err := h.wechatConfigService.GetActiveConfig(c.UserContext()) + wechatConfig, err := h.wechatConfigService.GetAuthorizationConfig(c.UserContext()) if err != nil { return err } diff --git a/internal/handler/callback/payment.go b/internal/handler/callback/payment.go index 1cae8d9..220c2b1 100644 --- a/internal/handler/callback/payment.go +++ b/internal/handler/callback/payment.go @@ -38,6 +38,7 @@ type AgentRechargeServiceInterface interface { type WechatConfigServiceInterface interface { GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error) + GetPaymentServiceForCallback(config *model.WechatConfig) (wechat.PaymentServiceInterface, error) } type PaymentHandler struct { @@ -98,7 +99,7 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error { body := c.Body() ctx := c.UserContext() - // 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置 + // 预解析订单号(不验签),用于按支付单冻结商户或历史 payment_config_id 双读加载配置 orderNo, err := wechat.PeekOrderNo(body) if err != nil { h.logger.Error("微信回调:预解析订单号失败", zap.Error(err)) @@ -144,12 +145,13 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error { } case model.ProviderTypeWechat: - if h.wechatPayment == nil { - return errors.New(errors.CodeWechatCallbackInvalid, "微信 v3 支付未初始化") + paymentSvc, err := h.wechatConfigService.GetPaymentServiceForCallback(cfg) + if err != nil { + return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "构建微信 v3 回调验签实例失败") } var httpReq http.Request fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true) - _, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error { + _, err = paymentSvc.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error { if result.TradeState != "SUCCESS" { return h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{ PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID, @@ -421,7 +423,7 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam, "订单号不能为空") } - // 按 payment_config_id 加载创建支付单时所用的配置(支持已停用配置) + // 按支付单冻结商户或历史 payment_config_id 双读加载配置(支持已停用配置) cfg, err := h.wechatConfigService.GetConfigForCallback(ctx, outTradeNo) if err != nil || cfg == nil { h.logger.Error("支付宝回调:加载支付配置失败", @@ -494,13 +496,14 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error { return errors.New(errors.CodeWechatCallbackInvalid, "支付通道校验失败") } - // 校验 payment_config_id 与当前配置一致;旧数据为空时兼容并记录 warn - if payment.PaymentConfigID == nil { + // 商户支付单已由 GetConfigForCallback 按 merchant_id 绑定当前商户凭证; + // payment_config_id 只属于历史双读路径,不能用于否定新商户回调。 + if payment.MerchantID == nil && payment.PaymentConfigID == nil { h.logger.Warn("支付宝回调:payment_config_id 为空,跳过配置 ID 校验(旧数据兼容)", zap.String("out_trade_no", outTradeNo), zap.Uint("config_id", cfg.ID), ) - } else if *payment.PaymentConfigID != cfg.ID { + } else if payment.MerchantID == nil && *payment.PaymentConfigID != cfg.ID { h.logger.Error("支付宝回调:payment_config_id 不匹配", zap.String("out_trade_no", outTradeNo), zap.Uint("payment_config_id", *payment.PaymentConfigID), @@ -567,7 +570,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error { ctx := c.UserContext() c.Set("Content-Type", "text/plain; charset=utf-8") - // 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置 + // 预解析订单号(不验签),用于按支付单冻结商户或历史 payment_config_id 双读加载配置 preNotify, err := fuiou.ParseNotify(body) if err != nil { h.logger.Error("富友回调:预解析失败", diff --git a/internal/model/dto/payment_merchant_dto.go b/internal/model/dto/payment_merchant_dto.go new file mode 100644 index 0000000..45511eb --- /dev/null +++ b/internal/model/dto/payment_merchant_dto.go @@ -0,0 +1,124 @@ +package dto + +import ( + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +// PaymentMerchantRequest 是受控商户管理写入请求。 +type PaymentMerchantRequest struct { + Name string `json:"name" validate:"required,min=1,max=100" description:"商户名称"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" enum:"wechat,alipay" description:"支付方式:wechat=微信支付,alipay=支付宝支付"` + ProviderType string `json:"provider_type" validate:"required,max=30" description:"服务商类型:微信仅支持 wechat、wechat_v2、fuiou;支付宝为 alipay"` + MerchantIdentity string `json:"merchant_identity" validate:"required,max=100" description:"商户号或应用标识"` + Credentials model.JSONB `json:"credentials" description:"商户受控凭证,仅专用管理接口传输"` + Enabled bool `json:"enabled" description:"是否启用"` + Remark string `json:"remark" validate:"max=1000" description:"备注"` +} + +// PaymentMerchantUpdateRequest 是受控商户管理更新请求。 +type PaymentMerchantUpdateRequest struct { + Name *string `json:"name" description:"商户名称"` + PaymentMethod *string `json:"payment_method" enum:"wechat,alipay" description:"支付方式:wechat=微信支付,alipay=支付宝支付"` + ProviderType *string `json:"provider_type" description:"服务商类型:微信仅支持 wechat、wechat_v2、fuiou;支付宝为 alipay"` + MerchantIdentity *string `json:"merchant_identity" description:"商户号或应用标识"` + Credentials *model.JSONB `json:"credentials" description:"商户受控凭证"` + Enabled *bool `json:"enabled" description:"是否启用"` + Remark *string `json:"remark" description:"备注"` +} + +// PaymentMerchantDeleteRequest 是受控商户删除二次确认请求。 +type PaymentMerchantDeleteRequest struct { + Confirm bool `json:"confirm" validate:"required" description:"确认删除,必须为true"` +} + +// PaymentMerchantListRequest 是支付商户分页查询条件。 +type PaymentMerchantListRequest struct { + Page int `query:"page" description:"页码"` + PageSize int `query:"page_size" description:"每页数量"` + PaymentMethod *string `query:"payment_method" enum:"wechat,alipay" description:"支付方式:wechat=微信支付,alipay=支付宝支付"` + Enabled *bool `query:"enabled" description:"是否启用"` +} + +// PaymentMerchantResponse 是受控商户管理响应。 +type PaymentMerchantResponse struct { + ID uint `json:"id" description:"商户ID"` + Name string `json:"name" description:"商户名称"` + PaymentMethod string `json:"payment_method" enum:"wechat,alipay" description:"支付方式:wechat=微信支付,alipay=支付宝支付"` + ProviderType string `json:"provider_type" description:"服务商类型"` + MerchantIdentity string `json:"merchant_identity" description:"商户号或应用标识"` + Credentials model.JSONB `json:"credentials" description:"商户受控凭证,仅专用管理接口返回"` + CredentialVersion int64 `json:"credential_version" description:"凭证版本"` + Enabled bool `json:"enabled" description:"是否启用"` + Remark string `json:"remark" description:"备注"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} + +// PaymentMerchantPoolRequest 是商户池创建或更新请求。 +type PaymentMerchantPoolRequest struct { + Name string `json:"name" validate:"required,min=1,max=100" description:"商户池名称"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay" enum:"wechat,alipay" description:"支付方式:wechat=微信支付,alipay=支付宝支付"` + Enabled bool `json:"enabled" description:"是否启用"` + Strategy string `json:"strategy" validate:"required,oneof=amount count time" enum:"amount,count,time" description:"轮询策略:amount=金额阈值,count=笔数阈值,time=按时间段轮换"` + ThresholdAmount *int64 `json:"threshold_amount" description:"金额轮询阈值,单位分;仅 amount 策略"` + ThresholdCount *int64 `json:"threshold_count" description:"笔数轮询阈值;仅 count 策略"` + StatisticCycle *string `json:"statistic_cycle" enum:"round,day,month" description:"金额/笔数统计周期:round=每轮,day=自然日,month=自然月"` + TimePeriodValue *int64 `json:"time_period_value" description:"时间轮询周期数值;仅 time 策略,最小 1"` + TimePeriodUnit *string `json:"time_period_unit" enum:"minute,hour,day" description:"时间轮询单位:minute=分钟,hour=小时,day=天"` + TimePeriodStartedAt *time.Time `json:"time_period_started_at" description:"时间轮询起始时间;仅 time 策略"` + MemberIDs []uint `json:"member_ids" validate:"required,min=1" description:"有序商户ID列表"` + Remark string `json:"remark" description:"备注"` +} + +// PaymentMerchantPoolListRequest 是商户池分页查询条件。 +type PaymentMerchantPoolListRequest struct { + Page int `query:"page" description:"页码"` + PageSize int `query:"page_size" description:"每页数量"` +} + +// PaymentMerchantPoolResponse 是商户池管理响应。 +type PaymentMerchantPoolResponse struct { + ID uint `json:"id" description:"商户池ID"` + Name string `json:"name" description:"商户池名称"` + PaymentMethod string `json:"payment_method" enum:"wechat,alipay" description:"支付方式:wechat=微信支付,alipay=支付宝支付"` + Enabled bool `json:"enabled" description:"是否启用"` + Strategy string `json:"strategy" enum:"amount,count,time" description:"轮询策略:amount=金额阈值,count=笔数阈值,time=按时间段轮换"` + ThresholdAmount *int64 `json:"threshold_amount,omitempty" description:"金额阈值,分"` + ThresholdCount *int64 `json:"threshold_count,omitempty" description:"笔数阈值"` + StatisticCycle *string `json:"statistic_cycle,omitempty" enum:"round,day,month" description:"统计周期:round=每轮,day=自然日,month=自然月"` + TimePeriodValue *int64 `json:"time_period_value,omitempty" description:"时间周期数值"` + TimePeriodUnit *string `json:"time_period_unit,omitempty" enum:"minute,hour,day" description:"时间单位:minute=分钟,hour=小时,day=天"` + TimePeriodStartedAt *time.Time `json:"time_period_started_at,omitempty" description:"时间轮询起始时间"` + RoutingEpoch int64 `json:"routing_epoch" description:"当前路由统计世代"` + MemberIDs []uint `json:"member_ids" description:"有序商户ID列表"` + Remark string `json:"remark" description:"备注"` +} + +// WechatAuthorizationRequest 是微信授权配置管理写入请求。 +type WechatAuthorizationRequest struct { + OaAppID string `json:"oa_app_id" description:"公众号AppID"` + OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret"` + OaToken string `json:"oa_token" description:"公众号Token"` + OaAesKey string `json:"oa_aes_key" description:"公众号AES密钥"` + OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"公众号OAuth回调地址"` + MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"` + MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret"` + Enabled bool `json:"enabled" description:"是否启用"` +} + +// WechatAuthorizationResponse 是微信授权配置管理响应。 +type WechatAuthorizationResponse struct { + ID uint `json:"id" description:"授权配置ID"` + OaAppID string `json:"oa_app_id" description:"公众号AppID"` + OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret,仅专用管理接口返回"` + OaToken string `json:"oa_token" description:"公众号Token,仅专用管理接口返回"` + OaAesKey string `json:"oa_aes_key" description:"公众号AES密钥,仅专用管理接口返回"` + OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"公众号OAuth回调地址"` + MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"` + MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret,仅专用管理接口返回"` + CredentialVersion int64 `json:"credential_version" description:"凭证版本"` + Enabled bool `json:"enabled" description:"是否启用"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` +} diff --git a/internal/model/payment.go b/internal/model/payment.go index aa88ad8..023d9d0 100644 --- a/internal/model/payment.go +++ b/internal/model/payment.go @@ -8,23 +8,31 @@ import ( ) type Payment struct { - ID uint `gorm:"column:id;primaryKey" json:"id"` - PaymentNo string `gorm:"column:payment_no;type:varchar(40);uniqueIndex;not null" json:"payment_no"` - OrderID uint `gorm:"column:order_id;not null" json:"order_id"` - OrderType string `gorm:"column:order_type;type:varchar(30);not null" json:"order_type"` - PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"` - MerchantIdentity string `gorm:"column:merchant_identity;type:varchar(100)" json:"-"` - Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"` - Status int `gorm:"column:status;type:smallint;not null;default:0" json:"status"` - ThirdPartyTradeNo string `gorm:"column:third_party_trade_no;type:varchar(100)" json:"third_party_trade_no,omitempty"` - PaymentConfigID *uint `gorm:"column:payment_config_id" json:"payment_config_id,omitempty"` - PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500)" json:"payment_voucher_key,omitempty"` - QRContent string `gorm:"column:qr_content;type:text" json:"-"` - PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"` - ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"` - CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` + ID uint `gorm:"column:id;primaryKey" json:"id"` + PaymentNo string `gorm:"column:payment_no;type:varchar(40);uniqueIndex;not null" json:"payment_no"` + OrderID uint `gorm:"column:order_id;not null" json:"order_id"` + OrderType string `gorm:"column:order_type;type:varchar(30);not null" json:"order_type"` + PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"` + MerchantIdentity string `gorm:"column:merchant_identity;type:varchar(100)" json:"-"` + MerchantID *uint `gorm:"column:merchant_id" json:"merchant_id,omitempty"` + MerchantPoolID *uint `gorm:"column:merchant_pool_id" json:"merchant_pool_id,omitempty"` + MerchantNameSnapshot string `gorm:"column:merchant_name_snapshot" json:"merchant_name_snapshot,omitempty"` + MerchantPaymentMethodSnapshot string `gorm:"column:merchant_payment_method_snapshot" json:"merchant_payment_method_snapshot,omitempty"` + MerchantProviderTypeSnapshot string `gorm:"column:merchant_provider_type_snapshot" json:"merchant_provider_type_snapshot,omitempty"` + MerchantPoolNameSnapshot string `gorm:"column:merchant_pool_name_snapshot" json:"merchant_pool_name_snapshot,omitempty"` + RoutingStrategySnapshot string `gorm:"column:routing_strategy_snapshot" json:"routing_strategy_snapshot,omitempty"` + RoutingEpoch *int64 `gorm:"column:routing_epoch" json:"routing_epoch,omitempty"` + Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"` + Status int `gorm:"column:status;type:smallint;not null;default:0" json:"status"` + ThirdPartyTradeNo string `gorm:"column:third_party_trade_no;type:varchar(100)" json:"third_party_trade_no,omitempty"` + PaymentConfigID *uint `gorm:"column:payment_config_id" json:"payment_config_id,omitempty"` + PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500)" json:"payment_voucher_key,omitempty"` + QRContent string `gorm:"column:qr_content;type:text" json:"-"` + PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"` + ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` } func (Payment) TableName() string { diff --git a/internal/model/payment_merchant.go b/internal/model/payment_merchant.go new file mode 100644 index 0000000..cf726e8 --- /dev/null +++ b/internal/model/payment_merchant.go @@ -0,0 +1,94 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +const ( + PaymentMerchantStatusDisabled = 0 + PaymentMerchantStatusEnabled = 1 + + PaymentMerchantStrategyAmount = "amount" + PaymentMerchantStrategyCount = "count" + PaymentMerchantStrategyTime = "time" +) + +// PaymentMerchant 是实际收款商户,凭证只保存在受控字段。 +type PaymentMerchant struct { + gorm.Model + BaseModel `gorm:"embedded"` + Name string `gorm:"column:name" json:"name"` + PaymentMethod string `gorm:"column:payment_method" json:"payment_method"` + ProviderType string `gorm:"column:provider_type" json:"provider_type"` + MerchantIdentity string `gorm:"column:merchant_identity" json:"merchant_identity"` + Credentials JSONB `gorm:"column:credentials;type:jsonb" json:"-"` + CredentialVersion int64 `gorm:"column:credential_version" json:"credential_version"` + Status int `gorm:"column:status" json:"status"` + Remark string `gorm:"column:remark" json:"remark"` +} + +func (PaymentMerchant) TableName() string { return "tb_payment_merchant" } + +// PaymentMerchantPool 是一种支付方式的商户轮询池。 +type PaymentMerchantPool struct { + gorm.Model + BaseModel `gorm:"embedded"` + Name string `gorm:"column:name" json:"name"` + PaymentMethod string `gorm:"column:payment_method" json:"payment_method"` + Status int `gorm:"column:status" json:"status"` + Strategy string `gorm:"column:strategy" json:"strategy"` + ThresholdAmount *int64 `gorm:"column:threshold_amount" json:"threshold_amount,omitempty"` + ThresholdCount *int64 `gorm:"column:threshold_count" json:"threshold_count,omitempty"` + StatisticCycle *string `gorm:"column:statistic_cycle" json:"statistic_cycle,omitempty"` + TimePeriodValue *int64 `gorm:"column:time_period_value" json:"time_period_value,omitempty"` + TimePeriodUnit *string `gorm:"column:time_period_unit" json:"time_period_unit,omitempty"` + TimePeriodStartedAt *time.Time `gorm:"column:time_period_started_at" json:"time_period_started_at,omitempty"` + RoutingEpoch int64 `gorm:"column:routing_epoch" json:"routing_epoch"` + Remark string `gorm:"column:remark" json:"remark"` +} + +func (PaymentMerchantPool) TableName() string { return "tb_payment_merchant_pool" } + +// PaymentMerchantPoolMember 是商户池内按顺序参与轮询的商户。 +type PaymentMerchantPoolMember struct { + gorm.Model + BaseModel `gorm:"embedded"` + PoolID uint `gorm:"column:pool_id" json:"pool_id"` + MerchantID uint `gorm:"column:merchant_id" json:"merchant_id"` + SortOrder int64 `gorm:"column:sort_order" json:"sort_order"` +} + +func (PaymentMerchantPoolMember) TableName() string { return "tb_payment_merchant_pool_member" } + +// WechatAuthorization 是 C 端微信 OAuth、小程序与支付 AppID 的全局授权配置。 +type WechatAuthorization struct { + gorm.Model + BaseModel `gorm:"embedded"` + OaAppID string `gorm:"column:oa_app_id" json:"oa_app_id"` + OaAppSecret string `gorm:"column:oa_app_secret" json:"-"` + OaToken string `gorm:"column:oa_token" json:"-"` + OaAesKey string `gorm:"column:oa_aes_key" json:"-"` + OaOAuthRedirectURL string `gorm:"column:oa_oauth_redirect_url" json:"oa_oauth_redirect_url"` + MiniappAppID string `gorm:"column:miniapp_app_id" json:"miniapp_app_id"` + MiniappAppSecret string `gorm:"column:miniapp_app_secret" json:"-"` + CredentialVersion int64 `gorm:"column:credential_version" json:"credential_version"` + Status int `gorm:"column:status" json:"status"` +} + +func (WechatAuthorization) TableName() string { return "tb_wechat_authorization" } + +// PaymentMerchantRoutingSuccess 是支付首次成功的唯一统计事实。 +type PaymentMerchantRoutingSuccess struct { + ID uint `gorm:"column:id;primaryKey" json:"id"` + PaymentID uint `gorm:"column:payment_id" json:"payment_id"` + MerchantID uint `gorm:"column:merchant_id" json:"merchant_id"` + PoolID uint `gorm:"column:pool_id" json:"pool_id"` + RoutingEpoch int64 `gorm:"column:routing_epoch" json:"routing_epoch"` + Amount int64 `gorm:"column:amount" json:"amount"` + PaidAt time.Time `gorm:"column:paid_at" json:"paid_at"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` +} + +func (PaymentMerchantRoutingSuccess) TableName() string { return "tb_payment_merchant_routing_success" } diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 253390b..a6599d2 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -126,6 +126,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.WechatConfig != nil { registerWechatConfigRoutes(authGroup, handlers.WechatConfig, doc, basePath) } + if handlers.PaymentMerchant != nil { + registerPaymentMerchantRoutes(authGroup, handlers.PaymentMerchant, doc, basePath) + } if handlers.AgentRecharge != nil { registerAgentRechargeRoutes(authGroup, handlers.AgentRecharge, doc, basePath) } diff --git a/internal/routes/payment_merchant.go b/internal/routes/payment_merchant.go new file mode 100644 index 0000000..b34667c --- /dev/null +++ b/internal/routes/payment_merchant.go @@ -0,0 +1,35 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +func registerPaymentMerchantRoutes(router fiber.Router, handler *admin.PaymentMerchantHandler, doc *openapi.Generator, basePath string) { + group := router.Group("", func(c *fiber.Ctx) error { + kind := middleware.GetUserTypeFromContext(c.UserContext()) + if kind != constants.UserTypeSuperAdmin && kind != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, "无权限访问支付商户配置") + } + return c.Next() + }) + Register(group, doc, basePath, "GET", "/payment-merchants", handler.ListMerchants, RouteSpec{Summary: "查询支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.PaymentMerchantListRequest), Output: new(dto.PaymentMerchantResponse), Auth: true}) + Register(group, doc, basePath, "POST", "/payment-merchants", handler.CreateMerchant, RouteSpec{Summary: "创建支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.PaymentMerchantRequest), Output: new(dto.PaymentMerchantResponse), Auth: true}) + Register(group, doc, basePath, "GET", "/payment-merchants/:id", handler.GetMerchant, RouteSpec{Summary: "查询支付商户详情", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantResponse), Auth: true}) + Register(group, doc, basePath, "PUT", "/payment-merchants/:id", handler.UpdateMerchant, RouteSpec{Summary: "更新支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantUpdateRequest), Output: new(dto.PaymentMerchantResponse), Auth: true}) + Register(group, doc, basePath, "DELETE", "/payment-merchants/:id", handler.DeleteMerchant, RouteSpec{Summary: "删除支付商户", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"支付商户管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantDeleteRequest), Output: nil, Auth: true}) + Register(group, doc, basePath, "GET", "/payment-merchant-pools", handler.ListPools, RouteSpec{Summary: "查询商户池", Description: "仅超级管理员和平台用户可访问。支持分页,返回当前页商户池及其有序成员。", Tags: []string{"商户池管理"}, Input: new(dto.PaymentMerchantPoolListRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true}) + Register(group, doc, basePath, "GET", "/payment-merchant-pools/:id", handler.GetPool, RouteSpec{Summary: "查询商户池详情", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true}) + Register(group, doc, basePath, "POST", "/payment-merchant-pools", handler.CreatePool, RouteSpec{Summary: "创建商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.PaymentMerchantPoolRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true}) + Register(group, doc, basePath, "PUT", "/payment-merchant-pools/:id", handler.UpdatePool, RouteSpec{Summary: "更新商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Body: new(dto.PaymentMerchantPoolRequest), Output: new(dto.PaymentMerchantPoolResponse), Auth: true}) + Register(group, doc, basePath, "POST", "/payment-merchant-pools/:id/enable", handler.EnablePool, RouteSpec{Summary: "启用商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true}) + Register(group, doc, basePath, "POST", "/payment-merchant-pools/:id/disable", handler.DisablePool, RouteSpec{Summary: "停用商户池", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"商户池管理"}, Input: new(dto.IDReq), Output: new(dto.PaymentMerchantPoolResponse), Auth: true}) + Register(group, doc, basePath, "GET", "/wechat-authorizations", handler.GetAuthorization, RouteSpec{Summary: "获取微信授权配置", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"微信授权配置"}, Output: new(dto.WechatAuthorizationResponse), Auth: true}) + Register(group, doc, basePath, "PUT", "/wechat-authorizations/current", handler.SaveAuthorization, RouteSpec{Summary: "保存微信授权配置", Description: "仅超级管理员和平台用户可访问。", Tags: []string{"微信授权配置"}, Input: new(dto.WechatAuthorizationRequest), Output: new(dto.WechatAuthorizationResponse), Auth: true}) +} diff --git a/internal/service/client_auth/service.go b/internal/service/client_auth/service.go index 55f153f..b4624f0 100644 --- a/internal/service/client_auth/service.go +++ b/internal/service/client_auth/service.go @@ -137,10 +137,12 @@ func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest, ExpiresIn: assetTokenExpireSeconds, } - if wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx); err == nil && wechatConfig != nil { - resp.OaAppID = wechatConfig.OaAppID - resp.MiniappAppID = wechatConfig.MiniappAppID + wechatAuthorization, err := s.wechatConfigService.GetAuthorizationConfig(ctx) + if err != nil { + return nil, err } + resp.OaAppID = wechatAuthorization.OaAppID + resp.MiniappAppID = wechatAuthorization.MiniappAppID return resp, nil } @@ -156,13 +158,10 @@ func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest, return nil, err } - wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx) + wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx) if err != nil { return nil, err } - if wechatConfig == nil { - return nil, errors.New(errors.CodeWechatConfigUnavailable) - } oaApp, err := wechat.NewOfficialAccountAppFromConfig(wechatConfig, s.wechatCache, s.logger) if err != nil { @@ -219,13 +218,10 @@ func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest return nil, err } - wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx) + wechatConfig, err := s.wechatConfigService.GetAuthorizationConfig(ctx) if err != nil { return nil, err } - if wechatConfig == nil { - return nil, errors.New(errors.CodeWechatConfigUnavailable) - } miniService, err := wechat.NewMiniAppServiceFromConfig(wechatConfig, s.logger) if err != nil { diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go index 95f62b9..587eed6 100644 --- a/internal/service/client_order/service.go +++ b/internal/service/client_order/service.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/model" @@ -33,9 +34,10 @@ const ( clientPurchaseLockTTL = 10 * time.Second ) -// WechatConfigServiceInterface 微信配置服务接口。 +// WechatConfigServiceInterface 只读取历史支付单的综合支付配置。 type WechatConfigServiceInterface interface { - GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) + GetAuthorizationConfig(ctx context.Context) (*model.WechatConfig, error) + GetByIDUnscoped(ctx context.Context, id uint) (*model.WechatConfig, error) } // OrderWalletPayServiceInterface 订单钱包支付服务接口。 @@ -70,7 +72,6 @@ type Service struct { openIDStore *postgres.PersonalCustomerOpenIDStore personalCustomerStore *postgres.PersonalCustomerStore personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore - wechatConfigService WechatConfigServiceInterface orderPaymentService OrderWalletPayServiceInterface packageSeriesStore *postgres.PackageSeriesStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore @@ -82,6 +83,8 @@ type Service struct { paymentMethodPolicy PaymentMethodPolicy auditWriter *audit.Writer paymentIntegration *integrationlog.Repository + merchantRuntime *merchantpayment.RuntimeLoader + wechatConfigService WechatConfigServiceInterface } // SetPaymentMethodPolicy 注入 C 端支付方式策略。 @@ -95,6 +98,11 @@ func (s *Service) SetPaymentAudit(writer *audit.Writer, integration *integration s.paymentIntegration = integration } +// SetLegacyPaymentConfigService 注入仅供 merchant_id 为空历史支付单使用的旧配置读取。 +func (s *Service) SetLegacyPaymentConfigService(wechatConfigService WechatConfigServiceInterface) { + s.wechatConfigService = wechatConfigService +} + // New 创建客户端订单服务。 func New( assetService *asset.Service, @@ -107,7 +115,6 @@ func New( openIDStore *postgres.PersonalCustomerOpenIDStore, personalCustomerStore *postgres.PersonalCustomerStore, personalCustomerPhoneStore *postgres.PersonalCustomerPhoneStore, - wechatConfigService WechatConfigServiceInterface, orderPaymentService OrderWalletPayServiceInterface, packageSeriesStore *postgres.PackageSeriesStore, shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, @@ -128,7 +135,6 @@ func New( openIDStore: openIDStore, personalCustomerStore: personalCustomerStore, personalCustomerPhoneStore: personalCustomerPhoneStore, - wechatConfigService: wechatConfigService, orderPaymentService: orderPaymentService, packageSeriesStore: packageSeriesStore, shopSeriesAllocationStore: shopSeriesAllocationStore, @@ -137,6 +143,7 @@ func New( db: db, redis: redisClient, logger: logger, + merchantRuntime: merchantpayment.NewRuntimeLoader(db, redisClient), } } @@ -301,18 +308,10 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli // } if req.PaymentMethod == model.PaymentMethodAlipay { - // 支付宝强充:不需要 app_type / OpenID - activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx) - if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败") - } - if activeConfig == nil { - return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置") - } - return s.createAlipayForceRechargeOrder(skipCtx, customerID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, &created) + return s.createAlipayForceRechargeOrder(skipCtx, customerID, assetInfo, validationResult, forceRecharge, redisKey, &created) } // 微信强充:app_type 必须传入 - activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType) + authorization, appID, err := s.loadWechatAuthorization(skipCtx, req.AppType) if err != nil { return nil, err } @@ -320,11 +319,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli if err != nil { return nil, err } - paymentProvider, err := s.newPaymentProvider(activeConfig, appID, req.AppType) - if err != nil { - return nil, err - } - return s.createForceRechargeOrder(skipCtx, customerID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentProvider, &created) + return s.createForceRechargeOrder(skipCtx, customerID, openID, assetInfo, validationResult, authorization, appID, req.AppType, forceRecharge, redisKey, &created) } return s.createPackageOrder(skipCtx, customerID, validationResult, req.PaymentMethod, redisKey, &created) @@ -362,31 +357,49 @@ func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetReso } } -func (s *Service) resolveWechatConfig(ctx context.Context, appType string) (*model.WechatConfig, string, error) { - activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx) +func (s *Service) loadWechatAuthorization(ctx context.Context, appType string) (*model.WechatAuthorization, string, error) { + if s.merchantRuntime == nil { + return nil, "", errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置") + } + authorization, err := s.merchantRuntime.LoadAuthorization(ctx) if err != nil { - return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信配置失败") + return nil, "", err } - if activeConfig == nil { - return nil, "", errors.New(errors.CodeWechatPayFailed, "未找到生效的微信支付配置") - } - switch appType { case "official_account": - if activeConfig.OaAppID == "" { - return nil, "", errors.New(errors.CodeWechatPayFailed, "公众号支付配置不完整") + if strings.TrimSpace(authorization.OaAppID) == "" { + return nil, "", errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置") } - return activeConfig, activeConfig.OaAppID, nil + return authorization, authorization.OaAppID, nil case "miniapp": - if activeConfig.MiniappAppID == "" { - return nil, "", errors.New(errors.CodeWechatPayFailed, "小程序支付配置不完整") + if strings.TrimSpace(authorization.MiniappAppID) == "" { + return nil, "", errors.New(errors.CodeWechatConfigUnavailable, "微信授权未配置") } - return activeConfig, activeConfig.MiniappAppID, nil + return authorization, authorization.MiniappAppID, nil default: return nil, "", errors.New(errors.CodeInvalidParam) } } +func (s *Service) selectMerchantConfig(ctx context.Context, tx *gorm.DB, paymentMethod string, authorization *model.WechatAuthorization) (*merchantpayment.RouteSelection, *model.WechatConfig, error) { + if s.merchantRuntime == nil { + return nil, nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置") + } + route, err := s.merchantRuntime.SelectForNewPaymentWithTx(ctx, tx, paymentMethod, time.Now()) + if err != nil { + return nil, nil, err + } + config, err := merchantpayment.MerchantConfig(route.Merchant, authorization) + if err != nil { + return nil, nil, err + } + return route, config, nil +} + +func freezePaymentRoute(payment *model.Payment, route *merchantpayment.RouteSelection) { + merchantpayment.FreezeRoute(payment, route) +} + func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, appID string) (string, error) { records, err := s.openIDStore.ListByCustomerID(ctx, customerID) if err != nil { @@ -405,6 +418,41 @@ func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, ap return "", errors.New(errors.CodeNotFound, "未找到当前应用的微信授权信息") } +// merchantConfigForPayment 按冻结商户或历史 payment_config_id 加载支付凭证。 +// merchant_id 为空仅是历史支付保留期兼容:独立 Change 清理后删除此读取, +// 绝不按当前商户池或综合配置推断、回填该支付的商户。 +func (s *Service) merchantConfigForPayment(ctx context.Context, payment *model.Payment) (*model.WechatConfig, error) { + if payment != nil && payment.MerchantID != nil { + if s.merchantRuntime == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "商户池路由能力未配置") + } + merchant, err := s.merchantRuntime.LoadMerchant(ctx, *payment.MerchantID) + if err != nil { + return nil, err + } + // 仅微信直连(v3/v2)商户需要全局授权配置中的 AppID;富友商户不依赖该配置。 + var authorization *model.WechatAuthorization + if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 { + authorization, err = s.merchantRuntime.LoadAuthorization(ctx) + if err != nil { + return nil, err + } + } + return merchantpayment.MerchantConfig(merchant, authorization) + } + if payment == nil || payment.PaymentConfigID == nil { + return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付宝支付单缺少支付配置") + } + if s.wechatConfigService == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "旧支付配置加载能力未配置") + } + config, err := s.wechatConfigService.GetByIDUnscoped(ctx, *payment.PaymentConfigID) + if err != nil { + return nil, errors.Wrap(errors.CodeNoPaymentConfig, err, "历史支付宝支付配置不可用") + } + return config, nil +} + func (s *Service) createPackageOrder( ctx context.Context, customerID uint, @@ -465,10 +513,10 @@ func (s *Service) createForceRechargeOrder( openID string, assetInfo *dto.AssetResolveResponse, validationResult *purchase_validation.PurchaseValidationResult, - activeConfig *model.WechatConfig, + authorization *model.WechatAuthorization, + appID, appType string, forceRecharge *ForceRechargeRequirement, redisKey string, - paymentProvider PaymentProvider, created *bool, ) (*dto.ClientCreateOrderResponse, error) { resourceType, resourceID, err := resolveWalletResource(validationResult) @@ -519,16 +567,27 @@ func (s *Service) createForceRechargeOrder( paymentNo := generateClientPaymentNo() payment := &model.Payment{ - PaymentNo: paymentNo, - OrderID: rechargeOrder.ID, - OrderType: model.PaymentOrderTypeRecharge, - PaymentMethod: model.PaymentByWechat, - Amount: forceRecharge.ForceRechargeAmount, - Status: model.PaymentRecordStatusPending, - PaymentConfigID: &activeConfig.ID, + PaymentNo: paymentNo, + OrderID: rechargeOrder.ID, + OrderType: model.PaymentOrderTypeRecharge, + PaymentMethod: model.PaymentByWechat, + Amount: forceRecharge.ForceRechargeAmount, + Status: model.PaymentRecordStatusPending, } + var activeConfig *model.WechatConfig + var paymentProvider PaymentProvider if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + route, config, err := s.selectMerchantConfig(ctx, tx, model.PaymentByWechat, authorization) + if err != nil { + return err + } + paymentProvider, err = s.newPaymentProvider(config, appID, appType) + if err != nil { + return err + } + activeConfig = config + freezePaymentRoute(payment, route) if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") } @@ -967,26 +1026,22 @@ func (s *Service) getOrBuildAlipayPaymentLink( amount int64, subject string, ) (*dto.ClientPaymentLink, error) { - activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx) - if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败") - } - if activeConfig == nil { - return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置") - } - - // 查找最新的 alipay 待支付单 existing, _ := s.paymentStore.FindLatestPendingByOrderAndMethod( ctx, orderID, orderType, model.PaymentByAlipay, ) var payment *model.Payment + var activeConfig *model.WechatConfig + created := false now := time.Now() if existing != nil && existing.ExpireAt != nil && existing.ExpireAt.After(now) { - // 复用未过期的支付单 payment = existing + var err error + activeConfig, err = s.merchantConfigForPayment(ctx, payment) + if err != nil { + return nil, err + } } else { - // 过期或不存在,标记旧单 failed 并新建 if existing != nil { if updateErr := s.markPaymentFailed(ctx, existing, "支付宝支付记录过期关闭"); updateErr != nil { s.logger.Warn("标记过期支付宝支付单 failed 失败", @@ -995,44 +1050,51 @@ func (s *Service) getOrBuildAlipayPaymentLink( ) } } - expireMinutes := activeConfig.AliPayExpireMinutes - if expireMinutes <= 0 { - expireMinutes = 30 - } - expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute) - newPayment := &model.Payment{ - PaymentNo: generateClientPaymentNo(), - OrderID: orderID, - OrderType: orderType, - PaymentMethod: model.PaymentByAlipay, - Amount: amount, - Status: model.PaymentRecordStatusPending, - PaymentConfigID: &activeConfig.ID, - ExpireAt: &expireAt, - } if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + route, config, err := s.selectMerchantConfig(ctx, tx, model.PaymentByAlipay, nil) + if err != nil { + return err + } + expireMinutes := config.AliPayExpireMinutes + if expireMinutes <= 0 { + expireMinutes = 30 + } + expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute) + newPayment := &model.Payment{ + PaymentNo: generateClientPaymentNo(), + OrderID: orderID, + OrderType: orderType, + PaymentMethod: model.PaymentByAlipay, + Amount: amount, + Status: model.PaymentRecordStatusPending, + ExpireAt: &expireAt, + } + freezePaymentRoute(newPayment, route) if err := s.paymentStore.CreateWithTx(ctx, tx, newPayment); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败") } - return s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil) + if err := s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil); err != nil { + return err + } + payment = newPayment + activeConfig = config + return nil }); err != nil { return nil, err } - payment = newPayment + created = true s.logger.Info("创建支付宝支付单", zap.String("payment_no", payment.PaymentNo), zap.String("order_type", orderType), zap.Uint("order_id", orderID), zap.Int64("amount", amount), - zap.Time("expire_at", expireAt), - zap.Uint("config_id", activeConfig.ID), + zap.Time("expire_at", *payment.ExpireAt), ) } wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, subject) if err != nil { - // 新建的 payment 生成链接失败,标记 failed - if existing == nil || payment.ID != existing.ID { + if created { _ = s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录") } return nil, err @@ -1056,7 +1118,6 @@ func (s *Service) createAlipayForceRechargeOrder( customerID uint, assetInfo *dto.AssetResolveResponse, validationResult *purchase_validation.PurchaseValidationResult, - activeConfig *model.WechatConfig, forceRecharge *ForceRechargeRequirement, redisKey string, created *bool, @@ -1105,27 +1166,31 @@ func (s *Service) createAlipayForceRechargeOrder( LinkedCarrierType: assetInfo.AssetType, LinkedCarrierID: &resourceID, AutoPurchaseStatus: model.AutoPurchaseStatusPending, - PaymentConfigID: &activeConfig.ID, } - expireMinutesForce := activeConfig.AliPayExpireMinutes - if expireMinutesForce <= 0 { - expireMinutesForce = 30 - } - expireAt := time.Now().Add(time.Duration(expireMinutesForce) * time.Minute) paymentNo := generateClientPaymentNo() payment := &model.Payment{ - PaymentNo: paymentNo, - OrderType: model.PaymentOrderTypeRecharge, - PaymentMethod: model.PaymentByAlipay, - Amount: forceRecharge.ForceRechargeAmount, - Status: model.PaymentRecordStatusPending, - PaymentConfigID: &activeConfig.ID, - ExpireAt: &expireAt, + PaymentNo: paymentNo, + OrderType: model.PaymentOrderTypeRecharge, + PaymentMethod: model.PaymentByAlipay, + Amount: forceRecharge.ForceRechargeAmount, + Status: model.PaymentRecordStatusPending, } - - // 事务创建充值单 + 支付单(WAP URL 本地签名,不需要先调第三方) + var activeConfig *model.WechatConfig + var expireAt time.Time if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + route, config, err := s.selectMerchantConfig(ctx, tx, model.PaymentByAlipay, nil) + if err != nil { + return err + } + expireMinutes := config.AliPayExpireMinutes + if expireMinutes <= 0 { + expireMinutes = 30 + } + expireAt = time.Now().Add(time.Duration(expireMinutes) * time.Minute) + activeConfig = config + payment.ExpireAt = &expireAt + freezePaymentRoute(payment, route) if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") } @@ -1154,7 +1219,6 @@ func (s *Service) createAlipayForceRechargeOrder( zap.String("recharge_no", rechargeOrderNo), zap.Int64("amount", forceRecharge.ForceRechargeAmount), zap.Time("expire_at", expireAt), - zap.Uint("config_id", activeConfig.ID), ) s.markClientPurchaseCreated(ctx, redisKey, rechargeOrderNo) @@ -1432,7 +1496,7 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r return &dto.ClientPayOrderResponse{PaymentMethod: paymentMethod}, nil case model.PaymentMethodWechat: - activeConfig, appID, err := s.resolveWechatConfig(skipCtx, req.AppType) + authorization, appID, err := s.loadWechatAuthorization(skipCtx, req.AppType) if err != nil { return nil, err } @@ -1440,29 +1504,29 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r if err != nil { return nil, err } - paymentProvider, err := s.newPaymentProvider(activeConfig, appID, req.AppType) - if err != nil { - return nil, err - } paymentNo := generateClientPaymentNo() payment := &model.Payment{ - PaymentNo: paymentNo, - OrderID: order.ID, - OrderType: model.PaymentOrderTypePackage, - PaymentMethod: model.PaymentByWechat, - Amount: order.TotalAmount, - Status: model.PaymentRecordStatusPending, - PaymentConfigID: &activeConfig.ID, + PaymentNo: paymentNo, + OrderID: order.ID, + OrderType: model.PaymentOrderTypePackage, + PaymentMethod: model.PaymentByWechat, + Amount: order.TotalAmount, + Status: model.PaymentRecordStatusPending, } - + var activeConfig *model.WechatConfig + var paymentProvider PaymentProvider if err := s.db.WithContext(skipCtx).Transaction(func(tx *gorm.DB) error { - // 订单仍保留最近一次支付配置,兼容旧 ORD 回调。 - if err := tx.Model(&model.Order{}). - Where("id = ?", orderID). - Update("payment_config_id", activeConfig.ID).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新订单支付配置失败") + route, config, err := s.selectMerchantConfig(skipCtx, tx, model.PaymentByWechat, authorization) + if err != nil { + return err } + paymentProvider, err = s.newPaymentProvider(config, appID, req.AppType) + if err != nil { + return err + } + activeConfig = config + freezePaymentRoute(payment, route) if err := s.paymentStore.CreateWithTx(skipCtx, tx, payment); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败") } @@ -1498,30 +1562,12 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r }, nil case model.PaymentMethodAlipay: - // 支付宝支付:不需要 app_type / OpenID - activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx) - if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付配置失败") - } - if activeConfig == nil { - return nil, errors.New(errors.CodeInvalidParam, "未找到生效的支付配置") - } - paymentLink, err := s.getOrBuildAlipayPaymentLink( skipCtx, orderID, model.PaymentOrderTypePackage, order.TotalAmount, "套餐购买", ) if err != nil { return nil, err } - // 更新订单支付配置 ID,兼容回调配置回溯 - if dbErr := s.db.WithContext(skipCtx).Model(&model.Order{}). - Where("id = ?", orderID). - Update("payment_config_id", activeConfig.ID).Error; dbErr != nil { - s.logger.Warn("更新订单支付配置 ID 失败", - zap.Uint("order_id", orderID), - zap.Error(dbErr), - ) - } return &dto.ClientPayOrderResponse{ PaymentMethod: paymentMethod, PaymentLink: paymentLink, diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 18e814e..d17f0ee 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -11,6 +11,7 @@ import ( "time" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" @@ -2018,8 +2019,10 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str if thirdPartyTradeNo != "" { paymentUpdates["third_party_trade_no"] = thirdPartyTradeNo } + // 迟到首次成功必须允许 pending→paid 与 failed→paid;支付创建时冻结的 + // merchant_id/routing_epoch 仍决定统计归属,失败状态不表示已计入或已冲销。 paymentResult := tx.Model(&model.Payment{}). - Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending). + Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}). Updates(paymentUpdates) if paymentResult.Error != nil { return errors.Wrap(errors.CodeDatabaseError, paymentResult.Error, "更新支付记录失败") @@ -2035,6 +2038,10 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str return errors.New(errors.CodeInvalidStatus, "支付记录状态不允许处理") } + if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, now); err != nil { + return err + } + result := tx.Model(&model.Order{}). Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending). Updates(map[string]any{ diff --git a/internal/service/recharge_order/service.go b/internal/service/recharge_order/service.go index 2230884..7af83c9 100644 --- a/internal/service/recharge_order/service.go +++ b/internal/service/recharge_order/service.go @@ -5,6 +5,7 @@ import ( "strconv" "time" + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -86,7 +87,8 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p return nil } - if payment.Status != model.PaymentRecordStatusPending { + // 迟到首次成功必须允许 pending 与 failed 状态进入确认;冻结路由决定统计归属。 + if payment.Status != model.PaymentRecordStatusPending && payment.Status != model.PaymentRecordStatusFailed { return errors.New(errors.CodeInvalidStatus, "支付状态不允许处理") } @@ -102,7 +104,11 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p now := time.Now() err = s.db.Transaction(func(tx *gorm.DB) error { - oldPaymentStatus := model.PaymentRecordStatusPending + // 乐观锁允许 pending 或 failed 首次进入 paid;重复成功由 RowsAffected/唯一事实保护。 + oldPaymentStatus := payment.Status + if oldPaymentStatus != model.PaymentRecordStatusPending && oldPaymentStatus != model.PaymentRecordStatusFailed { + return errors.New(errors.CodeInvalidStatus, "支付状态不允许处理") + } if err := s.paymentStore.UpdateStatusWithOptimisticLockDB(ctx, tx, payment.ID, &oldPaymentStatus, model.PaymentRecordStatusPaid, &now); err != nil { if err == gorm.ErrRecordNotFound { return nil @@ -117,6 +123,10 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p return errors.Wrap(errors.CodeDatabaseError, err, "更新支付信息失败") } + if err := merchantpayment.RecordFirstSuccess(ctx, tx, payment, now); err != nil { + return err + } + oldRechargeStatus := model.RechargeOrderStatusPending if err := s.rechargeOrderStore.UpdateStatusWithOptimisticLockDB(ctx, tx, rechargeOrder.ID, &oldRechargeStatus, model.RechargeOrderStatusPaid, &now); err != nil { if err == gorm.ErrRecordNotFound { diff --git a/internal/service/refund/approval_decision.go b/internal/service/refund/approval_decision.go index b6fcf3f..38e41cf 100644 --- a/internal/service/refund/approval_decision.go +++ b/internal/service/refund/approval_decision.go @@ -62,6 +62,9 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, &order); err != nil { return err } + if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil { + return err + } if refund.Status == model.RefundStatusPending { changed = true result := tx.WithContext(ctx).Model(&model.RefundRequest{}). diff --git a/internal/service/refund/payment_merchant.go b/internal/service/refund/payment_merchant.go new file mode 100644 index 0000000..8d31055 --- /dev/null +++ b/internal/service/refund/payment_merchant.go @@ -0,0 +1,99 @@ +package refund + +import ( + "context" + "strings" + + "gorm.io/gorm" + + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// SetPaymentMerchantRuntime 注入冻结商户的当前凭证版本加载器。 +func (s *Service) SetPaymentMerchantRuntime(runtime *merchantpayment.RuntimeLoader) { + s.paymentMerchantRuntime = runtime +} + +// preparePaymentRefundCredentials 只为既有套餐订单退款流程装载和校验支付凭证;本 Change +// 不发起任何渠道退款请求。钱包支付和线下支付没有收款商户凭证,直接放行。 +// merchant_id 为空仅是留存期内的历史线上支付,独立 Change 清理后才可删除按 +// payment_config_id 读取的兼容路径,绝不能按当前商户池推断商户。 +func (s *Service) preparePaymentRefundCredentials(ctx context.Context, tx *gorm.DB, orderID uint) error { + var payment model.Payment + err := tx.WithContext(ctx). + Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid). + Order("id DESC"). + First(&payment).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联支付单失败") + } + // 钱包支付和线下支付没有收款商户凭证,不被本检查阻断。 + if payment.PaymentMethod == model.PaymentByWallet || payment.PaymentMethod == model.PaymentByOffline { + return nil + } + if payment.MerchantID != nil { + if s.paymentMerchantRuntime == nil { + return errors.New(errors.CodeServiceUnavailable, "支付商户加载能力未配置") + } + merchant, loadErr := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID) + if loadErr != nil { + return loadErr + } + return validateFrozenMerchantRefundCredentials(merchant) + } + if payment.PaymentConfigID == nil { + return errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置") + } + var legacy model.WechatConfig + if err := tx.WithContext(ctx).Unscoped().First(&legacy, *payment.PaymentConfigID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用") + } + return errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败") + } + return nil +} + +// validateFrozenMerchantRefundCredentials 只判断既有渠道流程所需凭证是否完整。 +// 当前系统没有任何渠道原路退款 Adapter,因此返回前不调用微信、支付宝或富友。 +func validateFrozenMerchantRefundCredentials(merchant *model.PaymentMerchant) error { + config, err := merchantpayment.MerchantConfig(merchant, nil) + if err != nil { + return err + } + switch config.ProviderType { + case model.ProviderTypeWechat: + if blank(config.WxMchID, config.WxAPIV3Key, config.WxCertContent, config.WxKeyContent, config.WxSerialNo, config.WxNotifyURL) { + return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整") + } + case model.ProviderTypeWechatV2: + if blank(config.WxMchID, config.WxAPIV2Key, config.WxNotifyURL) { + return errors.New(errors.CodeNoPaymentConfig, "冻结微信商户退款凭证不完整") + } + case model.ProviderTypeFuiou: + if blank(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey, config.FyPublicKey, config.FyAPIURL, config.FyNotifyURL) { + return errors.New(errors.CodeNoPaymentConfig, "冻结富友商户退款凭证不完整") + } + case "alipay": + if blank(config.AliAppID, config.AliPrivateKey, config.AliPublicKey, config.AliNotifyURL, config.AliReturnURL) { + return errors.New(errors.CodeNoPaymentConfig, "冻结支付宝商户退款凭证不完整") + } + default: + return errors.New(errors.CodeNoPaymentConfig, "冻结商户不支持现有退款流程") + } + return nil +} + +func blank(values ...string) bool { + for _, value := range values { + if strings.TrimSpace(value) == "" { + return true + } + } + return false +} diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 7c37fd9..5f24769 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -15,6 +15,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" @@ -56,6 +57,7 @@ type Service struct { notificationOutbox *outbox.Repository auditWriter *audit.Writer logger *zap.Logger + paymentMerchantRuntime *merchantpayment.RuntimeLoader } // New 创建退款业务服务实例 @@ -355,6 +357,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe if orderResult.RowsAffected == 0 { return errors.New(errors.CodeInvalidStatus, "订单状态已变更,无法执行退款审批") } + if err := s.preparePaymentRefundCredentials(ctx, tx, order.ID); err != nil { + return err + } if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil { return err diff --git a/internal/service/wechat_config/service.go b/internal/service/wechat_config/service.go index 1597fbe..f6c3413 100644 --- a/internal/service/wechat_config/service.go +++ b/internal/service/wechat_config/service.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" + merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -22,6 +23,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/wechat" ) // Redis 缓存键 @@ -544,6 +546,28 @@ func (s *Service) GetActiveConfig(ctx context.Context) (*model.WechatConfig, err return config, nil } +// GetAuthorizationConfig 读取唯一启用且按凭证版本缓存的 C 端微信授权配置。 +// 仅用于兼容现有微信 SDK 的配置结构,绝不回退到 tb_wechat_config。 +func (s *Service) GetAuthorizationConfig(ctx context.Context) (*model.WechatConfig, error) { + if s == nil || s.store == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "微信授权加载能力未配置") + } + authorization, err := merchantpayment.NewRuntimeLoader(s.store.DB(), s.redis).LoadAuthorization(ctx) + if err != nil { + return nil, err + } + return &model.WechatConfig{ + IsActive: true, + OaAppID: authorization.OaAppID, + OaAppSecret: authorization.OaAppSecret, + OaToken: authorization.OaToken, + OaAesKey: authorization.OaAesKey, + OaOAuthRedirectURL: authorization.OaOAuthRedirectURL, + MiniappAppID: authorization.MiniappAppID, + MiniappAppSecret: authorization.MiniappAppSecret, + }, nil +} + // GetActiveConfigForAPI 获取当前生效的支付配置(API 响应,已脱敏) func (s *Service) GetActiveConfigForAPI(ctx context.Context) (*dto.WechatConfigResponse, error) { config, err := s.GetActiveConfig(ctx) @@ -594,30 +618,74 @@ func (s *Service) mergeSensitiveField(target *string, newVal *string) { } } -// GetConfigForCallback 按订单号查找创建该订单时所用的支付配置(含已软删除/停用的记录) -// 回调验签必须使用创建订单时的密钥,否则签名不匹配。 -// 若找不到 payment_config_id(旧订单或数据缺失),回退到当前激活配置。 +// GetByIDUnscoped loads a historical comprehensive payment configuration, including soft-deleted rows. +func (s *Service) GetByIDUnscoped(ctx context.Context, id uint) (*model.WechatConfig, error) { + if s == nil || s.store == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "旧支付配置加载能力未配置") + } + return s.store.GetByIDUnscoped(ctx, id) +} + +// GetConfigForCallback 按支付单冻结的商户或旧支付配置加载回调凭证。 +// merchant_id 为空仅表示数据留存期内的历史支付:本 Change 只允许按其 +// payment_config_id 读取,独立 Change 完成留存清理后才可删除这条旧路径。 +// 禁止按当前启用池、当前综合配置推断或回填历史支付的实际商户。 func (s *Service) GetConfigForCallback(ctx context.Context, orderNo string) (*model.WechatConfig, error) { + if s.paymentStore != nil { + payment, err := s.paymentStore.GetByPaymentNo(ctx, orderNo) + if err == nil && payment.MerchantID != nil { + loader := merchantpayment.NewRuntimeLoader(s.store.DB(), s.redis) + merchant, loadErr := loader.LoadMerchant(ctx, *payment.MerchantID) + if loadErr != nil { + return nil, loadErr + } + // 仅微信直连(v3/v2)商户需要全局授权配置中的 AppID;富友商户不依赖该配置。 + // 授权字段只注入内存中的渠道配置,绝不写入支付快照、日志或审计。 + if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 { + authorization, authErr := loader.LoadAuthorization(ctx) + if authErr != nil { + return nil, authErr + } + return merchantpayment.MerchantConfig(merchant, authorization) + } + return merchantpayment.MerchantConfig(merchant, nil) + } + if err != nil && err != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询支付单路由失败") + } + if err == nil && payment.PaymentConfigID == nil { + return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置") + } + } + configID, err := s.resolvePaymentConfigID(ctx, orderNo) if err != nil { - s.logger.Warn("回调:查询订单 payment_config_id 失败,回退激活配置", - zap.String("order_no", orderNo), - zap.Error(err), - ) + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询历史支付配置失败") } - - if configID != nil { - cfg, err := s.store.GetByIDUnscoped(ctx, *configID) - if err == nil { - return cfg, nil - } - s.logger.Warn("回调:按 config_id 加载配置失败,回退激活配置", - zap.Uint("config_id", *configID), - zap.Error(err), - ) + if configID == nil { + return nil, errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置") } + cfg, loadErr := s.store.GetByIDUnscoped(ctx, *configID) + if loadErr != nil { + return nil, errors.Wrap(errors.CodeNoPaymentConfig, loadErr, "历史支付配置不可用") + } + return cfg, nil +} - return s.GetActiveConfig(ctx) +// GetPaymentServiceForCallback 使用已选定的支付配置构造回调验签实例。 +// 回调验签只能使用支付单冻结商户的当前凭证,不能复用进程启动时的旧支付实例。 +func (s *Service) GetPaymentServiceForCallback(config *model.WechatConfig) (wechat.PaymentServiceInterface, error) { + if s == nil || s.redis == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "微信支付回调加载能力未配置") + } + if config == nil || config.ProviderType != model.ProviderTypeWechat { + return nil, errors.New(errors.CodeWechatConfigUnavailable, "微信支付回调配置不可用") + } + app, err := wechat.NewPaymentAppFromConfig(config, config.OaAppID, wechat.NewRedisCache(s.redis), s.logger) + if err != nil { + return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "构建微信支付回调验签实例失败") + } + return wechat.NewPaymentService(app, s.logger), nil } // resolvePaymentConfigID 按订单号前缀从对应表中取 payment_config_id diff --git a/migrations/000210_add_payment_merchant_pools.down.sql b/migrations/000210_add_payment_merchant_pools.down.sql new file mode 100644 index 0000000..45bdfe1 --- /dev/null +++ b/migrations/000210_add_payment_merchant_pools.down.sql @@ -0,0 +1,32 @@ +-- 回滚商户池支付路由 Schema。 +-- 任一新路由支付、成功累计或新配置事实存在时均禁止破坏性回滚。 +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tb_payment WHERE merchant_id IS NOT NULL OR merchant_pool_id IS NOT NULL) + OR EXISTS (SELECT 1 FROM tb_payment_merchant_routing_success) + OR EXISTS (SELECT 1 FROM tb_payment_merchant) + OR EXISTS (SELECT 1 FROM tb_payment_merchant_pool) + OR EXISTS (SELECT 1 FROM tb_payment_merchant_pool_member) + OR EXISTS (SELECT 1 FROM tb_wechat_authorization) THEN + RAISE EXCEPTION '存在商户池支付路由业务事实,禁止回滚商户池支付路由迁移'; + END IF; +END $$; + +DROP INDEX IF EXISTS idx_payment_merchant_pool_route; +DROP INDEX IF EXISTS idx_payment_merchant_route; + +ALTER TABLE tb_payment + DROP COLUMN IF EXISTS routing_epoch, + DROP COLUMN IF EXISTS routing_strategy_snapshot, + DROP COLUMN IF EXISTS merchant_pool_name_snapshot, + DROP COLUMN IF EXISTS merchant_provider_type_snapshot, + DROP COLUMN IF EXISTS merchant_payment_method_snapshot, + DROP COLUMN IF EXISTS merchant_name_snapshot, + DROP COLUMN IF EXISTS merchant_pool_id, + DROP COLUMN IF EXISTS merchant_id; + +DROP TABLE IF EXISTS tb_payment_merchant_routing_success; +DROP TABLE IF EXISTS tb_wechat_authorization; +DROP TABLE IF EXISTS tb_payment_merchant_pool_member; +DROP TABLE IF EXISTS tb_payment_merchant_pool; +DROP TABLE IF EXISTS tb_payment_merchant; diff --git a/migrations/000210_add_payment_merchant_pools.up.sql b/migrations/000210_add_payment_merchant_pools.up.sql new file mode 100644 index 0000000..89b798f --- /dev/null +++ b/migrations/000210_add_payment_merchant_pools.up.sql @@ -0,0 +1,237 @@ +-- 商户池支付路由:收款商户、商户池、微信授权、成功统计事实及支付快照。 +-- 不使用数据库外键;关联以 ID 保存并由应用层显式校验。 + +CREATE TABLE tb_payment_merchant ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + payment_method VARCHAR(20) NOT NULL, + provider_type VARCHAR(30) NOT NULL, + merchant_identity VARCHAR(100) NOT NULL, + credentials JSONB NOT NULL DEFAULT '{}'::jsonb, + credential_version BIGINT NOT NULL DEFAULT 1, + status SMALLINT NOT NULL DEFAULT 0, + remark TEXT NOT NULL DEFAULT '', + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_payment_merchant_method CHECK (payment_method IN ('wechat', 'alipay')), + CONSTRAINT chk_payment_merchant_status CHECK (status IN (0, 1)), + CONSTRAINT chk_payment_merchant_credential_version CHECK (credential_version >= 1) +); + +CREATE UNIQUE INDEX uk_payment_merchant_method_identity_active + ON tb_payment_merchant (payment_method, merchant_identity) + WHERE deleted_at IS NULL; +CREATE INDEX idx_payment_merchant_method_status + ON tb_payment_merchant (payment_method, status, id) + WHERE deleted_at IS NULL; + +CREATE TABLE tb_payment_merchant_pool ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + payment_method VARCHAR(20) NOT NULL, + status SMALLINT NOT NULL DEFAULT 0, + strategy VARCHAR(20) NOT NULL, + threshold_amount BIGINT, + threshold_count BIGINT, + statistic_cycle VARCHAR(20), + time_period_value BIGINT, + time_period_unit VARCHAR(10), + time_period_started_at TIMESTAMPTZ, + routing_epoch BIGINT NOT NULL DEFAULT 1, + remark TEXT NOT NULL DEFAULT '', + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_payment_merchant_pool_method CHECK (payment_method IN ('wechat', 'alipay')), + CONSTRAINT chk_payment_merchant_pool_status CHECK (status IN (0, 1)), + CONSTRAINT chk_payment_merchant_pool_strategy CHECK (strategy IN ('amount', 'count', 'time')), + CONSTRAINT chk_payment_merchant_pool_epoch CHECK (routing_epoch >= 1), + CONSTRAINT chk_payment_merchant_pool_statistic_cycle CHECK ( + statistic_cycle IS NULL OR statistic_cycle IN ('round', 'day', 'month') + ), + CONSTRAINT chk_payment_merchant_pool_time_unit CHECK ( + time_period_unit IS NULL OR time_period_unit IN ('minute', 'hour', 'day') + ), + CONSTRAINT chk_payment_merchant_pool_strategy_parameters CHECK ( + (strategy = 'amount' AND threshold_amount IS NOT NULL AND threshold_amount > 0 + AND threshold_count IS NULL AND statistic_cycle IS NOT NULL + AND time_period_value IS NULL AND time_period_unit IS NULL AND time_period_started_at IS NULL) + OR + (strategy = 'count' AND threshold_count IS NOT NULL AND threshold_count > 0 + AND threshold_amount IS NULL AND statistic_cycle IS NOT NULL + AND time_period_value IS NULL AND time_period_unit IS NULL AND time_period_started_at IS NULL) + OR + (strategy = 'time' AND time_period_value IS NOT NULL AND time_period_value >= 1 + AND time_period_unit IS NOT NULL AND time_period_started_at IS NOT NULL + AND threshold_amount IS NULL AND threshold_count IS NULL AND statistic_cycle IS NULL) + ) +); + +CREATE UNIQUE INDEX uk_payment_merchant_pool_name_active + ON tb_payment_merchant_pool (name) + WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uk_payment_merchant_pool_enabled_method + ON tb_payment_merchant_pool (payment_method) + WHERE status = 1 AND deleted_at IS NULL; +CREATE INDEX idx_payment_merchant_pool_method_status + ON tb_payment_merchant_pool (payment_method, status, id) + WHERE deleted_at IS NULL; + +CREATE TABLE tb_payment_merchant_pool_member ( + id BIGSERIAL PRIMARY KEY, + pool_id BIGINT NOT NULL, + merchant_id BIGINT NOT NULL, + sort_order BIGINT NOT NULL, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_payment_merchant_pool_member_order CHECK (sort_order >= 0) +); + +CREATE UNIQUE INDEX uk_payment_merchant_pool_member_active + ON tb_payment_merchant_pool_member (pool_id, merchant_id) + WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uk_payment_merchant_pool_member_order_active + ON tb_payment_merchant_pool_member (pool_id, sort_order) + WHERE deleted_at IS NULL; +CREATE INDEX idx_payment_merchant_pool_member_merchant + ON tb_payment_merchant_pool_member (merchant_id, pool_id) + WHERE deleted_at IS NULL; + +CREATE TABLE tb_wechat_authorization ( + id BIGSERIAL PRIMARY KEY, + oa_app_id VARCHAR(100) NOT NULL DEFAULT '', + oa_app_secret VARCHAR(200) NOT NULL DEFAULT '', + oa_token VARCHAR(200) NOT NULL DEFAULT '', + oa_aes_key VARCHAR(200) NOT NULL DEFAULT '', + oa_oauth_redirect_url VARCHAR(500) NOT NULL DEFAULT '', + miniapp_app_id VARCHAR(100) NOT NULL DEFAULT '', + miniapp_app_secret VARCHAR(200) NOT NULL DEFAULT '', + credential_version BIGINT NOT NULL DEFAULT 1, + status SMALLINT NOT NULL DEFAULT 0, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_wechat_authorization_status CHECK (status IN (0, 1)), + CONSTRAINT chk_wechat_authorization_credential_version CHECK (credential_version >= 1) +); + +CREATE UNIQUE INDEX uk_wechat_authorization_enabled + ON tb_wechat_authorization ((status)) + WHERE status = 1 AND deleted_at IS NULL; + +CREATE TABLE tb_payment_merchant_routing_success ( + id BIGSERIAL PRIMARY KEY, + payment_id BIGINT NOT NULL, + merchant_id BIGINT NOT NULL, + pool_id BIGINT NOT NULL, + routing_epoch BIGINT NOT NULL, + amount BIGINT NOT NULL, + paid_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_payment_merchant_routing_success_payment UNIQUE (payment_id), + CONSTRAINT chk_payment_merchant_routing_success_epoch CHECK (routing_epoch >= 1), + CONSTRAINT chk_payment_merchant_routing_success_amount CHECK (amount > 0) +); + +CREATE INDEX idx_payment_merchant_routing_success_select + ON tb_payment_merchant_routing_success (pool_id, routing_epoch, merchant_id, paid_at, id); + +ALTER TABLE tb_payment + ADD COLUMN merchant_id BIGINT, + ADD COLUMN merchant_pool_id BIGINT, + ADD COLUMN merchant_name_snapshot VARCHAR(100), + ADD COLUMN merchant_payment_method_snapshot VARCHAR(20), + ADD COLUMN merchant_provider_type_snapshot VARCHAR(30), + ADD COLUMN merchant_pool_name_snapshot VARCHAR(100), + ADD COLUMN routing_strategy_snapshot VARCHAR(20), + ADD COLUMN routing_epoch BIGINT; + +CREATE INDEX idx_payment_merchant_route + ON tb_payment (merchant_id, routing_epoch, status, id) + WHERE deleted_at IS NULL AND merchant_id IS NOT NULL; +CREATE INDEX idx_payment_merchant_pool_route + ON tb_payment (merchant_pool_id, routing_epoch, id) + WHERE deleted_at IS NULL AND merchant_pool_id IS NOT NULL; + +-- 从唯一生效综合配置复制完整凭证。单成员池使用一分钟时间轮询, +-- 对单成员没有选择差异;后续成员或策略调整由管理配置显式产生新世代。 +DO $$ +DECLARE + active_count BIGINT; + legacy tb_wechat_config%ROWTYPE; + copied_merchant_id BIGINT; + copied_pool_id BIGINT; +BEGIN + SELECT COUNT(*) INTO active_count + FROM tb_wechat_config + WHERE is_active = TRUE AND deleted_at IS NULL; + + IF active_count > 1 THEN + RAISE EXCEPTION '存在多条生效综合支付配置,无法安全迁移商户池'; + END IF; + IF active_count = 0 THEN + RETURN; + END IF; + + SELECT * INTO legacy + FROM tb_wechat_config + WHERE is_active = TRUE AND deleted_at IS NULL; + + IF legacy.provider_type IN ('wechat', 'wechat_v2') + AND legacy.wx_mch_id <> '' + AND ((legacy.provider_type = 'wechat' AND legacy.wx_api_v3_key <> '' AND legacy.wx_cert_content <> '' AND legacy.wx_key_content <> '' AND legacy.wx_serial_no <> '' AND legacy.wx_notify_url <> '') + OR (legacy.provider_type = 'wechat_v2' AND legacy.wx_api_v2_key <> '' AND legacy.wx_notify_url <> '')) THEN + INSERT INTO tb_payment_merchant (name, payment_method, provider_type, merchant_identity, credentials, credential_version, status) + VALUES (legacy.name || '-微信商户', 'wechat', legacy.provider_type, legacy.wx_mch_id, + jsonb_build_object('wx_mch_id', legacy.wx_mch_id, 'wx_api_v3_key', legacy.wx_api_v3_key, 'wx_api_v2_key', legacy.wx_api_v2_key, 'wx_cert_content', legacy.wx_cert_content, 'wx_key_content', legacy.wx_key_content, 'wx_serial_no', legacy.wx_serial_no, 'wx_notify_url', legacy.wx_notify_url), 1, 1) + RETURNING id INTO copied_merchant_id; + ELSIF legacy.provider_type = 'fuiou' + AND legacy.fy_ins_cd <> '' AND legacy.fy_mchnt_cd <> '' AND legacy.fy_term_id <> '' + AND legacy.fy_private_key <> '' AND legacy.fy_public_key <> '' AND legacy.fy_api_url <> '' AND legacy.fy_notify_url <> '' THEN + INSERT INTO tb_payment_merchant (name, payment_method, provider_type, merchant_identity, credentials, credential_version, status) + VALUES (legacy.name || '-富友商户', 'wechat', 'fuiou', legacy.fy_mchnt_cd, + jsonb_build_object('fy_ins_cd', legacy.fy_ins_cd, 'fy_mchnt_cd', legacy.fy_mchnt_cd, 'fy_term_id', legacy.fy_term_id, 'fy_private_key', legacy.fy_private_key, 'fy_public_key', legacy.fy_public_key, 'fy_api_url', legacy.fy_api_url, 'fy_notify_url', legacy.fy_notify_url), 1, 1) + RETURNING id INTO copied_merchant_id; + END IF; + IF copied_merchant_id IS NOT NULL THEN + INSERT INTO tb_payment_merchant_pool (name, payment_method, status, strategy, time_period_value, time_period_unit, time_period_started_at, routing_epoch) + VALUES ('迁移微信商户池', 'wechat', 1, 'time', 1, 'minute', NOW(), 1) + RETURNING id INTO copied_pool_id; + INSERT INTO tb_payment_merchant_pool_member (pool_id, merchant_id, sort_order) VALUES (copied_pool_id, copied_merchant_id, 0); + END IF; + + copied_merchant_id := NULL; + IF legacy.ali_app_id <> '' AND legacy.ali_private_key <> '' AND legacy.ali_public_key <> '' AND legacy.ali_notify_url <> '' AND legacy.ali_return_url <> '' THEN + INSERT INTO tb_payment_merchant (name, payment_method, provider_type, merchant_identity, credentials, credential_version, status) + VALUES (legacy.name || '-支付宝商户', 'alipay', 'alipay', legacy.ali_app_id, + jsonb_build_object('ali_app_id', legacy.ali_app_id, 'ali_private_key', legacy.ali_private_key, 'ali_public_key', legacy.ali_public_key, 'ali_notify_url', legacy.ali_notify_url, 'ali_return_url', legacy.ali_return_url, 'ali_production', legacy.ali_production, 'ali_pay_expire_minutes', legacy.ali_pay_expire_minutes), 1, 1) + RETURNING id INTO copied_merchant_id; + INSERT INTO tb_payment_merchant_pool (name, payment_method, status, strategy, time_period_value, time_period_unit, time_period_started_at, routing_epoch) + VALUES ('迁移支付宝商户池', 'alipay', 1, 'time', 1, 'minute', NOW(), 1) + RETURNING id INTO copied_pool_id; + INSERT INTO tb_payment_merchant_pool_member (pool_id, merchant_id, sort_order) VALUES (copied_pool_id, copied_merchant_id, 0); + END IF; + + IF legacy.oa_app_id <> '' AND legacy.oa_app_secret <> '' + AND legacy.miniapp_app_id <> '' AND legacy.miniapp_app_secret <> '' THEN + INSERT INTO tb_wechat_authorization (oa_app_id, oa_app_secret, oa_token, oa_aes_key, oa_oauth_redirect_url, miniapp_app_id, miniapp_app_secret, credential_version, status) + VALUES (legacy.oa_app_id, legacy.oa_app_secret, legacy.oa_token, legacy.oa_aes_key, legacy.oa_oauth_redirect_url, legacy.miniapp_app_id, legacy.miniapp_app_secret, 1, 1); + END IF; +END $$; + +COMMENT ON TABLE tb_payment_merchant IS '实际收款商户,凭证仅在受控字段保存'; +COMMENT ON TABLE tb_payment_merchant_pool IS '按支付方式轮询实际收款商户的商户池'; +COMMENT ON TABLE tb_payment_merchant_pool_member IS '商户池有序成员;支付方式一致性由应用事务校验'; +COMMENT ON TABLE tb_wechat_authorization IS 'C端微信授权配置,不保存收款商户凭证'; +COMMENT ON TABLE tb_payment_merchant_routing_success IS '支付首次成功的路由累计唯一事实'; +COMMENT ON COLUMN tb_payment.routing_epoch IS '支付创建时冻结的商户池统计世代'; diff --git a/openspec/changes/add-payment-merchant-pools/design.md b/openspec/changes/add-payment-merchant-pools/design.md index d6c9c7d..ab5e584 100644 --- a/openspec/changes/add-payment-merchant-pools/design.md +++ b/openspec/changes/add-payment-merchant-pools/design.md @@ -5,16 +5,22 @@ ## Decisions ### 独立实体与不可变路由快照 -新增商户、商户池、池成员、微信授权配置及金额/笔数统计事实;支付单扩展实际商户和商户池 ID 及非敏感快照。商户凭证只留在商户表受控字段,支付单/审计不复制。被引用后锁定商户支付方式、服务商与身份,避免历史验签与退款语义漂移。 +新增商户、商户池、池成员、微信授权配置及金额/笔数统计事实;支付单扩展实际商户和商户池 ID、非敏感快照及创建时的 `routing_epoch`。商户凭证只留在商户表受控字段,支付单/审计不复制。被引用后锁定商户支付方式、服务商与身份,避免历史验签与退款语义漂移。 ### 路由和统计在支付创建/成功边界闭合 -支付创建在事务内读取唯一启用池、按方式选择成员并冻结路由;无成员/池失败即返回。金额/笔数统计只在现有“支付成功首次生效”路径按实际商户条件递增,以支付 ID 唯一约束避免重复回调累计;不在预下单预占。时间方式由当前时间和受控起点计算,不写逐次路由日志。 +支付创建在事务内读取唯一启用池、按方式选择成员并冻结路由和 `routing_epoch`;无成员/池失败即返回。`routing_epoch` 是该次创建命中的池策略、统计周期与成员排序世代。金额/笔数统计只在现有“支付成功首次生效”路径以支付 ID 唯一事实按冻结商户和 epoch 递增,不在预下单预占。迟到的首次成功仍记入冻结 epoch,当前选择器只读取当前 epoch,因此周期切换、成员调整或排序变化不改写已创建支付的归属。时间方式由当前时间和受控起点计算,不写逐次路由日志。 + +### 凭证版本与读取一致性 +商户和微信授权配置各保存递增的凭证版本。凭证更新在事务提交时递增版本;支付创建、回调验签、查单和退款先从主库取得当前版本,再使用“配置 ID + 版本”读取缓存。旧版本缓存提交后可删除,但即使并发残留也不得再被新读取命中;缓存不可用时读取当前数据库记录。历史支付仍读取命中商户的当前有效凭证,以支持轮换。 ### 新旧配置双读切换 -新支付单具有 merchant ID 时,支付加载、回调验签、查询和退款均从商户加载服务商凭证;merchant ID 为空的历史单保持现有 `payment_config_id` 路径。迁移先复制生效配置中完整凭证,再发布新支付路径;不可将现有记录批量回填为新商户,因为其实际历史身份无法保证一致。 +迁移完成后部署支持 merchant ID 或旧 `payment_config_id` 双读的 API/Worker。三类后续新支付立即只走商户池并在创建事务冻结路由;无可用池、成员或池停用时明确失败,绝不回退旧综合支付配置创建。merchant ID 为空仅表示历史支付,回调验签、查询和已有可达的原路退款继续按 `payment_config_id` 处理,直到独立 Change 根据数据留存期删除旧读取路径。故障只允许在仍支持双读的版本上前向修复;一旦存在 merchant ID 非空支付、成功事实或新配置,禁止部署不识别新路由的旧二进制和执行破坏性 down。不可将现有记录批量回填为新商户,因为其实际历史身份无法保证一致。 ### 敏感配置边界 -后台管理响应按 PRD 向两类已认证管理角色完整返回凭证;所有 logger、错误、审计 payload、支付单快照和导出只允许写 ID、名称和脱敏/非敏感身份。更新凭证后使现有配置加载缓存失效;历史回调读取最新有效凭证以支持轮换。 +仅超级管理员和平台用户的专用管理列表、详情响应可完整返回凭证,管理写入请求可传递凭证;所有其他 DTO、logger、错误、审计 payload、支付单快照和导出只允许写 ID、名称和脱敏/非敏感身份。 + +### 已确认范围 +本 Change 只为已有且可达的原路退款路径提供冻结商户的凭证加载与能力判定,不新增微信、支付宝、富友或其他渠道退款调用;服务商没有既有退款能力时保持不支持,且不提供人工开关。富友 `CommonQuery` 保持现有请求格式、签名算法、验签、状态映射和恢复语义的兼容基线,只实现本地双读配置来源和商户加载,不改变协议或业务能力,富友退款不新增;未第三方实测可记录但不得作为实施、验证或归档阻塞。零条 active 综合配置时仅创建 Schema 和管理入口,不插入商户、商户池或微信授权配置数据。代理在线充值的全局允许范围、交集计算和对外方式查询由对应代理自充 Change 负责;本 Change 在方式已获准后负责商户池可用性与实际商户冻结。 ## 管理与支付动作契约 @@ -46,13 +52,16 @@ ## Risks / Trade-offs - 并发成功回调超过阈值:这是“只统计成功、不预占”的明确结果,下一次选路才跳过。 -- 商户停用后的退款:停用不阻断历史退款,实际调用仍由凭证/渠道结果决定。 -- 迁移缺失凭证:不造空商户池,受影响新支付明确失败。 +- 商户停用后的退款:停用不阻断已有历史退款路径,实际调用仍由当前凭证、服务商已有能力和渠道结果决定;本 Change 不新增退款能力。 +- 零条 active 综合配置:仅创建 Schema 和管理入口,不创建业务配置数据;所有新线上支付明确失败,微信授权相关功能明确返回未配置。一条 active 综合配置中支付凭证不完整的方式不建池,微信授权字段不完整时不建授权配置且相关 C 端微信功能明确失败;多条 active 综合配置无法安全选择来源,迁移必须失败。 - 旧回调误入新路径:以支付单 merchant ID 为唯一分流条件,严禁按当前启用池推断。 +- 富友查单:`CommonQuery` 保持现有请求格式、签名算法、验签、状态映射和恢复语义,只调整本地双读配置来源和商户加载;不改变协议、状态解释、恢复规则或业务能力,不增加富友退款。未第三方实测仅作为记录,不阻塞本 Change。 ## Migration Plan -1. 新增成对迁移创建商户/池/成员/微信授权表、唯一启用约束、支付单路由列和成功统计索引。 -2. 在迁移事务中从唯一生效综合配置复制完整凭证并创建单成员池;全过程禁止日志输出密钥/证书。 -3. 隔离库验证微信直连、富友、支付宝、空配置、停用历史商户、回调兼容、三种轮询及 up/down/up。 -4. 回滚前停止新支付创建;有新路由单时仅回退应用流量,不执行会破坏新支付事实的 down。 \ No newline at end of file +1. 新增成对迁移创建商户/池/成员/微信授权表、唯一启用约束、支付单路由与 `routing_epoch` 列、凭证版本及成功统计唯一索引;迁移编号在实施开始前按当时迁移目录的最大编号确定,本规划不预占编号。 +2. 迁移遇到一条 active 综合配置时只复制完整支付凭证形成对应单成员池;微信授权字段完整时创建授权配置。零条 active 配置时仅创建 Schema 和管理入口,不插入商户、商户池或微信授权配置数据;多条 active 配置时中止迁移。全过程禁止日志输出密钥/证书。 +3. 在本地工作区以明确 `DB_*` 指向维护者提供的 `junhong_cmp_test` PostgreSQL 与 Redis DB6,执行 migration up/down/up 和数据行为验证;允许本 Change fixture 创建/删除,禁止重置整个库。完成验证后才提交推送 Iteration/8-11;仅连接、迁移或实际行为失败时阻塞对应验证。 +4. Iteration/8-11 分支 Gitea 仅以本次提交 SHA 构建/部署 `cmp-test` 测试镜像并检查 migration version,不自动执行 migration up/down 或重置。记录 API/Worker 容器状态、健康检查和 `/opt/junhong_cmp/logs` 的有限日志;不建运行时开关,这不是生产发布。 +5. 迁移完成后的部署版本支持 merchant ID 或旧 `payment_config_id` 双读;三类后续新支付立即只走商户池并冻结路由,无池、成员或停用池时明确失败且无旧创建回退。验证新支付始终走池、历史 merchant ID 为空支付继续旧读取路径。故障只允许在仍支持双读的版本上前向修复;存在 merchant ID 非空支付、成功事实或新配置时,禁止部署不识别新路由的旧二进制和执行破坏性 down。 +6. 富友 `CommonQuery` 仅保持现有请求格式、签名算法、验签、状态映射和恢复语义并完成本地双读商户加载,不改变协议或增加退款;未第三方实测仅记录,不阻塞验证或归档。验证覆盖微信直连、富友、支付宝、零/一/多 active 配置、授权或支付凭证缺失、停用历史商户、回调兼容、三种轮询、迟到成功归属、凭证轮换及 up/down/up。 \ No newline at end of file diff --git a/openspec/changes/add-payment-merchant-pools/proposal.md b/openspec/changes/add-payment-merchant-pools/proposal.md index fe1fa9e..1be5177 100644 --- a/openspec/changes/add-payment-merchant-pools/proposal.md +++ b/openspec/changes/add-payment-merchant-pools/proposal.md @@ -1,6 +1,6 @@ ## Why -当前所有线上支付依赖一份综合支付配置,无法按支付方式在多个实际收款商户之间受控轮询;创建支付后的商户事实也不足以让停用商户的历史回调、查单和原路退款继续安全执行。 +当前所有线上支付依赖一份综合支付配置,无法按支付方式在多个实际收款商户之间受控轮询;已存在且可达的回调、查单和原路退款路径也缺少冻结商户事实,无法在商户停用后继续安全加载对应凭证。 本 Change 落实 AUG26-002:将收款商户、支付路由和 C 端微信授权分离,并以商户池快照取代新业务对旧综合支付配置的依赖。 @@ -9,8 +9,9 @@ - 新增独立商户管理:微信商户与支付宝商户分别建档;商户保存支付能力和敏感凭证,但历史支付单仅保存非敏感身份快照。 - 新增每种支付方式至多一个启用商户池,支持按成功收款金额、成功笔数或时间周期轮询;新支付仅从命中池选择实际商户。 - 新增全局唯一启用的微信授权配置,专供 C 端公众号 H5/JSSDK、小程序登录、OpenID 和支付 AppID;它不是微信收款商户。 -- 新建 C 端套餐购买、资产钱包充值、代理在线预存款充值按商户池路由;后台线下/钱包支付不经过商户池。无可用商户时失败,不得回退旧配置或自动换商户重试。 -- 从当前生效综合支付配置一次性复制完整凭证形成新商户、单成员商户池及微信授权配置;新支付切换后,旧配置和历史订单仅继续处理其自身回调、查询和退款。 +- 新建 C 端套餐购买、资产钱包充值、代理在线预存款充值按商户池路由;后台线下/钱包支付不经过商户池。代理全局允许范围、其与商户池方式的交集及对外方式查询仍由对应代理自充 Change 负责;本 Change 只在方式已获准后选择实际商户。无可用商户时失败,不得回退旧配置或自动换商户重试。 +- 迁移完成后部署支持按 merchant ID 或旧 `payment_config_id` 双读的 API/Worker;三类后续新支付立即只走商户池并冻结路由,无可用池、成员或停用池时明确失败且不回退旧综合支付配置。merchant ID 为空的仅为历史订单,继续处理其自身回调、查单和已有可达的原路退款,直到独立 Change 按数据留存期删除旧读取路径。 +- 零条当前生效综合支付配置时仅创建新 Schema 和管理入口,不插入空商户、空商户池或空微信授权配置;新线上支付明确失败,微信授权功能明确返回未配置。富友 `CommonQuery` 保持现有请求、签名、验签、状态和恢复兼容基线,只实现本地双读凭证与商户加载,不改协议或退款;未第三方实测可记录但不构成本 Change 实施或归档阻塞。验证在本地工作区以明确 `DB_*` 指向维护者指定的 `junhong_cmp_test` PostgreSQL、Redis DB6 执行;完成后才提交推送 Iteration/8-11,由 Gitea 构建/部署 `cmp-test` 测试镜像并检查迁移版本,日志位于 `/opt/junhong_cmp/logs`,不重置整库。 ## Capabilities @@ -20,7 +21,7 @@ ### Modified Capabilities -- 无。该能力向既有支付创建、回调和退款调用链提供已选商户事实,不改变其资金幂等不变量。 +- 无。该能力向既有支付创建、回调、查单和已有原路退款调用链提供已选商户事实,不改变既有资金幂等不变量,也不新增渠道退款能力。 ## Impact diff --git a/openspec/changes/add-payment-merchant-pools/specs/merchant-payment-routing/spec.md b/openspec/changes/add-payment-merchant-pools/specs/merchant-payment-routing/spec.md index 41df335..33e6666 100644 --- a/openspec/changes/add-payment-merchant-pools/specs/merchant-payment-routing/spec.md +++ b/openspec/changes/add-payment-merchant-pools/specs/merchant-payment-routing/spec.md @@ -1,13 +1,13 @@ ## Purpose -管理实际收款商户、商户池轮询和全局微信授权配置,使新线上支付的收款身份可冻结、历史支付可继续使用其原商户,并避免凭证泄露或无配置时静默回退。 +管理实际收款商户、商户池轮询和全局微信授权配置,使新线上支付的收款身份可冻结、历史支付可继续使用其原商户,并避免凭证泄露或无配置时静默回退;本能力不新增任何支付渠道退款能力。 ## ADDED Requirements ### Requirement: 商户与微信授权配置管理 系统 SHALL 将实际收款商户与微信授权配置分离。一个商户 MUST 仅对应 `wechat` 或 `alipay` 一种支付方式,并保存名称、支付方式、服务商类型、商户号或应用标识、敏感凭证、状态和备注;微信直连与富友均为微信支付商户。平台最多存在一个启用的微信授权配置,该配置保存 C 端公众号 H5/JSSDK、小程序登录所需参数,C 端微信登录、OpenID 和微信支付 AppID MUST 只读取该配置。 -超级管理员和平台用户可创建、编辑、启用、停用商户、商户池和微信授权配置,其他角色无管理入口。被支付单引用的商户 MUST NOT 删除且其支付方式、服务商类型、商户号/应用标识不得修改;未被引用商户仅可移出所有商户池并经二次确认删除。停用只影响新支付单,历史支付的回调、查单和原路退款仍使用该商户当前凭证。管理 API 可向上述已认证管理角色返回完整凭证,但日志、审计快照、错误和普通业务响应 MUST NOT 保存或返回敏感凭证。 +超级管理员和平台用户可创建、编辑、启用、停用商户、商户池和微信授权配置,其他角色无管理入口。仅上述角色的专用管理列表和详情响应可返回完整凭证;日志、审计快照、错误、导出、支付快照和其他业务响应 MUST NOT 保存或返回敏感凭证。被支付单引用的商户 MUST NOT 删除且其支付方式、服务商类型、商户号/应用标识不得修改;未被引用商户仅可移出所有商户池并经二次确认删除。停用只影响新支付单,历史支付的回调、查单和已有可达的原路退款仍使用该商户当前凭证;服务商无既有退款能力时保持不支持,本能力不得为此新增渠道退款调用或人工开关。 #### Scenario: 受引用商户停用 - **WHEN** 管理员停用已被支付单命中的商户 @@ -17,28 +17,73 @@ - **WHEN** 不具备超级管理员或平台用户身份的账号请求商户或微信授权配置 - **THEN** 系统拒绝访问且不返回任何凭证或身份字段 +#### Scenario: 凭证轮换后处理历史支付 +- **GIVEN** 已被支付单引用的商户或当前微信授权配置完成凭证更新 +- **WHEN** 更新提交后创建支付、处理回调、查单或原路退款 +- **THEN** 系统只使用更新后的当前有效凭证,不得继续使用更新前的缓存凭证 + ### Requirement: 商户池唯一性与轮询配置 系统 SHALL 为每种支付方式最多启用一个商户池;停用历史池可保留,但不得同时启用多个同支付方式池。商户池成员支付方式 MUST 与池一致,成员按明确顺序排列;金额/笔数方式必须配置 `每轮累计`、`自然日累计` 或 `自然月累计` 统计周期,时间方式必须配置最小为 1 分钟的数值、单位和起始时间。 -金额和笔数轮询只统计已确认支付成功结果,不在预下单时预占,也不因退款回冲。达到阈值的成员在当前周期跳过;所有成员达到阈值时新支付失败。时间轮询自起始时间按固定时段和成员顺序选择,成员停用即时跳下一个可用成员但不重置时段。预下单失败不得自动切换或重试,失败单不计入统计;客户再次发起时重新选择。修改阈值保留当前统计,修改统计周期、金额/笔数方式、时间周期、起始时间或每轮排序按 PRD 规则开启新周期;自然周期排序调整保留未移除成员累计。 +金额和笔数轮询只统计已确认支付成功结果,不在预下单时预占,也不因退款回冲。支付创建时 MUST 冻结本次路由所属的统计世代;首次成功只按支付 ID 一次性计入冻结商户和冻结统计世代,当前选路只读取当前统计世代。达到阈值的成员在当前周期跳过;所有成员达到阈值时新支付失败。时间轮询自起始时间按固定时段和成员顺序选择,成员停用即时跳下一个可用成员但不重置时段。预下单失败不得自动切换或重试,失败单不计入统计;客户再次发起时重新选择。修改阈值保留当前统计,修改统计周期、金额/笔数方式、时间周期、起始时间或每轮排序按 PRD 规则开启新周期;自然周期排序调整保留未移除成员累计。 #### Scenario: 并发预下单未预占额度 - **WHEN** 多个客户并发创建金额或笔数轮询支付单且当前成员尚未达到阈值 - **THEN** 系统可使这些支付单均命中当前成员,只有后续确认成功的支付才计入累计,已创建支付单不因轮询切换改挂商户 +#### Scenario: 迟到首次成功归属冻结统计世代 +- **GIVEN** 支付已创建但尚未成功,之后商户池切换统计周期、成员或排序 +- **WHEN** 该支付首次确认成功 +- **THEN** 系统仅将金额或笔数写入该支付创建时冻结的商户和统计世代,不得改写当前选路统计或重复累计 + #### Scenario: 当期没有可用商户 - **WHEN** 启用商户池中不存在启用且未达阈值的成员,或商户池已停用 - **THEN** 系统拒绝创建新支付单并提示暂无可用商户,不回退到旧综合支付配置 ### Requirement: 新支付商户快照与历史兼容 -C 端套餐购买、C 端资产钱包充值及代理在线预存款充值 SHALL 按支付方式通过对应启用商户池选择实际商户;后台线下订单和钱包余额支付 MUST NOT 经过商户池。每笔通过商户池创建的支付单 MUST 保存商户 ID、商户名称/支付方式/服务商类型/商户号或应用标识快照、商户池 ID/名称快照及轮询方式快照,但不得复制敏感凭证。支付、回调验签、查单和原路退款读取该实际商户当前凭证;商户退款能力只由服务商类型和退款必需凭证完整性决定,不提供人工开关。 +C 端套餐购买、C 端资产钱包充值及代理在线预存款充值 SHALL 按支付方式通过对应启用商户池选择实际商户;后台线下订单和钱包余额支付 MUST NOT 经过商户池。代理在线充值的全局允许范围、其与商户池方式的交集及对外方式查询由对应代理自充能力定义;本能力在方式已获准后负责实际商户选择,并在无可用商户时拒绝创建。每笔通过商户池创建的支付单 MUST 保存商户 ID、商户名称/支付方式/服务商类型/商户号或应用标识快照、商户池 ID/名称快照、轮询方式快照及统计世代快照,但不得复制敏感凭证。支付、回调验签、查单和已有可达的原路退款读取该实际商户当前凭证;服务商类型和退款必需凭证完整性只用于已有退款路径的能力判定,不提供人工开关,也不得新增渠道退款能力。 -上线迁移 MUST 从当前生效综合支付配置复制完整凭证:创建全局微信授权配置、微信/支付宝商户和各自单成员启用池。凭证不完整的方式不建池;迁移仅在数据库复制敏感数据。新订单必须只走商户池;旧配置和其历史订单不改写,继续服务历史回调、查询和退款。 +上线迁移在存在唯一当前生效综合支付配置时复制完整凭证:具备完整凭证的微信/支付宝方式创建商户和各自单成员启用池,微信授权字段完整时创建全局微信授权配置;不完整的支付方式不建池,授权字段不完整时不建授权配置并使相关 C 端微信功能明确失败。没有当前生效综合支付配置时迁移仅创建 Schema 和管理入口,不创建商户、商户池或微信授权配置数据;新订单按暂无可用商户失败,微信授权相关功能明确返回未配置。存在多条当前生效综合支付配置时迁移 MUST 失败。迁移完成后部署支持按 merchant ID 或旧 `payment_config_id` 双读的 API/Worker;三类后续新支付 MUST 立即只走商户池并冻结路由,无可用池、成员或停用池时明确失败且不回退旧综合支付配置。merchant ID 为空仅表示历史订单,继续按 `payment_config_id` 服务历史回调、查询和已有原路退款,直到独立 Change 按数据留存期删除旧读取路径。本 Change MUST NOT 自动删除旧配置或旧读取路径。 #### Scenario: 新支付冻结实际商户 - **WHEN** 客户以微信或支付宝创建覆盖范围内的新线上支付单 - **THEN** 系统选择并冻结一个实际商户和商户池路由快照,并使用该商户的服务商凭证发起支付 +#### Scenario: 迁移后的新支付只走商户池 +- **GIVEN** 迁移完成且已部署支持 merchant ID 或旧 `payment_config_id` 双读的 API/Worker +- **WHEN** 客户创建覆盖范围内的后续新线上支付 +- **THEN** 系统立即经启用商户池选择并冻结实际商户;无可用池、成员或池已停用时明确失败,不得走旧综合支付配置创建 + +#### Scenario: 历史支付保留旧读取路径 +- **GIVEN** 支付单 merchant ID 为空 +- **WHEN** 该支付经过回调、查单或已有原路退款路径 +- **THEN** 系统仅按其 `payment_config_id` 处理,不因当前商户池推断或改写其商户,直到独立 Change 按数据留存期删除旧读取路径 + #### Scenario: 旧支付单回调 - **WHEN** 商户池切换后收到未带新商户快照的历史支付单回调 - **THEN** 系统按既有综合支付配置兼容处理该历史单,不将其改挂到任何新商户 + +#### Scenario: 微信授权配置迁移缺失 +- **GIVEN** 当前生效综合支付配置的微信授权字段不完整 +- **WHEN** 执行商户池配置迁移后访问 C 端微信登录、OpenID、JSSDK 或微信支付 AppID 功能 +- **THEN** 系统明确返回微信授权未配置,不得回退旧综合支付配置 + +#### Scenario: 多个当前生效综合支付配置 +- **GIVEN** 存在多条当前生效综合支付配置 +- **WHEN** 执行商户池配置迁移 +- **THEN** 迁移失败且不选择任一配置作为复制来源 + +#### Scenario: 富友双读兼容基线 +- **GIVEN** 富友 `CommonQuery` 未经第三方实测 +- **WHEN** 系统以商户当前凭证完成富友支付的本地双读配置来源和商户加载接线,且不改变现有 `CommonQuery` 请求格式、签名算法、验签、状态映射或恢复语义 +- **THEN** 系统保留该兼容基线且不新增富友退款;未第三方实测可以记录,但不得作为本 Change 的实施、验证或归档阻塞 + +#### Scenario: 维护者指定测试环境的配置迁移验证 +- **GIVEN** 维护者指定的 `junhong_cmp_test` PostgreSQL、Redis DB6 与当前 Change fixture +- **WHEN** 本地工作区以明确 `DB_*` 完成 migration up/down/up 和数据行为验证后提交推送 Iteration/8-11 +- **THEN** Gitea 只构建/部署 `cmp-test` 测试镜像并检查迁移版本;测试日志保存在 `/opt/junhong_cmp/logs`,不得自动执行迁移或重置整库 + +#### Scenario: 历史支付路径保留 +- **GIVEN** 存在 merchant ID 为空的历史支付 +- **WHEN** 商户池功能已上线且独立 Change 尚未按数据留存期删除旧读取路径 +- **THEN** 系统仍按该支付的 `payment_config_id` 处理回调、查询和退款,不自动删除旧配置或将其改挂商户 diff --git a/openspec/changes/add-payment-merchant-pools/tasks.md b/openspec/changes/add-payment-merchant-pools/tasks.md index 5c627a2..f94e8c8 100644 --- a/openspec/changes/add-payment-merchant-pools/tasks.md +++ b/openspec/changes/add-payment-merchant-pools/tasks.md @@ -1,20 +1,21 @@ -## 1. 数据与配置管理 +## 1. 数据、配置与双读基础 -- [ ] 1.1 追踪 `tb_wechat_config`、订单/充值、`tb_payment`、支付加载器和三类回调的现有 `payment_config_id` 读写链路,列出新旧分流点和敏感字段清单。 -- [ ] 1.2 新增成对迁移:商户、商户池、成员、微信授权配置、成功累计/路由快照所需表列、唯一启用/成员支付方式/历史引用约束和查询索引;不得修改既有迁移。 -- [ ] 1.3 实现商户、商户池及微信授权配置模型、管理 Query/Handler/RouteSpec:管理角色权限、启停、成员排序、受引用字段锁定、移出后确认删除及无敏感审计。 -- [ ] 1.4 实现迁移时从当前生效综合支付配置复制完整微信授权、微信/支付宝商户和单成员池;不完整方式不创建池,迁移日志不得含凭证。 +- [x] 1.1 冻结现状契约与新旧分流边界:追踪 `tb_wechat_config`、订单/充值、`tb_payment`、支付加载器、微信/支付宝/富友回调、查单和现有退款处理的 `payment_config_id` 读写链路,形成文件/符号级实现清单。明确新单以 `merchant_id` 非空走商户加载,历史 `merchant_id` 为空继续走旧 `payment_config_id`;明确三类新支付仅为 C 端套餐购买、C 端资产钱包充值、代理在线预存款充值,后台线下订单、后台钱包余额支付和员工线下代充值不经过商户池;列出敏感字段、富友 CommonQuery 接缝及退款 A(仅商户加载/能力判定)的边界。不得修改源码。 +- [x] 1.2 新增成对 Schema 迁移:创建商户、商户池、成员、微信授权配置、凭证版本、成功累计唯一事实及支付路由/统计世代快照所需表列;建立每种支付方式最多一个启用池、成员支付方式一致、历史引用保护、唯一累计事实和查询索引约束。不得修改既有迁移,不把迁移文件编号写死在任务或实现契约中。 +- [x] 1.3 实现商户、商户池与微信授权配置管理:交付模型、Query、Handler、可执行 RouteSpec 及管理审计;写操作仅限超级管理员和平台用户;实现启停、成员顺序、引用字段锁定、移除后二次确认删除、唯一启用池和全局唯一微信授权配置。C 端微信登录、OpenID、JSSDK 和支付 AppID 只读授权配置;普通 DTO、错误、日志、审计、快照、导出不得返回或保存敏感凭证。 +- [x] 1.4 先行实现商户双读与凭证版本化加载:在三类新支付接入前,使商户/微信授权凭证更新在事务提交时递增版本;支付创建、回调验签、查单和退款 A 先从主库取得当前版本,再按“配置 ID + 版本”读取缓存,旧版本提交后不得被新读取命中,缓存不可用时回源当前数据库记录。`merchant_id` 非空的新单走商户服务商凭证,空值历史单走 `payment_config_id`;不得按当前启用池推断历史商户。 +- [x] 1.5 实现上线迁移与零 active A 规则:存在唯一当前生效综合支付配置时,仅复制完整凭证;按完整微信/支付宝凭证分别创建商户及单成员启用池,微信授权字段完整时创建全局授权配置;凭证不完整的方式不建池,授权字段不完整不建授权配置。零条 active 综合支付配置时仅创建新 Schema 和管理入口,绝不插入商户、商户池或微信授权业务行;新线上支付明确按“暂无可用商户”失败且不回退旧配置。多条 active 配置时迁移失败且不选择来源。敏感数据仅在数据库复制,迁移日志不得输出凭证,不批量回填既有支付单 `merchant_id`。 -## 2. 商户池选择与支付链路 +## 2. 商户池与支付链路 -- [ ] 2.1 实现金额、笔数、时间轮询选择器及池配置变更重置规则;使用事务/受控查询保证唯一启用池和成员状态一致。 -- [ ] 2.2 在 C 端套餐支付、资产钱包充值、代理在线充值的创建路径接入选择器,保存商户/池/方式非敏感快照;无可用商户和预下单失败不回退、不换商户。 -- [ ] 2.3 在支付成功首次生效路径按支付 ID 幂等累计金额/笔数;退款不得回冲,失败/重复回调不得计入。 -- [ ] 2.4 改造支付加载、微信/支付宝/富友回调、查单和原路退款:新单读取实际商户,历史 merchant ID 为空的单继续读取旧 `payment_config_id`;停用商户仍可处理历史单。 -- [ ] 2.5 清理日志、错误、审计和普通 DTO 中的敏感凭证,管理 API 仅向超级管理员/平台用户按 PRD 返回完整配置并使凭证更新刷新加载缓存。 +- [x] 2.1 实现商户池选择器与统计世代规则:支持金额、笔数、时间轮询;金额/笔数支持每轮累计、自然日累计、自然月累计,时间周期最小 1 分钟并带单位和起始时间。支付创建事务内选择启用且未达阈值成员,冻结商户、池、轮询快照和 `routing_epoch`;修改阈值保留当前统计,修改统计周期、策略、时间周期、起始时间或每轮排序按规则开启新世代;自然周期仅排序调整时保留未移除成员累计;停用成员即时跳过但不改写既有支付。不得预下单预占。 +- [x] 2.2 接入支付成功首次生效统计:以支付 ID 唯一事实按支付创建时冻结的商户和 `routing_epoch` 累计金额/笔数;迟到首次成功仍写入冻结世代,不改写当前选路世代;失败、关闭、预下单和退款不计入;重复回调、重复事件和重复执行不得重复累计。 +- [x] 2.3 接入三类新支付创建并保持代理边界:在双读/版本加载和选择器完成后,将商户池选择接入 C 端套餐购买、C 端资产钱包充值、代理在线预存款充值。每笔支付在同一业务事务内冻结 merchant/pool/支付方式/服务商/非敏感身份/轮询及统计世代快照,再使用所选商户凭证创建支付。三类后续新支付始终只走商户池;无池、无可用成员或池停用时明确失败,预下单失败不得回退旧综合配置、不得自动换商户重试。后续 Apply 必须删除商户池新支付创建开关、所有引用及任何旧综合配置创建回退。仅代理在线预存款充值走池;员工线下代充值、后台线下订单、后台钱包余额支付及其它代理后台钱包操作不得经过商户池。 +- [x] 2.4 改造回调、查单及退款 A 的商户加载:微信、支付宝、富友回调和代理在线查单对 `merchant_id` 非空新单使用商户当前凭证,对空值历史单继续按 `payment_config_id` 双读;停用商户不得阻断已冻结历史单的本地回调/查询/退款 A 路径。退款 A 仅实现依据冻结商户加载当前凭证并作退款能力/必需凭证完整性判断、接入既有退款流程;不得新增或验收微信/支付宝/富友实际渠道退款 API、退款请求、渠道退款回调或外部退款成功;保留客户凭证退款、代理钱包回退及既有幂等语义。不得按当前池推断历史商户。 -## 3. 文档与验证 +## 3. 文档、渠道核验与发布控制 -- [ ] 3.1 更新支付、商户和微信授权管理接口 OpenAPI,并运行 `go run cmd/gendocs/main.go`。 -- [ ] 3.2 在隔离数据库验证迁移 up/down/up、配置迁移、三类新支付、缺失配置、三种轮询、并发成功累计、商户停用历史回调/退款及旧单兼容。 -- [ ] 3.3 运行 `gofmt -w`(变更 Go 文件)、`go build ./cmd/api ./cmd/worker`、`openspec validate add-payment-merchant-pools --strict`、`openspec doctor --json` 和 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。 \ No newline at end of file +- [x] 3.1 更新支付、商户、商户池和微信授权管理接口 OpenAPI、可执行路由说明及生成入口,覆盖权限、启停、成员排序、引用锁定、二次确认删除、零 active 失败提示、三类新支付和后台排除边界。敏感凭证的脱敏/禁止泄露仅适用于普通 DTO、错误、日志、审计、支付快照、导出及非专用管理响应;超级管理员和平台用户的专用管理列表、详情响应继续允许返回完整凭证。文档与 OpenAPI 示例只能使用占位值,绝不写入真实凭据。不得把具体迁移编号或 candidate 目录写入契约,不新增测试体系;不得暗示本 Change 实现渠道退款 API。 +- [x] 3.2 富友 B:保持现有 `CommonQuery` 请求格式、签名算法、验签、状态映射和恢复语义;仅改本地双读配置来源与商户加载,使双读查单可按商户当前凭证调用。不得改变协议、验签、状态解释、恢复规则或业务能力,不得增加退款能力;未第三方实测可记录但不得作为任务或归档阻塞。 +- [x] 3.3 在本地工作区以明确 `DB_*` 指向维护者提供的 `junhong_cmp_test` PostgreSQL 与 Redis DB6,完成 migration up/down/up 和数据行为验证:零/一/多 active、支付/授权凭证缺失、三类新支付始终走商户池、后台 wallet/offline 排除、缺失配置、三种轮询、统计世代与迟到成功、凭证轮换及缓存一致性、并发成功累计、商户停用后的历史回调/查单/退款 A、merchant ID 为空旧单兼容、敏感字段不泄露和重复处理幂等。允许仅为当前 Change 创建/删除 fixtures,禁止重置整个库;仅连接、迁移或实际行为失败时阻塞对应场景。完成后才提交推送 Iteration/8-11;每个场景提供可观察状态/错误/快照/统计事实证据;不新增测试体系,不验收实际渠道退款成功。 +- [ ] 3.4 Iteration/8-11 分支 Gitea 以本次提交 SHA 仅构建/部署 `cmp-test` 测试镜像并检查 migration version,不自动执行 migration up/down 或重置整库;记录 API/Worker 容器状态、健康检查和 `/opt/junhong_cmp/logs` 的有限日志,不建运行时开关且不是生产发布。部署后验证三类后续新支付立即走商户池并冻结路由,无池、成员或停用池明确失败且无旧创建回退,merchant ID 为空历史支付仍读旧路径。故障仅在双读版本上前向修复;存在非空 `merchant_id` 支付、成功事实或新配置时,禁止部署不识别新路由的旧二进制和破坏性 down。后续 Apply 必须删除商户池新支付创建开关、所有引用及任何关闭后恢复旧创建的代码。 \ No newline at end of file diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index b4308a8..991fd6d 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -19,7 +19,7 @@ func BuildDocHandlers() *bootstrap.Handlers { PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil), ClientAuth: app.NewClientAuthHandler(nil, nil), ClientAsset: app.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil), - ClientWallet: app.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), + ClientWallet: app.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), ClientOrder: app.NewClientOrderHandler(nil, nil), ClientExchange: app.NewClientExchangeHandler(nil), ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil, nil), @@ -68,6 +68,7 @@ func BuildDocHandlers() *bootstrap.Handlers { AssetLifecycle: admin.NewAssetLifecycleHandler(nil), AssetWallet: admin.NewAssetWalletHandler(nil), WechatConfig: admin.NewWechatConfigHandler(nil), + PaymentMerchant: admin.NewPaymentMerchantHandler(nil), AgentRecharge: admin.NewAgentRechargeHandler(nil, nil), Refund: admin.NewRefundHandler(nil), OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil),