Files
junhong_cmp_fiber/internal/service/wechat_config/service.go
break ba0855d9eb feat(退款): AUG26-006 退款方式选择与原路退款
按 PRD 2.3/2.4/2.5 落地套餐退款的方式矩阵与原路渠道退款:

- 退款申请派生并冻结权威实收金额(线上取原成功支付记录,钱包/线下取订单实际收款),
  提交人不可填写或修改;按来源支付方式生成可选方式矩阵并在创建、提交、执行前重复校验。
- 审批切换为「每次提交一条不可变审批尝试记录 + 独立企业微信审批实例」,业务标识取尝试
  记录主键;终态消费按尝试记录优先、退款申请兜底双读,兼容存量无实例与已关联实例申请。
  新增活动退款部分唯一索引 (order_id) WHERE status IN (1,5,6)。
- 本地人工终审保持既有开关,补齐通过入口的 approval_instance_id IS NULL 守卫,使三个
  入口一致拒绝已关联审批实例的申请;重提按尝试模式重写(仅已拒绝/已退回/原路失败且无异常)。
- 权益时点:企微通过事务写退款终态、按方式确定的订单态、钱包回款、员工账单冲销与可靠
  失效事实;套餐失效/接续/停机仍由既有可靠机制最终一致执行,不把外部调用放入资金事务。
  订单支付状态按方式置位:凭证退款与退回原钱包在企微通过时置已退款,原路须渠道明确成功。
- 按官方契约实现微信直连 v3、微信 v2(双向证书)、富友(/commonRefund 与 /refundQuery)、
  支付宝四类原路退款;能力只由服务商类型与退款必需凭证完整性决定,无人工开关。
  渠道请求号在提交时冻结到尝试记录,并以 channel_submitted_at 条件认领保证资金动作至多
  提交一次(重复投递只查询不二次提交);不向任何渠道传递退款结果通知地址。
- 新增 refund:channel:recovery 恢复任务只查询回填;本地查询窗口超期(富友 72 小时、
  微信 v2 7 天)转原路退款失败、渠道状态已失败、分类超时未知并置异常转人工,不放行自动
  重提以避免重复退款。
- 同步退款 DTO/导出/审计资源与审计查询关联、商户凭证文档,并修正 fuiou 集成契约文档。

迁移 000218(退款尝试与渠道退款事实)、000219(微信 v2 客户端证书凭证)成对提供,
未修改既有迁移;测试库 junhong_cmp_test 完成 up/down/up 与行为核对,未调用真实渠道。
2026-09-14 11:55:16 +08:00

734 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package wechat_config 提供微信参数配置管理的业务逻辑服务
// 包含配置的 CRUD、激活/停用、Redis 缓存等功能
package wechat_config
import (
"context"
"fmt"
"strconv"
"time"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"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"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"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 缓存键
const redisActiveConfigKey = "wechat:config:active"
// Service 微信参数配置业务服务
type Service struct {
store *postgres.WechatConfigStore
orderStore *postgres.OrderStore
rechargeOrderStore *postgres.RechargeOrderStore
agentRechargeStore *postgres.AgentRechargeStore
paymentStore *postgres.PaymentStore
audit systemconfigapp.AuditWriter
redis *redis.Client
logger *zap.Logger
}
// New 创建微信参数配置服务实例
func New(
store *postgres.WechatConfigStore,
orderStore *postgres.OrderStore,
rechargeOrderStore *postgres.RechargeOrderStore,
agentRechargeStore *postgres.AgentRechargeStore,
paymentStore *postgres.PaymentStore,
audit systemconfigapp.AuditWriter,
rdb *redis.Client,
logger *zap.Logger,
) *Service {
return &Service{
store: store,
orderStore: orderStore,
rechargeOrderStore: rechargeOrderStore,
agentRechargeStore: agentRechargeStore,
paymentStore: paymentStore,
audit: audit,
redis: rdb,
logger: logger,
}
}
// Create 创建微信参数配置
// POST /api/admin/wechat-configs
func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest) (*dto.WechatConfigResponse, error) {
// 根据 provider_type 校验必填字段
if err := s.validateProviderFields(req); err != nil {
s.recordAuditFailure(ctx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationPaymentConfigCreate,
Description: "拒绝创建非法支付连接配置", ConfigKey: "payment_config.new:" + req.Name,
DisplayName: req.Name, Identity: map[string]any{"name": req.Name, "provider_type": req.ProviderType, "credentials_configured": paymentRequestCredentialsConfigured(req)},
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidParam), ErrorSummary: "支付连接配置字段校验失败",
})
return nil, err
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
}
var desc *string
if req.Description != "" {
desc = &req.Description
}
config := &model.WechatConfig{
Name: req.Name,
Description: desc,
ProviderType: req.ProviderType,
IsActive: false,
OaAppID: req.OaAppID,
OaAppSecret: req.OaAppSecret,
OaToken: req.OaToken,
OaAesKey: req.OaAesKey,
OaOAuthRedirectURL: req.OaOAuthRedirectURL,
MiniappAppID: req.MiniappAppID,
MiniappAppSecret: req.MiniappAppSecret,
WxMchID: req.WxMchID,
WxAPIV3Key: req.WxAPIV3Key,
WxAPIV2Key: req.WxAPIV2Key,
WxCertContent: req.WxCertContent,
WxKeyContent: req.WxKeyContent,
WxSerialNo: req.WxSerialNo,
WxNotifyURL: req.WxNotifyURL,
WxClientCertContent: req.WxClientCertContent,
WxClientKeyContent: req.WxClientKeyContent,
FyInsCd: req.FyInsCd,
FyMchntCd: req.FyMchntCd,
FyTermID: req.FyTermID,
FyPrivateKey: req.FyPrivateKey,
FyPublicKey: req.FyPublicKey,
FyAPIURL: req.FyAPIURL,
FyNotifyURL: req.FyNotifyURL,
AliAppID: req.AliAppID,
AliPrivateKey: req.AliPrivateKey,
AliPublicKey: req.AliPublicKey,
AliNotifyURL: req.AliNotifyURL,
AliReturnURL: req.AliReturnURL,
AliProduction: req.AliProduction,
AliPayExpireMinutes: req.AliPayExpireMinutes,
}
// 支付宝过期分钟数默认值
if config.AliPayExpireMinutes == 0 {
config.AliPayExpireMinutes = model.DefaultAliPayExpireMinutes
}
config.Creator = middleware.GetUserIDFromContext(ctx)
err := s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.store.WithTx(tx).Create(ctx, config); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigCreate, "创建支付连接配置", nil, config)
})
if err != nil {
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigCreate, "创建支付连接配置失败", config, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建微信支付配置失败")
}
return dto.FromWechatConfigModel(config), nil
}
// List 获取配置列表
// GET /api/admin/wechat-configs
func (s *Service) List(ctx context.Context, req *dto.WechatConfigListRequest) ([]*dto.WechatConfigResponse, int64, error) {
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
OrderBy: "id DESC",
}
if opts.Page == 0 {
opts.Page = 1
}
if opts.PageSize == 0 {
opts.PageSize = constants.DefaultPageSize
}
filters := make(map[string]interface{})
if req.ProviderType != nil {
filters["provider_type"] = *req.ProviderType
}
filters["is_active"] = req.IsActive
configs, total, err := s.store.List(ctx, opts, filters)
if err != nil {
return nil, 0, errors.Wrap(errors.CodeInternalError, err, "查询微信支付配置列表失败")
}
responses := make([]*dto.WechatConfigResponse, len(configs))
for i, c := range configs {
responses[i] = dto.FromWechatConfigModel(c)
}
return responses, total, nil
}
// Get 获取配置详情
// GET /api/admin/wechat-configs/:id
func (s *Service) Get(ctx context.Context, id uint) (*dto.WechatConfigResponse, error) {
config, err := s.store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWechatConfigNotFound)
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
}
return dto.FromWechatConfigModel(config), nil
}
// Update 更新微信参数配置
// PUT /api/admin/wechat-configs/:id
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConfigRequest) (*dto.WechatConfigResponse, error) {
config, err := s.store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWechatConfigNotFound)
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
}
before := *config
// 合并字段:指针非 nil 时更新,敏感字段空字符串表示保持原值
if req.Name != nil {
config.Name = *req.Name
}
if req.Description != nil {
config.Description = req.Description
}
if req.ProviderType != nil {
config.ProviderType = *req.ProviderType
}
// OAuth 公众号
s.mergeStringField(&config.OaAppID, req.OaAppID)
s.mergeSensitiveField(&config.OaAppSecret, req.OaAppSecret)
s.mergeSensitiveField(&config.OaToken, req.OaToken)
s.mergeSensitiveField(&config.OaAesKey, req.OaAesKey)
s.mergeStringField(&config.OaOAuthRedirectURL, req.OaOAuthRedirectURL)
// OAuth 小程序
s.mergeStringField(&config.MiniappAppID, req.MiniappAppID)
s.mergeSensitiveField(&config.MiniappAppSecret, req.MiniappAppSecret)
// 微信直连支付
s.mergeStringField(&config.WxMchID, req.WxMchID)
s.mergeSensitiveField(&config.WxAPIV3Key, req.WxAPIV3Key)
s.mergeSensitiveField(&config.WxAPIV2Key, req.WxAPIV2Key)
s.mergeSensitiveField(&config.WxCertContent, req.WxCertContent)
s.mergeSensitiveField(&config.WxKeyContent, req.WxKeyContent)
s.mergeStringField(&config.WxSerialNo, req.WxSerialNo)
s.mergeStringField(&config.WxNotifyURL, req.WxNotifyURL)
s.mergeSensitiveField(&config.WxClientCertContent, req.WxClientCertContent)
s.mergeSensitiveField(&config.WxClientKeyContent, req.WxClientKeyContent)
// 富友支付
s.mergeStringField(&config.FyInsCd, req.FyInsCd)
s.mergeStringField(&config.FyMchntCd, req.FyMchntCd)
s.mergeStringField(&config.FyTermID, req.FyTermID)
s.mergeSensitiveField(&config.FyPrivateKey, req.FyPrivateKey)
s.mergeSensitiveField(&config.FyPublicKey, req.FyPublicKey)
s.mergeStringField(&config.FyAPIURL, req.FyAPIURL)
s.mergeStringField(&config.FyNotifyURL, req.FyNotifyURL)
// 支付宝支付
s.mergeStringField(&config.AliAppID, req.AliAppID)
s.mergeSensitiveField(&config.AliPrivateKey, req.AliPrivateKey)
s.mergeSensitiveField(&config.AliPublicKey, req.AliPublicKey)
s.mergeStringField(&config.AliNotifyURL, req.AliNotifyURL)
s.mergeStringField(&config.AliReturnURL, req.AliReturnURL)
if req.AliProduction != nil {
config.AliProduction = *req.AliProduction
}
if req.AliPayExpireMinutes != nil && *req.AliPayExpireMinutes > 0 {
config.AliPayExpireMinutes = *req.AliPayExpireMinutes
}
config.Updater = middleware.GetUserIDFromContext(ctx)
err = s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.store.WithTx(tx).Update(ctx, config); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigUpdate, "更新支付连接配置", &before, config)
})
if err != nil {
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigUpdate, "更新支付连接配置失败", config, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新微信支付配置失败")
}
// 如果当前配置处于激活状态,清除缓存
if config.IsActive {
s.clearActiveConfigCache(ctx)
}
return dto.FromWechatConfigModel(config), nil
}
// Delete 删除微信参数配置
// DELETE /api/admin/wechat-configs/:id
func (s *Service) Delete(ctx context.Context, id uint) error {
config, err := s.store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWechatConfigNotFound)
}
return errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
}
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
}
// 不允许删除正在激活的配置
if config.IsActive {
s.recordPaymentDenied(ctx, constants.AuditOperationPaymentConfigDelete, "拒绝删除生效中的支付连接配置", config, errors.CodeWechatConfigActive)
return errors.New(errors.CodeWechatConfigActive)
}
// 检查是否存在待支付订单
pendingOrders, err := s.store.CountPendingOrdersByConfigID(ctx, id)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "检查在途订单失败")
}
pendingRecharges, err := s.store.CountPendingRechargesByConfigID(ctx, id)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "检查在途充值失败")
}
if pendingOrders > 0 || pendingRecharges > 0 {
s.recordPaymentDenied(ctx, constants.AuditOperationPaymentConfigDelete, "拒绝删除存在在途业务的支付连接配置", config, errors.CodeWechatConfigHasPendingOrders)
return errors.New(errors.CodeWechatConfigHasPendingOrders)
}
err = s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.store.WithTx(tx).SoftDelete(ctx, id); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDelete, "删除支付连接配置", config, nil)
})
if err != nil {
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigDelete, "删除支付连接配置失败", config, err)
return errors.Wrap(errors.CodeInternalError, err, "删除微信支付配置失败")
}
s.clearActiveConfigCache(ctx)
return nil
}
// Activate 激活指定配置(同一时间只有一个激活配置)
// POST /api/admin/wechat-configs/:id/activate
func (s *Service) Activate(ctx context.Context, id uint) (*dto.WechatConfigResponse, error) {
config, err := s.store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWechatConfigNotFound)
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
}
before := *config
// 保留原激活配置快照,确保自动停用也进入该配置自身时间线。
oldActive, oldErr := s.store.GetActive(ctx)
// 事务内激活
db := s.store.DB()
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.store.ActivateInTx(ctx, tx, id); err != nil {
return err
}
after := before
after.IsActive = true
if oldErr == nil && oldActive != nil && oldActive.ID != id {
oldAfter := *oldActive
oldAfter.IsActive = false
if err := s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDeactivate, "激活其他配置时停用原支付连接配置", oldActive, &oldAfter); err != nil {
return err
}
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigActivate, "激活支付连接配置", &before, &after)
}); err != nil {
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigActivate, "激活支付连接配置失败", config, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "激活微信支付配置失败")
}
s.clearActiveConfigCache(ctx)
// 重新查询最新状态
config, _ = s.store.GetByID(ctx, id)
return dto.FromWechatConfigModel(config), nil
}
// Deactivate 停用指定配置
// POST /api/admin/wechat-configs/:id/deactivate
func (s *Service) Deactivate(ctx context.Context, id uint) (*dto.WechatConfigResponse, error) {
config, err := s.store.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWechatConfigNotFound)
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败")
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置")
}
before := *config
after := before
after.IsActive = false
err = s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.store.WithTx(tx).Deactivate(ctx, id); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDeactivate, "停用支付连接配置", &before, &after)
})
if err != nil {
s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigDeactivate, "停用支付连接配置失败", config, err)
return nil, errors.Wrap(errors.CodeInternalError, err, "停用微信支付配置失败")
}
s.clearActiveConfigCache(ctx)
// 重新查询最新状态
config, _ = s.store.GetByID(ctx, id)
return dto.FromWechatConfigModel(config), nil
}
func (s *Service) writeAudit(ctx context.Context, tx *gorm.DB, operation, description string, before, after *model.WechatConfig) error {
config := after
if config == nil {
config = before
}
resourceID := strconv.FormatUint(uint64(config.ID), 10)
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: "payment_config." + resourceID, Module: "payment", ResourceID: &resourceID,
DisplayName: config.Name, Identity: paymentConfigIdentity(config),
BeforeData: paymentConfigAuditSnapshot(before), AfterData: paymentConfigAuditSnapshot(after),
RequestID: requestID, CorrelationID: requestID,
})
}
func (s *Service) recordPaymentDenied(ctx context.Context, operation, description string, config *model.WechatConfig, code int) {
s.recordAuditFailure(ctx, paymentFailureAudit(ctx, operation, description, config, constants.AuditResultDenied, code))
}
func (s *Service) recordPaymentFailure(ctx context.Context, operation, description string, config *model.WechatConfig, _ error) {
s.recordAuditFailure(ctx, paymentFailureAudit(ctx, operation, description, config, constants.AuditResultFailed, errors.CodeDatabaseError))
}
func (s *Service) recordAuditFailure(ctx context.Context, audit systemconfigapp.ChangeAudit) {
if s.audit == nil || s.store == nil || s.store.DB() == nil || audit.OperatorID == 0 || audit.ConfigKey == "" {
return
}
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
audit.RequestID = *value
audit.CorrelationID = *value
}
if err := s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteConfigChange(ctx, tx, audit)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(audit.OperationType, audit.ConfigKey, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err)
}
}
func paymentFailureAudit(ctx context.Context, operation, description string, config *model.WechatConfig, result string, code int) systemconfigapp.ChangeAudit {
resourceID := strconv.FormatUint(uint64(config.ID), 10)
return systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: "payment_config." + resourceID, Module: "payment", ResourceID: &resourceID,
DisplayName: config.Name, Identity: paymentConfigIdentity(config), BeforeData: paymentConfigAuditSnapshot(config),
Result: result, ErrorCode: strconv.Itoa(code), ErrorSummary: description,
}
}
func paymentConfigIdentity(config *model.WechatConfig) map[string]any {
if config == nil {
return nil
}
return map[string]any{
"id": config.ID, "name": config.Name, "provider_type": config.ProviderType,
"is_active": config.IsActive, "credentials_configured": paymentConfigCredentialsConfigured(config),
}
}
func paymentConfigAuditSnapshot(config *model.WechatConfig) map[string]any {
if config == nil {
return nil
}
return map[string]any{
"id": config.ID, "name": config.Name, "description": config.Description,
"provider_type": config.ProviderType, "is_active": config.IsActive,
"oa_app_id": config.OaAppID, "oa_oauth_redirect_url": config.OaOAuthRedirectURL,
"miniapp_app_id": config.MiniappAppID, "wx_mch_id": config.WxMchID,
"wx_serial_no": config.WxSerialNo, "wx_notify_url": config.WxNotifyURL,
"fy_ins_cd": config.FyInsCd, "fy_mchnt_cd": config.FyMchntCd, "fy_term_id": config.FyTermID,
"fy_api_url": config.FyAPIURL, "fy_notify_url": config.FyNotifyURL,
"ali_app_id": config.AliAppID, "ali_notify_url": config.AliNotifyURL,
"ali_return_url": config.AliReturnURL, "ali_production": config.AliProduction,
"ali_pay_expire_minutes": config.AliPayExpireMinutes,
"credentials_configured": paymentConfigCredentialsConfigured(config),
"oauth_configured": config.OaAppSecret != "" || config.OaToken != "" || config.OaAesKey != "" || config.MiniappAppSecret != "",
"wechat_payment_configured": config.WxAPIV3Key != "" || config.WxAPIV2Key != "" || config.WxCertContent != "" || config.WxKeyContent != "",
"fuiou_configured": config.FyPrivateKey != "" || config.FyPublicKey != "",
"alipay_configured": config.AliPrivateKey != "" || config.AliPublicKey != "",
}
}
func paymentConfigCredentialsConfigured(config *model.WechatConfig) bool {
if config == nil {
return false
}
return config.OaAppSecret != "" || config.OaToken != "" || config.OaAesKey != "" || config.MiniappAppSecret != "" ||
config.WxAPIV3Key != "" || config.WxAPIV2Key != "" || config.WxCertContent != "" || config.WxKeyContent != "" ||
config.FyPrivateKey != "" || config.FyPublicKey != "" || config.AliPrivateKey != "" || config.AliPublicKey != ""
}
func paymentRequestCredentialsConfigured(request *dto.CreateWechatConfigRequest) bool {
return request != nil && (request.OaAppSecret != "" || request.OaToken != "" || request.OaAesKey != "" || request.MiniappAppSecret != "" ||
request.WxAPIV3Key != "" || request.WxAPIV2Key != "" || request.WxCertContent != "" || request.WxKeyContent != "" ||
request.FyPrivateKey != "" || request.FyPublicKey != "" || request.AliPrivateKey != "" || request.AliPublicKey != "")
}
// GetActiveConfig 获取当前生效的支付配置(带 Redis 缓存)
// 缓存策略:命中直接返回,未命中查 DB 后缓存 5 分钟,无记录缓存 "none" 1 分钟
func (s *Service) GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) {
// 尝试从 Redis 获取
val, err := s.redis.Get(ctx, redisActiveConfigKey).Result()
if err == nil {
if val == "none" {
return nil, nil
}
var config model.WechatConfig
if err := sonic.UnmarshalString(val, &config); err == nil {
return &config, nil
}
}
// Redis 未命中,查询数据库
config, err := s.store.GetActive(ctx)
if err != nil {
if err == gorm.ErrRecordNotFound {
// 无激活配置,缓存空标记 1 分钟
s.redis.Set(ctx, redisActiveConfigKey, "none", 1*time.Minute)
return nil, nil
}
return nil, errors.Wrap(errors.CodeInternalError, err, "查询激活配置失败")
}
// 缓存配置 5 分钟
if data, err := sonic.MarshalString(config); err == nil {
s.redis.Set(ctx, redisActiveConfigKey, data, 5*time.Minute)
}
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)
if err != nil {
return nil, err
}
if config == nil {
return nil, nil
}
return dto.FromWechatConfigModel(config), nil
}
// clearActiveConfigCache 清除激活配置的 Redis 缓存
func (s *Service) clearActiveConfigCache(ctx context.Context) {
if err := s.redis.Del(ctx, redisActiveConfigKey).Err(); err != nil {
s.logger.Warn("清除微信支付配置缓存失败", zap.Error(err))
}
}
// validateProviderFields 根据支付渠道类型校验必填字段
func (s *Service) validateProviderFields(req *dto.CreateWechatConfigRequest) error {
switch req.ProviderType {
case model.ProviderTypeWechat:
if req.WxMchID == "" || req.WxAPIV3Key == "" || req.WxCertContent == "" ||
req.WxKeyContent == "" || req.WxSerialNo == "" || req.WxNotifyURL == "" {
return errors.New(errors.CodeInvalidParam, "微信直连支付必填字段不完整wx_mch_id, wx_api_v3_key, wx_cert_content, wx_key_content, wx_serial_no, wx_notify_url")
}
case model.ProviderTypeFuiou:
if req.FyInsCd == "" || req.FyMchntCd == "" || req.FyTermID == "" ||
req.FyPrivateKey == "" || req.FyPublicKey == "" || req.FyAPIURL == "" || req.FyNotifyURL == "" {
return errors.New(errors.CodeInvalidParam, "富友支付必填字段不完整fy_ins_cd, fy_mchnt_cd, fy_term_id, fy_private_key, fy_public_key, fy_api_url, fy_notify_url")
}
}
return nil
}
// mergeStringField 合并普通字符串字段:指针非 nil 时用新值覆盖
func (s *Service) mergeStringField(target *string, newVal *string) {
if newVal != nil {
*target = *newVal
}
}
// mergeSensitiveField 合并敏感字段:指针非 nil 且非空字符串时覆盖,空字符串保持原值
func (s *Service) mergeSensitiveField(target *string, newVal *string) {
if newVal != nil && *newVal != "" {
*target = *newVal
}
}
// 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 {
return nil, errors.Wrap(errors.CodeDatabaseError, 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
}
// 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
func (s *Service) resolvePaymentConfigID(ctx context.Context, orderNo string) (*uint, error) {
if s.paymentStore != nil {
payment, err := s.paymentStore.GetByPaymentNo(ctx, orderNo)
if err == nil {
if payment.PaymentConfigID != nil {
return payment.PaymentConfigID, nil
}
} else if err != gorm.ErrRecordNotFound {
return nil, err
}
}
switch {
case len(orderNo) >= 3 && orderNo[:3] == "ORD":
order, err := s.orderStore.GetByOrderNo(ctx, orderNo)
if err != nil {
return nil, err
}
return order.PaymentConfigID, nil
case len(orderNo) >= 4 && orderNo[:4] == constants.AssetRechargeOrderPrefix:
record, err := s.rechargeOrderStore.GetByRechargeOrderNo(ctx, orderNo)
if err != nil {
return nil, err
}
return record.PaymentConfigID, nil
case len(orderNo) >= 4 && orderNo[:4] == constants.AgentRechargeOrderPrefix:
record, err := s.agentRechargeStore.GetByRechargeNo(ctx, orderNo)
if err != nil {
return nil, err
}
return record.PaymentConfigID, nil
default:
return nil, fmt.Errorf("未知订单号前缀: %s", orderNo)
}
}