按 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 与行为核对,未调用真实渠道。
207 lines
8.7 KiB
Go
207 lines
8.7 KiB
Go
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
|
||
}
|