feat: 新增富友支付 SDK(RSA 签名、GBK 编解码、XML 协议、回调验签)
- pkg/fuiou/types.go: WxPreCreateRequest/Response、NotifyRequest 等 XML 结构体 - pkg/fuiou/client.go: Client 结构体、NewClient、字典序+GBK+MD5+RSA 签名/验签、HTTP 请求 - pkg/fuiou/wxprecreate.go: WxPreCreate 方法,支持公众号 JSAPI(JSAPI)和小程序(LETPAY) - pkg/fuiou/notify.go: VerifyNotify(GBK→UTF-8+XML 解析+RSA 验签)、BuildNotifyResponse Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
317
pkg/fuiou/client.go
Normal file
317
pkg/fuiou/client.go
Normal file
@@ -0,0 +1,317 @@
|
||||
package fuiou
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
"golang.org/x/text/transform"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Client 富友支付客户端
|
||||
type Client struct {
|
||||
InsCd string // 机构号
|
||||
MchntCd string // 商户号
|
||||
TermId string // 终端号
|
||||
ApiURL string // 富友 API 地址
|
||||
NotifyURL string // 支付回调地址
|
||||
privateKey *rsa.PrivateKey // 商户私钥(用于签名)
|
||||
publicKey *rsa.PublicKey // 富友公钥(用于验签)
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewClient 从配置参数构造富友客户端
|
||||
// privateKeyBase64: 商户 RSA 私钥 Base64 编码(支持 PEM/DER、PKCS1/PKCS8 格式)
|
||||
// publicKeyBase64: 富友 RSA 公钥 Base64 编码(支持 PEM/DER 格式)
|
||||
func NewClient(insCd, mchntCd, termId, apiURL, notifyURL, privateKeyBase64, publicKeyBase64 string, logger *zap.Logger) (*Client, error) {
|
||||
privKey, err := parsePrivateKey(privateKeyBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析商户私钥失败: %w", err)
|
||||
}
|
||||
|
||||
pubKey, err := parsePublicKey(publicKeyBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析富友公钥失败: %w", err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
InsCd: insCd,
|
||||
MchntCd: mchntCd,
|
||||
TermId: termId,
|
||||
ApiURL: apiURL,
|
||||
NotifyURL: notifyURL,
|
||||
privateKey: privKey,
|
||||
publicKey: pubKey,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Sign 对请求数据签名
|
||||
// 算法: 按字典序排列非空字段 → key=value&key=value → GBK 编码 → MD5 → RSA PKCS1v15 签名 → Base64
|
||||
func (c *Client) Sign(data interface{}) (string, error) {
|
||||
signStr := buildSignString(data)
|
||||
|
||||
// UTF-8 → GBK
|
||||
gbkBytes, err := utf8ToGBK([]byte(signStr))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GBK 编码失败: %w", err)
|
||||
}
|
||||
|
||||
// MD5
|
||||
hash := md5.Sum(gbkBytes)
|
||||
|
||||
// RSA PKCS1v15 签名
|
||||
signature, err := rsa.SignPKCS1v15(rand.Reader, c.privateKey, crypto.MD5, hash[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("RSA 签名失败: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(signature), nil
|
||||
}
|
||||
|
||||
// Verify 验证签名
|
||||
func (c *Client) Verify(data interface{}, sign string) error {
|
||||
signStr := buildSignString(data)
|
||||
|
||||
gbkBytes, err := utf8ToGBK([]byte(signStr))
|
||||
if err != nil {
|
||||
return fmt.Errorf("GBK 编码失败: %w", err)
|
||||
}
|
||||
|
||||
hash := md5.Sum(gbkBytes)
|
||||
|
||||
sigBytes, err := base64.StdEncoding.DecodeString(sign)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Base64 解码签名失败: %w", err)
|
||||
}
|
||||
|
||||
return rsa.VerifyPKCS1v15(c.publicKey, crypto.MD5, hash[:], sigBytes)
|
||||
}
|
||||
|
||||
// DoRequest 发送 HTTP 请求到富友
|
||||
// 处理流程: XML 编码 → GBK 转换 → 双重 URL 编码 → POST → URL 解码 → GBK→UTF-8 → XML 解析
|
||||
func (c *Client) DoRequest(path string, req interface{}, resp interface{}) error {
|
||||
// 1. XML 编码
|
||||
xmlBytes, err := xml.Marshal(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("XML 编码失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 添加 XML 声明并替换编码声明为 GBK
|
||||
xmlStr := `<?xml version="1.0" encoding="GBK"?>` + string(xmlBytes)
|
||||
|
||||
// 3. UTF-8 → GBK
|
||||
gbkBytes, err := utf8ToGBK([]byte(xmlStr))
|
||||
if err != nil {
|
||||
return fmt.Errorf("GBK 编码失败: %w", err)
|
||||
}
|
||||
|
||||
// 4. 两次 URL 编码
|
||||
encoded := url.QueryEscape(url.QueryEscape(string(gbkBytes)))
|
||||
|
||||
// 5. POST 请求
|
||||
reqURL := strings.TrimRight(c.ApiURL, "/") + path
|
||||
body := "req=" + encoded
|
||||
|
||||
c.logger.Debug("富友请求发送",
|
||||
zap.String("url", reqURL),
|
||||
zap.String("path", path),
|
||||
)
|
||||
|
||||
httpResp, err := http.Post(reqURL, "application/x-www-form-urlencoded", strings.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("HTTP 请求失败: %w", err)
|
||||
}
|
||||
defer httpResp.Body.Close()
|
||||
|
||||
// 6. 读取响应
|
||||
respBody, err := io.ReadAll(httpResp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 7. URL 解码
|
||||
decoded, err := url.QueryUnescape(string(respBody))
|
||||
if err != nil {
|
||||
return fmt.Errorf("URL 解码响应失败: %w", err)
|
||||
}
|
||||
|
||||
// 8. GBK → UTF-8
|
||||
utf8Bytes, err := GBKToUTF8([]byte(decoded))
|
||||
if err != nil {
|
||||
return fmt.Errorf("UTF-8 转换失败: %w", err)
|
||||
}
|
||||
|
||||
// 9. 替换 XML 声明中的编码为 UTF-8
|
||||
utf8Str := strings.Replace(string(utf8Bytes), `encoding="GBK"`, `encoding="UTF-8"`, 1)
|
||||
|
||||
// 10. XML 解析
|
||||
if err := xml.Unmarshal([]byte(utf8Str), resp); err != nil {
|
||||
return fmt.Errorf("XML 解析响应失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildSignString 构建签名原文
|
||||
// 提取非空字段(排除 sign 和 reserved_ 开头字段),按字典序拼接为 key=value&key=value
|
||||
func buildSignString(data interface{}) string {
|
||||
fields := structToMap(data)
|
||||
|
||||
// 按 key 字典序排列
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
// 拼接
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, k+"="+fields[k])
|
||||
}
|
||||
|
||||
return strings.Join(pairs, "&")
|
||||
}
|
||||
|
||||
// structToMap 通过反射提取结构体的 xml tag 和值
|
||||
// 排除 sign 字段、reserved_ 开头字段、空值字段
|
||||
func structToMap(data interface{}) map[string]string {
|
||||
result := make(map[string]string)
|
||||
|
||||
v := reflect.ValueOf(data)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
t := v.Type()
|
||||
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
value := v.Field(i).String()
|
||||
|
||||
tag := field.Tag.Get("xml")
|
||||
if tag == "" || tag == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 排除 sign 字段
|
||||
if tag == "sign" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 排除 reserved_ 开头字段
|
||||
if strings.HasPrefix(tag, "reserved_") {
|
||||
continue
|
||||
}
|
||||
|
||||
// 排除空值
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
result[tag] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// utf8ToGBK 将 UTF-8 字节转换为 GBK 编码
|
||||
func utf8ToGBK(input []byte) ([]byte, error) {
|
||||
reader := transform.NewReader(strings.NewReader(string(input)), simplifiedchinese.GBK.NewEncoder())
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
// GBKToUTF8 将 GBK 字节转换为 UTF-8 编码
|
||||
func GBKToUTF8(input []byte) ([]byte, error) {
|
||||
reader := transform.NewReader(strings.NewReader(string(input)), simplifiedchinese.GBK.NewDecoder())
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
// generateRandomStr 生成 32 字符随机字符串
|
||||
func generateRandomStr() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return fmt.Sprintf("%x", b)
|
||||
}
|
||||
|
||||
// parsePrivateKey 解析 Base64 编码的 RSA 私钥
|
||||
// 支持多种格式: PEM(PKCS1/PKCS8) 和 DER(PKCS1/PKCS8)
|
||||
func parsePrivateKey(base64Str string) (*rsa.PrivateKey, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(base64Str)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Base64 解码失败: %w", err)
|
||||
}
|
||||
|
||||
// 尝试 PEM 格式(Base64 解码后是 PEM 文本)
|
||||
if block, _ := pem.Decode(decoded); block != nil {
|
||||
return parseDERPrivateKey(block.Bytes)
|
||||
}
|
||||
|
||||
// 尝试 DER 格式(Base64 解码后直接是 DER 字节)
|
||||
return parseDERPrivateKey(decoded)
|
||||
}
|
||||
|
||||
// parseDERPrivateKey 从 DER 字节解析 RSA 私钥,先尝试 PKCS8 再尝试 PKCS1
|
||||
func parseDERPrivateKey(derBytes []byte) (*rsa.PrivateKey, error) {
|
||||
// 先尝试 PKCS8
|
||||
if key, err := x509.ParsePKCS8PrivateKey(derBytes); err == nil {
|
||||
if rsaKey, ok := key.(*rsa.PrivateKey); ok {
|
||||
return rsaKey, nil
|
||||
}
|
||||
return nil, fmt.Errorf("PKCS8 解析结果不是 RSA 私钥")
|
||||
}
|
||||
|
||||
// 再尝试 PKCS1
|
||||
if key, err := x509.ParsePKCS1PrivateKey(derBytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("无法解析私钥(已尝试 PKCS8 和 PKCS1 格式)")
|
||||
}
|
||||
|
||||
// parsePublicKey 解析 Base64 编码的 RSA 公钥
|
||||
// 支持 PEM 和 DER 格式
|
||||
func parsePublicKey(base64Str string) (*rsa.PublicKey, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(base64Str)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Base64 解码失败: %w", err)
|
||||
}
|
||||
|
||||
// 尝试 PEM 格式
|
||||
if block, _ := pem.Decode(decoded); block != nil {
|
||||
return parseDERPublicKey(block.Bytes)
|
||||
}
|
||||
|
||||
// 尝试 DER 格式
|
||||
return parseDERPublicKey(decoded)
|
||||
}
|
||||
|
||||
// parseDERPublicKey 从 DER 字节解析 RSA 公钥
|
||||
func parseDERPublicKey(derBytes []byte) (*rsa.PublicKey, error) {
|
||||
pub, err := x509.ParsePKIXPublicKey(derBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PKIX 公钥解析失败: %w", err)
|
||||
}
|
||||
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("解析结果不是 RSA 公钥")
|
||||
}
|
||||
|
||||
return rsaPub, nil
|
||||
}
|
||||
Reference in New Issue
Block a user