按 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 与行为核对,未调用真实渠道。
734 lines
29 KiB
Go
734 lines
29 KiB
Go
// 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)
|
||
}
|
||
}
|