Files
2026-06-08 18:07:48 +08:00

338 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}