Files
junhong_cmp_fiber/pkg/fuiou/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

207 lines
8.7 KiB
Go
Raw Permalink 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 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
}