229 lines
6.5 KiB
Go
229 lines
6.5 KiB
Go
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"`
|
||
} |