package payment import ( "context" "strconv" "strings" "time" "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" "go.uber.org/zap" 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/alipay" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/fuiou" wechatpay "github.com/break/junhong_cmp_fiber/pkg/wechat" ) // fuiouShanghaiLocation 富友要求按上海时区回传原交易日期。 var fuiouShanghaiLocation = time.FixedZone("CST", 8*3600) // RefundAdapter 按冻结商户的服务商类型分派渠道原路退款与退款查询。 // // 本适配器只做渠道原始调用与结果映射:不生成请求号、不写数据库、不写审计。 // 渠道适配按官方契约实现,且不向任何渠道传递退款结果通知地址——退款终态只由 // 同步响应与主动退款查询确认。 type RefundAdapter struct { cache kernel.CacheInterface logger *zap.Logger } // NewRefundAdapter 创建渠道原路退款适配器。 func NewRefundAdapter(cache kernel.CacheInterface, logger *zap.Logger) *RefundAdapter { return &RefundAdapter{cache: cache, logger: logger} } var _ refundchannel.Refunder = (*RefundAdapter)(nil) // Refund 按服务商类型提交一次渠道退款请求。 // 凭证取自 Target.Config(冻结商户的当前凭证),只用于构造渠道客户端,绝不记录或持久化。 // 渠道明确表态时返回结果且不报错;传输或解析失败返回 error,由调用方按结果未知处理。 func (a *RefundAdapter) Refund(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { switch target.ProviderType { case model.ProviderTypeWechat: return a.refundWechatV3(ctx, target) case model.ProviderTypeWechatV2: return a.refundWechatV2(ctx, target) case model.ProviderTypeFuiou: return a.refundFuiou(target) case "alipay": return a.refundAlipay(ctx, target) default: return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: constants.RefundFailureCredentialInvalid, FailureMessage: "该商户支付渠道的退款凭证不完整,无法执行原路退款", }, nil } } // Query 按服务商类型查询渠道退款状态,绝不发起资金动作。 func (a *RefundAdapter) Query(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { switch target.ProviderType { case model.ProviderTypeWechat: service, err := a.wechatService(target.Config) if err != nil { return refundchannel.Result{}, err } result, err := service.QueryRefund(ctx, target.ChannelRefundRequestNo) if err != nil { return refundchannel.Result{}, err } return mapWechatRefundResult(result), nil case model.ProviderTypeWechatV2: service, err := wechatpay.NewPaymentV2ServiceFromConfig(target.Config, target.Config.OaAppID, a.logger) if err != nil { return refundchannel.Result{}, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信 v2 退款查询配置不可用") } result, err := service.QueryRefund(ctx, target.ChannelRefundRequestNo) if err != nil { return refundchannel.Result{}, err } return mapWechatV2RefundResult(result, target.RefundAmount), nil case model.ProviderTypeFuiou: client, err := newFuiouClient(target.Config, a.logger) if err != nil { return refundchannel.Result{}, err } resp, err := client.RefundQuery(target.ChannelRefundRequestNo) if err != nil { return refundchannel.Result{}, err } return mapFuiouRefundQuery(resp), nil case "alipay": result, err := alipay.QueryRefund(ctx, target.Config, alipay.RefundRequest{ OutTradeNo: target.PaymentNo, TradeNo: target.ChannelTradeNo, OutRequestNo: target.ChannelRefundRequestNo, RefundAmount: target.RefundAmount, }) if err != nil { return refundchannel.Result{}, err } return mapAlipayRefundResult(result, target.RefundAmount), nil default: return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: constants.RefundFailureCredentialInvalid, FailureMessage: "该商户支付渠道的退款凭证不完整,无法查询退款结果", }, nil } } // refundWechatV3 执行微信支付 v3 原路退款。 func (a *RefundAdapter) refundWechatV3(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { service, err := a.wechatService(target.Config) if err != nil { return refundchannel.Result{}, err } result, err := service.RefundOrder(ctx, wechatpay.RefundOrderRequest{ OutTradeNo: target.PaymentNo, OutRefundNo: target.ChannelRefundRequestNo, Amount: target.FrozenActualReceivedAmount, Refund: target.RefundAmount, }) if err != nil { return refundchannel.Result{}, err } return mapWechatRefundResult(result), nil } // wechatService 用商户当前凭证构造微信 v3 支付服务。 func (a *RefundAdapter) wechatService(config *model.WechatConfig) (*wechatpay.PaymentService, error) { if config == nil { return nil, errors.New(errors.CodeNoPaymentConfig, "商户支付凭证不可用") } app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger) if err != nil { return nil, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信支付退款配置不可用") } return wechatpay.NewPaymentService(app, a.logger), nil } // refundFuiou 执行富友原路退款。 // 回传原交易日期才能覆盖 30 天以上的原交易;不回传仅支持 30 天内,因此始终回传。 func (a *RefundAdapter) refundFuiou(target refundchannel.Target) (refundchannel.Result, error) { client, err := newFuiouClient(target.Config, a.logger) if err != nil { return refundchannel.Result{}, err } origiDt := "" if target.PaidAt != nil { origiDt = target.PaidAt.In(fuiouShanghaiLocation).Format("20060102") } resp, err := client.CommonRefund(&fuiou.CommonRefundRequest{ OrderType: target.ChannelOrderType, MchntOrderNo: target.PaymentNo, RefundOrderNo: target.ChannelRefundRequestNo, TotalAmt: strconv.FormatInt(target.FrozenActualReceivedAmount, 10), RefundAmt: strconv.FormatInt(target.RefundAmount, 10), ReservedOrigiDt: origiDt, }) if err != nil { return refundchannel.Result{}, err } return mapFuiouRefundResponse(resp), nil } // refundAlipay 执行支付宝原路退款。 func (a *RefundAdapter) refundAlipay(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { result, err := alipay.Refund(ctx, target.Config, alipay.RefundRequest{ OutTradeNo: target.PaymentNo, TradeNo: target.ChannelTradeNo, OutRequestNo: target.ChannelRefundRequestNo, RefundAmount: target.RefundAmount, }) if err != nil { return refundchannel.Result{}, err } return mapAlipayRefundResult(result, target.RefundAmount), nil } // refundWechatV2 执行微信支付 v2 原路退款(双向证书接口)。 // // 渠道契约规定申请接口的返回仅代表受理情况,退款是否成功必须由退款查询确认, // 因此受理成功在此按「结果未知」返回,由恢复任务查询收敛;渠道明确拒绝时按失败分类返回。 func (a *RefundAdapter) refundWechatV2(ctx context.Context, target refundchannel.Target) (refundchannel.Result, error) { service, err := wechatpay.NewPaymentV2ServiceFromConfig(target.Config, target.Config.OaAppID, a.logger) if err != nil { return refundchannel.Result{}, errors.Wrap(errors.CodeNoPaymentConfig, err, "微信 v2 退款配置不可用") } result, err := service.RefundOrderV2(ctx, wechatpay.V2RefundRequest{ OutTradeNo: target.PaymentNo, OutRefundNo: target.ChannelRefundRequestNo, TotalFee: target.FrozenActualReceivedAmount, RefundFee: target.RefundAmount, }) if err != nil { return refundchannel.Result{}, err } return mapWechatV2RefundResult(result, target.RefundAmount), nil } // mapWechatV2RefundResult 映射微信 v2 退款申请或退款查询结果。 // Accepted 为真表示渠道已受理但结果待查询确认,按结果未知处理;受理接口不返回退款状态。 func mapWechatV2RefundResult(result *wechatpay.V2RefundResult, requestedAmount int64) refundchannel.Result { if result == nil { return refundchannel.Result{ State: refundchannel.StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: "渠道返回空响应,退款结果未知", } } if result.Success { amount := result.RefundFee if amount <= 0 { amount = requestedAmount } return refundchannel.Result{ State: refundchannel.StateSuccess, ChannelRefundNo: result.RefundID, ChannelRefundAmount: amount, } } if result.Accepted { // 渠道已受理:终态必须由退款查询确认,不得在此标记成功。 return refundchannel.Result{ State: refundchannel.StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: "微信 v2 已受理退款申请,等待退款查询确认结果", } } if result.ErrCode != "" { return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: classifyWechatRejection(result.ErrCode), FailureMessage: safeChannelMessage(result.ErrCode + " " + result.Message), } } switch strings.ToUpper(strings.TrimSpace(result.Status)) { case "REFUNDCLOSE", "CHANGE": return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: constants.RefundFailureChannelRejected, FailureMessage: safeChannelMessage(result.Message), } default: return refundchannel.Result{ State: refundchannel.StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: safeChannelMessage(result.Message), } } } // mapWechatRefundResult 映射微信 v3 退款结果。 // 渠道业务错误码表示退款单未被受理,按错误码分类;处理中或状态为空表示渠道尚未给出终态, // 保持结果未知交由恢复任务查询。 func mapWechatRefundResult(result *wechatpay.RefundOrderResult) refundchannel.Result { if result == nil { return refundchannel.Result{ State: refundchannel.StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: "渠道返回空响应,退款结果未知", } } if result.Success { return refundchannel.Result{ State: refundchannel.StateSuccess, ChannelRefundNo: result.RefundID, ChannelRefundAmount: result.RefundFee, } } if result.ChannelCode != "" { return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: classifyWechatRejection(result.ChannelCode), FailureMessage: safeChannelMessage(result.Message), } } switch strings.ToUpper(strings.TrimSpace(result.Status)) { case "CLOSED", "ABNORMAL": return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: constants.RefundFailureChannelRejected, FailureMessage: safeChannelMessage(result.Message), } default: return refundchannel.Result{ State: refundchannel.StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: safeChannelMessage(result.Message), } } } // classifyWechatRejection 把微信拒绝类错误码映射为稳定失败分类。 // 系统异常与限频不代表渠道已明确拒绝,保持结果未知交恢复任务查询。 func classifyWechatRejection(code string) string { switch strings.ToUpper(strings.TrimSpace(code)) { case "SYSTEM_ERROR", "FREQUENCY_LIMITED", "RATELIMIT_EXCEED": return constants.RefundFailureTimeoutUnknown case "NOT_ENOUGH", "BALANCE_NOT_ENOUGH": return constants.RefundFailureInsufficientBalance case "NO_AUTH", "SIGN_ERROR": return constants.RefundFailureCredentialInvalid default: return constants.RefundFailureChannelRejected } } // mapFuiouRefundResponse 映射富友退款申请响应。 func mapFuiouRefundResponse(resp *fuiou.CommonRefundResponse) refundchannel.Result { if resp == nil { return refundchannel.Result{ State: refundchannel.StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: "渠道返回空响应,退款结果未知", } } if resp.ResultCode != fuiou.ResultCodeSuccess { return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: classifyFuiouRejection(resp.ResultMsg), FailureMessage: safeChannelMessage(resp.ResultMsg), } } return refundchannel.Result{ State: refundchannel.StateSuccess, ChannelRefundNo: resp.RefundId, ChannelRefundAmount: parseFuiouAmount(resp.ReservedRefundAmt), SettledAt: resp.ReservedFySettleDt, } } // mapFuiouRefundQuery 映射富友退款查询响应。 // trans_stat 只取 SUCCESS 或 PAYERROR:PAYERROR 视为渠道明确失败,其余保持未知。 func mapFuiouRefundQuery(resp *fuiou.RefundQueryResponse) refundchannel.Result { if resp == nil { return refundchannel.Result{ State: refundchannel.StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: "渠道返回空响应,退款结果未知", } } if resp.ResultCode != fuiou.ResultCodeSuccess { return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: classifyFuiouRejection(resp.ResultMsg), FailureMessage: safeChannelMessage(resp.ResultMsg), } } switch strings.ToUpper(strings.TrimSpace(resp.TransStat)) { case fuiou.TransStatSuccess: return refundchannel.Result{ State: refundchannel.StateSuccess, ChannelRefundNo: resp.RefundId, ChannelRefundAmount: parseFuiouAmount(resp.ReservedRefundAmt), SettledAt: resp.ReservedFySettleDt, } case fuiou.TransStatPayError: return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: constants.RefundFailureChannelRejected, FailureMessage: "富友退款交易状态为失败", } default: return refundchannel.Result{ State: refundchannel.StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: "富友退款交易仍在办理中", } } } // classifyFuiouRejection 按富友错误文案粗分类;无法识别时归为渠道明确拒绝。 func classifyFuiouRejection(message string) string { text := strings.TrimSpace(message) switch { case strings.Contains(text, "余额") || strings.Contains(text, "不足"): return constants.RefundFailureInsufficientBalance case strings.Contains(text, "签名") || strings.Contains(text, "验签") || strings.Contains(text, "密钥"): return constants.RefundFailureCredentialInvalid default: return constants.RefundFailureChannelRejected } } // mapAlipayRefundResult 映射支付宝退款或退款查询结果。 func mapAlipayRefundResult(result *alipay.RefundResult, requestedAmount int64) refundchannel.Result { if result == nil { return refundchannel.Result{ State: refundchannel.StateUnknown, FailureReason: constants.RefundFailureTimeoutUnknown, FailureMessage: "渠道返回空响应,退款结果未知", } } if result.Success { amount := result.RefundFee if amount == 0 { amount = requestedAmount } return refundchannel.Result{ State: refundchannel.StateSuccess, ChannelRefundNo: result.TradeNo, ChannelRefundAmount: amount, } } return refundchannel.Result{ State: refundchannel.StateFailed, FailureReason: classifyAlipayRejection(result.Message), FailureMessage: safeChannelMessage(result.Message), } } // classifyAlipayRejection 按支付宝错误码粗分类;无法识别时归为渠道明确拒绝。 func classifyAlipayRejection(message string) string { text := strings.ToUpper(strings.TrimSpace(message)) switch { case strings.Contains(text, "BALANCE_NOT_ENOUGH") || strings.Contains(text, "余额不足"): return constants.RefundFailureInsufficientBalance case strings.Contains(text, "SIGN") || strings.Contains(text, "AUTH"): return constants.RefundFailureCredentialInvalid case strings.Contains(text, "SYSTEM_ERROR"): return constants.RefundFailureTimeoutUnknown default: return constants.RefundFailureChannelRejected } } // safeChannelMessage 截断渠道文案并去掉换行,避免把渠道报文原文写入失败摘要。 func safeChannelMessage(message string) string { text := strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(message, "\n", " "), "\r", " ")) if len(text) > 200 { return text[:200] } return text } // parseFuiouAmount 把富友返回的金额字符串精确转换为分;空值或不可解析时按 0 处理。 func parseFuiouAmount(amount string) int64 { text := strings.TrimSpace(amount) if text == "" { return 0 } value, err := strconv.ParseInt(text, 10, 64) if err != nil { return 0 } return value } // newFuiouClient 用商户当前凭证构造富友客户端。 func newFuiouClient(config *model.WechatConfig, logger *zap.Logger) (*fuiou.Client, error) { if config == nil { return nil, errors.New(errors.CodeNoPaymentConfig, "商户支付凭证不可用") } return fuiou.NewClient(config.FyInsCd, config.FyMchntCd, config.FyTermID, config.FyAPIURL, config.FyNotifyURL, config.FyPrivateKey, config.FyPublicKey, logger) }