package sms import ( "context" "crypto/md5" "encoding/hex" "fmt" "time" "github.com/bytedance/sonic" "go.uber.org/zap" ) // Client 短信客户端 type Client struct { gatewayURL string // 短信网关地址 username string // 账号用户名 password string // 账号密码 signature string // 短信签名 timeout time.Duration // 请求超时时间 logger *zap.Logger // 日志记录器 httpClient HTTPClient // HTTP 客户端接口 } // HTTPClient HTTP 客户端接口(便于测试) type HTTPClient interface { Post(ctx context.Context, url string, body []byte) ([]byte, error) } // NewClient 创建短信客户端 func NewClient(gatewayURL, username, password, signature string, timeout time.Duration, logger *zap.Logger, httpClient HTTPClient) *Client { return &Client{ gatewayURL: gatewayURL, username: username, password: password, signature: signature, timeout: timeout, logger: logger, httpClient: httpClient, } } // SendMessage 发送短信 // content: 短信内容(不包含签名,签名会自动添加) // phones: 接收手机号列表 func (c *Client) SendMessage(ctx context.Context, content string, phones []string) (*SendResponse, error) { // 生成时间戳(毫秒) timestamp := time.Now().UnixMilli() // 计算签名 sign := c.calculateSign(timestamp) // 构造完整的短信内容(添加签名) fullContent := c.signature + content // 构造请求 req := &SendRequest{ UserName: c.username, Content: fullContent, PhoneList: phones, Timestamp: timestamp, Sign: sign, } // 序列化请求 reqBody, err := sonic.Marshal(req) if err != nil { c.logger.Error("序列化短信请求失败", zap.Error(err), zap.Strings("phones", phones), ) return nil, fmt.Errorf("序列化短信请求失败: %w", err) } // 记录请求日志(脱敏处理) c.logger.Info("发送短信请求", zap.Strings("phones", phones), zap.String("content_preview", c.truncateContent(content)), zap.Int64("timestamp", timestamp), ) // 发送请求 url := c.gatewayURL + "/api/sendMessageMass" // 创建带超时的上下文 reqCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() respBody, err := c.httpClient.Post(reqCtx, url, reqBody) if err != nil { c.logger.Error("发送短信请求失败", zap.Error(err), zap.String("url", url), zap.Strings("phones", phones), ) return nil, fmt.Errorf("发送短信请求失败: %w", err) } // 解析响应 var resp SendResponse if err := sonic.Unmarshal(respBody, &resp); err != nil { c.logger.Error("解析短信响应失败", zap.Error(err), zap.ByteString("response_body", respBody), ) return nil, fmt.Errorf("解析短信响应失败: %w", err) } // 记录响应日志 if resp.Code == CodeSuccess { c.logger.Info("短信发送成功", zap.Int64("msg_id", resp.MsgID), zap.Int("sms_count", resp.SMSCount), zap.Strings("phones", phones), ) } else { c.logger.Warn("短信发送失败", zap.Int("code", resp.Code), zap.String("message", resp.Message), zap.Strings("phones", phones), ) } // 检查响应状态 if resp.Code != CodeSuccess { return &resp, NewSMSError(resp.Code, resp.Message) } return &resp, nil } // calculateSign 计算签名 // 规则: MD5(userName + timestamp + MD5(password)) func (c *Client) calculateSign(timestamp int64) string { // 第一步:计算 MD5(password) passwordMD5 := c.md5Hash(c.password) // 第二步:组合字符串 combined := fmt.Sprintf("%s%d%s", c.username, timestamp, passwordMD5) // 第三步:计算最终签名 return c.md5Hash(combined) } // md5Hash 计算 MD5 哈希值(小写) func (c *Client) md5Hash(s string) string { h := md5.New() h.Write([]byte(s)) return hex.EncodeToString(h.Sum(nil)) } // truncateContent 截断内容用于日志记录 func (c *Client) truncateContent(content string) string { const maxLen = 50 runes := []rune(content) if len(runes) > maxLen { return string(runes[:maxLen]) + "..." } return content }