f004408ca0
Co-authored-by: Cursor <cursoragent@cursor.com>
323 lines
8.2 KiB
Go
323 lines
8.2 KiB
Go
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 的管理端客户端。
|
||
//
|
||
// 适用于已通过 service-token-login 等方式获取 Bearer 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 签名。
|
||
// 仅可调用短信发送接口(SendBatch/SendMulti/ListSendRecords/GetSendStatus)。
|
||
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)
|
||
}
|