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:
185
pkg/alipay/refund.go
Normal file
185
pkg/alipay/refund.go
Normal 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
|
||||
}
|
||||
@@ -361,6 +361,18 @@ const (
|
||||
AuditActionRefundCommissionInvalidated = "refund.invalidate_commission"
|
||||
// AuditActionRefundAssetProcessed 表示退款后的套餐与资产处理已完成。
|
||||
AuditActionRefundAssetProcessed = "refund.process_asset"
|
||||
// AuditActionRefundAttemptSubmitted 表示提交或重提退款审批尝试并创建独立审批实例。
|
||||
AuditActionRefundAttemptSubmitted = "refund.attempt_submit"
|
||||
// AuditActionRefundAttemptApproved 表示企业微信终审通过退款审批尝试。
|
||||
AuditActionRefundAttemptApproved = "refund.attempt_approve"
|
||||
// AuditActionRefundAttemptClosed 表示企业微信驳回、撤销或删除退款审批尝试。
|
||||
AuditActionRefundAttemptClosed = "refund.attempt_close"
|
||||
// AuditActionRefundAnomalyFlagged 表示企业微信通过后撤销,只写正交异常标记并转人工处理。
|
||||
AuditActionRefundAnomalyFlagged = "refund.anomaly_flag"
|
||||
// AuditActionRefundChannelCalled 表示按退款方式发起渠道原路退款调用。
|
||||
AuditActionRefundChannelCalled = "refund.channel_call"
|
||||
// AuditActionRefundChannelRecovered 表示恢复任务按渠道退款请求号回填原路退款结果。
|
||||
AuditActionRefundChannelRecovered = "refund.channel_recover"
|
||||
// AuditActionApprovalRequested 表示创建通用审批实例并请求渠道提交。
|
||||
AuditActionApprovalRequested = "approval.request"
|
||||
// AuditActionApprovalSubmissionSynced 表示同步审批渠道提交结果。
|
||||
@@ -648,6 +660,10 @@ const (
|
||||
AuditResourceOrder = "order"
|
||||
// AuditResourceRefund 表示退款资源。
|
||||
AuditResourceRefund = "refund"
|
||||
// AuditResourceRefundAttempt 表示退款审批尝试记录资源,主键同时充当审批业务标识。
|
||||
AuditResourceRefundAttempt = "refund_attempt"
|
||||
// AuditResourceRefundChannelRefund 表示退款单上的渠道原路退款事实资源。
|
||||
AuditResourceRefundChannelRefund = "refund_channel_refund"
|
||||
// AuditResourceAccount 表示后台账号资源。
|
||||
AuditResourceAccount = "account"
|
||||
// AuditResourceRole 表示后台角色资源。
|
||||
@@ -936,6 +952,8 @@ const (
|
||||
AuditResourceRoleRefundPackageUsage = "refund_package_usage"
|
||||
// AuditResourceRoleRefundNotification 表示退款完成通知的可靠 Outbox 事实。
|
||||
AuditResourceRoleRefundNotification = "refund_notification"
|
||||
// AuditResourceRoleRefundChannelRefund 表示退款单上的渠道原路退款事实。
|
||||
AuditResourceRoleRefundChannelRefund = "refund_channel_refund"
|
||||
// AuditResourceRoleCommissionRecord 表示佣金计算或入账涉及的佣金记录。
|
||||
AuditResourceRoleCommissionRecord = "commission_record"
|
||||
// AuditResourceRoleCommissionOrder 表示佣金关联订单。
|
||||
@@ -1065,6 +1083,8 @@ const (
|
||||
AuditActorIDRefundAssetPostProcessing = "refund_asset_post_processing"
|
||||
// AuditActorIDRefundCommissionPostProcessing 表示退款佣金自动回扣任务。
|
||||
AuditActorIDRefundCommissionPostProcessing = "refund_commission_post_processing"
|
||||
// AuditActorIDRefundChannel 表示退款渠道原路退款的调用与恢复任务。
|
||||
AuditActorIDRefundChannel = "refund_channel"
|
||||
// AuditActorIDCommissionCalculationWorker 表示订单佣金计算任务。
|
||||
AuditActorIDCommissionCalculationWorker = "commission_calculation_worker"
|
||||
// AuditActorIDRetentionWorker 表示日志留存清理任务。
|
||||
|
||||
@@ -91,6 +91,7 @@ const (
|
||||
TaskTypeWeComApprovalSync = "wecom:approval:sync" // 企业微信审批详情异步同步
|
||||
TaskTypeWeComApprovalRecovery = "wecom:approval:recovery" // 企业微信审批主动恢复与轮询
|
||||
TaskTypeAgentRechargeRecovery = "agent_recharge:payment:recovery" // 代理在线充值支付恢复与查单
|
||||
TaskTypeRefundChannelRecovery = "refund:channel:recovery" // 渠道原路退款结果恢复与查询
|
||||
)
|
||||
|
||||
// 用户状态常量
|
||||
@@ -306,7 +307,7 @@ func QueueForTaskType(taskType string) string {
|
||||
return QueueCardObservationSeries
|
||||
case TaskTypeWeComApprovalSync, TaskTypeWeComApprovalRecovery:
|
||||
return QueueWeComApproval
|
||||
case TaskTypeAgentRechargeRecovery:
|
||||
case TaskTypeAgentRechargeRecovery, TaskTypeRefundChannelRecovery:
|
||||
return QueueDefault
|
||||
default:
|
||||
return QueueDefault
|
||||
|
||||
@@ -57,12 +57,12 @@ const (
|
||||
|
||||
// Gateway 卡状态
|
||||
const (
|
||||
GatewayCardStatusReady = "准备" // 网关卡状态:准备
|
||||
GatewayCardStatusNormal = "正常" // 网关卡状态:正常
|
||||
GatewayCardStatusStopped = "停机" // 网关卡状态:停机
|
||||
GatewayCardStatusReady = "准备" // 网关卡状态:准备
|
||||
GatewayCardStatusNormal = "正常" // 网关卡状态:正常
|
||||
GatewayCardStatusStopped = "停机" // 网关卡状态:停机
|
||||
GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活
|
||||
GatewayCardExtendMachineSeparated = "机卡分离停机" // 网关扩展状态:机卡分离停机
|
||||
GatewayCardStartExtendMachineSeparated = "2" // 机卡分离复机时上传给运营商的 extend 值
|
||||
GatewayCardStartExtendMachineSeparated = "2" // 机卡分离复机时上传给运营商的 extend 值
|
||||
GatewayCardExtendRiskStop = "风险停机" // 网关扩展状态:运营商风险停机(独立卡不允许复机)
|
||||
GatewayCardExtendCancelled = "已销户" // 网关扩展状态:已销户(独立卡不允许复机)
|
||||
)
|
||||
@@ -409,7 +409,7 @@ func GetOrderCommissionResultName(result int) string {
|
||||
}
|
||||
|
||||
// GetRefundStatusName 获取退款申请状态名称
|
||||
// 1=待审批, 2=已通过, 3=已拒绝, 4=已退回
|
||||
// 1=待审批, 2=已通过, 3=已拒绝, 4=已退回, 5=原路退款处理中, 6=原路退款失败
|
||||
func GetRefundStatusName(status int) string {
|
||||
switch status {
|
||||
case 1:
|
||||
@@ -420,6 +420,10 @@ func GetRefundStatusName(status int) string {
|
||||
return "已拒绝"
|
||||
case 4:
|
||||
return "已退回"
|
||||
case 5:
|
||||
return "原路退款处理中"
|
||||
case 6:
|
||||
return "原路退款失败"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
|
||||
110
pkg/constants/refund.go
Normal file
110
pkg/constants/refund.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
// RefundMethodOriginalRoute 表示按原支付渠道原路退款。
|
||||
RefundMethodOriginalRoute = "original_route"
|
||||
// RefundMethodCustomerAccount 表示退至客户提供的收款信息。
|
||||
RefundMethodCustomerAccount = "customer_account"
|
||||
// RefundMethodAssetWallet 表示退回原资产钱包。
|
||||
RefundMethodAssetWallet = "asset_wallet"
|
||||
// RefundMethodAgentWallet 表示退回原代理主钱包。
|
||||
RefundMethodAgentWallet = "agent_wallet"
|
||||
)
|
||||
|
||||
// RefundMethodName 返回退款方式中文名称。
|
||||
func RefundMethodName(method string) string {
|
||||
switch method {
|
||||
case RefundMethodOriginalRoute:
|
||||
return "原路退款"
|
||||
case RefundMethodCustomerAccount:
|
||||
return "客户收款信息退款"
|
||||
case RefundMethodAssetWallet:
|
||||
return "退回资产钱包"
|
||||
case RefundMethodAgentWallet:
|
||||
return "退回代理主钱包"
|
||||
default:
|
||||
return "未知方式"
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
// RefundChannelStatusNone 表示未发起渠道退款或不适用该方式。
|
||||
RefundChannelStatusNone = 0
|
||||
// RefundChannelStatusProcessing 表示渠道退款已提交但结果未确认。
|
||||
RefundChannelStatusProcessing = 1
|
||||
// RefundChannelStatusSucceeded 表示渠道明确退款成功。
|
||||
RefundChannelStatusSucceeded = 2
|
||||
// RefundChannelStatusFailed 表示渠道明确退款失败。
|
||||
RefundChannelStatusFailed = 3
|
||||
)
|
||||
|
||||
// RefundChannelStatusName 返回渠道退款状态中文名称。
|
||||
func RefundChannelStatusName(status int) string {
|
||||
switch status {
|
||||
case RefundChannelStatusProcessing:
|
||||
return "处理中"
|
||||
case RefundChannelStatusSucceeded:
|
||||
return "已成功"
|
||||
case RefundChannelStatusFailed:
|
||||
return "已失败"
|
||||
default:
|
||||
return "未发起"
|
||||
}
|
||||
}
|
||||
|
||||
// 退款结构化失败分类稳定编码。
|
||||
// 分类既用于详情展示与审计,也用于后续佣金回溯判定「明确失败可回溯 / 在途不可回溯」。
|
||||
const (
|
||||
// RefundFailureChannelRejected 表示渠道明确拒绝退款。
|
||||
RefundFailureChannelRejected = "channel_rejected"
|
||||
// RefundFailureCredentialInvalid 表示该商户退款必需凭证缺失或失效,渠道侧不可执行。
|
||||
RefundFailureCredentialInvalid = "credential_invalid"
|
||||
// RefundFailureInsufficientBalance 表示渠道账户余额不足。
|
||||
RefundFailureInsufficientBalance = "insufficient_balance"
|
||||
// RefundFailureTimeoutUnknown 表示调用超时或渠道结果未确认,属可恢复状态。
|
||||
RefundFailureTimeoutUnknown = "timeout_unknown"
|
||||
// RefundFailureApprovalRejected 表示企业微信驳回、撤销或删除。
|
||||
RefundFailureApprovalRejected = "approval_rejected"
|
||||
// RefundFailureRevokedAfterApproved 表示企业微信通过后撤销,属需人工处理的异常。
|
||||
RefundFailureRevokedAfterApproved = "revoked_after_approved"
|
||||
// RefundFailurePaymentFactInvalid 表示本地原支付单、实际收款商户、原渠道流水或可退金额校验不通过。
|
||||
RefundFailurePaymentFactInvalid = "payment_fact_invalid"
|
||||
)
|
||||
|
||||
// RefundFailureReasonName 返回失败分类中文名称。
|
||||
func RefundFailureReasonName(reason string) string {
|
||||
switch reason {
|
||||
case RefundFailureChannelRejected:
|
||||
return "渠道明确拒绝"
|
||||
case RefundFailureCredentialInvalid:
|
||||
return "渠道凭证失效"
|
||||
case RefundFailureInsufficientBalance:
|
||||
return "渠道余额不足"
|
||||
case RefundFailureTimeoutUnknown:
|
||||
return "超时或结果未知"
|
||||
case RefundFailureApprovalRejected:
|
||||
return "企业微信驳回或关闭"
|
||||
case RefundFailureRevokedAfterApproved:
|
||||
return "企业微信通过后撤销"
|
||||
case RefundFailurePaymentFactInvalid:
|
||||
return "本地原支付事实不可用"
|
||||
default:
|
||||
return "无失败"
|
||||
}
|
||||
}
|
||||
|
||||
// RefundFailureReasonIsDefinitive 判断失败分类是否属于明确失败。
|
||||
// 明确失败表示退款已终结且可进入后续回溯判定;超时未知与企业微信通过后撤销均非明确失败,
|
||||
// 前者保持可恢复、后者转人工处理,都不得据此回溯佣金。
|
||||
func RefundFailureReasonIsDefinitive(reason string) bool {
|
||||
switch reason {
|
||||
case RefundFailureChannelRejected,
|
||||
RefundFailureCredentialInvalid,
|
||||
RefundFailureInsufficientBalance,
|
||||
RefundFailureApprovalRejected,
|
||||
RefundFailurePaymentFactInvalid:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
206
pkg/fuiou/refund.go
Normal file
206
pkg/fuiou/refund.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package fuiou
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 退款接口结果常量
|
||||
const (
|
||||
ResultCodeSuccess = "000000" // 富友接口成功结果码
|
||||
TransStatSuccess = "SUCCESS" // 退款交易状态:退款成功
|
||||
TransStatPayError = "PAYERROR" // 退款交易状态:退款失败
|
||||
)
|
||||
|
||||
// 渠道退款请求号(refund_order_no)生成规则参数
|
||||
const (
|
||||
// refundOrderNoAlphabet 随机段字符集:大写字母与数字
|
||||
refundOrderNoAlphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
// refundOrderNoRandomLen 随机段长度,取富友规则上限 18 位
|
||||
refundOrderNoRandomLen = 18
|
||||
// refundOrderNoLength 渠道退款请求号总长:机构码 4 + 日期 8 + 随机段 18
|
||||
refundOrderNoLength = 30
|
||||
)
|
||||
|
||||
// shanghaiLocation 上海时区(东八区),用于按富友规则生成日期段
|
||||
var shanghaiLocation = time.FixedZone("CST", 8*3600)
|
||||
|
||||
// CommonRefundRequest 退款申请请求(POST /commonRefund)。
|
||||
// reserved 开头字段与 sign 不参与签名,但仍须随 XML 发出(不得加 omitempty)。
|
||||
type CommonRefundRequest struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Version string `xml:"version"` // 版本号: 1.0
|
||||
InsCd string `xml:"ins_cd"` // 机构号
|
||||
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||
TermId string `xml:"term_id"` // 终端号
|
||||
MchntOrderNo string `xml:"mchnt_order_no"` // 原支付商户订单号
|
||||
RandomStr string `xml:"random_str"` // 随机字符串
|
||||
Sign string `xml:"sign"` // 签名
|
||||
OrderType string `xml:"order_type"` // 原交易订单类型,须与原支付一致
|
||||
RefundOrderNo string `xml:"refund_order_no"` // 商户退款订单号(全局永久唯一)
|
||||
TotalAmt string `xml:"total_amt"` // 原订单总金额(分)
|
||||
RefundAmt string `xml:"refund_amt"` // 本次退款金额(分)
|
||||
OperatorId string `xml:"operator_id"` // 操作员(可选)
|
||||
ReservedFyTermId string `xml:"reserved_fy_term_id"` // 富友终端号(reserved,不参与签名)
|
||||
ReservedOrigiDt string `xml:"reserved_origi_dt"` // 原交易日期(reserved,不参与签名)
|
||||
ReservedAddnInf string `xml:"reserved_addn_inf"` // 附加数据(reserved,不参与签名)
|
||||
ReservedRefundDesc string `xml:"reserved_refund_desc"` // 退款备注(reserved,不参与签名)
|
||||
}
|
||||
|
||||
// CommonRefundResponse 退款申请响应
|
||||
type CommonRefundResponse struct {
|
||||
ResultCode string `xml:"result_code"` // 结果码: 000000=成功
|
||||
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||
InsCd string `xml:"ins_cd"` // 机构号
|
||||
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||
RandomStr string `xml:"random_str"` // 随机字符串
|
||||
Sign string `xml:"sign"` // 签名
|
||||
RefundId string `xml:"refund_id"` // 富友退款流水号
|
||||
TransactionId string `xml:"transaction_id"` // 富友交易流水号
|
||||
ReservedRefundAmt string `xml:"reserved_refund_amt"` // 退款金额(分,reserved)
|
||||
ReservedFySettleDt string `xml:"reserved_fy_settle_dt"` // 富友清算日期(reserved)
|
||||
}
|
||||
|
||||
// RefundQueryRequest 退款查询请求(POST /refundQuery)。
|
||||
// 仅支持查询 3 日内的退款交易。
|
||||
type RefundQueryRequest struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
Version string `xml:"version"` // 版本号: 1.0
|
||||
InsCd string `xml:"ins_cd"` // 机构号
|
||||
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||
TermId string `xml:"term_id"` // 终端号
|
||||
RandomStr string `xml:"random_str"` // 随机字符串
|
||||
Sign string `xml:"sign"` // 签名
|
||||
RefundOrderNo string `xml:"refund_order_no"` // 商户退款订单号
|
||||
}
|
||||
|
||||
// RefundQueryResponse 退款查询响应
|
||||
type RefundQueryResponse struct {
|
||||
ResultCode string `xml:"result_code"` // 结果码: 000000=成功
|
||||
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||
InsCd string `xml:"ins_cd"` // 机构号
|
||||
MchntCd string `xml:"mchnt_cd"` // 商户号
|
||||
RandomStr string `xml:"random_str"` // 随机字符串
|
||||
Sign string `xml:"sign"` // 签名
|
||||
TransStat string `xml:"trans_stat"` // 退款交易状态: SUCCESS=成功 PAYERROR=失败
|
||||
RefundId string `xml:"refund_id"` // 富友退款流水号
|
||||
TransactionId string `xml:"transaction_id"` // 富友交易流水号
|
||||
ReservedRefundAmt string `xml:"reserved_refund_amt"` // 退款金额(分,reserved)
|
||||
ReservedFySettleDt string `xml:"reserved_fy_settle_dt"` // 富友清算日期(reserved)
|
||||
}
|
||||
|
||||
// CommonRefund 申请渠道原路退款(POST /commonRefund)。
|
||||
// 调用方只需填写业务字段(mchnt_order_no、order_type、refund_order_no、total_amt、refund_amt 及可选字段),
|
||||
// 公共字段由客户端填充并签名。reserved 开头字段随 XML 发出但不参与签名。
|
||||
// 本方法只做渠道原始调用与结果映射,不含幂等键生成、状态机、数据库写入与审计。
|
||||
func (c *Client) CommonRefund(req *CommonRefundRequest) (*CommonRefundResponse, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("富友退款申请请求为空")
|
||||
}
|
||||
|
||||
req.Version = "1.0"
|
||||
req.InsCd = c.InsCd
|
||||
req.MchntCd = c.MchntCd
|
||||
req.TermId = c.TermId
|
||||
req.RandomStr = generateRandomStr()
|
||||
|
||||
sign, err := c.Sign(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("签名失败: %w", err)
|
||||
}
|
||||
req.Sign = sign
|
||||
|
||||
var resp CommonRefundResponse
|
||||
if err := c.DoRequest("/commonRefund", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("请求富友失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.ResultCode != ResultCodeSuccess {
|
||||
c.logger.Error("富友退款申请失败",
|
||||
zap.String("refund_order_no", req.RefundOrderNo),
|
||||
zap.String("mchnt_order_no", req.MchntOrderNo),
|
||||
zap.String("result_code", resp.ResultCode),
|
||||
zap.String("result_msg", resp.ResultMsg),
|
||||
)
|
||||
return nil, fmt.Errorf("富友退款申请失败: %s", resp.ResultMsg)
|
||||
}
|
||||
|
||||
c.logger.Info("富友退款申请成功",
|
||||
zap.String("refund_order_no", req.RefundOrderNo),
|
||||
zap.String("mchnt_order_no", req.MchntOrderNo),
|
||||
zap.String("refund_id", resp.RefundId),
|
||||
)
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// RefundQuery 查询退款交易状态(POST /refundQuery)。
|
||||
// refundOrderNo: 发起退款时使用的商户退款订单号。
|
||||
// 富友仅支持查询 3 日内的退款交易,超期查询由调用方处理。
|
||||
func (c *Client) RefundQuery(refundOrderNo string) (*RefundQueryResponse, error) {
|
||||
req := &RefundQueryRequest{
|
||||
Version: "1.0",
|
||||
InsCd: c.InsCd,
|
||||
MchntCd: c.MchntCd,
|
||||
TermId: c.TermId,
|
||||
RandomStr: generateRandomStr(),
|
||||
RefundOrderNo: refundOrderNo,
|
||||
}
|
||||
|
||||
sign, err := c.Sign(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("签名失败: %w", err)
|
||||
}
|
||||
req.Sign = sign
|
||||
|
||||
var resp RefundQueryResponse
|
||||
if err := c.DoRequest("/refundQuery", req, &resp); err != nil {
|
||||
return nil, fmt.Errorf("请求富友失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.ResultCode != ResultCodeSuccess {
|
||||
c.logger.Error("富友退款查询失败",
|
||||
zap.String("refund_order_no", refundOrderNo),
|
||||
zap.String("result_code", resp.ResultCode),
|
||||
zap.String("result_msg", resp.ResultMsg),
|
||||
)
|
||||
return nil, fmt.Errorf("富友退款查询失败: %s", resp.ResultMsg)
|
||||
}
|
||||
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// BuildRefundOrderNo 按富友流水号规则生成渠道退款请求号。
|
||||
// 格式:机构码(4) + 日期(yyyyMMdd,上海时区) + 随机段(18 位大写字母与数字),总长 30。
|
||||
// insCd 不足 4 位时左侧补 0,超过 4 位时取前 4 位;日期取 now 对应的上海时区日期。
|
||||
func BuildRefundOrderNo(insCd string, now time.Time) string {
|
||||
var b strings.Builder
|
||||
b.Grow(refundOrderNoLength)
|
||||
b.WriteString(normalizeInsCd(insCd))
|
||||
b.WriteString(now.In(shanghaiLocation).Format("20060102"))
|
||||
|
||||
buf := make([]byte, refundOrderNoRandomLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
// 随机源不可用时退回既有 32 位十六进制随机串,保证结果仍满足格式与长度约束
|
||||
b.WriteString(strings.ToUpper(generateRandomStr())[:refundOrderNoRandomLen])
|
||||
return b.String()
|
||||
}
|
||||
for _, v := range buf {
|
||||
b.WriteByte(refundOrderNoAlphabet[int(v)%len(refundOrderNoAlphabet)])
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// normalizeInsCd 将机构码规整为 4 位:不足左侧补 0,超过取前 4 位。
|
||||
func normalizeInsCd(insCd string) string {
|
||||
if len(insCd) >= 4 {
|
||||
return insCd[:4]
|
||||
}
|
||||
return strings.Repeat("0", 4-len(insCd)) + insCd
|
||||
}
|
||||
@@ -122,7 +122,8 @@ func NewPaymentAppFromConfig(wechatConfig *model.WechatConfig, appID string, cac
|
||||
}
|
||||
|
||||
// NewPaymentV2ServiceFromConfig 从数据库配置创建微信支付 v2 服务实例
|
||||
// v2 仅需 APIv2Key,不要求证书序列号和 v3 密钥
|
||||
// v2 仅需 APIv2Key,不要求证书序列号和 v3 密钥。
|
||||
// 若配置含 API 客户端证书,则一并在实例上注入,使该商户具备 v2 退款(双向证书)能力。
|
||||
func NewPaymentV2ServiceFromConfig(wechatConfig *model.WechatConfig, appID string, logger *zap.Logger) (*PaymentV2Service, error) {
|
||||
if wechatConfig == nil {
|
||||
return nil, fmt.Errorf("微信配置不能为空")
|
||||
@@ -134,7 +135,12 @@ func NewPaymentV2ServiceFromConfig(wechatConfig *model.WechatConfig, appID strin
|
||||
return nil, fmt.Errorf("微信支付 v2 配置不完整:缺少 wx_mch_id 或 wx_api_v2_key")
|
||||
}
|
||||
|
||||
return NewPaymentV2Service(appID, wechatConfig.WxMchID, wechatConfig.WxAPIV2Key, wechatConfig.WxNotifyURL, logger), nil
|
||||
service := NewPaymentV2Service(appID, wechatConfig.WxMchID, wechatConfig.WxAPIV2Key, wechatConfig.WxNotifyURL, logger)
|
||||
// 证书不可用时不影响 v2 支付与查单:退款能力随之为不可用,由凭证完整性判定体现。
|
||||
if err := service.SetClientCertificate(wechatConfig.WxClientCertContent, wechatConfig.WxClientKeyContent); err != nil {
|
||||
logger.Warn("微信支付 v2 客户端证书不可用,退款能力将被判定为不可用", zap.Error(err))
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func writeWechatPemTempFile(pattern, content string) (string, error) {
|
||||
|
||||
@@ -33,6 +33,11 @@ type PaymentV2Service struct {
|
||||
apiKey string
|
||||
notifyURL string
|
||||
logger *zap.Logger
|
||||
// 退款接口需要双向证书,因此单独持有带客户端证书的 HTTP 客户端;
|
||||
// 未配置证书时 refundClient 为 nil,退款能力按凭证不完整判定为不可用。
|
||||
refundClient *http.Client
|
||||
clientCertPEM string
|
||||
clientKeyPEM string
|
||||
}
|
||||
|
||||
// NewPaymentV2Service 创建微信支付 v2 服务
|
||||
|
||||
310
pkg/wechat/payment_v2_refund.go
Normal file
310
pkg/wechat/payment_v2_refund.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// 微信支付 v2 退款相关端点。退款申请为双向证书接口,退款查询不需要证书。
|
||||
const (
|
||||
wechatPayV2RefundURL = "https://api.mch.weixin.qq.com/secapi/pay/refund"
|
||||
wechatPayV2RefundQueryURL = "https://api.mch.weixin.qq.com/pay/refundquery"
|
||||
)
|
||||
|
||||
// 微信支付 v2 退款状态(refundquery 的 refund_status_$n 取值)。
|
||||
const (
|
||||
v2RefundStatusSuccess = "SUCCESS" // 退款成功
|
||||
v2RefundStatusRefundClose = "REFUNDCLOSE" // 退款关闭
|
||||
v2RefundStatusProcessing = "PROCESSING" // 退款处理中
|
||||
v2RefundStatusChange = "CHANGE" // 退款异常,需人工处理
|
||||
)
|
||||
|
||||
// SetClientCertificate 注入 API 客户端证书,用于微信支付 v2 的双向证书退款接口。
|
||||
// certPEM 为 apiclient_cert.pem 内容,keyPEM 为 apiclient_key.pem 内容。
|
||||
// 两者任一为空时清除证书,退款能力随之为不可用;支付与查单接口不依赖该证书。
|
||||
func (s *PaymentV2Service) SetClientCertificate(certPEM, keyPEM string) error {
|
||||
if strings.TrimSpace(certPEM) == "" || strings.TrimSpace(keyPEM) == "" {
|
||||
s.refundClient = nil
|
||||
s.clientCertPEM = ""
|
||||
s.clientKeyPEM = ""
|
||||
return nil
|
||||
}
|
||||
pair, err := tls.X509KeyPair([]byte(certPEM), []byte(keyPEM))
|
||||
if err != nil {
|
||||
return fmt.Errorf("微信支付 v2 客户端证书不可用: %w", err)
|
||||
}
|
||||
s.clientCertPEM = certPEM
|
||||
s.clientKeyPEM = keyPEM
|
||||
s.refundClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
// 退款接口要求商户出示 API 客户端证书,即双向 TLS。
|
||||
TLSClientConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{pair},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
},
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasClientCertificate 报告该实例是否具备可执行双向证书退款请求的客户端证书。
|
||||
func (s *PaymentV2Service) HasClientCertificate() bool {
|
||||
return s != nil && s.refundClient != nil
|
||||
}
|
||||
|
||||
// V2RefundRequest 微信支付 v2 原路退款请求。
|
||||
// 金额单位均为分;结构不包含任何通知地址字段,退款结果只由同步响应与退款查询确认。
|
||||
type V2RefundRequest struct {
|
||||
OutTradeNo string // 原支付单商户订单号,与 TransactionID 二选一
|
||||
TransactionID string
|
||||
OutRefundNo string // 商户退款单号
|
||||
TotalFee int64 // 原订单总金额(分)
|
||||
RefundFee int64 // 本次退款金额(分)
|
||||
RefundDesc string // 退款原因,可选
|
||||
}
|
||||
|
||||
// V2RefundResult 微信支付 v2 退款结果。
|
||||
//
|
||||
// 退款申请接口的返回只代表渠道受理情况,渠道明确要求通过退款查询获取最终结果,
|
||||
// 因此 RefundOrder 的受理成功不等于退款成功(Success 为 false),须由退款查询确认。
|
||||
type V2RefundResult struct {
|
||||
// Success 仅当渠道明确退款成功时为 true。
|
||||
Success bool
|
||||
// Accepted 表示渠道已受理该退款申请(申请接口 result_code=SUCCESS)。
|
||||
Accepted bool
|
||||
RefundID string // 微信退款单号
|
||||
OutRefundNo string
|
||||
OutTradeNo string
|
||||
TransactionID string
|
||||
// Status 渠道退款状态原文:SUCCESS/REFUNDCLOSE/PROCESSING/CHANGE;申请接口不返回该字段。
|
||||
Status string
|
||||
RefundFee int64
|
||||
// ErrCode 渠道业务错误码,仅渠道明确拒绝时非空。
|
||||
ErrCode string
|
||||
Message string
|
||||
}
|
||||
|
||||
// v2RefundRequest 退款申请请求(v2 XML 格式)。
|
||||
type v2RefundRequest struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
AppID string `xml:"appid"`
|
||||
MchID string `xml:"mch_id"`
|
||||
NonceStr string `xml:"nonce_str"`
|
||||
Sign string `xml:"sign"`
|
||||
OutTradeNo string `xml:"out_trade_no,omitempty"`
|
||||
TransactionID string `xml:"transaction_id,omitempty"`
|
||||
OutRefundNo string `xml:"out_refund_no"`
|
||||
TotalFee int64 `xml:"total_fee"`
|
||||
RefundFee int64 `xml:"refund_fee"`
|
||||
RefundDesc string `xml:"refund_desc,omitempty"`
|
||||
}
|
||||
|
||||
// v2RefundResponse 退款申请响应(v2 XML 格式)。
|
||||
type v2RefundResponse struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ReturnCode string `xml:"return_code"`
|
||||
ReturnMsg string `xml:"return_msg"`
|
||||
ResultCode string `xml:"result_code"`
|
||||
ErrCode string `xml:"err_code"`
|
||||
ErrCodeDes string `xml:"err_code_des"`
|
||||
TransactionID string `xml:"transaction_id"`
|
||||
OutTradeNo string `xml:"out_trade_no"`
|
||||
OutRefundNo string `xml:"out_refund_no"`
|
||||
RefundID string `xml:"refund_id"`
|
||||
RefundFee int64 `xml:"refund_fee"`
|
||||
TotalFee int64 `xml:"total_fee"`
|
||||
CashRefundFee int64 `xml:"cash_refund_fee"`
|
||||
}
|
||||
|
||||
// v2RefundQueryRequest 退款查询请求(v2 XML 格式)。
|
||||
type v2RefundQueryRequest struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
AppID string `xml:"appid"`
|
||||
MchID string `xml:"mch_id"`
|
||||
NonceStr string `xml:"nonce_str"`
|
||||
Sign string `xml:"sign"`
|
||||
OutRefundNo string `xml:"out_refund_no"`
|
||||
}
|
||||
|
||||
// v2RefundQueryResponse 退款查询响应(v2 XML 格式)。
|
||||
// 微信按退款笔数下标返回字段,本系统按商户退款单号查询,因此取第 0 笔。
|
||||
type v2RefundQueryResponse struct {
|
||||
XMLName xml.Name `xml:"xml"`
|
||||
ReturnCode string `xml:"return_code"`
|
||||
ReturnMsg string `xml:"return_msg"`
|
||||
ResultCode string `xml:"result_code"`
|
||||
ErrCode string `xml:"err_code"`
|
||||
ErrCodeDes string `xml:"err_code_des"`
|
||||
OutTradeNo string `xml:"out_trade_no"`
|
||||
TransactionID string `xml:"transaction_id"`
|
||||
RefundCount int `xml:"refund_count"`
|
||||
OutRefundNo0 string `xml:"out_refund_no_0"`
|
||||
RefundID0 string `xml:"refund_id_0"`
|
||||
RefundStatus0 string `xml:"refund_status_0"`
|
||||
RefundFee0 int64 `xml:"refund_fee_0"`
|
||||
SettlementRefundFee0 int64 `xml:"settlement_refund_fee_0"`
|
||||
}
|
||||
|
||||
// RefundOrder 申请微信支付 v2 原路退款(/secapi/pay/refund,双向证书)。
|
||||
//
|
||||
// 渠道契约明确规定:申请接口的返回仅代表业务受理情况,退款是否成功必须通过退款查询获取,
|
||||
// 因此本方法在渠道受理成功时返回 Accepted=true 且 Success=false;渠道明确拒绝时返回
|
||||
// ErrCode 与 Message 且不返回 error,只有传输或解析失败才作为 error 返回。
|
||||
// 本方法不传递任何退款结果通知地址。
|
||||
func (s *PaymentV2Service) RefundOrderV2(ctx context.Context, req V2RefundRequest) (*V2RefundResult, error) {
|
||||
if req.OutTradeNo == "" && req.TransactionID == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "商户订单号与微信订单号至少填写一个")
|
||||
}
|
||||
if req.OutRefundNo == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "商户退款单号不能为空")
|
||||
}
|
||||
if req.TotalFee <= 0 || req.RefundFee <= 0 || req.RefundFee > req.TotalFee {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "退款金额必须为正且不超过原订单金额")
|
||||
}
|
||||
if !s.HasClientCertificate() {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "微信支付 v2 退款缺少 API 客户端证书")
|
||||
}
|
||||
|
||||
nonceStr := v2GenerateNonceStr()
|
||||
params := map[string]string{
|
||||
"appid": s.appID, "mch_id": s.mchID, "nonce_str": nonceStr,
|
||||
"out_trade_no": req.OutTradeNo, "transaction_id": req.TransactionID,
|
||||
"out_refund_no": req.OutRefundNo,
|
||||
"total_fee": fmt.Sprintf("%d", req.TotalFee),
|
||||
"refund_fee": fmt.Sprintf("%d", req.RefundFee),
|
||||
"refund_desc": req.RefundDesc,
|
||||
}
|
||||
params["sign"] = v2SignMD5(params, s.apiKey)
|
||||
body := &v2RefundRequest{
|
||||
AppID: params["appid"], MchID: params["mch_id"], NonceStr: params["nonce_str"], Sign: params["sign"],
|
||||
OutTradeNo: params["out_trade_no"], TransactionID: params["transaction_id"],
|
||||
OutRefundNo: params["out_refund_no"],
|
||||
TotalFee: req.TotalFee, RefundFee: req.RefundFee, RefundDesc: req.RefundDesc,
|
||||
}
|
||||
|
||||
respBytes, err := s.postV2XMLWithClient(ctx, s.refundClient, wechatPayV2RefundURL, body)
|
||||
if err != nil {
|
||||
s.logger.Error("调用微信 v2 退款接口失败", zap.String("out_refund_no", req.OutRefundNo), zap.Error(err))
|
||||
return nil, errors.New(errors.CodeWechatPayFailed, "申请微信支付 v2 退款失败")
|
||||
}
|
||||
var resp v2RefundResponse
|
||||
if err = xml.Unmarshal(respBytes, &resp); err != nil {
|
||||
s.logger.Error("解析微信 v2 退款响应失败", zap.String("out_refund_no", req.OutRefundNo), zap.Error(err))
|
||||
return nil, errors.New(errors.CodeWechatPayFailed, "申请微信支付 v2 退款失败")
|
||||
}
|
||||
if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil {
|
||||
s.logger.Error("微信 v2 退款响应验签失败", zap.String("out_refund_no", req.OutRefundNo), zap.Error(err))
|
||||
return nil, errors.New(errors.CodeWechatPayFailed, "申请微信支付 v2 退款失败")
|
||||
}
|
||||
|
||||
result := &V2RefundResult{
|
||||
RefundID: resp.RefundID, OutRefundNo: resp.OutRefundNo, OutTradeNo: resp.OutTradeNo,
|
||||
TransactionID: resp.TransactionID, RefundFee: resp.RefundFee,
|
||||
}
|
||||
if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" {
|
||||
// 渠道明确拒绝受理:返回渠道错误码供调用方归类,不视为传输失败。
|
||||
result.ErrCode = resp.ErrCode
|
||||
result.Message = strings.TrimSpace(resp.ErrCode + " " + resp.ErrCodeDes + " " + resp.ReturnMsg)
|
||||
s.logger.Warn("微信 v2 退款未被受理", zap.String("out_refund_no", req.OutRefundNo),
|
||||
zap.String("err_code", resp.ErrCode), zap.String("return_msg", resp.ReturnMsg))
|
||||
return result, nil
|
||||
}
|
||||
// 受理成功:渠道要求以退款查询确认最终结果。
|
||||
result.Accepted = true
|
||||
result.Message = "渠道已受理退款申请,等待退款查询确认结果"
|
||||
s.logger.Info("微信 v2 退款已受理", zap.String("out_refund_no", req.OutRefundNo), zap.String("refund_id", resp.RefundID))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// QueryRefund 查询微信支付 v2 退款结果(/pay/refundquery,不需要证书)。
|
||||
// 仅 refund_status=SUCCESS 视为渠道明确退款成功;REFUNDCLOSE 与 CHANGE 视为明确失败;
|
||||
// PROCESSING 或未返回状态保持结果未知。
|
||||
func (s *PaymentV2Service) QueryRefund(ctx context.Context, outRefundNo string) (*V2RefundResult, error) {
|
||||
if outRefundNo == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "商户退款单号不能为空")
|
||||
}
|
||||
|
||||
nonceStr := v2GenerateNonceStr()
|
||||
params := map[string]string{
|
||||
"appid": s.appID, "mch_id": s.mchID, "nonce_str": nonceStr, "out_refund_no": outRefundNo,
|
||||
}
|
||||
params["sign"] = v2SignMD5(params, s.apiKey)
|
||||
body := &v2RefundQueryRequest{
|
||||
AppID: params["appid"], MchID: params["mch_id"], NonceStr: params["nonce_str"],
|
||||
Sign: params["sign"], OutRefundNo: params["out_refund_no"],
|
||||
}
|
||||
|
||||
respBytes, err := s.postV2XML(ctx, wechatPayV2RefundQueryURL, body)
|
||||
if err != nil {
|
||||
s.logger.Error("调用微信 v2 退款查询失败", zap.String("out_refund_no", outRefundNo), zap.Error(err))
|
||||
return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付 v2 退款失败")
|
||||
}
|
||||
var resp v2RefundQueryResponse
|
||||
if err = xml.Unmarshal(respBytes, &resp); err != nil {
|
||||
s.logger.Error("解析微信 v2 退款查询响应失败", zap.String("out_refund_no", outRefundNo), zap.Error(err))
|
||||
return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付 v2 退款失败")
|
||||
}
|
||||
if err = verifyV2ResponseSign(respBytes, s.apiKey); err != nil {
|
||||
s.logger.Error("微信 v2 退款查询响应验签失败", zap.String("out_refund_no", outRefundNo), zap.Error(err))
|
||||
return nil, errors.New(errors.CodeWechatPayFailed, "查询微信支付 v2 退款失败")
|
||||
}
|
||||
|
||||
result := &V2RefundResult{
|
||||
OutRefundNo: resp.OutRefundNo0, RefundID: resp.RefundID0, OutTradeNo: resp.OutTradeNo,
|
||||
TransactionID: resp.TransactionID, RefundFee: resp.RefundFee0, Status: resp.RefundStatus0,
|
||||
}
|
||||
if resp.ReturnCode != "SUCCESS" || resp.ResultCode != "SUCCESS" {
|
||||
result.ErrCode = resp.ErrCode
|
||||
result.Message = strings.TrimSpace(resp.ErrCode + " " + resp.ErrCodeDes + " " + resp.ReturnMsg)
|
||||
return result, nil
|
||||
}
|
||||
switch resp.RefundStatus0 {
|
||||
case v2RefundStatusSuccess:
|
||||
result.Success = true
|
||||
result.Message = "退款成功"
|
||||
case v2RefundStatusRefundClose:
|
||||
result.Message = "退款关闭"
|
||||
case v2RefundStatusChange:
|
||||
result.Message = "退款异常,需人工核对退款账户"
|
||||
case v2RefundStatusProcessing:
|
||||
result.Message = "退款处理中"
|
||||
default:
|
||||
result.Message = "退款状态未知"
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// postV2XMLWithClient 使用指定 HTTP 客户端发送 v2 XML 请求。
|
||||
// 退款接口必须使用持有商户 API 客户端证书的客户端,支付与查单沿用默认客户端。
|
||||
func (s *PaymentV2Service) postV2XMLWithClient(ctx context.Context, client *http.Client, endpoint string, body any) ([]byte, error) {
|
||||
if client == nil {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "微信支付 v2 请求客户端不可用")
|
||||
}
|
||||
xmlBytes, err := xml.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(xmlBytes)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/xml")
|
||||
httpResp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
if httpResp.StatusCode < http.StatusOK || httpResp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("微信支付接口返回 HTTP 状态码 %d", httpResp.StatusCode)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(httpResp.Body, 1<<20))
|
||||
}
|
||||
215
pkg/wechat/refund.go
Normal file
215
pkg/wechat/refund.go
Normal file
@@ -0,0 +1,215 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user