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,273 @@
package refund
import (
"context"
"strings"
"time"
"gorm.io/gorm"
refundchannel "github.com/break/junhong_cmp_fiber/internal/application/refundchannel"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// refundMethodOption 描述一种可选退款方式及其不可用的原因。
type refundMethodOption struct {
Method string
Name string
Available bool
// Reason 在方式不可用时说明具体原因,供前端禁用并展示。
Reason string
}
// refundMethodDecision 是一次退款方式判定的完整结果。
type refundMethodDecision struct {
Options []refundMethodOption
// FrozenActualReceivedAmount 是系统派生的权威实收金额(分),提交人不可填写或修改。
FrozenActualReceivedAmount int64
// Payment 是派生实收金额时使用的原成功支付记录,线上订单才有值。
Payment *model.Payment
// ChannelConfig 是原路退款实际使用的冻结商户当前凭证,非线上订单或原路不可用时为 nil。
ChannelConfig *model.WechatConfig
}
// decideRefundMethods 派生权威实收金额并按来源实际支付方式生成可选退款方式。
//
// 派生来源:线上支付取该订单原成功支付记录金额;资产钱包、代理主钱包与后台线下套餐订单
// 取订单实际收款或实际扣款金额。无法确定或金额非正时拒绝,绝不允许提交人以自填金额替代。
func (s *Service) decideRefundMethods(ctx context.Context, order *model.Order) (*refundMethodDecision, error) {
if order == nil || order.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "退款来源订单无效")
}
decision := &refundMethodDecision{}
switch order.PaymentMethod {
case model.PaymentMethodWechat, model.PaymentMethodAlipay:
payment, err := s.loadPaidPackagePayment(ctx, order.ID)
if err != nil {
return nil, err
}
if payment.Amount <= 0 {
return nil, errors.New(errors.CodeInvalidParam, "原成功支付记录金额非正,无法确定权威实收金额")
}
decision.Payment = payment
decision.FrozenActualReceivedAmount = payment.Amount
originalRoute, reason, config := s.originalRouteAvailability(ctx, order, payment)
if originalRoute {
decision.ChannelConfig = config
}
decision.Options = []refundMethodOption{
{Method: constants.RefundMethodOriginalRoute, Name: constants.RefundMethodName(constants.RefundMethodOriginalRoute), Available: originalRoute, Reason: reason},
{Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true},
}
case model.PaymentMethodWallet:
amount, err := orderActualPaidAmount(order)
if err != nil {
return nil, err
}
decision.FrozenActualReceivedAmount = amount
switch order.BuyerType {
case model.BuyerTypeAgent:
decision.Options = []refundMethodOption{
{Method: constants.RefundMethodAgentWallet, Name: constants.RefundMethodName(constants.RefundMethodAgentWallet), Available: true},
}
case model.BuyerTypePersonal:
decision.Options = []refundMethodOption{
{Method: constants.RefundMethodAssetWallet, Name: constants.RefundMethodName(constants.RefundMethodAssetWallet), Available: true},
}
default:
return nil, errors.New(errors.CodeInvalidParam, "钱包支付订单的买家类型不支持退款")
}
case model.PaymentMethodOffline:
amount, err := orderActualPaidAmount(order)
if err != nil {
return nil, err
}
decision.FrozenActualReceivedAmount = amount
decision.Options = []refundMethodOption{
{Method: constants.RefundMethodCustomerAccount, Name: constants.RefundMethodName(constants.RefundMethodCustomerAccount), Available: true},
}
default:
return nil, errors.New(errors.CodeInvalidParam, "该订单的支付方式不支持退款")
}
return decision, nil
}
// orderActualPaidAmount 取订单实际收款或实际扣款金额作为权威实收金额。
func orderActualPaidAmount(order *model.Order) (int64, error) {
if order.ActualPaidAmount == nil || *order.ActualPaidAmount <= 0 {
return 0, errors.New(errors.CodeInvalidParam, "订单实际收款金额缺失或非正,无法确定权威实收金额")
}
return *order.ActualPaidAmount, nil
}
// loadPaidPackagePayment 读取该订单原成功的线上支付记录。
func (s *Service) loadPaidPackagePayment(ctx context.Context, orderID uint) (*model.Payment, error) {
var payment model.Payment
err := s.db.WithContext(ctx).
Where("order_id = ? AND order_type = ? AND status = ?", orderID, model.PaymentOrderTypePackage, model.PaymentRecordStatusPaid).
Order("id DESC").
First(&payment).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeInvalidParam, "未找到该订单的原成功支付记录,无法确定权威实收金额")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联支付记录失败")
}
return &payment, nil
}
// originalRouteAvailability 预检线上订单的原路退款可退性,并返回实际使用的商户凭证。
//
// 条件全部本地可判定:存在成功支付记录、可定位实际收款商户、该商户具备其服务商类型的
// 退款能力与必需凭证、且原交易未超出渠道可退时限。实际调用渠道后的结果才是退款资格的
// 最终判断;预检只用于在申请阶段禁用原路并说明原因。
func (s *Service) originalRouteAvailability(ctx context.Context, order *model.Order, payment *model.Payment) (bool, string, *model.WechatConfig) {
if payment == nil {
return false, "缺少原成功支付记录", nil
}
if strings.TrimSpace(payment.ThirdPartyTradeNo) == "" {
return false, "原支付记录缺少渠道交易流水号", nil
}
if s.paymentMerchantRuntime == nil {
return false, "商户凭证加载能力未配置", nil
}
var config *model.WechatConfig
if payment.MerchantID != nil {
merchant, err := s.paymentMerchantRuntime.LoadMerchant(ctx, *payment.MerchantID)
if err != nil {
return false, "实际收款商户不可用", nil
}
// 商户停用不阻断历史单的原路退款,因此只校验凭证完整性,不校验商户状态。
config, err = merchantRefundConfig(merchant)
if err != nil {
return false, err.Error(), nil
}
} else {
if payment.PaymentConfigID == nil {
return false, "历史支付单缺少支付配置", nil
}
legacy, err := loadLegacyPaymentConfig(ctx, s.db, *payment.PaymentConfigID)
if err != nil {
return false, err.Error(), nil
}
config = legacy
}
if reason := channelRefundCredentialIssue(config); reason != "" {
return false, reason, nil
}
if reason := channelRefundWindowIssue(config.ProviderType, payment.PaidAt); reason != "" {
return false, reason, nil
}
return true, "", config
}
// channelRefundWindowIssue 判定原交易是否超出渠道可退时限。
// 富友按是否回传原交易日期区分:不回传仅支持 30 天内,回传可退 360 天内。
// 系统始终回传原交易日期,因此按 360 天判定;微信与支付宝不设本地时限。
func channelRefundWindowIssue(providerType string, paidAt *time.Time) string {
if providerType != model.ProviderTypeFuiou {
return ""
}
if paidAt == nil {
return "富友原交易缺少支付时间,无法回传原交易日期"
}
if time.Since(*paidAt) > 360*24*time.Hour {
return "富友原交易已超出渠道可退时限360 天)"
}
return ""
}
// requestChannelRefundNo 按冻结商户生成渠道退款请求号,并在提交时冻结到审批尝试记录。
// 重提会生成新值;同一尝试的渠道重试复用该值,因此渠道侧以它作为幂等标识。
func requestChannelRefundNo(config *model.WechatConfig, now time.Time) string {
return refundchannel.BuildChannelRefundRequestNo(merchantChannelPrefix(config), now)
}
// merchantChannelPrefix 取商户标识中的数字段作为请求号前缀。
// 富友要求前缀为其机构码;其余渠道只要求请求号在其长度与字符集约束内唯一。
func merchantChannelPrefix(config *model.WechatConfig) string {
if config == nil {
return "0000"
}
if config.ProviderType == model.ProviderTypeFuiou && strings.TrimSpace(config.FyInsCd) != "" {
return config.FyInsCd
}
return config.WxMchID + config.AliAppID
}
// validateRefundMethod 校验提交的退款方式属于该订单的可选方式集合。
func validateRefundMethod(decision *refundMethodDecision, method string) error {
method = strings.TrimSpace(method)
if method == "" {
return errors.New(errors.CodeInvalidParam, "退款方式不能为空")
}
for _, option := range decision.Options {
if option.Method != method {
continue
}
if !option.Available {
return errors.New(errors.CodeInvalidParam, "该订单当前不支持此退款方式:"+option.Reason)
}
return nil
}
return errors.New(errors.CodeInvalidParam, "该订单不支持此退款方式")
}
// validateCustomerAccountMaterial 校验客户收款信息方式的材料完整性。
// 客户收款信息与至少一个客户凭证附件必须同时存在;不得复用公司员工收款方式字典。
func validateCustomerAccountMaterial(method, customerAccountInfo string, voucherKeys []string) error {
if method != constants.RefundMethodCustomerAccount {
return nil
}
if strings.TrimSpace(customerAccountInfo) == "" {
return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须填写客户收款信息")
}
hasVoucher := false
for _, key := range voucherKeys {
if strings.TrimSpace(key) != "" {
hasVoucher = true
break
}
}
if !hasVoucher {
return errors.New(errors.CodeInvalidParam, "客户收款信息退款必须至少上传一个客户收款凭证")
}
return nil
}
// validateFrozenRefundAmount 校验退款金额为正分且不超过冻结实收金额。
func validateFrozenRefundAmount(amount, frozenActualReceivedAmount int64) error {
if amount <= 0 {
return errors.New(errors.CodeInvalidParam, "退款金额必须为正分")
}
if frozenActualReceivedAmount <= 0 {
return errors.New(errors.CodeInvalidParam, "冻结实收金额缺失或非正,无法受理退款申请")
}
if amount > frozenActualReceivedAmount {
return errors.New(errors.CodeInvalidParam, "退款金额不能超过冻结实收金额")
}
return nil
}
// buildAttemptFromDecision 依据方式判定结果构造一条不可变审批尝试记录。
func buildAttemptFromDecision(decision *refundMethodDecision, refund *model.RefundRequest, config *model.WechatConfig, now time.Time) *model.RefundRequestAttempt {
attempt := &model.RefundRequestAttempt{
RefundID: refund.ID,
Method: refund.Method,
RefundAmount: refund.RequestedRefundAmount,
FrozenActualReceivedAmount: decision.FrozenActualReceivedAmount,
RefundReason: refund.RefundReason,
CustomerAccountInfo: refund.CustomerAccountInfo,
CustomerVoucherKeys: refund.RefundVoucherKey,
SubmittedByAccountID: refund.Creator,
}
if refund.Method == constants.RefundMethodOriginalRoute {
attempt.ChannelRefundRequestNo = requestChannelRefundNo(config, now)
}
return attempt
}