Files
soul-yongping/soul-api/internal/wechat/transferv3/client.go
卡若 76965adb23 chore: 清理敏感与开发文档,仅同步代码
- 永久忽略并从仓库移除 开发文档/
- 移除并忽略 .env 与小程序私有配置
- 同步小程序/管理端/API与脚本改动

Made-with: Cursor
2026-03-17 17:50:12 +08:00

129 lines
3.5 KiB
Go
Raw 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 transferv3
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"time"
)
const wechatAPIBase = "https://api.mch.weixin.qq.com"
func nonce() string {
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 32)
_, _ = rand.Read(b)
for i := range b {
b[i] = chars[int(b[i])%len(chars)]
}
return string(b)
}
// Client 文档 V3 商家转账到零钱(签名 + HTTP
type Client struct {
MchID string
AppID string
SerialNo string
PrivateKey *rsa.PrivateKey
BaseURL string
}
// NewClient 使用已有私钥创建 Client
func NewClient(mchID, appID, serialNo string, privateKey *rsa.PrivateKey) *Client {
base := wechatAPIBase
return &Client{
MchID: mchID,
AppID: appID,
SerialNo: serialNo,
PrivateKey: privateKey,
BaseURL: base,
}
}
// LoadPrivateKeyFromPath 从 PEM 文件路径加载商户私钥
func LoadPrivateKeyFromPath(path string) (*rsa.PrivateKey, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return LoadPrivateKeyFromPEM(data)
}
// LoadPrivateKeyFromPEM 从 PEM 内容解析商户私钥(支持 PKCS#1 或 PKCS#8
func LoadPrivateKeyFromPEM(pemContent []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemContent)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err == nil {
return key, nil
}
k, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rsaKey, ok := k.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("not RSA private key")
}
return rsaKey, nil
}
// do 带签名的 HTTP 请求
func (c *Client) do(method, path, body string) ([]byte, int, error) {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
nonceStr := nonce()
signMsg := BuildSignMessage(method, path, timestamp, nonceStr, body)
sig, err := Sign(signMsg, c.PrivateKey)
if err != nil {
return nil, 0, err
}
auth := BuildAuthorization(c.MchID, nonceStr, sig, timestamp, c.SerialNo)
fullURL := c.BaseURL + path
req, err := http.NewRequest(method, fullURL, bytes.NewBufferString(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Authorization", auth)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
return data, resp.StatusCode, nil
}
// PostBatches 发起转账文档POST /v3/transfer/batches
func (c *Client) PostBatches(body []byte) ([]byte, int, error) {
return c.do("POST", "/v3/transfer/batches", string(body))
}
// GetTransferDetail 按商户批次单号、商户明细单号查询文档GET .../batch-id/{}/details/detail-id/{}
func (c *Client) GetTransferDetail(outBatchNo, outDetailNo string) ([]byte, int, error) {
path := "/v3/transfer/batches/batch-id/" + url.PathEscape(outBatchNo) +
"/details/detail-id/" + url.PathEscape(outDetailNo)
return c.do("GET", path, "")
}
// GetMerchantBalance 查询商户平台账户实时余额文档GET /v3/merchant/fund/balance/{account_type}
// accountType: BASIC(基本户) | OPERATION(运营账户) | FEES(手续费账户)
// 注意普通商户可能需向微信申请开通权限403 NO_AUTH 表示未开通
func (c *Client) GetMerchantBalance(accountType string) ([]byte, int, error) {
path := "/v3/merchant/fund/balance/" + url.PathEscape(accountType)
return c.do("GET", path, "")
}