按 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 与行为核对,未调用真实渠道。
216 lines
7.2 KiB
Go
216 lines
7.2 KiB
Go
package wechat
|
||
|
||
import (
|
||
"context"
|
||
|
||
refundRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/refund/request"
|
||
refundResponse "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/refund/response"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// 微信支付 v3 退款单状态(渠道 status 字段原文)
|
||
const (
|
||
// refundStatusSuccess 退款成功
|
||
refundStatusSuccess = "SUCCESS"
|
||
// refundStatusClosed 退款关闭
|
||
refundStatusClosed = "CLOSED"
|
||
// refundStatusProcessing 退款处理中
|
||
refundStatusProcessing = "PROCESSING"
|
||
// refundStatusAbnormal 退款异常
|
||
refundStatusAbnormal = "ABNORMAL"
|
||
)
|
||
|
||
// RefundOrderRequest 微信支付 v3 原路退款请求
|
||
//
|
||
// 金额单位均为分,且均为整型直传渠道;本结构不包含任何通知地址字段,
|
||
// 退款进度由调用方通过 QueryRefund 主动查询获得。
|
||
type RefundOrderRequest struct {
|
||
// OutTradeNo 商户订单号(原支付单号)
|
||
OutTradeNo string `json:"out_trade_no"`
|
||
// OutRefundNo 商户退款单号
|
||
OutRefundNo string `json:"out_refund_no"`
|
||
// Amount 原订单金额,单位分
|
||
Amount int64 `json:"amount"`
|
||
// Refund 本次退款金额,单位分
|
||
Refund int64 `json:"refund"`
|
||
// Reason 退款原因,可选
|
||
Reason string `json:"reason"`
|
||
// Currency 退款币种,可选,为空时使用默认值 CNY
|
||
Currency string `json:"currency"`
|
||
}
|
||
|
||
// RefundOrderResult 微信支付 v3 退款结果
|
||
type RefundOrderResult struct {
|
||
// Success 是否退款成功,仅当渠道状态为 SUCCESS 时为 true
|
||
Success bool `json:"success"`
|
||
// RefundID 微信支付退款单号
|
||
RefundID string `json:"refund_id"`
|
||
// OutRefundNo 商户退款单号
|
||
OutRefundNo string `json:"out_refund_no"`
|
||
// OutTradeNo 商户订单号
|
||
OutTradeNo string `json:"out_trade_no"`
|
||
// TransactionID 微信支付订单号
|
||
TransactionID string `json:"transaction_id"`
|
||
// Status 渠道退款状态原文:SUCCESS/CLOSED/PROCESSING/ABNORMAL
|
||
Status string `json:"status"`
|
||
// RefundFee 退款金额,单位分
|
||
RefundFee int64 `json:"refund_fee"`
|
||
// ChannelCode 渠道业务错误码,仅在渠道返回错误响应体时非空
|
||
ChannelCode string `json:"channel_code"`
|
||
// Message 状态说明
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// RefundOrder 发起微信支付 v3 原路退款
|
||
//
|
||
// 不向渠道传递任何通知地址(notify_url 留空,接口调用时该字段不会出现在请求体中),
|
||
// 退款结果以本次返回的 Status 为准,异步进度由调用方通过 QueryRefund 查询。
|
||
func (s *PaymentService) RefundOrder(ctx context.Context, req RefundOrderRequest) (*RefundOrderResult, error) {
|
||
if req.OutTradeNo == "" || req.OutRefundNo == "" {
|
||
return nil, errors.New(errors.CodeInvalidParam, "商户订单号和商户退款单号不能为空")
|
||
}
|
||
if req.Amount <= 0 || req.Refund <= 0 {
|
||
return nil, errors.New(errors.CodeInvalidParam, "原订单金额和退款金额必须大于 0")
|
||
}
|
||
if req.Refund > req.Amount {
|
||
return nil, errors.New(errors.CodeInvalidParam, "退款金额不能大于原订单金额")
|
||
}
|
||
|
||
currency := req.Currency
|
||
if currency == "" {
|
||
currency = "CNY"
|
||
}
|
||
|
||
resp, err := s.app.Refund.Refund(ctx, &refundRequest.RequestRefund{
|
||
OutTradeNo: req.OutTradeNo,
|
||
OutRefundNo: req.OutRefundNo,
|
||
Reason: req.Reason,
|
||
Amount: &refundRequest.RefundAmount{
|
||
Refund: int(req.Refund),
|
||
Total: int(req.Amount),
|
||
Currency: currency,
|
||
},
|
||
})
|
||
if err != nil {
|
||
s.logger.Error("发起微信退款失败",
|
||
zap.String("out_trade_no", req.OutTradeNo),
|
||
zap.String("out_refund_no", req.OutRefundNo),
|
||
zap.Int64("refund", req.Refund),
|
||
zap.Error(err),
|
||
)
|
||
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
|
||
}
|
||
if resp == nil {
|
||
s.logger.Error("发起微信退款失败:空响应",
|
||
zap.String("out_trade_no", req.OutTradeNo),
|
||
zap.String("out_refund_no", req.OutRefundNo),
|
||
)
|
||
return nil, errors.New(errors.CodeWechatPayFailed, "微信退款失败:渠道返回空响应")
|
||
}
|
||
|
||
result := mapRefundResult(resp)
|
||
if result.Success {
|
||
s.logger.Info("发起微信退款成功",
|
||
zap.String("out_trade_no", req.OutTradeNo),
|
||
zap.String("out_refund_no", req.OutRefundNo),
|
||
zap.String("refund_id", result.RefundID),
|
||
zap.String("status", result.Status),
|
||
)
|
||
} else {
|
||
s.logger.Warn("发起微信退款未成功",
|
||
zap.String("out_trade_no", req.OutTradeNo),
|
||
zap.String("out_refund_no", req.OutRefundNo),
|
||
zap.String("status", result.Status),
|
||
zap.String("channel_code", result.ChannelCode),
|
||
zap.String("message", result.Message),
|
||
)
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// QueryRefund 查询微信支付 v3 退款单
|
||
//
|
||
// 状态与金额映射规则与 RefundOrder 一致。
|
||
func (s *PaymentService) QueryRefund(ctx context.Context, outRefundNo string) (*RefundOrderResult, error) {
|
||
if outRefundNo == "" {
|
||
return nil, errors.New(errors.CodeInvalidParam, "商户退款单号不能为空")
|
||
}
|
||
|
||
resp, err := s.app.Refund.Query(ctx, outRefundNo)
|
||
if err != nil {
|
||
s.logger.Error("查询微信退款失败",
|
||
zap.String("out_refund_no", outRefundNo),
|
||
zap.Error(err),
|
||
)
|
||
return nil, errors.Wrap(errors.CodeWechatPayFailed, err)
|
||
}
|
||
if resp == nil {
|
||
s.logger.Error("查询微信退款失败:空响应", zap.String("out_refund_no", outRefundNo))
|
||
return nil, errors.New(errors.CodeWechatPayFailed, "查询微信退款失败:渠道返回空响应")
|
||
}
|
||
|
||
result := mapRefundResult(resp)
|
||
s.logger.Debug("查询微信退款完成",
|
||
zap.String("out_refund_no", outRefundNo),
|
||
zap.String("refund_id", result.RefundID),
|
||
zap.String("status", result.Status),
|
||
zap.String("channel_code", result.ChannelCode),
|
||
)
|
||
|
||
return result, nil
|
||
}
|
||
|
||
// mapRefundResult 将微信退款接口响应映射为统一结果
|
||
//
|
||
// 渠道 status 为 SUCCESS 时 Success 为 true;CLOSED/ABNORMAL/PROCESSING 等明确状态
|
||
// 返回结果且不报错,由调用方按 Status 与 Message 处理。
|
||
// 渠道返回业务错误码(响应体含 code,通常为 4xx,退款单未被受理)同样返回结果,
|
||
// 由调用方按 ChannelCode 判定;只有传输或解析失败才在调用处作为 error 返回——
|
||
// 这样调用方才能把「渠道明确表态」与「结果未知」区分开。
|
||
func mapRefundResult(resp *refundResponse.ResponseRefund) *RefundOrderResult {
|
||
if resp.Code != "" {
|
||
return &RefundOrderResult{
|
||
OutRefundNo: resp.OutRefundNO,
|
||
OutTradeNo: resp.OutTradeNO,
|
||
ChannelCode: resp.Code,
|
||
Message: "微信退款失败:" + resp.Code + " " + resp.Message,
|
||
}
|
||
}
|
||
|
||
result := &RefundOrderResult{
|
||
RefundID: resp.RefundID,
|
||
OutRefundNo: resp.OutRefundNO,
|
||
OutTradeNo: resp.OutTradeNO,
|
||
TransactionID: resp.TransactionID,
|
||
Status: resp.Status,
|
||
}
|
||
if resp.Amount != nil {
|
||
result.RefundFee = int64(resp.Amount.Refund)
|
||
}
|
||
|
||
result.Success = resp.Status == refundStatusSuccess
|
||
result.Message = refundStatusMessage(resp.Status)
|
||
|
||
return result
|
||
}
|
||
|
||
// refundStatusMessage 返回微信退款状态的说明文案
|
||
func refundStatusMessage(status string) string {
|
||
switch status {
|
||
case refundStatusSuccess:
|
||
return "退款成功"
|
||
case refundStatusClosed:
|
||
return "退款已关闭"
|
||
case refundStatusAbnormal:
|
||
return "退款异常"
|
||
case refundStatusProcessing:
|
||
return "退款处理中"
|
||
case "":
|
||
return "退款状态未知,请稍后查询"
|
||
default:
|
||
return "退款状态:" + status
|
||
}
|
||
}
|