Files
junhong_cmp_fiber/internal/service/refund/method.go
break 70e6b186df
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m23s
fix(退款): 修复创建退款申请插入审批尝试记录失败
创建路径把审批尝试记录在事务外预构造后原样插入,缺失只能在事务内生成的
attempt_no 与 package_usage_snapshot,触发 NOT NULL 与 CHECK 约束,
接口统一返回 2002 数据库错误(5xx 脱敏掩盖了具体消息)。

- 尝试记录收敛为事务内唯一构造点 buildAttempt,CreateCommand 与
  ResubmitCommand 只传按冻结商户派生的渠道退款请求号
- TriggerHistorical 补上缺失的尝试记录插入,此前 attempt.ID 恒为 0,
  必然以「关联已变化」冲突收场
- 按「首次接入企业微信审批的实例」语义回写
  tb_refund_request.approval_instance_id,恢复本地人工终审的 IS NULL
  防重保护与退款导出投影
2026-09-17 15:49:04 +08:00

266 lines
11 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 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
}
// buildChannelRefundRequestNo 仅在原路退款方式下按冻结商户派生本次尝试的渠道退款请求号。
// 请求号由收口审批申请用例冻结到不可变尝试记录上,同一尝试的渠道重试复用它作为幂等标识,
// 重提生成新值;非原路方式返回空串。
func buildChannelRefundRequestNo(method string, config *model.WechatConfig) string {
if method != constants.RefundMethodOriginalRoute {
return ""
}
return requestChannelRefundNo(config, time.Now())
}