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 serviceToken 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) } c.serviceToken = serviceToken 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) { return doRequestRetry[T](c, ctx, method, path, body, query, false) } func doRequestRetry[T any](c *Client, ctx context.Context, method, path string, body interface{}, query url.Values, retried bool) (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 == 401 && c.serviceToken != "" && !retried { newToken, loginErr := c.serviceTokenLogin(ctx, c.serviceToken) if loginErr != nil { return zero, &APIError{Code: 401, Message: apiResp.Message} } c.bearer = newToken return doRequestRetry[T](c, ctx, method, path, body, query, true) } 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) }