Files
junhong_cmp_fiber/pkg/fuiou/client.go
huang a308ee228b 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>
2026-03-16 23:28:42 +08:00

318 lines
8.2 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}