按 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 与行为核对,未调用真实渠道。
702 lines
31 KiB
Go
702 lines
31 KiB
Go
// Package refundchannel 执行与恢复渠道原路退款。
|
||
//
|
||
// 本包只编排渠道退款的资金动作与本地状态流转:请求号决定执行幂等、结果按条件更新回写、
|
||
// 失败按稳定分类终结、未知结果交由恢复扫描查询收敛。具体渠道协议由按服务商类型注入的
|
||
// Refunder 实现,本包不依赖任何渠道 SDK,也绝不在数据库事务内发起渠道调用。
|
||
package refundchannel
|
||
|
||
import (
|
||
"context"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
|
||
merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
|
||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
// State 是渠道调用的稳定结果状态。
|
||
type State string
|
||
|
||
const (
|
||
StateSuccess State = "success" // 渠道明确成功
|
||
StateFailed State = "failed" // 渠道明确失败
|
||
StateUnknown State = "unknown" // 超时或结果未确认,可恢复
|
||
)
|
||
|
||
// 渠道原路退款的固定运行参数。
|
||
const (
|
||
// recoveryBatchSize 是恢复扫描的单批上限,与既有批次扫描用例保持一致。
|
||
recoveryBatchSize = 50
|
||
// fuiouQueryWindow 是富友退款查询窗口:其退款查询接口只支持 3 日内的退款交易。
|
||
fuiouQueryWindow = 72 * time.Hour
|
||
// wechatV2QueryWindow 是微信 v2 退款结果的本地确认上限。
|
||
// 微信 v2 退款接口的受理响应不含退款状态,终态只能由退款查询确认;渠道侧没有查询时限,
|
||
// 因此这里只设本地的放弃阈值:超过该期限仍未确认即停止轮询并转人工核对,避免无限查询。
|
||
wechatV2QueryWindow = 7 * 24 * time.Hour
|
||
// channelCallTimeout 是单次渠道退款申请或查询调用的最长等待时间。
|
||
channelCallTimeout = 30 * time.Second
|
||
// fuiouOrderTypeWechat 是富友原交易的 order_type 当前唯一可达值(富友微信主扫)。
|
||
// 与 pkg/fuiou.OrderTypeWechat 取值一致;本包不引入渠道 SDK,因此在此固定回传该冻结值。
|
||
fuiouOrderTypeWechat = "WECHAT"
|
||
// anomalyReasonFuiouQueryWindow 是富友退款查询窗口超期的异常原因。
|
||
anomalyReasonFuiouQueryWindow = "富友退款查询窗口已过,需人工核对"
|
||
// anomalyReasonWechatV2QueryWindow 是微信 v2 退款结果超过本地确认上限的异常原因。
|
||
anomalyReasonWechatV2QueryWindow = "微信 v2 退款超过 7 天未确认结果,需人工核对"
|
||
// failureMessageUnknown 是渠道退款调用结果未确认时的安全摘要。
|
||
failureMessageUnknown = "渠道退款调用结果未确认,等待查询恢复"
|
||
// failureMessagePaymentFact 是本地原支付事实不可用时的安全摘要。
|
||
failureMessagePaymentFact = "本地原支付事实不可用,未能发起渠道退款"
|
||
// failureMessageCredential 是商户退款必需凭证不完整时的安全摘要。
|
||
failureMessageCredential = "商户退款必需凭证不完整,未发起渠道退款"
|
||
// failureMessageNoRequestNo 是退款申请缺少渠道退款请求号时的安全摘要。
|
||
failureMessageNoRequestNo = "退款申请缺少渠道退款请求号,未发起渠道退款"
|
||
// failureMessageMaxRunes 是失败安全摘要的字符上限,与 failure_message 列宽约束一致。
|
||
failureMessageMaxRunes = 480
|
||
// providerTypeAlipay 是支付宝商户的 provider_type 取值;model 未定义该常量,
|
||
// 取值与商户凭证管理保持的 "alipay" 完全一致。
|
||
providerTypeAlipay = "alipay"
|
||
)
|
||
|
||
// Target 是执行一次渠道原路退款所需的全部冻结事实。
|
||
type Target struct {
|
||
RefundID uint
|
||
RefundNo string
|
||
OrderID uint
|
||
OrderNo string
|
||
ProviderType string // model.ProviderType*
|
||
Config *model.WechatConfig // 商户当前凭证,绝不落库或记日志
|
||
PaymentNo string // 原支付单商户订单号(微信/支付宝 out_trade_no、富友 mchnt_order_no)
|
||
ChannelTradeNo string // 原支付单渠道交易流水
|
||
ChannelOrderType string // 富友原交易 order_type
|
||
PaidAt *time.Time
|
||
PaidAmount int64 // 原支付单渠道订单总金额(分),渠道退款请求的 total_amt 必须回传该值
|
||
RefundAmount int64
|
||
FrozenActualReceivedAmount int64
|
||
ChannelRefundRequestNo string
|
||
}
|
||
|
||
// Result 是渠道调用或查询的映射结果。
|
||
type Result struct {
|
||
State State
|
||
ChannelRefundNo string // 渠道退款流水号
|
||
ChannelRefundAmount int64 // 渠道退款金额(分)
|
||
SettledAt string // 渠道结算日期原文,可空
|
||
FailureReason string // pkg/constants.RefundFailure* 稳定编码,仅 State!=StateSuccess 时有值
|
||
FailureMessage string // 安全摘要,不得含凭证或报文原文
|
||
}
|
||
|
||
// Refunder 是渠道原路退款 Port,由基础设施层按服务商类型实现。
|
||
type Refunder interface {
|
||
// Refund 至多提交一次可确认的退款请求;请求号由 Target.ChannelRefundRequestNo 提供。
|
||
Refund(ctx context.Context, target Target) (Result, error)
|
||
// Query 只查询渠道退款状态,不得发起资金动作。
|
||
Query(ctx context.Context, target Target) (Result, error)
|
||
}
|
||
|
||
// MerchantLoader 按冻结商户 ID 加载商户当前凭证与渠道所需的全局授权配置。
|
||
type MerchantLoader interface {
|
||
LoadMerchant(ctx context.Context, id uint) (*model.PaymentMerchant, error)
|
||
LoadAuthorization(ctx context.Context) (*model.WechatAuthorization, error)
|
||
}
|
||
|
||
// Service 执行与恢复原路退款。
|
||
type Service struct {
|
||
db *gorm.DB
|
||
loader MerchantLoader
|
||
refunder Refunder
|
||
audit AuditWriter
|
||
notifier CompletionNotifier
|
||
logger *zap.Logger
|
||
now func() time.Time
|
||
}
|
||
|
||
// NewService 创建渠道原路退款用例。
|
||
func NewService(db *gorm.DB, loader MerchantLoader, refunder Refunder, audit AuditWriter) *Service {
|
||
return &Service{db: db, loader: loader, refunder: refunder, audit: audit, logger: zap.NewNop(), now: time.Now}
|
||
}
|
||
|
||
// SetCompletionNotifier 注入退款完成通知写入能力;未注入时成功路径不写通知事实。
|
||
func (s *Service) SetCompletionNotifier(notifier CompletionNotifier) *Service {
|
||
if s == nil {
|
||
return s
|
||
}
|
||
s.notifier = notifier
|
||
return s
|
||
}
|
||
|
||
// SetLogger 注入渠道原路退款运行日志。
|
||
func (s *Service) SetLogger(logger *zap.Logger) *Service {
|
||
if s == nil {
|
||
return s
|
||
}
|
||
if logger == nil {
|
||
logger = zap.NewNop()
|
||
}
|
||
s.logger = logger
|
||
return s
|
||
}
|
||
|
||
// PrepareInTx 在企微通过事务内为原路方式生成请求号并把退款申请置为原路处理中。
|
||
//
|
||
// 请求号由提交或重提在不可变审批尝试记录上生成并冻结;尝试记录已带请求号时直接复用,
|
||
// 仅在缺失时防御性补生成。条件更新要求申请仍处于待审批,否则视为并发冲突。
|
||
func (s *Service) PrepareInTx(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, attempt *model.RefundRequestAttempt) error {
|
||
if s == nil || tx == nil || refund == nil || refund.ID == 0 {
|
||
return errors.New(errors.CodeInvalidParam, "渠道原路退款准备参数无效")
|
||
}
|
||
if refund.Method != constants.RefundMethodOriginalRoute {
|
||
return nil
|
||
}
|
||
requestNo := ""
|
||
if attempt != nil {
|
||
requestNo = strings.TrimSpace(attempt.ChannelRefundRequestNo)
|
||
}
|
||
if requestNo == "" {
|
||
// 正常运行不会走到这里:请求号在提交/重提时已冻结到尝试记录上。
|
||
requestNo = BuildChannelRefundRequestNo(strconv.FormatUint(uint64(refund.ID), 10), s.now())
|
||
}
|
||
now := s.now().UTC()
|
||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending).
|
||
Updates(map[string]any{
|
||
"status": model.RefundStatusChannelProcessing,
|
||
"channel_refund_status": constants.RefundChannelStatusProcessing,
|
||
"channel_refund_request_no": requestNo,
|
||
"updated_at": now,
|
||
})
|
||
if updated.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "进入渠道原路退款处理中失败")
|
||
}
|
||
if updated.RowsAffected != 1 {
|
||
return errors.New(errors.CodeConflict, "退款申请状态不允许进入渠道原路退款处理中")
|
||
}
|
||
if attempt != nil && attempt.ID != 0 {
|
||
write := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{}).
|
||
Where("id = ?", attempt.ID).
|
||
Update("channel_refund_request_no", requestNo)
|
||
if write.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, write.Error, "写入退款尝试渠道退款请求号失败")
|
||
}
|
||
if write.RowsAffected != 1 {
|
||
s.logger.Warn("退款尝试渠道退款请求号未写入", zap.Uint("refund_id", refund.ID), zap.Uint("attempt_id", attempt.ID))
|
||
}
|
||
attempt.ChannelRefundRequestNo = requestNo
|
||
}
|
||
if err := AppendRefundChannelRefund(ctx, tx, outbox.NewRepository(), refund.ID, refund.OrderID); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款事件失败")
|
||
}
|
||
refund.Status = model.RefundStatusChannelProcessing
|
||
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
|
||
refund.ChannelRefundRequestNo = requestNo
|
||
return nil
|
||
}
|
||
|
||
// Execute 幂等执行一次原路退款;已明确成功或已失败终结的申请直接返回 nil。
|
||
//
|
||
// 本地事实在只读事务内锁定读取。资金动作「至多提交一次」由提交认领保证:
|
||
// 提交前先以 channel_submitted_at IS NULL 条件认领,只有认领成功的执行才调用 Refund;
|
||
// 认领失败表示该尝试已提交过渠道退款请求(例如 Outbox 事件被重复投递或人工重放),
|
||
// 此时只查询渠道结果并回填,绝不再次提交资金动作。
|
||
func (s *Service) Execute(ctx context.Context, refundID uint) error {
|
||
if err := s.requireReady(); err != nil {
|
||
return err
|
||
}
|
||
if refundID == 0 {
|
||
return errors.New(errors.CodeInvalidParam, "渠道原路退款缺少退款申请标识")
|
||
}
|
||
facts, proceed, err := s.loadExecutionFacts(ctx, refundID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !proceed {
|
||
return nil
|
||
}
|
||
payment, err := s.loadPaidPayment(ctx, facts.refund.OrderID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
target, failureReason, failureMessage, err := s.buildTarget(ctx, facts.refund, facts.attempt, payment)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
now := s.now().UTC()
|
||
if failureReason != "" {
|
||
// 本地事实不可用时绝不调用渠道,按稳定失败分类终结本次原路退款。
|
||
if _, err := s.writeback(ctx, facts.refund, target, Result{
|
||
State: StateFailed, FailureReason: failureReason, FailureMessage: failureMessage,
|
||
}, constants.AuditActionRefundChannelCalled, now); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
// 认领本次提交:认领成功才拥有提交权,失败则本次只做查询。
|
||
claimed, err := s.claimChannelSubmission(ctx, refundID, now)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
|
||
defer cancel()
|
||
if !claimed {
|
||
// 已提交过:只查询渠道结果,绝不再次提交资金动作。
|
||
result, callErr := s.refunder.Query(callCtx, target)
|
||
if callErr != nil {
|
||
// 查询失败不能推断渠道结果,保持原路处理中等待恢复扫描。
|
||
return nil
|
||
}
|
||
s.logger.Info("渠道退款请求已提交过,本次仅查询结果",
|
||
zap.Uint("refund_id", refundID), zap.String("channel_refund_request_no", target.ChannelRefundRequestNo))
|
||
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelRecovered, now)
|
||
return err
|
||
}
|
||
result, callErr := s.refunder.Refund(callCtx, target)
|
||
if callErr != nil {
|
||
// 传输层错误不能推断渠道未受理,一律按结果未知保持可恢复。
|
||
result = Result{State: StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: failureMessageUnknown}
|
||
}
|
||
_, err = s.writeback(ctx, facts.refund, target, result, constants.AuditActionRefundChannelCalled, now)
|
||
return err
|
||
}
|
||
|
||
// claimChannelSubmission 以条件更新认领本次渠道退款提交权。
|
||
//
|
||
// 返回 true 表示调用方获得提交权、可以调用渠道退款接口;false 表示该尝试在此之前
|
||
// 已提交过(重复投递或人工重放),调用方只能查询。认领与回写同以 status = 原路处理中
|
||
// 为谓词,因此并发执行也至多有一次认领成功。
|
||
func (s *Service) claimChannelSubmission(ctx context.Context, refundID uint, now time.Time) (bool, error) {
|
||
claimed := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
|
||
Where("id = ? AND status = ? AND channel_submitted_at IS NULL",
|
||
refundID, model.RefundStatusChannelProcessing).
|
||
UpdateColumn("channel_submitted_at", now)
|
||
if claimed.Error != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, claimed.Error, "认领渠道退款提交权失败")
|
||
}
|
||
return claimed.RowsAffected == 1, nil
|
||
}
|
||
|
||
// executionFacts 是一次渠道执行所需的本地冻结事实。
|
||
type executionFacts struct {
|
||
refund *model.RefundRequest
|
||
attempt *model.RefundRequestAttempt
|
||
}
|
||
|
||
// loadExecutionFacts 在只读事务内锁定退款申请并读取本次执行所需的尝试记录。
|
||
// proceed 为 false 表示申请已终结、方式不符或已由并发执行推进,调用方必须直接结束本次执行。
|
||
func (s *Service) loadExecutionFacts(ctx context.Context, refundID uint) (*executionFacts, bool, error) {
|
||
facts := &executionFacts{}
|
||
proceed := false
|
||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var refund model.RefundRequest
|
||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refundID).First(&refund).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||
}
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||
}
|
||
facts.refund = &refund
|
||
if refund.Method != constants.RefundMethodOriginalRoute {
|
||
s.logger.Warn("退款方式不是原路,跳过渠道退款", zap.Uint("refund_id", refund.ID), zap.String("method", refund.Method))
|
||
return nil
|
||
}
|
||
// 已通过或已失败终结的申请直接返回;渠道已明确成功的申请也不得再次调用渠道。
|
||
if refund.Status != model.RefundStatusChannelProcessing ||
|
||
refund.ChannelRefundStatus == constants.RefundChannelStatusSucceeded {
|
||
return nil
|
||
}
|
||
attempt, err := loadAttempt(ctx, tx, &refund)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
facts.attempt = attempt
|
||
proceed = true
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, false, err
|
||
}
|
||
return facts, proceed, nil
|
||
}
|
||
|
||
// loadAttempt 按申请冻结的最新尝试引用读取尝试记录;引用缺失时退回该申请的最大尝试序号。
|
||
func loadAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest) (*model.RefundRequestAttempt, error) {
|
||
var attempt model.RefundRequestAttempt
|
||
query := tx.WithContext(ctx).Model(&model.RefundRequestAttempt{})
|
||
if refund.LatestAttemptID != 0 {
|
||
if err := query.Where("id = ?", refund.LatestAttemptID).First(&attempt).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, nil
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
|
||
}
|
||
return &attempt, nil
|
||
}
|
||
if err := query.Where("refund_id = ?", refund.ID).Order("attempt_no DESC").First(&attempt).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, nil
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取退款审批尝试失败")
|
||
}
|
||
return &attempt, nil
|
||
}
|
||
|
||
// loadPaidPayment 读取订单最近一笔已支付的套餐支付单,作为原路退款的原支付事实。
|
||
func (s *Service) loadPaidPayment(ctx context.Context, orderID uint) (*model.Payment, error) {
|
||
var payment model.Payment
|
||
if err := s.db.WithContext(ctx).
|
||
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
|
||
Order("id DESC").First(&payment).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, nil
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取原支付单失败")
|
||
}
|
||
return &payment, nil
|
||
}
|
||
|
||
// buildTarget 在事务外组装渠道调用目标。
|
||
// 返回非空 failureReason 表示本地事实不可用:调用方必须按该分类回写且绝不调用渠道。
|
||
func (s *Service) buildTarget(ctx context.Context, refund *model.RefundRequest, attempt *model.RefundRequestAttempt, payment *model.Payment) (Target, string, string, error) {
|
||
target := Target{
|
||
RefundID: refund.ID, RefundNo: refund.RefundNo, OrderID: refund.OrderID, OrderNo: refund.OrderNo,
|
||
RefundAmount: resolveRefundAmount(refund, attempt),
|
||
FrozenActualReceivedAmount: resolveFrozenAmount(refund, attempt),
|
||
ChannelRefundRequestNo: resolveChannelRefundRequestNo(refund, attempt),
|
||
}
|
||
if target.ChannelRefundRequestNo == "" {
|
||
// 没有请求号就没有渠道幂等标识:本次尝试从未提交过资金动作,可按明确失败终结。
|
||
return target, constants.RefundFailurePaymentFactInvalid, failureMessageNoRequestNo, nil
|
||
}
|
||
if payment == nil {
|
||
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
|
||
}
|
||
target.PaymentNo = strings.TrimSpace(payment.PaymentNo)
|
||
target.ChannelTradeNo = strings.TrimSpace(payment.ThirdPartyTradeNo)
|
||
target.PaidAt = payment.PaidAt
|
||
target.PaidAmount = payment.Amount
|
||
if target.RefundAmount <= 0 || target.FrozenActualReceivedAmount <= 0 ||
|
||
target.RefundAmount > target.FrozenActualReceivedAmount {
|
||
return target, constants.RefundFailurePaymentFactInvalid, failureMessagePaymentFact, nil
|
||
}
|
||
config, providerType, err := s.loadChannelConfig(ctx, payment)
|
||
if err != nil {
|
||
if !credentialFailure(err) {
|
||
return target, "", "", err
|
||
}
|
||
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
|
||
}
|
||
if !credentialComplete(providerType, config) {
|
||
return target, constants.RefundFailureCredentialInvalid, failureMessageCredential, nil
|
||
}
|
||
target.ProviderType = providerType
|
||
target.Config = config
|
||
if providerType == model.ProviderTypeFuiou {
|
||
target.ChannelOrderType = fuiouOrderTypeWechat
|
||
}
|
||
return target, "", "", nil
|
||
}
|
||
|
||
// loadChannelConfig 加载原支付单实际收款商户的当前凭证。
|
||
// 新支付按冻结商户标识加载该商户当前凭证与全局微信授权;merchant_id 为空仅表示数据留存期内的
|
||
// 历史支付,按其原支付配置读取,禁止按当前启用商户池推断历史商户。
|
||
func (s *Service) loadChannelConfig(ctx context.Context, payment *model.Payment) (*model.WechatConfig, string, error) {
|
||
if payment.MerchantID != nil {
|
||
merchant, err := s.loader.LoadMerchant(ctx, *payment.MerchantID)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if merchant == nil {
|
||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "原支付收款商户不存在")
|
||
}
|
||
// 仅微信直连(v3/v2)需要全局微信授权配置中的 AppID;其他服务商传 nil 避免无谓失败。
|
||
var authorization *model.WechatAuthorization
|
||
if merchant.ProviderType == model.ProviderTypeWechat || merchant.ProviderType == model.ProviderTypeWechatV2 {
|
||
authorization, err = s.loader.LoadAuthorization(ctx)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
}
|
||
config, err := merchantpayment.MerchantConfig(merchant, authorization)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return config, merchant.ProviderType, nil
|
||
}
|
||
if payment.PaymentConfigID == nil {
|
||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付单缺少支付配置")
|
||
}
|
||
var legacy model.WechatConfig
|
||
if err := s.db.WithContext(ctx).Unscoped().Where("id = ?", *payment.PaymentConfigID).First(&legacy).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
return nil, "", errors.New(errors.CodeNoPaymentConfig, "历史支付配置不可用")
|
||
}
|
||
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "读取历史支付配置失败")
|
||
}
|
||
return &legacy, legacy.ProviderType, nil
|
||
}
|
||
|
||
// credentialComplete 判断该服务商类型发起原路退款所需的凭证是否完整。
|
||
// 规则与本 Change 冻结的商户退款凭证要求一致,只判断必需字段非空,不新增任何凭证键。
|
||
// RefundCredentialIssue 返回该服务商类型的退款必需凭证缺失原因;凭证完整时返回空串。
|
||
//
|
||
// 这是退款能力的唯一判定入口:退款请求不向渠道传递任何通知地址,因此支付通知地址与
|
||
// 支付跳转地址都不是退款必需凭证。微信 v2 退款接口(/secapi/pay/refund)请求需要双向
|
||
// 证书,因此其必需凭证包含 API 客户端证书;缺少该证书的 v2 商户按其凭证完整性判定为
|
||
// 不可用,补录证书后即可用。判定结果不提供人工开关。
|
||
func RefundCredentialIssue(providerType string, config *model.WechatConfig) string {
|
||
if config == nil {
|
||
return failureMessageCredential
|
||
}
|
||
switch providerType {
|
||
case model.ProviderTypeWechat:
|
||
if !completeFields(config.WxMchID, config.WxAPIV3Key, config.WxCertContent,
|
||
config.WxKeyContent, config.WxSerialNo) {
|
||
return "冻结微信商户退款凭证不完整"
|
||
}
|
||
case model.ProviderTypeWechatV2:
|
||
// v2 退款接口为双向证书接口:缺少 API 客户端证书时按其凭证完整性判定为不可用。
|
||
if !completeFields(config.WxMchID, config.WxAPIV2Key, config.WxClientCertContent, config.WxClientKeyContent) {
|
||
return "冻结微信 v2 商户退款凭证不完整(缺少 API 客户端证书)"
|
||
}
|
||
case model.ProviderTypeFuiou:
|
||
if !completeFields(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyPrivateKey,
|
||
config.FyPublicKey, config.FyAPIURL) {
|
||
return "冻结富友商户退款凭证不完整"
|
||
}
|
||
case providerTypeAlipay:
|
||
if !completeFields(config.AliAppID, config.AliPrivateKey, config.AliPublicKey) {
|
||
return "冻结支付宝商户退款凭证不完整"
|
||
}
|
||
default:
|
||
return "冻结商户不支持原路退款"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// credentialComplete 判断该服务商类型的退款必需凭证是否完整。
|
||
func credentialComplete(providerType string, config *model.WechatConfig) bool {
|
||
return RefundCredentialIssue(providerType, config) == ""
|
||
}
|
||
|
||
func completeFields(values ...string) bool {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) == "" {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// credentialFailure 判断凭证加载错误属于渠道侧不可执行的凭证问题,而不是可重试的基础设施错误。
|
||
func credentialFailure(err error) bool {
|
||
switch appErrorCode(err) {
|
||
case errors.CodeNoPaymentConfig, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeWechatConfigUnavailable:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// resolveChannelRefundRequestNo 取本次执行的渠道幂等标识。
|
||
// 尝试记录持有本次提交冻结的请求号,优先级高于退款单上的展示快照:重提会生成新请求号,
|
||
// 沿用旧快照会让渠道按旧请求号再次受理;两者一致时结果相同。
|
||
func resolveChannelRefundRequestNo(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) string {
|
||
if attempt != nil {
|
||
if requestNo := strings.TrimSpace(attempt.ChannelRefundRequestNo); requestNo != "" {
|
||
return requestNo
|
||
}
|
||
}
|
||
return strings.TrimSpace(refund.ChannelRefundRequestNo)
|
||
}
|
||
|
||
// resolveRefundAmount 取本次原路退款的权威金额:优先审批实际退款金额,其次尝试记录冻结金额。
|
||
func resolveRefundAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
|
||
if refund.ApprovedRefundAmount != nil && *refund.ApprovedRefundAmount > 0 {
|
||
return *refund.ApprovedRefundAmount
|
||
}
|
||
if refund.RequestedRefundAmount > 0 {
|
||
return refund.RequestedRefundAmount
|
||
}
|
||
if attempt != nil {
|
||
return attempt.RefundAmount
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// resolveFrozenAmount 取本次原路退款的冻结实收金额。
|
||
func resolveFrozenAmount(refund *model.RefundRequest, attempt *model.RefundRequestAttempt) int64 {
|
||
if refund.FrozenActualReceivedAmount > 0 {
|
||
return refund.FrozenActualReceivedAmount
|
||
}
|
||
if attempt != nil {
|
||
return attempt.FrozenActualReceivedAmount
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// writeback 在独立事务内按渠道结果条件更新退款申请、订单与审计事实。
|
||
// applied 为 false 表示记录已被并发推进,本次不改动任何状态。
|
||
func (s *Service) writeback(ctx context.Context, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
|
||
applied := false
|
||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
var err error
|
||
applied, err = s.applyResult(ctx, tx, refund, target, result, action, now)
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if !applied {
|
||
s.logger.Warn("渠道原路退款结果未回写,记录已被并发推进",
|
||
zap.Uint("refund_id", refund.ID), zap.String("action", action), zap.String("state", string(result.State)))
|
||
}
|
||
return applied, nil
|
||
}
|
||
|
||
// applyResult 按结果状态把渠道事实条件回写到退款申请,成功时同步把订单置为已退款。
|
||
// 所有状态流转都以 status = 原路处理中 为谓词,RowsAffected 为 0 表示并发已推进该记录。
|
||
func (s *Service) applyResult(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, target Target, result Result, action string, now time.Time) (bool, error) {
|
||
update := map[string]any{}
|
||
syncRefund := func() {}
|
||
orderRefunded := false
|
||
reason := result.FailureReason
|
||
message := ""
|
||
switch result.State {
|
||
case StateSuccess:
|
||
amount := result.ChannelRefundAmount
|
||
if amount <= 0 {
|
||
amount = target.RefundAmount
|
||
}
|
||
update["status"] = model.RefundStatusApproved
|
||
update["channel_refund_status"] = constants.RefundChannelStatusSucceeded
|
||
update["channel_refund_no"] = result.ChannelRefundNo
|
||
update["channel_refund_amount"] = amount
|
||
update["channel_refunded_at"] = now
|
||
update["processed_at"] = now
|
||
update["failure_reason"] = ""
|
||
update["failure_message"] = ""
|
||
update["updated_at"] = now
|
||
orderRefunded = true
|
||
message = "渠道原路退款明确成功"
|
||
syncRefund = func() {
|
||
refund.Status = model.RefundStatusApproved
|
||
refund.ChannelRefundStatus = constants.RefundChannelStatusSucceeded
|
||
refund.ChannelRefundNo = result.ChannelRefundNo
|
||
refund.ChannelRefundAmount = amount
|
||
refund.ChannelRefundedAt = &now
|
||
refund.ProcessedAt = &now
|
||
refund.FailureReason = ""
|
||
refund.FailureMessage = ""
|
||
}
|
||
case StateFailed:
|
||
if reason == "" {
|
||
reason = constants.RefundFailureChannelRejected
|
||
}
|
||
message = "渠道原路退款明确失败:" + constants.RefundFailureReasonName(reason)
|
||
failureMessage := safeMessage(result.FailureMessage, message)
|
||
update["status"] = model.RefundStatusChannelFailed
|
||
update["channel_refund_status"] = constants.RefundChannelStatusFailed
|
||
update["failure_reason"] = reason
|
||
update["failure_message"] = failureMessage
|
||
update["updated_at"] = now
|
||
syncRefund = func() {
|
||
refund.Status = model.RefundStatusChannelFailed
|
||
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
|
||
refund.FailureReason = reason
|
||
refund.FailureMessage = failureMessage
|
||
}
|
||
default:
|
||
// 超时或结果未确认:保持原路处理中,等待恢复扫描查询收敛。
|
||
// 不修改 updated_at,使富友查询窗口从进入原路处理中的时点起算。
|
||
reason = constants.RefundFailureTimeoutUnknown
|
||
message = "渠道原路退款结果未确认,保持处理中"
|
||
failureMessage := safeMessage(result.FailureMessage, failureMessageUnknown)
|
||
update["channel_refund_status"] = constants.RefundChannelStatusProcessing
|
||
update["failure_reason"] = reason
|
||
update["failure_message"] = failureMessage
|
||
syncRefund = func() {
|
||
refund.ChannelRefundStatus = constants.RefundChannelStatusProcessing
|
||
refund.FailureReason = reason
|
||
refund.FailureMessage = failureMessage
|
||
}
|
||
}
|
||
// UpdateColumns 不会隐式推进 updated_at:结果未知时必须保留进入原路处理中的时点,
|
||
// 富友 72 小时查询窗口正是以该时点起算;需要推进的分支已在 update 中显式写入。
|
||
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||
Where("id = ? AND status = ?", refund.ID, model.RefundStatusChannelProcessing).
|
||
UpdateColumns(update)
|
||
if updated.Error != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, updated.Error, "回写渠道原路退款结果失败")
|
||
}
|
||
if updated.RowsAffected != 1 {
|
||
return false, nil
|
||
}
|
||
syncRefund()
|
||
if orderRefunded {
|
||
if err := s.markOrderRefunded(ctx, tx, refund, now); err != nil {
|
||
return false, err
|
||
}
|
||
// 原路退款的完成时点是渠道明确成功,与客户收款信息退款在企微通过时完成的语义不同:
|
||
// 退款完成通知必须在同一事务内补写,否则该方式的店铺通知永远不会发出。
|
||
if s.notifier != nil {
|
||
if err := s.notifier.AppendCompletedNotification(ctx, tx, refund); err != nil {
|
||
return false, err
|
||
}
|
||
}
|
||
}
|
||
if err := s.audit.WriteRefundChannelResult(ctx, tx, refund, action, message); err != nil {
|
||
return false, errors.Wrap(errors.CodeDatabaseError, err, "写入渠道原路退款审计失败")
|
||
}
|
||
return true, nil
|
||
}
|
||
|
||
// markOrderRefunded 在渠道明确成功后按方式把订单置为已退款。
|
||
// 条件更新命中 0 行时容忍订单已是已退款;其他状态只记录告警,不覆盖业务事实。
|
||
func (s *Service) markOrderRefunded(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, now time.Time) error {
|
||
updated := tx.WithContext(ctx).Model(&model.Order{}).
|
||
Where("id = ? AND payment_status = ?", refund.OrderID, model.PaymentStatusPaid).
|
||
Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": now})
|
||
if updated.Error != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "更新订单退款状态失败")
|
||
}
|
||
if updated.RowsAffected == 1 {
|
||
return nil
|
||
}
|
||
var order model.Order
|
||
if err := tx.WithContext(ctx).Select("id", "payment_status").Where("id = ?", refund.OrderID).First(&order).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "读取退款关联订单状态失败")
|
||
}
|
||
if order.PaymentStatus != model.PaymentStatusRefunded {
|
||
s.logger.Warn("订单支付状态未置为已退款",
|
||
zap.Uint("refund_id", refund.ID), zap.Uint("order_id", refund.OrderID), zap.Int("payment_status", order.PaymentStatus))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// safeMessage 生成失败安全摘要:裁剪空白、限定字符数,空值退回该状态的固定摘要。
|
||
func safeMessage(message, fallback string) string {
|
||
text := strings.TrimSpace(message)
|
||
if text == "" {
|
||
text = fallback
|
||
}
|
||
runes := []rune(text)
|
||
if len(runes) > failureMessageMaxRunes {
|
||
text = string(runes[:failureMessageMaxRunes])
|
||
}
|
||
return text
|
||
}
|
||
|
||
// requireReady 校验渠道原路退款的全部依赖已配置。
|
||
func (s *Service) requireReady() error {
|
||
if s == nil || s.db == nil || s.loader == nil || s.refunder == nil || s.audit == nil {
|
||
return errors.New(errors.CodeServiceUnavailable, "渠道原路退款能力未配置")
|
||
}
|
||
return nil
|
||
}
|