Files
junhong_cmp_fiber/internal/service/refund/method.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

292 lines
12 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())
}
// frozenSourcePaymentNo 取原成功支付记录的支付单号作为冻结的来源支付单号。
// 线下订单没有线上支付记录payment 为 nil返回空串以空值保存不阻断申请创建。
func frozenSourcePaymentNo(payment *model.Payment) string {
if payment == nil {
return ""
}
return strings.TrimSpace(payment.PaymentNo)
}
// frozenOriginalChannelTradeNo 取原成功支付记录的渠道交易流水号作为冻结的原支付渠道交易流水号。
// 线下订单没有线上支付记录payment 为 nil 或流水号缺失),返回空串以空值保存。
func frozenOriginalChannelTradeNo(payment *model.Payment) string {
if payment == nil {
return ""
}
return strings.TrimSpace(payment.ThirdPartyTradeNo)
}
// applicantRemark 归一化请求中的申请人备注:缺省与纯空白一律视为未填写。
func applicantRemark(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}