feat: init sms-server-cli SDK

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shiran
2026-06-05 15:03:55 +08:00
commit 69be4bcb82
11 changed files with 1508 additions and 0 deletions
+322
View File
@@ -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)
}