ymt_v3_api-20260721-143604/ymt_v3_api/client.go

229 lines
6.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 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"`
}