按 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 与行为核对,未调用真实渠道。
186 lines
6.2 KiB
Go
186 lines
6.2 KiB
Go
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
|
||
}
|