添加文件: ymt_v3_api/client.go

This commit is contained in:
renzhiyuan 2026-07-21 14:47:07 +08:00
parent 91702f243c
commit db35f19def
1 changed files with 229 additions and 0 deletions

229
ymt_v3_api/client.go Normal file
View File

@ -0,0 +1,229 @@
package ymt_v3_api
import (
"bytes"
"context"
"crypto/rsa"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// Client 是营销开放平台 SDK 的客户端,封装了认证、加密、签名等逻辑。
type Client struct {
httpClient *http.Client
baseURL string
appID string
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
encryptKey []byte
encryptType string // "AES" 或 "SM4"
}
// Option 是客户端配置选项函数。
type Option func(*Client)
// WithBaseURL 设置 API 基础地址。
func WithBaseURL(url string) Option {
return func(c *Client) {
c.baseURL = url
}
}
// WithTimeout 设置 HTTP 请求超时时间。
func WithTimeout(d time.Duration) Option {
return func(c *Client) {
c.httpClient.Timeout = d
}
}
// WithHTTPClient 设置自定义的 HTTP 客户端。
func WithHTTPClient(client *http.Client) Option {
return func(c *Client) {
c.httpClient = client
}
}
// WithEncryptType 设置业务参数加密方式,支持 "AES" (默认) 或 "SM4"。
func WithEncryptType(t string) Option {
return func(c *Client) {
c.encryptType = t
}
}
// NewClient 创建一个新的客户端实例。
// appID: 应用 ID
// privateKey: 应用私钥PEM 格式字符串)
// publicKey: 平台公钥PEM 格式字符串)
// encryptKey: 业务参数加解密密钥字符串AES 或 SM4 密钥)
func NewClient(appID, privateKey, publicKey, encryptKey string, opts ...Option) (*Client, error) {
priv, err := parsePrivateKey(privateKey)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
pub, err := parsePublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("parse public key: %w", err)
}
c := &Client{
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: "https://market.api.86698.cn", // 默认正式环境
appID: appID,
privateKey: priv,
publicKey: pub,
encryptKey: []byte(encryptKey),
encryptType: "AES",
}
for _, opt := range opts {
opt(c)
}
return c, nil
}
// doRequest 执行一次完整的 API 调用:加密业务参数、签名、发送请求、解密响应。
func (c *Client) doRequest(ctx context.Context, path string, bizReq interface{}, bizResp interface{}) error {
// 1. 业务参数序列化为排序 JSON 并加密
ciphertext, err := c.encryptPayload(bizReq)
if err != nil {
return fmt.Errorf("encrypt payload: %w", err)
}
// 2. 生成时间戳
timestamp := time.Now().Format("2006-01-02 15:04:05")
// 3. 签名
sign, err := c.sign(c.appID, timestamp, ciphertext)
if err != nil {
return fmt.Errorf("sign: %w", err)
}
// 4. 构建请求体
reqBody := cipherRequest{Ciphertext: ciphertext}
bodyBytes, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal request body: %w", err)
}
// 5. 创建 HTTP 请求
url := c.baseURL + path
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes))
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Appid", c.appID)
req.Header.Set("Timestamp", timestamp)
req.Header.Set("Sign", sign)
// 6. 发送请求
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("http do: %w", err)
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read response body: %w", err)
}
// 7. 解析公共响应
var apiResp apiResponse
if err := json.Unmarshal(respBytes, &apiResp); err != nil {
return fmt.Errorf("unmarshal api response: %w", err)
}
if apiResp.Code != 200 {
return &APIError{
Code: apiResp.Code,
Message: apiResp.Message,
Reason: apiResp.Reason,
}
}
// 8. 解密业务响应
if apiResp.Data == nil || apiResp.Data.Ciphertext == "" {
// 某些接口可能直接返回 data 对象(如查询接口文档示例中 data 直接包含字段,但实际是 ciphertext 为空?)
// 根据文档,成功时 data.ciphertext 存在,但查询接口示例中 data 直接是业务字段,没有 ciphertext 包裹。
// 仔细看文档:获取券码响应成功时 data 里有 ciphertext券码查询响应示例中 data 直接是业务字段,没有 ciphertext。
// 但文档说“响应中的 data.ciphertext 使用与请求相同的加密方式和 key 进行解密”,可能查询接口也加密了,但示例展示了解密后的。
// 为了兼容,如果 data.ciphertext 为空,尝试直接将 data 整体反序列化为业务响应。
// 这里我们统一处理:如果 ciphertext 非空则解密,否则直接解析 data 字段。
var resp struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(respBytes, &resp); err != nil {
return fmt.Errorf("unmarshal data field: %w", err)
}
return json.Unmarshal(resp.Data, bizResp)
}
if err := c.decryptPayload(apiResp.Data.Ciphertext, bizResp); err != nil {
return fmt.Errorf("decrypt payload: %w", err)
}
return nil
}
// encryptPayload 将业务请求对象加密为 ciphertext 字符串。
func (c *Client) encryptPayload(payload interface{}) (string, error) {
plaintext, err := toSortedJSON(payload)
if err != nil {
return "", err
}
switch c.encryptType {
case "SM4":
return sm4CbcEncrypt(plaintext, c.encryptKey)
default:
return aesEcbEncrypt(plaintext, c.encryptKey)
}
}
// decryptPayload 将 ciphertext 解密并反序列化到目标对象。
func (c *Client) decryptPayload(ciphertext string, target interface{}) error {
var plaintext []byte
var err error
switch c.encryptType {
case "SM4":
plaintext, err = sm4CbcDecrypt(ciphertext, c.encryptKey)
default:
plaintext, err = aesEcbDecrypt(ciphertext, c.encryptKey)
}
if err != nil {
return err
}
return json.Unmarshal(plaintext, target)
}
// sign 生成请求签名。
func (c *Client) sign(appID, timestamp, ciphertext string) (string, error) {
data := appID + timestamp + ciphertext
return rsaSign([]byte(data), c.privateKey)
}
// verifySign 验证回调签名。
func (c *Client) verifySign(appID, timestamp, ciphertext, sign string) error {
data := appID + timestamp + ciphertext
return rsaVerify([]byte(data), sign, c.publicKey)
}
// cipherRequest 请求体结构
type cipherRequest struct {
Ciphertext string `json:"ciphertext"`
}
// apiResponse 公共响应结构
type apiResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Reason string `json:"reason,omitempty"`
Data *cipherData `json:"data,omitempty"`
}
type cipherData struct {
Ciphertext string `json:"ciphertext"`
}