Files
junhong_cmp_fiber/pkg/alipay/refund.go
break ba0855d9eb 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 与行为核对,未调用真实渠道。
2026-09-14 11:55:16 +08:00

186 lines
6.2 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 alipay
import (
"context"
"fmt"
"github.com/smartwalle/alipay/v3"
"github.com/break/junhong_cmp_fiber/internal/model"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RefundStatusSuccess 支付宝退款查询接口alipay.trade.fastpay.refund.query
// 响应字段 refund_status 中表示「退款处理成功」的取值。
const RefundStatusSuccess = "REFUND_SUCCESS"
// RefundRequest 支付宝退款请求参数。
// OutTradeNo 与 TradeNo 二选一OutRequestNo 标识一次退款请求,同一笔交易多次退款必须保证唯一。
type RefundRequest struct {
OutTradeNo string // 商户订单号,与 TradeNo 二选一
TradeNo string // 支付宝交易号,与 OutTradeNo 二选一
OutRequestNo string // 商户退款请求号,必填
RefundAmount int64 // 退款金额,单位:分
RefundReason string // 退款原因说明,可选
TotalAmount int64 // 订单总金额单位可选alipay.trade.refund 不接收该字段,仅供调用方上下文使用)
}
// RefundResult 支付宝退款、退款查询的统一结果。
// RefundFee、字段金额单位均为分Success 表示渠道业务处理成功。
type RefundResult struct {
Success bool // 渠道业务是否成功
TradeNo string // 支付宝交易号
OutTradeNo string // 商户订单号
OutRequestNo string // 商户退款请求号
RefundFee int64 // 退款金额,单位:分
RefundStatus string // 渠道返回的退款状态原文,可能为空
Message string // 渠道返回的失败原因,成功时为空
}
// Refund 发起支付宝退款alipay.trade.refund
// RefundAmount按金额精度要求转换为元字符串两位小数后提交。
// 渠道业务失败时返回 Success=false 与渠道 Message不返回 error渠道拒绝不是传输错误
// 传输失败、响应不可解析等 SDK 层错误才返回 error此时 result 为 nil
// 调用方因无法确认退款结果,应通过 QueryRefund 查询退款状态。
// TradeRefundRsp 不返回 out_request_no故 OutRequestNo 以入参为准;
// 该响应亦无 refund_status 字段,需要退款状态原文时使用 QueryRefund。
func Refund(ctx context.Context, cfg *model.WechatConfig, req RefundRequest) (*RefundResult, error) {
client, err := NewClientFromConfig(cfg)
if err != nil {
return nil, err
}
param := alipay.TradeRefund{
OutTradeNo: req.OutTradeNo,
TradeNo: req.TradeNo,
OutRequestNo: req.OutRequestNo,
RefundAmount: FenToYuan(req.RefundAmount),
RefundReason: req.RefundReason,
}
rsp, err := client.TradeRefund(ctx, param)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeServiceUnavailable, err, "支付宝退款请求失败")
}
result := &RefundResult{
TradeNo: preferChannel(rsp.TradeNo, req.TradeNo),
OutTradeNo: preferChannel(rsp.OutTradeNo, req.OutTradeNo),
OutRequestNo: req.OutRequestNo,
}
if rsp.IsFailure() {
result.Message = channelMessage(rsp.Error)
return result, nil
}
refundFee, err := parseChannelAmount(rsp.RefundFee)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeServiceUnavailable, err, "支付宝退款响应不可解析")
}
result.Success = true
result.RefundFee = refundFee
return result, nil
}
// QueryRefund 查询支付宝退款alipay.trade.fastpay.refund.query
// 以 OutRequestNo 定位一次退款请求,映射渠道返回的退款金额与退款状态。
// 仅当渠道业务码成功且 refund_status 为 REFUND_SUCCESS 时 Success=true
// 渠道拒绝、refund_status 非成功(未返回该字段表示退款请求未收到或退款失败)时
// 返回 Success=false 与渠道 Message/RefundStatus 原文,不返回 error
// 传输失败、响应不可解析等 SDK 层错误才返回 error此时 result 为 nil。
func QueryRefund(ctx context.Context, cfg *model.WechatConfig, req RefundRequest) (*RefundResult, error) {
client, err := NewClientFromConfig(cfg)
if err != nil {
return nil, err
}
param := alipay.TradeFastPayRefundQuery{
OutTradeNo: req.OutTradeNo,
TradeNo: req.TradeNo,
OutRequestNo: req.OutRequestNo,
}
rsp, err := client.TradeFastPayRefundQuery(ctx, param)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeServiceUnavailable, err, "支付宝退款查询请求失败")
}
result := &RefundResult{
TradeNo: preferChannel(rsp.TradeNo, req.TradeNo),
OutTradeNo: preferChannel(rsp.OutTradeNo, req.OutTradeNo),
OutRequestNo: preferChannel(rsp.OutRequestNo, req.OutRequestNo),
RefundStatus: rsp.RefundStatus,
}
if rsp.IsFailure() {
result.Message = channelMessage(rsp.Error)
return result, nil
}
refundFee, err := parseChannelAmount(rsp.RefundAmount)
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeServiceUnavailable, err, "支付宝退款查询响应不可解析")
}
result.RefundFee = refundFee
if rsp.RefundStatus != RefundStatusSuccess {
result.Message = "支付宝退款状态非成功"
if rsp.RefundStatus != "" {
result.Message += ": " + rsp.RefundStatus
}
return result, nil
}
result.Success = true
return result, nil
}
// preferChannel 渠道返回值优先,渠道未返回时回退到请求值。
func preferChannel(channelValue, requestValue string) string {
if channelValue != "" {
return channelValue
}
return requestValue
}
// channelMessage 拼接支付宝返回的错误信息,格式为「错误码/子错误码: 错误描述」,忽略空字段。
func channelMessage(e alipay.Error) string {
code := string(e.Code)
if e.SubCode != "" {
if code == "" {
code = e.SubCode
} else {
code += "/" + e.SubCode
}
}
detail := e.SubMsg
if detail == "" {
detail = e.Msg
}
switch {
case code == "":
return detail
case detail == "":
return code
default:
return code + ": " + detail
}
}
// parseChannelAmount 将渠道返回的元字符串(如 "1.00")精确转换为分。
// 渠道未返回该字段(空字符串)时按 0 处理,不丢精度。
func parseChannelAmount(yuan string) (int64, error) {
if yuan == "" {
return 0, nil
}
fen, err := YuanToFen(yuan)
if err != nil {
return 0, fmt.Errorf("渠道返回金额 %q 无法解析: %w", yuan, err)
}
return fen, nil
}