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

206
pkg/fuiou/refund.go Normal file
View 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
}