添加文件: ymt_v3_api/client.go
This commit is contained in:
parent
91702f243c
commit
db35f19def
|
|
@ -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"`
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue