diff --git a/ymt_v3_api/client.go b/ymt_v3_api/client.go new file mode 100644 index 0000000..8178421 --- /dev/null +++ b/ymt_v3_api/client.go @@ -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"` +} \ No newline at end of file