From a308ee228b0198d6a4d0ba7c9e409fcc2e349507 Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 16 Mar 2026 23:28:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=AF=8C=E5=8F=8B?= =?UTF-8?q?=E6=94=AF=E4=BB=98=20SDK=EF=BC=88RSA=20=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E3=80=81GBK=20=E7=BC=96=E8=A7=A3=E7=A0=81=E3=80=81XML=20?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E3=80=81=E5=9B=9E=E8=B0=83=E9=AA=8C=E7=AD=BE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- pkg/fuiou/client.go | 317 +++++++++++++++++++++++++++++++++++++++ pkg/fuiou/notify.go | 84 +++++++++++ pkg/fuiou/types.go | 61 ++++++++ pkg/fuiou/wxprecreate.go | 56 +++++++ 4 files changed, 518 insertions(+) create mode 100644 pkg/fuiou/client.go create mode 100644 pkg/fuiou/notify.go create mode 100644 pkg/fuiou/types.go create mode 100644 pkg/fuiou/wxprecreate.go diff --git a/pkg/fuiou/client.go b/pkg/fuiou/client.go new file mode 100644 index 0000000..8330de7 --- /dev/null +++ b/pkg/fuiou/client.go @@ -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 := `` + 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 +} diff --git a/pkg/fuiou/notify.go b/pkg/fuiou/notify.go new file mode 100644 index 0000000..9824898 --- /dev/null +++ b/pkg/fuiou/notify.go @@ -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), ¬ify); err != nil { + return nil, fmt.Errorf("XML 解析回调数据失败: %w", err) + } + + // 验证签名 + if err := c.Verify(¬ify, notify.Sign); err != nil { + return nil, fmt.Errorf("回调签名验证失败: %w", err) + } + + // 检查结果码 + if notify.ResultCode != "000000" { + return ¬ify, fmt.Errorf("回调结果非成功: %s - %s", notify.ResultCode, notify.ResultMsg) + } + + return ¬ify, nil +} + +// BuildNotifySuccessResponse 构建成功响应(GBK 编码的 XML) +func BuildNotifySuccessResponse() []byte { + return buildNotifyResponse("000000", "success") +} + +// BuildNotifyFailResponse 构建失败响应(GBK 编码的 XML) +func BuildNotifyFailResponse(msg string) []byte { + return buildNotifyResponse("999999", msg) +} + +// buildNotifyResponse 构建回调响应 XML(GBK 编码) +func buildNotifyResponse(code, msg string) []byte { + resp := NotifyResponse{ + ResultCode: code, + ResultMsg: msg, + } + + xmlBytes, err := xml.Marshal(resp) + if err != nil { + return []byte(`999999internal error`) + } + + xmlStr := `` + string(xmlBytes) + + gbkBytes, err := utf8ToGBK([]byte(xmlStr)) + if err != nil { + return []byte(xmlStr) + } + + return gbkBytes +} diff --git a/pkg/fuiou/types.go b/pkg/fuiou/types.go new file mode 100644 index 0000000..ed5834c --- /dev/null +++ b/pkg/fuiou/types.go @@ -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"` // 结果消息 +} diff --git a/pkg/fuiou/wxprecreate.go b/pkg/fuiou/wxprecreate.go new file mode 100644 index 0000000..e4e7037 --- /dev/null +++ b/pkg/fuiou/wxprecreate.go @@ -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 +}