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 与行为核对,未调用真实渠道。
This commit is contained in:
2026-09-14 11:55:16 +08:00
parent 48c85a4916
commit ba0855d9eb
51 changed files with 5995 additions and 986 deletions

View File

@@ -0,0 +1,193 @@
package refundchannel
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Stats 是一次恢复扫描的可观察结果。
//
// Scanned 为本次扫描到的申请数Confirmed 为回填为渠道明确成功的申请数;
// Failed 为回填为渠道失败终态的申请数(含渠道明确失败,以及富友与微信 v2 的本地查询窗口
// 超期后终止本次渠道执行Pending 为结果仍未知、等待下次扫描的申请数(含查询调用失败);
// Skipped 为本地事实不可用或已被并发推进而未由本次扫描改动状态的申请数。
type Stats struct{ Scanned, Confirmed, Failed, Pending, Skipped int }
// ProcessBatch 扫描原路处理中的退款并只查询渠道回填结果,绝不重复发起资金动作。
func (s *Service) ProcessBatch(ctx context.Context) (Stats, error) {
stats := Stats{}
if err := s.requireReady(); err != nil {
return stats, err
}
var refunds []model.RefundRequest
if err := s.db.WithContext(ctx).
// 已置异常标记的申请转人工处理,必须退出轮询:否则每次扫描都会重复查询同一笔未知结果。
Where("deleted_at IS NULL AND status = ? AND channel_refund_status = ? AND channel_refund_request_no <> ? AND anomaly_flag = ?",
model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing, "", 0).
Order("id ASC").Limit(recoveryBatchSize).Find(&refunds).Error; err != nil {
return stats, errors.Wrap(errors.CodeDatabaseError, err, "扫描原路处理中的退款申请失败")
}
stats.Scanned = len(refunds)
if len(refunds) == 0 {
return stats, nil
}
payments, err := s.loadPaidPayments(ctx, refunds)
if err != nil {
return stats, err
}
now := s.now().UTC()
var firstErr error
for index := range refunds {
if err := s.recoverOne(ctx, &refunds[index], payments, now, &stats); err != nil {
stats.Skipped++
s.logger.Warn("渠道原路退款恢复单条处理失败",
zap.Uint("refund_id", refunds[index].ID), zap.Error(err))
if firstErr == nil {
firstErr = err
}
}
}
return stats, firstErr
}
// recoverOne 只查询该申请对应的渠道退款状态并按结果回填,不发起任何资金动作。
func (s *Service) recoverOne(ctx context.Context, refund *model.RefundRequest, payments map[uint]*model.Payment, now time.Time, stats *Stats) error {
target, failureReason, _, err := s.buildTarget(ctx, refund, nil, payments[refund.OrderID])
if err != nil {
return err
}
if failureReason != "" {
// 恢复阶段绝不改写为明确失败:渠道可能已受理资金动作,只能留待人工与环境修复。
stats.Pending++
s.logger.Warn("渠道原路退款恢复缺少本地事实,跳过本次查询",
zap.Uint("refund_id", refund.ID), zap.String("failure_reason", failureReason))
return nil
}
if window, reason := queryWindowPolicy(target.ProviderType); window > 0 && now.Sub(refundWindowStart(refund)) > window {
return s.flagQueryWindowExpired(ctx, refund, reason, now, stats)
}
callCtx, cancel := context.WithTimeout(ctx, channelCallTimeout)
defer cancel()
result, callErr := s.refunder.Query(callCtx, target)
if callErr != nil {
// 查询失败不能推断渠道结果,保持原路处理中等待下次扫描。
stats.Pending++
return nil
}
applied, err := s.writeback(ctx, refund, target, result, constants.AuditActionRefundChannelRecovered, now)
if err != nil {
return err
}
if !applied {
stats.Skipped++
return nil
}
switch result.State {
case StateSuccess:
stats.Confirmed++
case StateFailed:
stats.Failed++
default:
stats.Pending++
}
return nil
}
// queryWindowPolicy 返回该服务商类型的本地查询窗口与其超期原因。
// 返回 0 表示不设本地窗口,持续查询直到渠道给出终态。
//
// - 富友:退款查询接口只支持 3 日内的退款交易,超期后渠道侧已无法查询,属渠道硬约束;
// - 微信 v2受理响应不含退款状态、渠道侧无查询时限此处按本地阈值放弃轮询并转人工
// 避免一笔未知结果被无限重试。
func queryWindowPolicy(providerType string) (time.Duration, string) {
switch providerType {
case model.ProviderTypeFuiou:
return fuiouQueryWindow, anomalyReasonFuiouQueryWindow
case model.ProviderTypeWechatV2:
return wechatV2QueryWindow, anomalyReasonWechatV2QueryWindow
default:
return 0, ""
}
}
// flagQueryWindowExpired 在本地查询窗口超期且结果仍未知时终止本次渠道执行并转人工处理。
//
// 生效后果:退款申请转「原路退款失败」、渠道退款状态转「已失败」、写入稳定的
// timeout_unknown 分类与异常标记,并写一次审计;重复扫描不重复写入。
// 「结果未确认」这一性质由 failure_reason 承载(它不是明确失败,因此不进入后续回溯判定),
// 而 status 只表达该尝试的渠道路径已终止。
//
// 为何不放行自动重提:本次渠道请求可能已被受理但结果未知,放行重提会以新的请求号再次
// 提交资金动作,存在重复退款风险。因此保留异常标记,由人工先向渠道核对再决定处置。
func (s *Service) flagQueryWindowExpired(ctx context.Context, refund *model.RefundRequest, reason string, now time.Time, stats *Stats) error {
stats.Failed++
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updated := tx.WithContext(ctx).Model(&model.RefundRequest{}).
Where("id = ? AND status = ? AND channel_refund_status = ? AND anomaly_flag = 0",
refund.ID, model.RefundStatusChannelProcessing, constants.RefundChannelStatusProcessing).
// UpdateColumns 不隐式推进 updated_at窗口起算点必须保留在进入原路处理中的时刻
// 否则置标记会把窗口重置,下一轮扫描将重新查询同一笔未知结果。
UpdateColumns(map[string]any{
"status": model.RefundStatusChannelFailed,
"channel_refund_status": constants.RefundChannelStatusFailed,
"failure_reason": constants.RefundFailureTimeoutUnknown,
"anomaly_flag": 1,
"anomaly_reason": reason,
})
if updated.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, updated.Error, "标记退款查询窗口超期失败")
}
if updated.RowsAffected != 1 {
return nil
}
refund.Status = model.RefundStatusChannelFailed
refund.ChannelRefundStatus = constants.RefundChannelStatusFailed
refund.FailureReason = constants.RefundFailureTimeoutUnknown
refund.AnomalyFlag = 1
refund.AnomalyReason = reason
return s.audit.WriteRefundChannelResult(ctx, tx, refund,
constants.AuditActionRefundAnomalyFlagged, reason+",结果未知,已终止渠道执行并转人工核对")
})
if err != nil {
if appErrorCode(err) != 0 {
return err
}
return errors.Wrap(errors.CodeDatabaseError, err, "标记退款查询窗口超期失败")
}
return nil
}
// refundWindowStart 返回查询窗口的起算时点:渠道明确成功时间优先,否则取最后一次实质性状态变更时间。
// 结果未知的回写不会推进 updated_at因此窗口始终从进入原路处理中的时点起算。
func refundWindowStart(refund *model.RefundRequest) time.Time {
if refund.ChannelRefundedAt != nil {
return refund.ChannelRefundedAt.UTC()
}
return refund.UpdatedAt.UTC()
}
// loadPaidPayments 批量读取该批订单最近一笔已支付的套餐支付单。
func (s *Service) loadPaidPayments(ctx context.Context, refunds []model.RefundRequest) (map[uint]*model.Payment, error) {
orderIDs := make([]uint, 0, len(refunds))
for index := range refunds {
orderIDs = append(orderIDs, refunds[index].OrderID)
}
var payments []model.Payment
if err := s.db.WithContext(ctx).
Where("order_id IN ? AND order_type = ? AND status = ?", orderIDs, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
Order("id ASC").Find(&payments).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量读取原支付单失败")
}
latest := make(map[uint]*model.Payment, len(payments))
for index := range payments {
latest[payments[index].OrderID] = &payments[index]
}
return latest, nil
}