feat: init sms-server-cli SDK
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
package smscli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type authMode int
|
||||
|
||||
const (
|
||||
authModeBearer authMode = 1
|
||||
authModeUserToken authMode = 2
|
||||
)
|
||||
|
||||
// Client 是访问 SMS Server 后端的 HTTP 客户端。
|
||||
//
|
||||
// 构造方式:
|
||||
// - NewServiceClient: 管理端(自动调用 service-token-login 获取 Bearer Token)
|
||||
// - NewBearerClient: 管理端(使用已有的 Bearer Token)
|
||||
// - NewUserTokenClient: 发送端(X-SMS-Token + 请求签名)
|
||||
type Client struct {
|
||||
baseURL string
|
||||
mode authMode
|
||||
bearer string
|
||||
userToken string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Option 用于在构造 Client 时传入可选配置。
|
||||
type Option func(*Client)
|
||||
|
||||
// WithHTTPClient 使用自定义 *http.Client。
|
||||
func WithHTTPClient(hc *http.Client) Option {
|
||||
return func(c *Client) { c.httpClient = hc }
|
||||
}
|
||||
|
||||
// WithTimeout 修改底层 HTTP 客户端超时(默认 30 秒)。
|
||||
func WithTimeout(d time.Duration) Option {
|
||||
return func(c *Client) { c.httpClient.Timeout = d }
|
||||
}
|
||||
|
||||
// NewServiceClient 创建管理端客户端。
|
||||
//
|
||||
// 自动调用 POST /api/auth/service-token-login 将 serviceToken
|
||||
// 兑换为 Bearer Token,后续请求通过 Authorization: Bearer 认证。
|
||||
// 可访问全部管理接口(签名/模板/Token/额度/发送记录/适配器等)。
|
||||
func NewServiceClient(baseURL, serviceToken string, opts ...Option) (*Client, error) {
|
||||
c := &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
mode: authModeBearer,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(c)
|
||||
}
|
||||
|
||||
token, err := c.serviceTokenLogin(context.Background(), serviceToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("service token login: %w", err)
|
||||
}
|
||||
c.bearer = token
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// NewBearerClient 创建使用已有 Bearer Token 的客户端。
|
||||
//
|
||||
// 适用于已通过其他方式获取 Bearer Token 的场景(如用户中心 Token)。
|
||||
func NewBearerClient(baseURL, bearerToken string, opts ...Option) *Client {
|
||||
c := &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
mode: authModeBearer,
|
||||
bearer: bearerToken,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// NewUserTokenClient 创建发送端客户端。
|
||||
//
|
||||
// 使用 X-SMS-Token 头认证,POST/PUT 请求体会自动计算 sign 签名。
|
||||
// 主要用于调用短信发送接口,也可管理当前用户的签名/模板/Token 等资源。
|
||||
func NewUserTokenClient(baseURL, userToken string, opts ...Option) *Client {
|
||||
c := &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
mode: authModeUserToken,
|
||||
userToken: userToken,
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// APIResponse 是后端统一响应体。
|
||||
type APIResponse[T any] struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data T `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// APIError 表示后端业务错误(code != 200)。
|
||||
//
|
||||
// 使用 errors.As 判断:
|
||||
//
|
||||
// var apiErr *smscli.APIError
|
||||
// if errors.As(err, &apiErr) { ... }
|
||||
type APIError struct {
|
||||
Code int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
return fmt.Sprintf("api error %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (c *Client) fullURL(path string) string {
|
||||
return c.baseURL + path
|
||||
}
|
||||
|
||||
func (c *Client) setAuth(req *http.Request) {
|
||||
switch c.mode {
|
||||
case authModeBearer:
|
||||
req.Header.Set("Authorization", "Bearer "+c.bearer)
|
||||
case authModeUserToken:
|
||||
req.Header.Set("X-SMS-Token", c.userToken)
|
||||
}
|
||||
}
|
||||
|
||||
// CalcSign 按 sms-server 签名算法计算 sign 值。
|
||||
//
|
||||
// 算法:排序 key → "key:value" 拼接 ";" → 去空白 → base64 → MD5(base64+token)。
|
||||
func CalcSign(body map[string]interface{}, token string) string {
|
||||
keys := make([]string, 0, len(body))
|
||||
for k := range body {
|
||||
if k == "sign" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
pairs := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
v := body[k]
|
||||
var valStr string
|
||||
switch tv := v.(type) {
|
||||
case string:
|
||||
valStr = tv
|
||||
case json.Number:
|
||||
valStr = tv.String()
|
||||
default:
|
||||
b, _ := json.Marshal(v)
|
||||
valStr = string(b)
|
||||
}
|
||||
pairs = append(pairs, k+":"+valStr)
|
||||
}
|
||||
|
||||
joined := strings.Join(pairs, ";")
|
||||
var sb strings.Builder
|
||||
for _, r := range joined {
|
||||
if !unicode.IsSpace(r) {
|
||||
sb.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b64 := base64.StdEncoding.EncodeToString([]byte(sb.String()))
|
||||
hash := md5.Sum([]byte(b64 + token))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
func structToMap(v interface{}) (map[string]interface{}, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(b))
|
||||
decoder.UseNumber()
|
||||
var m map[string]interface{}
|
||||
if err := decoder.Decode(&m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func doRequest[T any](c *Client, ctx context.Context, method, path string, body interface{}, query url.Values) (T, error) {
|
||||
var zero T
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
if c.mode == authModeUserToken && (method == http.MethodPost || method == http.MethodPut) {
|
||||
bodyMap, err := structToMap(body)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("marshal body to map: %w", err)
|
||||
}
|
||||
bodyMap["sign"] = CalcSign(bodyMap, c.userToken)
|
||||
b, err := json.Marshal(bodyMap)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("marshal signed body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
} else {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("marshal body: %w", err)
|
||||
}
|
||||
bodyReader = bytes.NewReader(b)
|
||||
}
|
||||
}
|
||||
|
||||
reqURL := c.fullURL(path)
|
||||
if len(query) > 0 {
|
||||
reqURL += "?" + query.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
c.setAuth(req)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("do request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp APIResponse[T]
|
||||
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
||||
return zero, fmt.Errorf("unmarshal response (status %d): %w\nbody: %s", resp.StatusCode, err, string(respBody))
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return zero, &APIError{Code: apiResp.Code, Message: apiResp.Message}
|
||||
}
|
||||
|
||||
return apiResp.Data, nil
|
||||
}
|
||||
|
||||
func (c *Client) serviceTokenLogin(ctx context.Context, serviceToken string) (string, error) {
|
||||
body := map[string]string{"service_token": serviceToken}
|
||||
b, _ := json.Marshal(body)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.fullURL("/api/auth/service-token-login"), bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var apiResp APIResponse[map[string]interface{}]
|
||||
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
||||
return "", fmt.Errorf("unmarshal login response: %w\nbody: %s", err, string(respBody))
|
||||
}
|
||||
|
||||
if apiResp.Code != 200 {
|
||||
return "", &APIError{Code: apiResp.Code, Message: apiResp.Message}
|
||||
}
|
||||
|
||||
token, ok := apiResp.Data["token"].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("login response missing token")
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Ping 健康检查。
|
||||
//
|
||||
// GET /api/index/ping
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
_, err := get[any](c, ctx, "/api/index/ping", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func get[T any](c *Client, ctx context.Context, path string, query url.Values) (T, error) {
|
||||
return doRequest[T](c, ctx, http.MethodGet, path, nil, query)
|
||||
}
|
||||
|
||||
func post[T any](c *Client, ctx context.Context, path string, body interface{}) (T, error) {
|
||||
return doRequest[T](c, ctx, http.MethodPost, path, body, nil)
|
||||
}
|
||||
|
||||
func put[T any](c *Client, ctx context.Context, path string, body interface{}) (T, error) {
|
||||
return doRequest[T](c, ctx, http.MethodPut, path, body, nil)
|
||||
}
|
||||
|
||||
func del[T any](c *Client, ctx context.Context, path string) (T, error) {
|
||||
return doRequest[T](c, ctx, http.MethodDelete, path, nil, nil)
|
||||
}
|
||||
Reference in New Issue
Block a user