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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user