package app import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) const smsResponseLimit = 256 << 10 type smsDeliveryResult struct { MessageID string } func parseSMSProviderEndpoint(provider, raw string) (*url.URL, error) { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { return nil, fmt.Errorf("%s API 地址必须是无账号、查询参数和片段的有效 HTTPS 地址", cloudSMSProviderName(provider)) } path := strings.TrimRight(parsed.EscapedPath(), "/") switch provider { case "aliyun", "tencent": if path != "" { return nil, fmt.Errorf("%s API 地址不能包含路径", cloudSMSProviderName(provider)) } case "huawei": if !strings.HasSuffix(path, "/sms/batchSendSms/v1") { return nil, fmt.Errorf("华为云 APP 接入地址必须包含 /sms/batchSendSms/v1") } } return parsed, nil } func (a *App) validateSMSProviderConfig(ctx context.Context) error { provider := a.configPlain(ctx, "sms.provider", "debug") if !containsString([]string{"aliyun", "tencent", "huawei", "webhook", "debug"}, provider) { return fmt.Errorf("不支持的短信厂商 %q", provider) } if a.config.Environment == "production" && provider == "debug" { return fmt.Errorf("生产环境禁止使用 debug 短信提供商") } for _, spec := range integrationSpecs["sms"] { if !spec.Required || (len(spec.Providers) > 0 && !containsString(spec.Providers, provider)) { continue } if strings.TrimSpace(a.configPlain(ctx, spec.Key, "")) == "" { return fmt.Errorf("请填写%s", spec.Label) } } switch provider { case "aliyun", "tencent", "huawei": endpoint := a.configPlain(ctx, "sms."+provider+".endpoint", "") if _, err := parseSMSProviderEndpoint(provider, endpoint); err != nil { return err } if _, err := a.smsTemplateParams(ctx, provider, "000000"); err != nil { return err } case "webhook": endpoint := a.configPlain(ctx, "sms.webhook_url", "") if a.config.Environment == "production" && !validHTTPSURL(endpoint) { return fmt.Errorf("生产环境 Webhook 地址必须使用 HTTPS") } } return nil } func cloudSMSProviderName(provider string) string { switch provider { case "aliyun": return "阿里云" case "tencent": return "腾讯云" case "huawei": return "华为云" case "webhook": return "Webhook" case "debug": return "本地调试" default: return provider } } func (a *App) smsTemplateID(ctx context.Context, provider, scene string) (string, error) { if !containsString([]string{"register", "login", "reset"}, scene) { return "", fmt.Errorf("短信验证码场景无效") } key := "sms." + provider + ".template_" + scene if provider == "webhook" { key = "sms.template_" + scene } value := strings.TrimSpace(a.configPlain(ctx, key, "")) if value == "" { return "", fmt.Errorf("%s%s模板未配置", cloudSMSProviderName(provider), scene) } return value, nil } func (a *App) smsTemplateParams(ctx context.Context, provider, code string) (string, error) { raw := a.configPlain(ctx, "sms."+provider+".template_params", "") expires, _ := strconv.Atoi(a.configPlain(ctx, "sms.expire_seconds", "300")) return renderSMSTemplateParams(provider, raw, code, expires) } func renderSMSTemplateParams(provider, raw, code string, expires int) (string, error) { if expires < 60 || expires > 1800 { expires = 300 } minutes := (expires + 59) / 60 rendered := strings.ReplaceAll(raw, "{{code}}", code) rendered = strings.ReplaceAll(rendered, "{{minutes}}", strconv.Itoa(minutes)) if provider == "aliyun" { var object map[string]any if json.Unmarshal([]byte(rendered), &object) != nil || len(object) == 0 { return "", fmt.Errorf("阿里云模板变量必须是有效的非空 JSON 对象") } payload, _ := json.Marshal(object) return string(payload), nil } var values []string if json.Unmarshal([]byte(rendered), &values) != nil || len(values) == 0 { return "", fmt.Errorf("%s模板参数必须是有效的非空 JSON 字符串数组", cloudSMSProviderName(provider)) } payload, _ := json.Marshal(values) return string(payload), nil } func huaweiWSSE(appKey, appSecret, nonce, created string) (string, string) { digestHash := sha256.Sum256([]byte(nonce + created + appSecret)) passwordDigest := base64.StdEncoding.EncodeToString(digestHash[:]) authorization := `WSSE realm="SDP",profile="UsernameToken",type="Appkey"` wsse := `UsernameToken Username="` + appKey + `",PasswordDigest="` + passwordDigest + `",Nonce="` + nonce + `",Created="` + created + `"` return authorization, wsse } func smsHTTPClient() *http.Client { return &http.Client{Timeout: 8 * time.Second} } func readSMSResponse(response *http.Response) ([]byte, error) { body, err := io.ReadAll(io.LimitReader(response.Body, smsResponseLimit)) if err != nil { return nil, err } if response.StatusCode < 200 || response.StatusCode >= 300 { return nil, fmt.Errorf("短信厂商返回 HTTP %d", response.StatusCode) } return body, nil } func providerError(provider, code, message string) error { message = strings.TrimSpace(message) if len(message) > 300 { message = message[:300] } if message == "" { message = "请求失败" } return fmt.Errorf("%s短信发送失败 [%s]: %s", cloudSMSProviderName(provider), code, message) } func (a *App) sendAliyunSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { endpoint := a.configPlain(ctx, "sms.aliyun.endpoint", "https://dysmsapi.aliyuncs.com") parsed, err := parseSMSProviderEndpoint("aliyun", endpoint) if err != nil { return smsDeliveryResult{}, err } templateID, err := a.smsTemplateID(ctx, "aliyun", scene) if err != nil { return smsDeliveryResult{}, err } templateParams, err := a.smsTemplateParams(ctx, "aliyun", code) if err != nil { return smsDeliveryResult{}, err } query := url.Values{ "PhoneNumbers": {phone}, "SignName": {a.configPlain(ctx, "sms.aliyun.sign_name", "")}, "TemplateCode": {templateID}, "TemplateParam": {templateParams}, } canonicalQuery := strings.ReplaceAll(query.Encode(), "+", "%20") parsed.RawQuery = canonicalQuery canonicalURI := "/" now := time.Now().UTC().Format("2006-01-02T15:04:05Z") nonce := randomToken()[:32] emptyHash := sha256.Sum256(nil) payloadHash := hex.EncodeToString(emptyHash[:]) canonicalHeaders := "host:" + parsed.Host + "\n" + "x-acs-action:SendSms\n" + "x-acs-content-sha256:" + payloadHash + "\n" + "x-acs-date:" + now + "\n" + "x-acs-signature-nonce:" + nonce + "\n" + "x-acs-version:2017-05-25\n" signedHeaders := "host;x-acs-action;x-acs-content-sha256;x-acs-date;x-acs-signature-nonce;x-acs-version" canonicalRequest := "POST\n" + canonicalURI + "\n" + canonicalQuery + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payloadHash requestHash := sha256.Sum256([]byte(canonicalRequest)) stringToSign := "ACS3-HMAC-SHA256\n" + hex.EncodeToString(requestHash[:]) accessKeyID := a.configPlain(ctx, "sms.aliyun.access_key_id", "") mac := hmac.New(sha256.New, []byte(a.configPlain(ctx, "sms.aliyun.access_key_secret", ""))) _, _ = mac.Write([]byte(stringToSign)) authorization := "ACS3-HMAC-SHA256 Credential=" + accessKeyID + ",SignedHeaders=" + signedHeaders + ",Signature=" + hex.EncodeToString(mac.Sum(nil)) request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil) if err != nil { return smsDeliveryResult{}, err } request.Header.Set("Accept", "application/json") request.Header.Set("Authorization", authorization) request.Header.Set("x-acs-action", "SendSms") request.Header.Set("x-acs-content-sha256", payloadHash) request.Header.Set("x-acs-date", now) request.Header.Set("x-acs-signature-nonce", nonce) request.Header.Set("x-acs-version", "2017-05-25") response, err := smsHTTPClient().Do(request) if err != nil { return smsDeliveryResult{}, fmt.Errorf("阿里云短信连接失败: %w", err) } defer response.Body.Close() body, err := readSMSResponse(response) if err != nil { return smsDeliveryResult{}, err } var result struct { BizID string `json:"BizId"` Code string `json:"Code"` Message string `json:"Message"` RequestID string `json:"RequestId"` } if json.Unmarshal(body, &result) != nil { return smsDeliveryResult{}, fmt.Errorf("阿里云短信返回无效 JSON") } if result.Code != "OK" { return smsDeliveryResult{}, providerError("aliyun", result.Code, result.Message) } return smsDeliveryResult{MessageID: result.BizID}, nil } func hmacSHA256(key, value []byte) []byte { mac := hmac.New(sha256.New, key) _, _ = mac.Write(value) return mac.Sum(nil) } func (a *App) sendTencentSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { endpoint := a.configPlain(ctx, "sms.tencent.endpoint", "https://sms.tencentcloudapi.com") parsed, err := parseSMSProviderEndpoint("tencent", endpoint) if err != nil { return smsDeliveryResult{}, err } templateID, err := a.smsTemplateID(ctx, "tencent", scene) if err != nil { return smsDeliveryResult{}, err } paramsJSON, err := a.smsTemplateParams(ctx, "tencent", code) if err != nil { return smsDeliveryResult{}, err } var params []string _ = json.Unmarshal([]byte(paramsJSON), ¶ms) payload, _ := json.Marshal(map[string]any{ "PhoneNumberSet": []string{"+86" + phone}, "SignName": a.configPlain(ctx, "sms.tencent.sign_name", ""), "SmsSdkAppId": a.configPlain(ctx, "sms.tencent.sdk_app_id", ""), "TemplateId": templateID, "TemplateParamSet": params, }) timestamp := time.Now().Unix() date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") contentType := "application/json; charset=utf-8" canonicalHeaders := "content-type:" + contentType + "\nhost:" + parsed.Host + "\n" signedHeaders := "content-type;host" payloadHash := sha256.Sum256(payload) canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hex.EncodeToString(payloadHash[:]) canonicalHash := sha256.Sum256([]byte(canonicalRequest)) credentialScope := date + "/sms/tc3_request" stringToSign := "TC3-HMAC-SHA256\n" + strconv.FormatInt(timestamp, 10) + "\n" + credentialScope + "\n" + hex.EncodeToString(canonicalHash[:]) secretKey := a.configPlain(ctx, "sms.tencent.secret_key", "") secretDate := hmacSHA256([]byte("TC3"+secretKey), []byte(date)) secretService := hmacSHA256(secretDate, []byte("sms")) secretSigning := hmacSHA256(secretService, []byte("tc3_request")) signature := hex.EncodeToString(hmacSHA256(secretSigning, []byte(stringToSign))) authorization := "TC3-HMAC-SHA256 Credential=" + a.configPlain(ctx, "sms.tencent.secret_id", "") + "/" + credentialScope + ", SignedHeaders=" + signedHeaders + ", Signature=" + signature request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), bytes.NewReader(payload)) if err != nil { return smsDeliveryResult{}, err } request.Header.Set("Authorization", authorization) request.Header.Set("Content-Type", contentType) request.Header.Set("X-TC-Action", "SendSms") request.Header.Set("X-TC-Version", "2021-01-11") request.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10)) request.Header.Set("X-TC-Region", a.configPlain(ctx, "sms.tencent.region", "ap-guangzhou")) response, err := smsHTTPClient().Do(request) if err != nil { return smsDeliveryResult{}, fmt.Errorf("腾讯云短信连接失败: %w", err) } defer response.Body.Close() body, err := readSMSResponse(response) if err != nil { return smsDeliveryResult{}, err } var result struct { Response struct { Error *struct { Code string `json:"Code"` Message string `json:"Message"` } `json:"Error"` RequestID string `json:"RequestId"` SendStatusSet []struct { Code string `json:"Code"` Message string `json:"Message"` SerialNo string `json:"SerialNo"` } `json:"SendStatusSet"` } `json:"Response"` } if json.Unmarshal(body, &result) != nil { return smsDeliveryResult{}, fmt.Errorf("腾讯云短信返回无效 JSON") } if result.Response.Error != nil { return smsDeliveryResult{}, providerError("tencent", result.Response.Error.Code, result.Response.Error.Message) } if len(result.Response.SendStatusSet) == 0 { return smsDeliveryResult{}, fmt.Errorf("腾讯云短信未返回发送状态") } status := result.Response.SendStatusSet[0] if !strings.EqualFold(status.Code, "Ok") { return smsDeliveryResult{}, providerError("tencent", status.Code, status.Message) } return smsDeliveryResult{MessageID: status.SerialNo}, nil } func (a *App) sendHuaweiSMS(ctx context.Context, phone, scene, code string) (smsDeliveryResult, error) { endpoint := a.configPlain(ctx, "sms.huawei.endpoint", "") parsed, err := parseSMSProviderEndpoint("huawei", endpoint) if err != nil { return smsDeliveryResult{}, err } templateID, err := a.smsTemplateID(ctx, "huawei", scene) if err != nil { return smsDeliveryResult{}, err } paramsJSON, err := a.smsTemplateParams(ctx, "huawei", code) if err != nil { return smsDeliveryResult{}, err } appKey := a.configPlain(ctx, "sms.huawei.app_key", "") nonce := randomToken()[:32] created := time.Now().UTC().Format("2006-01-02T15:04:05Z") authorization, wsse := huaweiWSSE(appKey, a.configPlain(ctx, "sms.huawei.app_secret", ""), nonce, created) form := url.Values{ "from": {a.configPlain(ctx, "sms.huawei.sender", "")}, "to": {"+86" + phone}, "templateId": {templateID}, "templateParas": {paramsJSON}, } if signature := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.signature", "")); signature != "" { form.Set("signature", signature) } if callback := strings.TrimSpace(a.configPlain(ctx, "sms.huawei.status_callback", "")); callback != "" { form.Set("statusCallback", callback) } request, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), strings.NewReader(form.Encode())) if err != nil { return smsDeliveryResult{}, err } request.Header.Set("Accept", "application/json") request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Authorization", authorization) request.Header.Set("X-WSSE", wsse) response, err := smsHTTPClient().Do(request) if err != nil { return smsDeliveryResult{}, fmt.Errorf("华为云短信连接失败: %w", err) } defer response.Body.Close() body, err := readSMSResponse(response) if err != nil { return smsDeliveryResult{}, err } var result struct { Code string `json:"code"` Description string `json:"description"` Result []struct { MessageID string `json:"smsMsgId"` Status string `json:"status"` } `json:"result"` } if json.Unmarshal(body, &result) != nil { return smsDeliveryResult{}, fmt.Errorf("华为云短信返回无效 JSON") } if result.Code != "000000" { return smsDeliveryResult{}, providerError("huawei", result.Code, result.Description) } if len(result.Result) == 0 || result.Result[0].Status != "000000" { status := "EMPTY_RESULT" if len(result.Result) > 0 { status = result.Result[0].Status } return smsDeliveryResult{}, providerError("huawei", status, "短信未被平台接受") } return smsDeliveryResult{MessageID: result.Result[0].MessageID}, nil }