有效天数问题,充值单回调问题
This commit is contained in:
@@ -5,8 +5,10 @@ import (
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
@@ -50,6 +52,9 @@ func NewClient(insCd, mchntCd, termId, apiURL, notifyURL, privateKeyBase64, publ
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析富友公钥失败: %w", err)
|
||||
}
|
||||
if rsaPublicKeysEqual(pubKey, &privKey.PublicKey) {
|
||||
return nil, fmt.Errorf("富友公钥配置错误:当前 fy_public_key 与商户私钥匹配,请配置富友平台提供的验签公钥")
|
||||
}
|
||||
|
||||
return &Client{
|
||||
InsCd: insCd,
|
||||
@@ -91,6 +96,13 @@ func (c *Client) Sign(data interface{}) (string, error) {
|
||||
// Verify 验证签名
|
||||
func (c *Client) Verify(data interface{}, sign string) error {
|
||||
signStr := buildSignString(data)
|
||||
return c.verifySignString(signStr, sign)
|
||||
}
|
||||
|
||||
// verifySignString 使用指定签名原文验证 RSA 签名。
|
||||
func (c *Client) verifySignString(signStr, sign string) error {
|
||||
c.logger.Debug("富友验签原文", zap.String("sign_str", signStr))
|
||||
c.logger.Debug("富友签名", zap.String("sign", sign))
|
||||
|
||||
gbkBytes, err := utf8ToGBK([]byte(signStr))
|
||||
if err != nil {
|
||||
@@ -104,7 +116,17 @@ func (c *Client) Verify(data interface{}, sign string) error {
|
||||
return fmt.Errorf("Base64 解码签名失败: %w", err)
|
||||
}
|
||||
|
||||
return rsa.VerifyPKCS1v15(c.publicKey, crypto.MD5, hash[:], sigBytes)
|
||||
if err := rsa.VerifyPKCS1v15(c.publicKey, crypto.MD5, hash[:], sigBytes); err != nil {
|
||||
c.logger.Error("富友验签失败诊断",
|
||||
zap.Error(err),
|
||||
zap.Int("signature_len", len(sigBytes)),
|
||||
zap.Int("public_key_bits", c.publicKey.N.BitLen()),
|
||||
zap.String("public_key_sha256", rsaPublicKeyFingerprint(c.publicKey)),
|
||||
zap.Bool("public_key_matches_private_key", rsaPublicKeysEqual(c.publicKey, &c.privateKey.PublicKey)),
|
||||
)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DoRequest 发送 HTTP 请求到富友
|
||||
@@ -175,10 +197,13 @@ func (c *Client) DoRequest(path string, req interface{}, resp interface{}) error
|
||||
}
|
||||
|
||||
// buildSignString 构建签名原文
|
||||
// 提取非空字段(排除 sign 和 reserved_ 开头字段),按字典序拼接为 key=value&key=value
|
||||
// 提取字段(排除 sign 和 reserved 开头字段),按字典序拼接为 key=value&key=value。
|
||||
func buildSignString(data interface{}) string {
|
||||
fields := structToMap(data)
|
||||
return buildSignStringFromMap(structToMap(data))
|
||||
}
|
||||
|
||||
// buildSignStringFromMap 按富友规则将字段集合拼接为签名原文。
|
||||
func buildSignStringFromMap(fields map[string]string) string {
|
||||
// 按 key 字典序排列
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
@@ -195,8 +220,8 @@ func buildSignString(data interface{}) string {
|
||||
return strings.Join(pairs, "&")
|
||||
}
|
||||
|
||||
// structToMap 通过反射提取结构体的 xml tag 和值
|
||||
// 排除 sign 字段、reserved_ 开头字段、空值字段
|
||||
// structToMap 通过反射提取结构体的 xml tag 和值。
|
||||
// 排除 sign 字段和 reserved 开头字段,保留空值字段参与签名。
|
||||
func structToMap(data interface{}) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
@@ -226,8 +251,8 @@ func structToMap(data interface{}) map[string]string {
|
||||
continue
|
||||
}
|
||||
|
||||
// 排除 reserved_ 开头字段
|
||||
if strings.HasPrefix(tag, "reserved_") {
|
||||
// 排除 reserved 开头字段
|
||||
if strings.HasPrefix(tag, "reserved") {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -342,3 +367,20 @@ func parseDERPublicKey(derBytes []byte) (*rsa.PublicKey, error) {
|
||||
|
||||
return rsaPub, nil
|
||||
}
|
||||
|
||||
// rsaPublicKeyFingerprint 返回 RSA 公钥的 SHA256 指纹,避免日志输出完整密钥。
|
||||
func rsaPublicKeyFingerprint(pub *rsa.PublicKey) string {
|
||||
derBytes, err := x509.MarshalPKIXPublicKey(pub)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(derBytes)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func rsaPublicKeysEqual(a, b *rsa.PublicKey) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return a.E == b.E && a.N.Cmp(b.N) == 0
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package fuiou
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
@@ -10,19 +11,10 @@ import (
|
||||
// ParseNotify 解析回调通知(不验签)
|
||||
// 用于在验签前提取订单号以确定使用哪套支付配置
|
||||
func ParseNotify(rawBody []byte) (*NotifyRequest, error) {
|
||||
content := string(rawBody)
|
||||
if strings.HasPrefix(content, "req=") {
|
||||
content = content[4:]
|
||||
}
|
||||
decoded, err := url.QueryUnescape(content)
|
||||
utf8Str, err := decodeNotifyXML(rawBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("URL 解码回调数据失败: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
utf8Bytes, err := GBKToUTF8([]byte(decoded))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GBK 转 UTF-8 失败: %w", err)
|
||||
}
|
||||
utf8Str := strings.Replace(string(utf8Bytes), `encoding="GBK"`, `encoding="UTF-8"`, 1)
|
||||
var notify NotifyRequest
|
||||
if err := xml.Unmarshal([]byte(utf8Str), ¬ify); err != nil {
|
||||
return nil, fmt.Errorf("XML 解析回调数据失败: %w", err)
|
||||
@@ -33,36 +25,22 @@ func ParseNotify(rawBody []byte) (*NotifyRequest, error) {
|
||||
// VerifyNotify 验证回调通知并解析数据
|
||||
// rawBody: 原始请求 body(可能包含 req= 前缀的 URL 编码 GBK XML)
|
||||
func (c *Client) VerifyNotify(rawBody []byte) (*NotifyRequest, error) {
|
||||
content := string(rawBody)
|
||||
|
||||
// 处理 req= 前缀(富友回调可能以 form 格式发送)
|
||||
if strings.HasPrefix(content, "req=") {
|
||||
content = content[4:]
|
||||
}
|
||||
|
||||
// URL 解码
|
||||
decoded, err := url.QueryUnescape(content)
|
||||
utf8Str, err := decodeNotifyXML(rawBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("URL 解码回调数据失败: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// GBK → UTF-8
|
||||
utf8Bytes, err := GBKToUTF8([]byte(decoded))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GBK 转 UTF-8 失败: %w", err)
|
||||
}
|
||||
|
||||
// 替换编码声明
|
||||
utf8Str := strings.Replace(string(utf8Bytes), `encoding="GBK"`, `encoding="UTF-8"`, 1)
|
||||
|
||||
// XML 解析
|
||||
var notify NotifyRequest
|
||||
if err := xml.Unmarshal([]byte(utf8Str), ¬ify); err != nil {
|
||||
return nil, fmt.Errorf("XML 解析回调数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 验证签名
|
||||
if err := c.Verify(¬ify, notify.Sign); err != nil {
|
||||
signStr, err := buildNotifySignString(utf8Str)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 富友回调字段会随支付通道扩展,验签必须以 XML 实际出现的字段为准。
|
||||
if err := c.verifySignString(signStr, notify.Sign); err != nil {
|
||||
return nil, fmt.Errorf("回调签名验证失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -74,14 +52,162 @@ func (c *Client) VerifyNotify(rawBody []byte) (*NotifyRequest, error) {
|
||||
return ¬ify, nil
|
||||
}
|
||||
|
||||
// BuildNotifySuccessResponse 构建成功响应(GBK 编码的 XML)
|
||||
func BuildNotifySuccessResponse() []byte {
|
||||
return buildNotifyResponse("000000", "success")
|
||||
// buildNotifySignString 从回调 XML 实际字段构建验签原文。
|
||||
func buildNotifySignString(utf8XML string) (string, error) {
|
||||
fields, err := extractNotifySignFields(utf8XML)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buildSignStringFromMap(fields), nil
|
||||
}
|
||||
|
||||
// BuildNotifyFailResponse 构建失败响应(GBK 编码的 XML)
|
||||
func BuildNotifyFailResponse(msg string) []byte {
|
||||
return buildNotifyResponse("999999", msg)
|
||||
// extractNotifySignFields 提取 XML 根节点下参与验签的字段。
|
||||
func extractNotifySignFields(utf8XML string) (map[string]string, error) {
|
||||
fields := make(map[string]string)
|
||||
decoder := xml.NewDecoder(strings.NewReader(utf8XML))
|
||||
depth := 0
|
||||
currentField := ""
|
||||
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("XML 提取验签字段失败: %w", err)
|
||||
}
|
||||
|
||||
switch t := token.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
if depth == 2 {
|
||||
currentField = t.Name.Local
|
||||
fields[currentField] = ""
|
||||
}
|
||||
case xml.CharData:
|
||||
if depth == 2 && currentField != "" {
|
||||
fields[currentField] += string(t)
|
||||
}
|
||||
case xml.EndElement:
|
||||
if depth == 2 {
|
||||
currentField = ""
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
|
||||
for key := range fields {
|
||||
if key == "sign" || strings.HasPrefix(key, "reserved") {
|
||||
delete(fields, key)
|
||||
}
|
||||
}
|
||||
return fields, nil
|
||||
}
|
||||
|
||||
// decodeNotifyXML 将富友回调报文统一解码为 UTF-8 XML。
|
||||
func decodeNotifyXML(rawBody []byte) (string, error) {
|
||||
content, err := extractNotifyContent(rawBody)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
decoded, err := urlDecodeNotifyContent(content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(decoded) == "" {
|
||||
return "", fmt.Errorf("回调 XML 内容为空")
|
||||
}
|
||||
|
||||
return notifyXMLToUTF8(decoded)
|
||||
}
|
||||
|
||||
// extractNotifyContent 提取 req 字段或原始 XML 内容。
|
||||
func extractNotifyContent(rawBody []byte) (string, error) {
|
||||
content := strings.TrimSpace(string(rawBody))
|
||||
if content == "" {
|
||||
return "", fmt.Errorf("回调数据为空")
|
||||
}
|
||||
if strings.HasPrefix(content, "<") {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
if values, err := url.ParseQuery(content); err == nil {
|
||||
if reqValues, ok := values["req"]; ok {
|
||||
if len(reqValues) == 0 || strings.TrimSpace(reqValues[0]) == "" {
|
||||
return "", fmt.Errorf("回调 req 内容为空")
|
||||
}
|
||||
return reqValues[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(content, "req=") {
|
||||
content = strings.TrimPrefix(content, "req=")
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return "", fmt.Errorf("回调 req 内容为空")
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// urlDecodeNotifyContent 兼容富友单次或双次 URL 编码的 XML。
|
||||
func urlDecodeNotifyContent(content string) (string, error) {
|
||||
decoded := strings.TrimSpace(content)
|
||||
for i := 0; i < 2; i++ {
|
||||
if strings.HasPrefix(decoded, "<") {
|
||||
return decoded, nil
|
||||
}
|
||||
if !strings.ContainsAny(decoded, "%+") {
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
next, err := url.QueryUnescape(decoded)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("URL 解码回调数据失败: %w", err)
|
||||
}
|
||||
decoded = strings.TrimSpace(next)
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// notifyXMLToUTF8 将 XML 内容转换为 UTF-8 并修正编码声明。
|
||||
func notifyXMLToUTF8(content string) (string, error) {
|
||||
if hasUTF8XMLDeclaration(content) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
utf8Bytes, err := GBKToUTF8([]byte(content))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GBK 转 UTF-8 失败: %w", err)
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer(
|
||||
`encoding="GBK"`, `encoding="UTF-8"`,
|
||||
`encoding="gbk"`, `encoding="UTF-8"`,
|
||||
`encoding='GBK'`, `encoding='UTF-8'`,
|
||||
`encoding='gbk'`, `encoding='UTF-8'`,
|
||||
)
|
||||
return replacer.Replace(string(utf8Bytes)), nil
|
||||
}
|
||||
|
||||
// hasUTF8XMLDeclaration 判断 XML 声明是否已标明 UTF-8。
|
||||
func hasUTF8XMLDeclaration(content string) bool {
|
||||
head := content
|
||||
if len(head) > 128 {
|
||||
head = head[:128]
|
||||
}
|
||||
head = strings.ToLower(head)
|
||||
return strings.Contains(head, `encoding="utf-8"`) || strings.Contains(head, `encoding='utf-8'`)
|
||||
}
|
||||
|
||||
// BuildNotifySuccessResponse 构建成功响应。
|
||||
func BuildNotifySuccessResponse() []byte {
|
||||
return []byte("1")
|
||||
}
|
||||
|
||||
// BuildNotifyFailResponse 构建失败响应。
|
||||
func BuildNotifyFailResponse(_ string) []byte {
|
||||
return []byte("0")
|
||||
}
|
||||
|
||||
// buildNotifyResponse 构建回调响应 XML(GBK 编码)
|
||||
|
||||
@@ -57,7 +57,13 @@ type NotifyRequest struct {
|
||||
InsCd string `xml:"ins_cd"` // 机构号
|
||||
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
|
||||
OrderAmt string `xml:"order_amt"` // 订单金额(分)
|
||||
SettleOrderAmt string `xml:"settle_order_amt"` // 清算订单金额(分)
|
||||
CurrType string `xml:"curr_type"` // 货币类型
|
||||
OrderType string `xml:"order_type"` // 支付类型
|
||||
TermId string `xml:"term_id"` // 终端号
|
||||
TransactionId string `xml:"transaction_id"` // 交易流水号
|
||||
TxnFinTs string `xml:"txn_fin_ts"` // 交易完成时间
|
||||
UserId string `xml:"user_id"` // 用户标识
|
||||
ResultCode string `xml:"result_code"` // 结果码
|
||||
ResultMsg string `xml:"result_msg"` // 结果消息
|
||||
Sign string `xml:"sign"` // 签名
|
||||
|
||||
Reference in New Issue
Block a user