package sms import ( "crypto/hmac" "crypto/sha1" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "sort" "strings" "time" "github.com/go-common/config" ) // SMS 短信发送器 type SMS struct { config *config.SMSConfig } // NewSMS 创建短信发送器 func NewSMS(cfg *config.SMSConfig) (*SMS, error) { if cfg == nil { return nil, fmt.Errorf("SMS config is nil") } if cfg.AccessKeyID == "" { return nil, fmt.Errorf("AccessKeyID is required") } if cfg.AccessKeySecret == "" { return nil, fmt.Errorf("AccessKeySecret is required") } if cfg.SignName == "" { return nil, fmt.Errorf("SignName is required") } // 设置默认值 if cfg.Region == "" { cfg.Region = "cn-hangzhou" } if cfg.Timeout == 0 { cfg.Timeout = 10 } return &SMS{ config: cfg, }, nil } // SendRequest 发送短信请求 type SendRequest struct { // PhoneNumbers 手机号列表 PhoneNumbers []string // TemplateCode 模板代码(如果为空,使用配置中的模板代码) TemplateCode string // TemplateParam 模板参数(可以是map或JSON字符串) // 如果是map,会自动转换为JSON字符串 // 如果是string,直接使用(必须是有效的JSON字符串) TemplateParam interface{} // SignName 签名(如果为空,使用配置中的签名) SignName string } // SendResponse 发送短信响应 type SendResponse struct { // RequestID 请求ID RequestID string `json:"RequestId"` // Code 响应码 Code string `json:"Code"` // Message 响应消息 Message string `json:"Message"` // BizID 业务ID BizID string `json:"BizId"` } // SendRaw 发送原始请求(允许外部完全控制请求参数) // params: 请求参数map,工具只负责添加必要的系统参数(如签名、时间戳等)并发送 func (s *SMS) SendRaw(params map[string]string) (*SendResponse, error) { if params == nil { params = make(map[string]string) } // 确保必要的系统参数存在 if params["Action"] == "" { params["Action"] = "SendSms" } if params["Version"] == "" { params["Version"] = "2017-05-25" } if params["RegionId"] == "" { params["RegionId"] = s.config.Region } if params["AccessKeyId"] == "" { params["AccessKeyId"] = s.config.AccessKeyID } if params["Format"] == "" { params["Format"] = "JSON" } if params["SignatureMethod"] == "" { params["SignatureMethod"] = "HMAC-SHA1" } if params["SignatureVersion"] == "" { params["SignatureVersion"] = "1.0" } if params["SignatureNonce"] == "" { params["SignatureNonce"] = fmt.Sprint(time.Now().UnixNano()) } if params["Timestamp"] == "" { params["Timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05Z") } // 计算签名 signature := s.calculateSignature(params, "POST") params["Signature"] = signature // 构建请求URL endpoint := s.config.Endpoint if endpoint == "" { endpoint = "https://dysmsapi.aliyuncs.com" } // 发送HTTP请求 formData := url.Values{} for k, v := range params { formData.Set(k, v) } httpReq, err := http.NewRequest("POST", endpoint, strings.NewReader(formData.Encode())) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") httpReq.Header.Set("Accept", "application/json") client := &http.Client{ Timeout: time.Duration(s.config.Timeout) * time.Second, } resp, err := client.Do(httpReq) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // 读取响应 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response: %w", err) } // 解析响应 var sendResp SendResponse if err := json.Unmarshal(body, &sendResp); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } // 检查响应码 if sendResp.Code != "OK" { return &sendResp, fmt.Errorf("SMS send failed: Code=%s, Message=%s", sendResp.Code, sendResp.Message) } return &sendResp, nil } // Send 发送短信(使用SendRequest结构) // 注意:如果需要完全控制请求参数,请使用SendRaw方法 func (s *SMS) Send(req *SendRequest) (*SendResponse, error) { if req == nil { return nil, fmt.Errorf("request is nil") } if len(req.PhoneNumbers) == 0 { return nil, fmt.Errorf("phone numbers are required") } // 使用配置中的模板代码和签名(如果请求中未指定) templateCode := req.TemplateCode if templateCode == "" { templateCode = s.config.TemplateCode } if templateCode == "" { return nil, fmt.Errorf("template code is required") } signName := req.SignName if signName == "" { signName = s.config.SignName } // 处理模板参数 var templateParamJSON string if req.TemplateParam != nil { switch v := req.TemplateParam.(type) { case string: // 直接使用字符串(必须是有效的JSON) templateParamJSON = v case map[string]string: // 转换为JSON字符串 paramBytes, err := json.Marshal(v) if err != nil { return nil, fmt.Errorf("failed to marshal template param: %w", err) } templateParamJSON = string(paramBytes) default: // 尝试JSON序列化 paramBytes, err := json.Marshal(v) if err != nil { return nil, fmt.Errorf("failed to marshal template param: %w", err) } templateParamJSON = string(paramBytes) } } else { templateParamJSON = "{}" } // 构建请求参数 params := make(map[string]string) params["PhoneNumbers"] = strings.Join(req.PhoneNumbers, ",") params["SignName"] = signName params["TemplateCode"] = templateCode params["TemplateParam"] = templateParamJSON // 使用SendRaw发送 return s.SendRaw(params) } // calculateSignature 计算签名 func (s *SMS) calculateSignature(params map[string]string, method string) string { // 对参数进行排序 keys := make([]string, 0, len(params)) for k := range params { keys = append(keys, k) } sort.Strings(keys) // 构建查询字符串 var queryParts []string for _, k := range keys { v := params[k] // URL编码 encodedKey := url.QueryEscape(k) encodedValue := url.QueryEscape(v) queryParts = append(queryParts, encodedKey+"="+encodedValue) } queryString := strings.Join(queryParts, "&") // 构建待签名字符串 stringToSign := method + "&" + url.QueryEscape("/") + "&" + url.QueryEscape(queryString) // 计算HMAC-SHA1签名 mac := hmac.New(sha1.New, []byte(s.config.AccessKeySecret+"&")) mac.Write([]byte(stringToSign)) signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) return signature } // SendSimple 发送简单短信(便捷方法) // phoneNumbers: 手机号列表 // templateParam: 模板参数 func (s *SMS) SendSimple(phoneNumbers []string, templateParam map[string]string) (*SendResponse, error) { return s.Send(&SendRequest{ PhoneNumbers: phoneNumbers, TemplateParam: templateParam, }) } // SendWithTemplate 使用指定模板发送短信(便捷方法) // phoneNumbers: 手机号列表 // templateCode: 模板代码 // templateParam: 模板参数 func (s *SMS) SendWithTemplate(phoneNumbers []string, templateCode string, templateParam map[string]string) (*SendResponse, error) { return s.Send(&SendRequest{ PhoneNumbers: phoneNumbers, TemplateCode: templateCode, TemplateParam: templateParam, }) }