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:
2026-03-16 23:28:42 +08:00
parent b0da71bd25
commit a308ee228b
4 changed files with 518 additions and 0 deletions

317
pkg/fuiou/client.go Normal file
View 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
}

84
pkg/fuiou/notify.go Normal file
View File

@@ -0,0 +1,84 @@
package fuiou
import (
"encoding/xml"
"fmt"
"net/url"
"strings"
)
// 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)
if err != nil {
return nil, fmt.Errorf("URL 解码回调数据失败: %w", 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), &notify); err != nil {
return nil, fmt.Errorf("XML 解析回调数据失败: %w", err)
}
// 验证签名
if err := c.Verify(&notify, notify.Sign); err != nil {
return nil, fmt.Errorf("回调签名验证失败: %w", err)
}
// 检查结果码
if notify.ResultCode != "000000" {
return &notify, fmt.Errorf("回调结果非成功: %s - %s", notify.ResultCode, notify.ResultMsg)
}
return &notify, nil
}
// BuildNotifySuccessResponse 构建成功响应GBK 编码的 XML
func BuildNotifySuccessResponse() []byte {
return buildNotifyResponse("000000", "success")
}
// BuildNotifyFailResponse 构建失败响应GBK 编码的 XML
func BuildNotifyFailResponse(msg string) []byte {
return buildNotifyResponse("999999", msg)
}
// buildNotifyResponse 构建回调响应 XMLGBK 编码)
func buildNotifyResponse(code, msg string) []byte {
resp := NotifyResponse{
ResultCode: code,
ResultMsg: msg,
}
xmlBytes, err := xml.Marshal(resp)
if err != nil {
return []byte(`<?xml version="1.0" encoding="GBK"?><xml><result_code>999999</result_code><result_msg>internal error</result_msg></xml>`)
}
xmlStr := `<?xml version="1.0" encoding="GBK"?>` + string(xmlBytes)
gbkBytes, err := utf8ToGBK([]byte(xmlStr))
if err != nil {
return []byte(xmlStr)
}
return gbkBytes
}

61
pkg/fuiou/types.go Normal file
View File

@@ -0,0 +1,61 @@
// Package fuiou 富友支付 SDK
// 实现富友 wxPreCreate 接口的签名、通信和回调处理
package fuiou
// WxPreCreateRequest wxPreCreate 下单请求
type WxPreCreateRequest struct {
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"` // 签名
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
TradeType string `xml:"trade_type"` // 交易类型: JSAPI=公众号 LETPAY=小程序
OrderAmt string `xml:"order_amt"` // 订单金额(分)
GoodsDesc string `xml:"goods_des"` // 商品描述
TermIp string `xml:"term_ip"` // 终端IP
NotifyUrl string `xml:"notify_url"` // 回调地址
SubAppid string `xml:"sub_appid"` // 子应用ID公众号/小程序AppID
SubOpenid string `xml:"sub_openid"` // 用户OpenID
LimitPay string `xml:"limit_pay"` // 限制支付方式(可选)
}
// WxPreCreateResponse wxPreCreate 下单响应
type WxPreCreateResponse 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"` // 签名
ReservedFyTraceNo string `xml:"reserved_fy_trace_no"` // 富友流水号
// JSAPI 支付参数
SdkAppid string `xml:"sdk_appid"` // 应用ID
SdkTimestamp string `xml:"sdk_timestamp"` // 时间戳
SdkNoncestr string `xml:"sdk_noncestr"` // 随机字符串
SdkPrepayid string `xml:"sdk_prepayid"` // 预支付ID
SdkPackage string `xml:"sdk_package"` // 扩展字段
SdkSigntype string `xml:"sdk_signtype"` // 签名类型
SdkPaysign string `xml:"sdk_paysign"` // 支付签名
}
// NotifyRequest 支付回调请求
type NotifyRequest struct {
MchntCd string `xml:"mchnt_cd"` // 商户号
InsCd string `xml:"ins_cd"` // 机构号
MchntOrderNo string `xml:"mchnt_order_no"` // 商户订单号
OrderAmt string `xml:"order_amt"` // 订单金额(分)
TransactionId string `xml:"transaction_id"` // 交易流水号
ResultCode string `xml:"result_code"` // 结果码
ResultMsg string `xml:"result_msg"` // 结果消息
Sign string `xml:"sign"` // 签名
RandomStr string `xml:"random_str"` // 随机字符串
ReservedFyTraceNo string `xml:"reserved_fy_trace_no"` // 富友流水号
}
// NotifyResponse 回调响应
type NotifyResponse struct {
ResultCode string `xml:"result_code"` // 结果码
ResultMsg string `xml:"result_msg"` // 结果消息
}

56
pkg/fuiou/wxprecreate.go Normal file
View File

@@ -0,0 +1,56 @@
package fuiou
import (
"fmt"
"go.uber.org/zap"
)
// WxPreCreate 微信预下单公众号JSAPI + 小程序)
// tradeType: "JSAPI"(公众号)或 "LETPAY"(小程序)
// subAppid: 公众号或小程序 AppID
// subOpenid: 用户在对应应用下的 OpenID
func (c *Client) WxPreCreate(orderNo, amount, goodsDesc, termIP, tradeType, subAppid, subOpenid string) (*WxPreCreateResponse, error) {
req := &WxPreCreateRequest{
Version: "1.0",
InsCd: c.InsCd,
MchntCd: c.MchntCd,
TermId: c.TermId,
RandomStr: generateRandomStr(),
MchntOrderNo: orderNo,
TradeType: tradeType,
OrderAmt: amount,
GoodsDesc: goodsDesc,
TermIp: termIP,
NotifyUrl: c.NotifyURL,
SubAppid: subAppid,
SubOpenid: subOpenid,
}
sign, err := c.Sign(req)
if err != nil {
return nil, fmt.Errorf("签名失败: %w", err)
}
req.Sign = sign
var resp WxPreCreateResponse
if err := c.DoRequest("/wxPreCreate", req, &resp); err != nil {
return nil, fmt.Errorf("请求富友失败: %w", err)
}
if resp.ResultCode != "000000" {
c.logger.Error("富友预下单失败",
zap.String("order_no", orderNo),
zap.String("result_code", resp.ResultCode),
zap.String("result_msg", resp.ResultMsg),
)
return nil, fmt.Errorf("富友预下单失败: %s", resp.ResultMsg)
}
c.logger.Info("富友预下单成功",
zap.String("order_no", orderNo),
zap.String("trade_type", tradeType),
)
return &resp, nil
}