Files
kefu/im/backend/internal/app/sms_providers.go
T
2026-08-27 14:04:28 +08:00

417 lines
15 KiB
Go

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), &params)
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
}