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

185
pkg/alipay/refund.go Normal file
View File

@@ -0,0 +1,185 @@
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
}