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 }