From 16121c854a7adea4a45380c66e2f31bbce327570 Mon Sep 17 00:00:00 2001 From: chenjianyong Date: Tue, 14 Apr 2026 14:48:00 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=96=B0=E5=A2=9ESMS=20=E7=9F=AD=E4=BF=A1?= =?UTF-8?q?=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 架构设计: - 管理员在「系统设置」中配置短信服务商和凭证 - 用户在「个人设置 → 通知配置」中选择"短信通知",只需填写手机号 支持 4 种短信服务商: 1. 阿里云短信 — POP v1 签名 (HMAC-SHA1),纯 HTTP 调用 dysmsapi.aliyuncs.com 2. SendCloud — MD5 签名,POST form 到 sendcloud.net/smsapi/send 3. 腾讯云短信 — TC3-HMAC-SHA256 签名,POST JSON 到 sms.tencentcloudapi.com 4. 通用 HTTP 接口 — 自定义 URL/方法/模板,支持 {{phone}}/{{title}}/{{content}} 占位符 --- common/constants.go | 18 + controller/option.go | 8 +- controller/user.go | 36 +- dto/user_settings.go | 2 + model/option.go | 48 ++ service/quota.go | 4 + service/sms_notify.go | 428 ++++++++++++++++++ service/user_notify.go | 6 + .../components/settings/PersonalSetting.jsx | 3 + web/src/components/settings/SystemSetting.jsx | 204 +++++++++ .../personal/cards/NotificationSettings.jsx | 28 +- web/src/i18n/locales/en.json | 34 ++ web/src/i18n/locales/fr.json | 34 ++ web/src/i18n/locales/ja.json | 34 ++ web/src/i18n/locales/ru.json | 34 ++ web/src/i18n/locales/vi.json | 34 ++ web/src/i18n/locales/zh-CN.json | 34 ++ 17 files changed, 986 insertions(+), 3 deletions(-) create mode 100644 service/sms_notify.go diff --git a/common/constants.go b/common/constants.go index 6caa7f5c0007..e2582910d90d 100644 --- a/common/constants.go +++ b/common/constants.go @@ -85,6 +85,24 @@ var SMTPAccount = "" var SMTPFrom = "" var SMTPToken = "" +// SMS 短信通知系统级配置 +var SMSProvider = "" // 短信服务商: aliyun, tencent, custom +var SMSAliyunAccessKeyId = "" // 阿里云 AccessKeyId +var SMSAliyunAccessKeySecret = "" // 阿里云 AccessKeySecret +var SMSAliyunSignName = "" // 阿里云短信签名 +var SMSAliyunTemplateCode = "" // 阿里云短信模板Code +var SMSSendCloudSmsUser = "" // SendCloud smsUser +var SMSSendCloudSmsKey = "" // SendCloud smsKey +var SMSSendCloudTemplateId = "" // SendCloud 短信模板ID +var SMSTencentSecretId = "" // 腾讯云 SecretId +var SMSTencentSecretKey = "" // 腾讯云 SecretKey +var SMSTencentSmsSdkAppId = "" // 腾讯云 SmsSdkAppId +var SMSTencentSignName = "" // 腾讯云短信签名 +var SMSTencentTemplateId = "" // 腾讯云短信模板ID +var SMSCustomUrl = "" // 通用HTTP接口地址 +var SMSCustomMethod = "POST" // 通用HTTP请求方法 +var SMSCustomTemplate = "" // 通用HTTP请求模板 + var GitHubClientId = "" var GitHubClientSecret = "" var LinuxDOClientId = "" diff --git a/controller/option.go b/controller/option.go index ecb1e25e8677..7ce4f2929820 100644 --- a/controller/option.go +++ b/controller/option.go @@ -70,7 +70,13 @@ func GetOptions(c *gin.Context) { strings.HasSuffix(k, "Secret") || strings.HasSuffix(k, "Key") || strings.HasSuffix(k, "secret") || - strings.HasSuffix(k, "api_key") { + strings.HasSuffix(k, "api_key") || + // SMS 敏感凭证字段 + k == "SMSAliyunAccessKeyId" || + k == "SMSSendCloudSmsUser" || + k == "SMSTencentSecretId" || + k == "SMSCustomUrl" || + k == "SMSCustomTemplate" { continue } options = append(options, &model.Option{ diff --git a/controller/user.go b/controller/user.go index a12d5c662d6f..dd303ac0b02d 100644 --- a/controller/user.go +++ b/controller/user.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "regexp" "strconv" "strings" "sync" @@ -1097,6 +1098,7 @@ type UpdateUserSettingRequest struct { GotifyUrl string `json:"gotify_url,omitempty"` GotifyToken string `json:"gotify_token,omitempty"` GotifyPriority int `json:"gotify_priority,omitempty"` + SmsPhoneNumber string `json:"sms_phone_number,omitempty"` UpstreamModelUpdateNotifyEnabled *bool `json:"upstream_model_update_notify_enabled,omitempty"` AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"` RecordIpLog bool `json:"record_ip_log"` @@ -1110,7 +1112,7 @@ func UpdateUserSetting(c *gin.Context) { } // 验证预警类型 - if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify { + if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify && req.QuotaWarningType != dto.NotifyTypeSms { common.ApiErrorI18n(c, i18n.MsgSettingInvalidType) return } @@ -1183,6 +1185,33 @@ func UpdateUserSetting(c *gin.Context) { } } + // 如果是SMS类型,验证手机号和系统SMS配置 + if req.QuotaWarningType == dto.NotifyTypeSms { + if req.SmsPhoneNumber == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "手机号码不能为空", + }) + return + } + // 验证手机号格式:允许国际号码格式,7-15位数字,可选+前缀 + phoneRegex := regexp.MustCompile(`^\+?[0-9]{7,15}$`) + if !phoneRegex.MatchString(req.SmsPhoneNumber) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "手机号码格式不正确", + }) + return + } + if common.SMSProvider == "" { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": "系统未配置短信服务,请联系管理员", + }) + return + } + } + userId := c.GetInt("id") user, err := model.GetUserById(userId, true) if err != nil { @@ -1234,6 +1263,11 @@ func UpdateUserSetting(c *gin.Context) { } } + // 如果是SMS类型,添加手机号到设置中 + if req.QuotaWarningType == dto.NotifyTypeSms { + settings.SmsPhoneNumber = req.SmsPhoneNumber + } + // 更新用户设置 user.SetSetting(settings) if err := user.Update(false); err != nil { diff --git a/dto/user_settings.go b/dto/user_settings.go index dbf555fadfa8..1f20fdc748bf 100644 --- a/dto/user_settings.go +++ b/dto/user_settings.go @@ -16,6 +16,7 @@ type UserSetting struct { SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置 BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包) Language string `json:"language,omitempty"` // Language 用户语言偏好 (zh, en) + SmsPhoneNumber string `json:"sms_phone_number,omitempty"` // SmsPhoneNumber 接收短信的手机号码 } var ( @@ -23,4 +24,5 @@ var ( NotifyTypeWebhook = "webhook" // Webhook NotifyTypeBark = "bark" // Bark 推送 NotifyTypeGotify = "gotify" // Gotify 推送 + NotifyTypeSms = "sms" // SMS 短信通知 ) diff --git a/model/option.go b/model/option.go index efa8c01daa7b..783d970df735 100644 --- a/model/option.go +++ b/model/option.go @@ -63,6 +63,22 @@ func InitOptionMap() { common.OptionMap["SMTPToken"] = "" common.OptionMap["SMTPSSLEnabled"] = strconv.FormatBool(common.SMTPSSLEnabled) common.OptionMap["SMTPForceAuthLogin"] = strconv.FormatBool(common.SMTPForceAuthLogin) + common.OptionMap["SMSProvider"] = "" + common.OptionMap["SMSAliyunAccessKeyId"] = "" + common.OptionMap["SMSAliyunAccessKeySecret"] = "" + common.OptionMap["SMSAliyunSignName"] = "" + common.OptionMap["SMSAliyunTemplateCode"] = "" + common.OptionMap["SMSSendCloudSmsUser"] = "" + common.OptionMap["SMSSendCloudSmsKey"] = "" + common.OptionMap["SMSSendCloudTemplateId"] = "" + common.OptionMap["SMSTencentSecretId"] = "" + common.OptionMap["SMSTencentSecretKey"] = "" + common.OptionMap["SMSTencentSmsSdkAppId"] = "" + common.OptionMap["SMSTencentSignName"] = "" + common.OptionMap["SMSTencentTemplateId"] = "" + common.OptionMap["SMSCustomUrl"] = "" + common.OptionMap["SMSCustomMethod"] = "POST" + common.OptionMap["SMSCustomTemplate"] = "" common.OptionMap["Notice"] = "" common.OptionMap["About"] = "" common.OptionMap["HomePageContent"] = "" @@ -333,6 +349,38 @@ func updateOptionMap(key string, value string) (err error) { common.SMTPFrom = value case "SMTPToken": common.SMTPToken = value + case "SMSProvider": + common.SMSProvider = value + case "SMSAliyunAccessKeyId": + common.SMSAliyunAccessKeyId = value + case "SMSAliyunAccessKeySecret": + common.SMSAliyunAccessKeySecret = value + case "SMSAliyunSignName": + common.SMSAliyunSignName = value + case "SMSAliyunTemplateCode": + common.SMSAliyunTemplateCode = value + case "SMSSendCloudSmsUser": + common.SMSSendCloudSmsUser = value + case "SMSSendCloudSmsKey": + common.SMSSendCloudSmsKey = value + case "SMSSendCloudTemplateId": + common.SMSSendCloudTemplateId = value + case "SMSTencentSecretId": + common.SMSTencentSecretId = value + case "SMSTencentSecretKey": + common.SMSTencentSecretKey = value + case "SMSTencentSmsSdkAppId": + common.SMSTencentSmsSdkAppId = value + case "SMSTencentSignName": + common.SMSTencentSignName = value + case "SMSTencentTemplateId": + common.SMSTencentTemplateId = value + case "SMSCustomUrl": + common.SMSCustomUrl = value + case "SMSCustomMethod": + common.SMSCustomMethod = value + case "SMSCustomTemplate": + common.SMSCustomTemplate = value case "ServerAddress": system_setting.ServerAddress = value case "WorkerUrl": diff --git a/service/quota.go b/service/quota.go index 4150c44434bb..958810e3c6df 100644 --- a/service/quota.go +++ b/service/quota.go @@ -441,6 +441,10 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon // Bark推送使用简短文本,不支持HTML content = "{{value}},剩余额度:{{value}},请及时充值" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + } else if notifyType == dto.NotifyTypeSms { + // SMS短信使用简短文本,不支持HTML + content = "{{value}},剩余额度:{{value}},请及时充值" + values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} } else if notifyType == dto.NotifyTypeGotify { content = "{{value}},当前剩余额度为 {{value}},请及时充值。" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} diff --git a/service/sms_notify.go b/service/sms_notify.go new file mode 100644 index 000000000000..aef18d247294 --- /dev/null +++ b/service/sms_notify.go @@ -0,0 +1,428 @@ +package service + +import ( + "bytes" + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/setting/system_setting" +) + +func sendSmsNotify(phoneNumber string, data dto.Notify) error { + if phoneNumber == "" { + return fmt.Errorf("sms phone number is empty") + } + + provider := common.SMSProvider + if provider == "" { + return fmt.Errorf("SMS provider is not configured, please contact the administrator") + } + + // 处理占位符 + content := data.Content + for _, value := range data.Values { + content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) + } + + switch provider { + case "aliyun": + return sendAliyunSms( + common.SMSAliyunAccessKeyId, + common.SMSAliyunAccessKeySecret, + common.SMSAliyunSignName, + common.SMSAliyunTemplateCode, + phoneNumber, + content, + ) + case "sendcloud": + return sendSendCloudSms( + common.SMSSendCloudSmsUser, + common.SMSSendCloudSmsKey, + common.SMSSendCloudTemplateId, + phoneNumber, + content, + ) + case "tencent": + return sendTencentSms( + common.SMSTencentSecretId, + common.SMSTencentSecretKey, + common.SMSTencentSmsSdkAppId, + common.SMSTencentSignName, + common.SMSTencentTemplateId, + phoneNumber, + content, + ) + case "custom": + return sendCustomSms( + common.SMSCustomUrl, + common.SMSCustomMethod, + common.SMSCustomTemplate, + phoneNumber, + data.Title, + content, + ) + default: + return fmt.Errorf("unsupported sms provider: %s", provider) + } +} + +// aliyunPercentEncode 阿里云专用的百分号编码 +func aliyunPercentEncode(s string) string { + encoded := url.QueryEscape(s) + encoded = strings.ReplaceAll(encoded, "+", "%20") + encoded = strings.ReplaceAll(encoded, "*", "%2A") + encoded = strings.ReplaceAll(encoded, "%7E", "~") + return encoded +} + +// sendAliyunSms 通过阿里云短信服务发送短信 (POP v1 签名, HMAC-SHA1) +func sendAliyunSms(accessKeyId, accessKeySecret, signName, templateCode, phoneNumber, content string) error { + templateParam, _ := json.Marshal(map[string]string{ + "content": content, + }) + + params := map[string]string{ + "AccessKeyId": accessKeyId, + "Action": "SendSms", + "Format": "JSON", + "PhoneNumbers": phoneNumber, + "SignName": signName, + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": common.GetUUID(), + "SignatureVersion": "1.0", + "TemplateCode": templateCode, + "TemplateParam": string(templateParam), + "Timestamp": time.Now().UTC().Format("2006-01-02T15:04:05Z"), + "Version": "2017-05-25", + } + + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + var queryParts []string + for _, k := range keys { + queryParts = append(queryParts, aliyunPercentEncode(k)+"="+aliyunPercentEncode(params[k])) + } + canonicalizedQueryString := strings.Join(queryParts, "&") + + stringToSign := "GET&" + aliyunPercentEncode("/") + "&" + aliyunPercentEncode(canonicalizedQueryString) + + mac := hmac.New(sha1.New, []byte(accessKeySecret+"&")) + mac.Write([]byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + finalURL := "https://dysmsapi.aliyuncs.com/?Signature=" + url.QueryEscape(signature) + "&" + canonicalizedQueryString + + resp, err := GetHttpClient().Get(finalURL) + if err != nil { + return fmt.Errorf("failed to send aliyun sms: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read aliyun sms response: %v", err) + } + + var result struct { + Code string `json:"Code"` + Message string `json:"Message"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("failed to parse aliyun sms response: %v", err) + } + + if result.Code != "OK" { + return fmt.Errorf("aliyun sms failed: %s - %s", result.Code, result.Message) + } + + return nil +} + +// sendSendCloudSms 通过 SendCloud 短信服务发送短信 +// 使用 MD5 签名: signature = MD5(smsKey + "&" + 排序参数串 + "&" + smsKey) +func sendSendCloudSms(smsUser, smsKey, templateId, phoneNumber, content string) error { + params := map[string]string{ + "smsUser": smsUser, + "templateId": templateId, + "phone": phoneNumber, + "msgType": "0", + } + + // vars 传递通知内容 + varsJSON, _ := json.Marshal(map[string]string{ + "content": content, + }) + params["vars"] = string(varsJSON) + + // 按 key 排序生成签名字符串 + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + var paramParts []string + for _, k := range keys { + paramParts = append(paramParts, k+"="+params[k]) + } + paramStr := strings.Join(paramParts, "&") + + // MD5 签名 + signStr := smsKey + "&" + paramStr + "&" + smsKey + h := md5.New() + h.Write([]byte(signStr)) + signature := hex.EncodeToString(h.Sum(nil)) + params["signature"] = signature + + // 构建 form data + formValues := url.Values{} + for k, v := range params { + formValues.Set(k, v) + } + + resp, err := GetHttpClient().PostForm("https://www.sendcloud.net/smsapi/send", formValues) + if err != nil { + return fmt.Errorf("failed to send sendcloud sms: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read sendcloud sms response: %v", err) + } + + var result struct { + Result bool `json:"result"` + StatusCode int `json:"statusCode"` + Message string `json:"message"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("failed to parse sendcloud sms response: %v", err) + } + + if !result.Result || result.StatusCode != 200 { + return fmt.Errorf("sendcloud sms failed: %d - %s", result.StatusCode, result.Message) + } + + return nil +} + +func hmacSHA256(key []byte, data string) []byte { + mac := hmac.New(sha256.New, key) + mac.Write([]byte(data)) + return mac.Sum(nil) +} + +func sha256Hex(data string) string { + h := sha256.New() + h.Write([]byte(data)) + return hex.EncodeToString(h.Sum(nil)) +} + +// sendTencentSms 通过腾讯云短信服务发送短信 (TC3-HMAC-SHA256 签名) +func sendTencentSms(secretId, secretKey, smsSdkAppId, signName, templateId, phoneNumber, content string) error { + host := "sms.tencentcloudapi.com" + service := "sms" + action := "SendSms" + version := "2021-01-11" + region := "ap-guangzhou" + + timestamp := time.Now().Unix() + timestampStr := fmt.Sprintf("%d", timestamp) + date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") + + type SendSmsRequest struct { + SmsSdkAppId string `json:"SmsSdkAppId"` + SignName string `json:"SignName"` + TemplateId string `json:"TemplateId"` + PhoneNumberSet []string `json:"PhoneNumberSet"` + TemplateParamSet []string `json:"TemplateParamSet"` + } + + payload := SendSmsRequest{ + SmsSdkAppId: smsSdkAppId, + SignName: signName, + TemplateId: templateId, + PhoneNumberSet: []string{phoneNumber}, + TemplateParamSet: []string{content}, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal tencent sms payload: %v", err) + } + payloadStr := string(payloadBytes) + + contentType := "application/json; charset=utf-8" + canonicalHeaders := "content-type:" + contentType + "\n" + "host:" + host + "\n" + signedHeaders := "content-type;host" + hashedPayload := sha256Hex(payloadStr) + + canonicalRequest := "POST\n/\n\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedPayload + + credentialScope := date + "/" + service + "/tc3_request" + stringToSign := "TC3-HMAC-SHA256\n" + timestampStr + "\n" + credentialScope + "\n" + sha256Hex(canonicalRequest) + + secretDate := hmacSHA256([]byte("TC3"+secretKey), date) + secretService := hmacSHA256(secretDate, service) + secretSigning := hmacSHA256(secretService, "tc3_request") + signature := hex.EncodeToString(hmacSHA256(secretSigning, stringToSign)) + + authorization := "TC3-HMAC-SHA256 " + + "Credential=" + secretId + "/" + credentialScope + ", " + + "SignedHeaders=" + signedHeaders + ", " + + "Signature=" + signature + + req, err := http.NewRequest(http.MethodPost, "https://"+host, bytes.NewBufferString(payloadStr)) + if err != nil { + return fmt.Errorf("failed to create tencent sms request: %v", err) + } + + req.Header.Set("Content-Type", contentType) + req.Header.Set("Host", host) + req.Header.Set("Authorization", authorization) + req.Header.Set("X-TC-Action", action) + req.Header.Set("X-TC-Timestamp", timestampStr) + req.Header.Set("X-TC-Version", version) + req.Header.Set("X-TC-Region", region) + + resp, err := GetHttpClient().Do(req) + if err != nil { + return fmt.Errorf("failed to send tencent sms: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read tencent sms response: %v", err) + } + + var result struct { + Response struct { + SendStatusSet []struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"SendStatusSet"` + Error *struct { + Code string `json:"Code"` + Message string `json:"Message"` + } `json:"Error"` + } `json:"Response"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("failed to parse tencent sms response: %v", err) + } + + if result.Response.Error != nil { + return fmt.Errorf("tencent sms failed: %s - %s", result.Response.Error.Code, result.Response.Error.Message) + } + + if len(result.Response.SendStatusSet) > 0 && result.Response.SendStatusSet[0].Code != "Ok" { + return fmt.Errorf("tencent sms failed: %s - %s", result.Response.SendStatusSet[0].Code, result.Response.SendStatusSet[0].Message) + } + + return nil +} + +// jsonEscapeString 对字符串进行 JSON 转义,防止模板注入 +func jsonEscapeString(s string) string { + b, _ := json.Marshal(s) + // json.Marshal 返回带引号的字符串,去掉首尾引号 + return string(b[1 : len(b)-1]) +} + +// sendCustomSms 通过通用HTTP接口发送短信 +func sendCustomSms(smsUrl, method, template, phoneNumber, title, content string) error { + if smsUrl == "" { + return fmt.Errorf("custom sms url is empty") + } + if method == "" { + method = "POST" + } + + finalURL := strings.ReplaceAll(smsUrl, "{{phone}}", url.QueryEscape(phoneNumber)) + finalURL = strings.ReplaceAll(finalURL, "{{title}}", url.QueryEscape(title)) + finalURL = strings.ReplaceAll(finalURL, "{{content}}", url.QueryEscape(content)) + + // body 模板中使用 JSON 转义,防止 JSON 注入 + finalBody := strings.ReplaceAll(template, "{{phone}}", jsonEscapeString(phoneNumber)) + finalBody = strings.ReplaceAll(finalBody, "{{title}}", jsonEscapeString(title)) + finalBody = strings.ReplaceAll(finalBody, "{{content}}", jsonEscapeString(content)) + + var req *http.Request + var resp *http.Response + var err error + + if system_setting.EnableWorker() { + workerReq := &WorkerRequest{ + URL: finalURL, + Key: system_setting.WorkerValidKey, + Method: method, + Headers: map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "OneAPI-SMS-Notify/1.0", + }, + } + if method == "POST" { + workerReq.Body = []byte(finalBody) + } + + resp, err = DoWorkerRequest(workerReq) + if err != nil { + return fmt.Errorf("failed to send custom sms through worker: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("custom sms request failed with status code: %d", resp.StatusCode) + } + } else { + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + return fmt.Errorf("request reject: %v", err) + } + + if method == "POST" { + req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBufferString(finalBody)) + } else { + req, err = http.NewRequest(http.MethodGet, finalURL, nil) + } + if err != nil { + return fmt.Errorf("failed to create custom sms request: %v", err) + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("User-Agent", "OneAPI-SMS-Notify/1.0") + + client := GetHttpClient() + resp, err = client.Do(req) + if err != nil { + return fmt.Errorf("failed to send custom sms request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("custom sms request failed with status code: %d", resp.StatusCode) + } + } + + return nil +} diff --git a/service/user_notify.go b/service/user_notify.go index 27a72b8be427..a6e58dd301cc 100644 --- a/service/user_notify.go +++ b/service/user_notify.go @@ -101,6 +101,12 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data return nil } return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data) + case dto.NotifyTypeSms: + if userSetting.SmsPhoneNumber == "" { + common.SysLog(fmt.Sprintf("user %d has no sms phone number, skip sending sms", userId)) + return nil + } + return sendSmsNotify(userSetting.SmsPhoneNumber, data) } return nil } diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx index 4f9ce2d60507..11b4430177e4 100644 --- a/web/src/components/settings/PersonalSetting.jsx +++ b/web/src/components/settings/PersonalSetting.jsx @@ -89,6 +89,7 @@ const PersonalSetting = () => { upstreamModelUpdateNotifyEnabled: false, acceptUnsetModelRatioModel: false, recordIpLog: false, + smsPhoneNumber: '', }); useEffect(() => { @@ -164,6 +165,7 @@ const PersonalSetting = () => { acceptUnsetModelRatioModel: settings.accept_unset_model_ratio_model || false, recordIpLog: settings.record_ip_log || false, + smsPhoneNumber: settings.sms_phone_number || '', }); } }, [userState?.user?.setting]); @@ -435,6 +437,7 @@ const PersonalSetting = () => { accept_unset_model_ratio_model: notificationSettings.acceptUnsetModelRatioModel, record_ip_log: notificationSettings.recordIpLog, + sms_phone_number: notificationSettings.smsPhoneNumber, }); if (res.data.success) { diff --git a/web/src/components/settings/SystemSetting.jsx b/web/src/components/settings/SystemSetting.jsx index 63b20c70f4d1..2e169b64ebce 100644 --- a/web/src/components/settings/SystemSetting.jsx +++ b/web/src/components/settings/SystemSetting.jsx @@ -69,6 +69,22 @@ const SystemSetting = () => { SMTPAccount: '', SMTPFrom: '', SMTPToken: '', + SMSProvider: '', + SMSAliyunAccessKeyId: '', + SMSAliyunAccessKeySecret: '', + SMSAliyunSignName: '', + SMSAliyunTemplateCode: '', + SMSSendCloudSmsUser: '', + SMSSendCloudSmsKey: '', + SMSSendCloudTemplateId: '', + SMSTencentSecretId: '', + SMSTencentSecretKey: '', + SMSTencentSmsSdkAppId: '', + SMSTencentSignName: '', + SMSTencentTemplateId: '', + SMSCustomUrl: '', + SMSCustomMethod: 'POST', + SMSCustomTemplate: '', WorkerUrl: '', WorkerValidKey: '', WorkerAllowHttpImageRequestEnabled: '', @@ -349,6 +365,46 @@ const SystemSetting = () => { } }; + const submitSMS = async () => { + const options = []; + const smsKeys = [ + 'SMSProvider', + 'SMSAliyunAccessKeyId', + 'SMSAliyunAccessKeySecret', + 'SMSAliyunSignName', + 'SMSAliyunTemplateCode', + 'SMSSendCloudSmsUser', + 'SMSSendCloudSmsKey', + 'SMSSendCloudTemplateId', + 'SMSTencentSecretId', + 'SMSTencentSecretKey', + 'SMSTencentSmsSdkAppId', + 'SMSTencentSignName', + 'SMSTencentTemplateId', + 'SMSCustomUrl', + 'SMSCustomMethod', + 'SMSCustomTemplate', + ]; + for (const key of smsKeys) { + if ( + originInputs[key] !== inputs[key] && + inputs[key] !== '' + ) { + options.push({ key, value: inputs[key] }); + } + } + // 允许清空 SMSProvider + if (originInputs['SMSProvider'] !== inputs['SMSProvider']) { + const exists = options.find((o) => o.key === 'SMSProvider'); + if (!exists) { + options.push({ key: 'SMSProvider', value: inputs['SMSProvider'] || '' }); + } + } + if (options.length > 0) { + await updateOptions(options); + } + }; + const submitEmailDomainWhitelist = async () => { if (Array.isArray(emailDomainWhitelist)) { await updateOptions([ @@ -1351,6 +1407,154 @@ const SystemSetting = () => { + + + {t('用以支持短信通知推送,用户可在个人设置中选择短信通知方式')} + + + + + + {inputs.SMSProvider === 'aliyun' && ( + <> + + + + + + + + + + + + + + + + + + )} + {inputs.SMSProvider === 'sendcloud' && ( + + + + + + + + + + + + )} + {inputs.SMSProvider === 'tencent' && ( + <> + + + + + + + + + + + + + + + + + + + + + )} + {inputs.SMSProvider === 'custom' && ( + <> + + + + + + + + + + + + + + + )} + + + diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx index 5e8d4fd8299f..870178363e1d 100644 --- a/web/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx @@ -33,7 +33,7 @@ import { Col, } from '@douyinfe/semi-ui'; import { IconMail, IconKey, IconBell, IconLink } from '@douyinfe/semi-icons'; -import { ShieldCheck, Bell, DollarSign, Settings } from 'lucide-react'; +import { ShieldCheck, Bell, DollarSign, Settings, Smartphone } from 'lucide-react'; import { renderQuotaWithPrompt, API, @@ -432,6 +432,7 @@ const NotificationSettings = ({ {t('Webhook通知')} {t('Bark通知')} {t('Gotify通知')} + {t('短信通知')} )} + + {/* 短信通知设置 */} + {notificationSettings.warningType === 'sms' && ( + handleFormChange('smsPhoneNumber', val)} + prefix={} + extraText={t( + '短信服务由管理员统一配置,您只需填写接收通知的手机号码', + )} + showClear + rules={[ + { + required: notificationSettings.warningType === 'sms', + message: t('请输入手机号码'), + }, + { + pattern: /^\+?[0-9]{7,15}$/, + message: t('手机号码格式不正确'), + }, + ]} + /> + )} diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index e273c38b405b..5736431fa2b1 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3266,6 +3266,40 @@ "通知邮箱": "Notification email", "通知配置": "Notification configuration", "通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。": "Groups enable differentiated pricing for different user tiers. For example, VIP users can enjoy lower API costs.", + "短信通知": "SMS notification", + "短信服务商": "SMS provider", + "阿里云短信": "Alibaba Cloud SMS", + "腾讯云短信": "Tencent Cloud SMS", + "通用HTTP接口": "Custom HTTP API", + "手机号码": "Phone number", + "请输入接收短信的手机号码": "Please enter the phone number to receive SMS", + "请输入手机号码": "Please enter phone number", + "手机号码格式不正确": "Invalid phone number format", + "短信服务由管理员统一配置,您只需填写接收通知的手机号码": "SMS service is configured by the administrator. You only need to enter your phone number", + "短信签名": "SMS signature", + "模板Code": "Template code", + "请输入模板Code": "Please enter template code", + "模板ID": "Template ID", + "请输入模板ID": "Please enter template ID", + "接口地址": "API URL", + "请输入短信接口地址": "Please enter SMS API URL", + "请求方法": "Request method", + "请求模板": "Request template", + "请输入请求模板": "Please enter request template", + "短信接口地址必须以http://或https://开头": "SMS API URL must start with http:// or https://", + "阿里云短信配置说明": "Alibaba Cloud SMS configuration", + "腾讯云短信配置说明": "Tencent Cloud SMS configuration", + "通用HTTP接口说明": "Custom HTTP API instructions", + "请输入短信签名": "Please enter SMS signature", + "在阿里云短信服务控制台创建签名和模板后获取相关参数": "Obtain the parameters after creating a signature and template in the Alibaba Cloud SMS console", + "在腾讯云短信控制台创建应用、签名和模板后获取相关参数": "Obtain the parameters after creating an app, signature and template in the Tencent Cloud SMS console", + "支持模板变量: {{phone}} (手机号), {{title}} (通知标题), {{content}} (通知内容)": "Supports template variables: {{phone}} (phone number), {{title}} (notification title), {{content}} (notification content)", + "短信模板中需包含一个变量用于接收通知内容": "The SMS template must contain a variable for notification content", + "配置短信服务": "Configure SMS Service", + "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "To support SMS notification push, users can select SMS notification in personal settings", + "未配置": "Not configured", + "敏感信息不会发送到前端显示": "Sensitive information will not be sent to the frontend", + "保存短信设置": "Save SMS Settings", "通过划转功能将奖励额度转入到您的账户余额中": "Transfer the reward amount to your account balance through the transfer function", "通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password", "通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。": "This feature allows showing different selectable group lists to users of different tiers.", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 61c6c37083ac..df5abc628a32 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -3232,6 +3232,40 @@ "通知邮箱": "E-mail de notification", "通知配置": "Notifications", "通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。": "Groups enable differentiated pricing for different user tiers. For example, VIP users can enjoy lower API costs.", + "短信通知": "Notification SMS", + "短信服务商": "Fournisseur SMS", + "阿里云短信": "Alibaba Cloud SMS", + "腾讯云短信": "Tencent Cloud SMS", + "通用HTTP接口": "API HTTP personnalisée", + "手机号码": "Numéro de téléphone", + "请输入接收短信的手机号码": "Veuillez entrer le numéro de téléphone pour recevoir les SMS", + "请输入手机号码": "Veuillez entrer le numéro de téléphone", + "手机号码格式不正确": "Format de numéro de téléphone incorrect", + "短信服务由管理员统一配置,您只需填写接收通知的手机号码": "Le service SMS est configuré par l'administrateur. Vous n'avez qu'à entrer votre numéro de téléphone", + "短信签名": "Signature SMS", + "模板Code": "Code du modèle", + "请输入模板Code": "Veuillez entrer le code du modèle", + "模板ID": "ID du modèle", + "请输入模板ID": "Veuillez entrer l'ID du modèle", + "接口地址": "URL de l'API", + "请输入短信接口地址": "Veuillez entrer l'URL de l'API SMS", + "请求方法": "Méthode de requête", + "请求模板": "Modèle de requête", + "请输入请求模板": "Veuillez entrer le modèle de requête", + "短信接口地址必须以http://或https://开头": "L'URL de l'API SMS doit commencer par http:// ou https://", + "阿里云短信配置说明": "Configuration Alibaba Cloud SMS", + "腾讯云短信配置说明": "Configuration Tencent Cloud SMS", + "通用HTTP接口说明": "Instructions API HTTP personnalisée", + "请输入短信签名": "Veuillez entrer la signature SMS", + "在阿里云短信服务控制台创建签名和模板后获取相关参数": "Obtenez les paramètres après avoir créé une signature et un modèle dans la console Alibaba Cloud SMS", + "在腾讯云短信控制台创建应用、签名和模板后获取相关参数": "Obtenez les paramètres après avoir créé une application, une signature et un modèle dans la console Tencent Cloud SMS", + "支持模板变量: {{phone}} (手机号), {{title}} (通知标题), {{content}} (通知内容)": "Variables de modèle: {{phone}} (téléphone), {{title}} (titre), {{content}} (contenu)", + "短信模板中需包含一个变量用于接收通知内容": "Le modèle SMS doit contenir une variable pour le contenu de la notification", + "配置短信服务": "Configurer le service SMS", + "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "Pour prendre en charge les notifications SMS, les utilisateurs peuvent sélectionner les notifications SMS dans les paramètres personnels", + "未配置": "Non configuré", + "敏感信息不会发送到前端显示": "Les informations sensibles ne seront pas envoyées au frontend", + "保存短信设置": "Enregistrer les paramètres SMS", "通过划转功能将奖励额度转入到您的账户余额中": "Transférez le montant de la récompense sur le solde de votre compte via la fonction de virement", "通过密码注册时需要进行邮箱验证": "La vérification par e-mail est requise lors de l'inscription via mot de passe", "通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。": "This feature allows showing different selectable group lists to users of different tiers.", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index e0dc5a723908..fbb1fc3f6fcd 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -3213,6 +3213,40 @@ "通知邮箱": "通知メールアドレス", "通知配置": "通知設定", "通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。": "グループにより異なるユーザー等級の差別化料金を実現できます。例えばVIPユーザーがより低いAPI呼び出し費用を享受できます。", + "短信通知": "SMS通知", + "短信服务商": "SMSプロバイダー", + "阿里云短信": "Alibaba Cloud SMS", + "腾讯云短信": "Tencent Cloud SMS", + "通用HTTP接口": "カスタムHTTP API", + "手机号码": "電話番号", + "请输入接收短信的手机号码": "SMSを受信する電話番号を入力してください", + "请输入手机号码": "電話番号を入力してください", + "手机号码格式不正确": "電話番号の形式が正しくありません", + "短信服务由管理员统一配置,您只需填写接收通知的手机号码": "SMSサービスは管理者が設定します。電話番号のみ入力してください", + "短信签名": "SMS署名", + "模板Code": "テンプレートコード", + "请输入模板Code": "テンプレートコードを入力してください", + "模板ID": "テンプレートID", + "请输入模板ID": "テンプレートIDを入力してください", + "接口地址": "API URL", + "请输入短信接口地址": "SMS API URLを入力してください", + "请求方法": "リクエストメソッド", + "请求模板": "リクエストテンプレート", + "请输入请求模板": "リクエストテンプレートを入力してください", + "短信接口地址必须以http://或https://开头": "SMS API URLはhttp://またはhttps://で始まる必要があります", + "阿里云短信配置说明": "Alibaba Cloud SMS設定", + "腾讯云短信配置说明": "Tencent Cloud SMS設定", + "通用HTTP接口说明": "カスタムHTTP API説明", + "请输入短信签名": "SMS署名を入力してください", + "在阿里云短信服务控制台创建签名和模板后获取相关参数": "Alibaba Cloud SMSコンソールで署名とテンプレートを作成した後、パラメータを取得してください", + "在腾讯云短信控制台创建应用、签名和模板后获取相关参数": "Tencent Cloud SMSコンソールでアプリ、署名、テンプレートを作成した後、パラメータを取得してください", + "支持模板变量: {{phone}} (手机号), {{title}} (通知标题), {{content}} (通知内容)": "テンプレート変数: {{phone}} (電話番号), {{title}} (通知タイトル), {{content}} (通知内容)", + "短信模板中需包含一个变量用于接收通知内容": "SMSテンプレートには通知内容用の変数が1つ必要です", + "配置短信服务": "SMSサービスの設定", + "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "SMS通知をサポートします。ユーザーは個人設定でSMS通知を選択できます", + "未配置": "未設定", + "敏感信息不会发送到前端显示": "機密情報はフロントエンドに表示されません", + "保存短信设置": "SMS設定を保存", "通过划转功能将奖励额度转入到您的账户余额中": "振替機能を利用して、特典をアカウントの残高に振り替えることができます", "通过密码注册时需要进行邮箱验证": "パスワードでのサインアップ時にメールアドレスの確認を必須にする", "通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。": "この機能により、ユーザーの所属グループに基づいて、異なる等級のユーザーに異なる選択リストを表示できます。", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index a35039042273..ba7715fb64b6 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3246,6 +3246,40 @@ "通知邮箱": "Email для уведомлений", "通知配置": "Конфигурация уведомлений", "通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。": "Groups enable differentiated pricing for different user tiers. For example, VIP users can enjoy lower API costs.", + "短信通知": "SMS-уведомление", + "短信服务商": "SMS-провайдер", + "阿里云短信": "Alibaba Cloud SMS", + "腾讯云短信": "Tencent Cloud SMS", + "通用HTTP接口": "Пользовательский HTTP API", + "手机号码": "Номер телефона", + "请输入接收短信的手机号码": "Введите номер телефона для получения SMS", + "请输入手机号码": "Введите номер телефона", + "手机号码格式不正确": "Неверный формат номера телефона", + "短信服务由管理员统一配置,您只需填写接收通知的手机号码": "SMS-сервис настраивается администратором. Вам нужно только ввести номер телефона", + "短信签名": "SMS-подпись", + "模板Code": "Код шаблона", + "请输入模板Code": "Введите код шаблона", + "模板ID": "ID шаблона", + "请输入模板ID": "Введите ID шаблона", + "接口地址": "URL API", + "请输入短信接口地址": "Введите URL SMS API", + "请求方法": "Метод запроса", + "请求模板": "Шаблон запроса", + "请输入请求模板": "Введите шаблон запроса", + "短信接口地址必须以http://或https://开头": "URL SMS API должен начинаться с http:// или https://", + "阿里云短信配置说明": "Настройка Alibaba Cloud SMS", + "腾讯云短信配置说明": "Настройка Tencent Cloud SMS", + "通用HTTP接口说明": "Инструкции для пользовательского HTTP API", + "请输入短信签名": "Введите SMS-подпись", + "在阿里云短信服务控制台创建签名和模板后获取相关参数": "Получите параметры после создания подписи и шаблона в консоли Alibaba Cloud SMS", + "在腾讯云短信控制台创建应用、签名和模板后获取相关参数": "Получите параметры после создания приложения, подписи и шаблона в консоли Tencent Cloud SMS", + "支持模板变量: {{phone}} (手机号), {{title}} (通知标题), {{content}} (通知内容)": "Переменные шаблона: {{phone}} (телефон), {{title}} (заголовок), {{content}} (содержание)", + "短信模板中需包含一个变量用于接收通知内容": "Шаблон SMS должен содержать переменную для содержания уведомления", + "配置短信服务": "Настройка SMS-сервиса", + "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "Для поддержки SMS-уведомлений. Пользователи могут выбрать SMS в личных настройках", + "未配置": "Не настроено", + "敏感信息不会发送到前端显示": "Конфиденциальная информация не будет отправлена на фронтенд", + "保存短信设置": "Сохранить настройки SMS", "通过划转功能将奖励额度转入到您的账户余额中": "Через функцию перевода переведите вознаграждение на баланс вашей учётной записи", "通过密码注册时需要进行邮箱验证": "При регистрации через пароль требуется проверка электронной почты", "通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。": "This feature allows showing different selectable group lists to users of different tiers.", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index dc3de89f9efc..3002868a666c 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3714,6 +3714,40 @@ "通知类型 (quota_exceed: 额度预警)": "Loại thông báo (quota_exceed: cảnh báo hạn ngạch)", "通知邮箱": "Email thông báo", "通知配置": "Cấu hình thông báo", + "短信通知": "Thông báo SMS", + "短信服务商": "Nhà cung cấp SMS", + "阿里云短信": "Alibaba Cloud SMS", + "腾讯云短信": "Tencent Cloud SMS", + "通用HTTP接口": "API HTTP tùy chỉnh", + "手机号码": "Số điện thoại", + "请输入接收短信的手机号码": "Vui lòng nhập số điện thoại để nhận SMS", + "请输入手机号码": "Vui lòng nhập số điện thoại", + "手机号码格式不正确": "Định dạng số điện thoại không hợp lệ", + "短信服务由管理员统一配置,您只需填写接收通知的手机号码": "Dịch vụ SMS được cấu hình bởi quản trị viên. Bạn chỉ cần nhập số điện thoại", + "短信签名": "Chữ ký SMS", + "模板Code": "Mã mẫu", + "请输入模板Code": "Vui lòng nhập mã mẫu", + "模板ID": "ID mẫu", + "请输入模板ID": "Vui lòng nhập ID mẫu", + "接口地址": "URL API", + "请输入短信接口地址": "Vui lòng nhập URL API SMS", + "请求方法": "Phương thức yêu cầu", + "请求模板": "Mẫu yêu cầu", + "请输入请求模板": "Vui lòng nhập mẫu yêu cầu", + "短信接口地址必须以http://或https://开头": "URL API SMS phải bắt đầu bằng http:// hoặc https://", + "阿里云短信配置说明": "Cấu hình Alibaba Cloud SMS", + "腾讯云短信配置说明": "Cấu hình Tencent Cloud SMS", + "通用HTTP接口说明": "Hướng dẫn API HTTP tùy chỉnh", + "请输入短信签名": "Vui lòng nhập chữ ký SMS", + "在阿里云短信服务控制台创建签名和模板后获取相关参数": "Lấy các tham số sau khi tạo chữ ký và mẫu trong bảng điều khiển Alibaba Cloud SMS", + "在腾讯云短信控制台创建应用、签名和模板后获取相关参数": "Lấy các tham số sau khi tạo ứng dụng, chữ ký và mẫu trong bảng điều khiển Tencent Cloud SMS", + "支持模板变量: {{phone}} (手机号), {{title}} (通知标题), {{content}} (通知内容)": "Biến mẫu: {{phone}} (số điện thoại), {{title}} (tiêu đề), {{content}} (nội dung)", + "短信模板中需包含一个变量用于接收通知内容": "Mẫu SMS phải chứa một biến cho nội dung thông báo", + "配置短信服务": "Cấu hình dịch vụ SMS", + "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "Hỗ trợ thông báo SMS. Người dùng có thể chọn thông báo SMS trong cài đặt cá nhân", + "未配置": "Chưa cấu hình", + "敏感信息不会发送到前端显示": "Thông tin nhạy cảm sẽ không được gửi đến frontend", + "保存短信设置": "Lưu cài đặt SMS", "通过": "Thông qua", "通过 GitHub 登录": "Đăng nhập qua GitHub", "通过 Google 登录": "Đăng nhập qua Google", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index a64e50d0320a..1de4e0775587 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2500,6 +2500,40 @@ "通知类型 (quota_exceed: 额度预警)": "通知类型 (quota_exceed: 额度预警)", "通知邮箱": "通知邮箱", "通知配置": "通知配置", + "短信通知": "短信通知", + "短信服务商": "短信服务商", + "阿里云短信": "阿里云短信", + "腾讯云短信": "腾讯云短信", + "通用HTTP接口": "通用HTTP接口", + "手机号码": "手机号码", + "请输入接收短信的手机号码": "请输入接收短信的手机号码", + "请输入手机号码": "请输入手机号码", + "手机号码格式不正确": "手机号码格式不正确", + "短信服务由管理员统一配置,您只需填写接收通知的手机号码": "短信服务由管理员统一配置,您只需填写接收通知的手机号码", + "短信签名": "短信签名", + "模板Code": "模板Code", + "请输入模板Code": "请输入模板Code", + "模板ID": "模板ID", + "请输入模板ID": "请输入模板ID", + "接口地址": "接口地址", + "请输入短信接口地址": "请输入短信接口地址", + "请求方法": "请求方法", + "请求模板": "请求模板", + "请输入请求模板": "请输入请求模板", + "短信接口地址必须以http://或https://开头": "短信接口地址必须以http://或https://开头", + "阿里云短信配置说明": "阿里云短信配置说明", + "腾讯云短信配置说明": "腾讯云短信配置说明", + "通用HTTP接口说明": "通用HTTP接口说明", + "请输入短信签名": "请输入短信签名", + "在阿里云短信服务控制台创建签名和模板后获取相关参数": "在阿里云短信服务控制台创建签名和模板后获取相关参数", + "在腾讯云短信控制台创建应用、签名和模板后获取相关参数": "在腾讯云短信控制台创建应用、签名和模板后获取相关参数", + "支持模板变量: {{phone}} (手机号), {{title}} (通知标题), {{content}} (通知内容)": "支持模板变量: {{phone}} (手机号), {{title}} (通知标题), {{content}} (通知内容)", + "短信模板中需包含一个变量用于接收通知内容": "短信模板中需包含一个变量用于接收通知内容", + "配置短信服务": "配置短信服务", + "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "用以支持短信通知推送,用户可在个人设置中选择短信通知方式", + "未配置": "未配置", + "敏感信息不会发送到前端显示": "敏感信息不会发送到前端显示", + "保存短信设置": "保存短信设置", "通过划转功能将奖励额度转入到您的账户余额中": "通过划转功能将奖励额度转入到您的账户余额中", "通过密码注册时需要进行邮箱验证": "通过密码注册时需要进行邮箱验证", "通道 ${name} 余额更新成功!": "通道 ${name} 余额更新成功!", From d7d35e98f3e43f8cc443d0248989e74ca34010dc Mon Sep 17 00:00:00 2001 From: chenjianyong Date: Wed, 15 Apr 2026 11:08:14 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9SMS=E7=9F=AD?= =?UTF-8?q?=E4=BF=A1=E4=BD=BF=E7=94=A8=E6=A8=A1=E6=9D=BF=E5=8F=98=E9=87=8F?= =?UTF-8?q?=EF=BC=9Avalues[0]=3D=E5=BD=93=E5=89=8D=E4=BD=99=E9=A2=9D,=20va?= =?UTF-8?q?lues[1]=3D=E5=91=8A=E8=AD=A6=E9=98=88=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- service/quota.go | 7 ++++--- service/sms_notify.go | 49 ++++++++++++++++++++++++++++++------------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/service/quota.go b/service/quota.go index 958810e3c6df..8a92352976bd 100644 --- a/service/quota.go +++ b/service/quota.go @@ -442,9 +442,10 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon content = "{{value}},剩余额度:{{value}},请及时充值" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} } else if notifyType == dto.NotifyTypeSms { - // SMS短信使用简短文本,不支持HTML - content = "{{value}},剩余额度:{{value}},请及时充值" - values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} + // SMS短信使用模板变量:values[0]=当前余额, values[1]=告警阈值 + content = "" + remainQuota := relayInfo.UserQuota - consumeQuota + values = []interface{}{logger.FormatQuota(remainQuota), logger.FormatQuota(threshold)} } else if notifyType == dto.NotifyTypeGotify { content = "{{value}},当前剩余额度为 {{value}},请及时充值。" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} diff --git a/service/sms_notify.go b/service/sms_notify.go index aef18d247294..795a3a2fef2c 100644 --- a/service/sms_notify.go +++ b/service/sms_notify.go @@ -32,7 +32,13 @@ func sendSmsNotify(phoneNumber string, data dto.Notify) error { return fmt.Errorf("SMS provider is not configured, please contact the administrator") } - // 处理占位符 + // 提取模板变量值列表(用于模板类短信服务商) + var templateValues []string + for _, v := range data.Values { + templateValues = append(templateValues, fmt.Sprintf("%v", v)) + } + + // 生成纯文本 content(用于自定义HTTP接口) content := data.Content for _, value := range data.Values { content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1) @@ -46,7 +52,7 @@ func sendSmsNotify(phoneNumber string, data dto.Notify) error { common.SMSAliyunSignName, common.SMSAliyunTemplateCode, phoneNumber, - content, + templateValues, ) case "sendcloud": return sendSendCloudSms( @@ -54,7 +60,7 @@ func sendSmsNotify(phoneNumber string, data dto.Notify) error { common.SMSSendCloudSmsKey, common.SMSSendCloudTemplateId, phoneNumber, - content, + templateValues, ) case "tencent": return sendTencentSms( @@ -64,7 +70,7 @@ func sendSmsNotify(phoneNumber string, data dto.Notify) error { common.SMSTencentSignName, common.SMSTencentTemplateId, phoneNumber, - content, + templateValues, ) case "custom": return sendCustomSms( @@ -90,10 +96,17 @@ func aliyunPercentEncode(s string) string { } // sendAliyunSms 通过阿里云短信服务发送短信 (POP v1 签名, HMAC-SHA1) -func sendAliyunSms(accessKeyId, accessKeySecret, signName, templateCode, phoneNumber, content string) error { - templateParam, _ := json.Marshal(map[string]string{ - "content": content, - }) +// templateValues: 模板变量值列表,按顺序映射为 user_money, balance_warn +func sendAliyunSms(accessKeyId, accessKeySecret, signName, templateCode, phoneNumber string, templateValues []string) error { + // 构建模板参数 JSON + templateParamMap := map[string]string{} + paramNames := []string{"user_money", "balance_warn"} + for i, name := range paramNames { + if i < len(templateValues) { + templateParamMap[name] = templateValues[i] + } + } + templateParam, _ := json.Marshal(templateParamMap) params := map[string]string{ "AccessKeyId": accessKeyId, @@ -158,7 +171,7 @@ func sendAliyunSms(accessKeyId, accessKeySecret, signName, templateCode, phoneNu // sendSendCloudSms 通过 SendCloud 短信服务发送短信 // 使用 MD5 签名: signature = MD5(smsKey + "&" + 排序参数串 + "&" + smsKey) -func sendSendCloudSms(smsUser, smsKey, templateId, phoneNumber, content string) error { +func sendSendCloudSms(smsUser, smsKey, templateId, phoneNumber string, templateValues []string) error { params := map[string]string{ "smsUser": smsUser, "templateId": templateId, @@ -166,10 +179,15 @@ func sendSendCloudSms(smsUser, smsKey, templateId, phoneNumber, content string) "msgType": "0", } - // vars 传递通知内容 - varsJSON, _ := json.Marshal(map[string]string{ - "content": content, - }) + // vars 传递模板变量 + varsMap := map[string]string{} + paramNames := []string{"user_money", "balance_warn"} + for i, name := range paramNames { + if i < len(templateValues) { + varsMap[name] = templateValues[i] + } + } + varsJSON, _ := json.Marshal(varsMap) params["vars"] = string(varsJSON) // 按 key 排序生成签名字符串 @@ -238,7 +256,8 @@ func sha256Hex(data string) string { } // sendTencentSms 通过腾讯云短信服务发送短信 (TC3-HMAC-SHA256 签名) -func sendTencentSms(secretId, secretKey, smsSdkAppId, signName, templateId, phoneNumber, content string) error { +// templateValues: 模板变量值列表,按顺序对应模板中的 {1}, {2}, ... +func sendTencentSms(secretId, secretKey, smsSdkAppId, signName, templateId, phoneNumber string, templateValues []string) error { host := "sms.tencentcloudapi.com" service := "sms" action := "SendSms" @@ -262,7 +281,7 @@ func sendTencentSms(secretId, secretKey, smsSdkAppId, signName, templateId, phon SignName: signName, TemplateId: templateId, PhoneNumberSet: []string{phoneNumber}, - TemplateParamSet: []string{content}, + TemplateParamSet: templateValues, } payloadBytes, err := json.Marshal(payload) From 2e7a7323ce75ad232f768d805d266c4a7d706b10 Mon Sep 17 00:00:00 2001 From: chenjianyong Date: Tue, 28 Apr 2026 11:22:54 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E6=A0=B9=E6=8D=AEPR=20#4306?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=84=8F=E8=A7=81=E4=BF=AE=E5=A4=8DSMS?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补全SMSProvider注释中遗漏的sendcloud服务商 - 调整敏感字段过滤:移除非敏感的SMSCustomUrl/SMSCustomTemplate,新增SMSTencentSmsSdkAppId - SMS验证错误响应改用i18n国际化(ApiErrorI18n),新增3组翻译(en/zh-CN/zh-TW) - SMSCustomMethod赋值前增加HTTP方法校验,空值保留默认POST - sms_notify.go中encoding/json替换为common.Marshal/Unmarshal(遵循Rule 1) - User-Agent从OneAPI改为NewAPI匹配项目品牌 - 移除6个locale JSON文件中重复的i18n key - 订阅额度通知(checkAndSendSubscriptionQuotaNotify)补充SMS分支 --- common/constants.go | 2 +- controller/option.go | 5 ++--- controller/user.go | 15 +++------------ i18n/keys.go | 3 +++ i18n/locales/en.yaml | 3 +++ i18n/locales/zh-CN.yaml | 3 +++ i18n/locales/zh-TW.yaml | 3 +++ model/option.go | 10 +++++++++- service/quota.go | 4 ++++ service/sms_notify.go | 21 ++++++++++----------- web/src/i18n/locales/en.json | 1 - web/src/i18n/locales/fr.json | 1 - web/src/i18n/locales/ja.json | 1 - web/src/i18n/locales/ru.json | 1 - web/src/i18n/locales/vi.json | 1 - web/src/i18n/locales/zh-CN.json | 1 - 16 files changed, 41 insertions(+), 34 deletions(-) diff --git a/common/constants.go b/common/constants.go index e2582910d90d..557107da4172 100644 --- a/common/constants.go +++ b/common/constants.go @@ -86,7 +86,7 @@ var SMTPFrom = "" var SMTPToken = "" // SMS 短信通知系统级配置 -var SMSProvider = "" // 短信服务商: aliyun, tencent, custom +var SMSProvider = "" // 短信服务商: aliyun, sendcloud, tencent, custom var SMSAliyunAccessKeyId = "" // 阿里云 AccessKeyId var SMSAliyunAccessKeySecret = "" // 阿里云 AccessKeySecret var SMSAliyunSignName = "" // 阿里云短信签名 diff --git a/controller/option.go b/controller/option.go index 7ce4f2929820..6ed6e740e3df 100644 --- a/controller/option.go +++ b/controller/option.go @@ -71,12 +71,11 @@ func GetOptions(c *gin.Context) { strings.HasSuffix(k, "Key") || strings.HasSuffix(k, "secret") || strings.HasSuffix(k, "api_key") || - // SMS 敏感凭证字段 + // SMS 敏感凭证字段(ID类也隐藏,防止凭证泄露) k == "SMSAliyunAccessKeyId" || k == "SMSSendCloudSmsUser" || k == "SMSTencentSecretId" || - k == "SMSCustomUrl" || - k == "SMSCustomTemplate" { + k == "SMSTencentSmsSdkAppId" { continue } options = append(options, &model.Option{ diff --git a/controller/user.go b/controller/user.go index dd303ac0b02d..053257aecf91 100644 --- a/controller/user.go +++ b/controller/user.go @@ -1188,26 +1188,17 @@ func UpdateUserSetting(c *gin.Context) { // 如果是SMS类型,验证手机号和系统SMS配置 if req.QuotaWarningType == dto.NotifyTypeSms { if req.SmsPhoneNumber == "" { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "手机号码不能为空", - }) + common.ApiErrorI18n(c, i18n.MsgSettingSmsPhoneEmpty) return } // 验证手机号格式:允许国际号码格式,7-15位数字,可选+前缀 phoneRegex := regexp.MustCompile(`^\+?[0-9]{7,15}$`) if !phoneRegex.MatchString(req.SmsPhoneNumber) { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "手机号码格式不正确", - }) + common.ApiErrorI18n(c, i18n.MsgSettingSmsPhoneInvalid) return } if common.SMSProvider == "" { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": "系统未配置短信服务,请联系管理员", - }) + common.ApiErrorI18n(c, i18n.MsgSettingSmsNotConfigured) return } } diff --git a/i18n/keys.go b/i18n/keys.go index 5123fa9d9bab..1cb100a46947 100644 --- a/i18n/keys.go +++ b/i18n/keys.go @@ -246,6 +246,9 @@ const ( MsgSettingGotifyTokenEmpty = "setting.gotify_token_empty" MsgSettingGotifyUrlInvalid = "setting.gotify_url_invalid" MsgSettingUrlMustHttp = "setting.url_must_http" + MsgSettingSmsPhoneEmpty = "setting.sms_phone_empty" + MsgSettingSmsPhoneInvalid = "setting.sms_phone_invalid" + MsgSettingSmsNotConfigured = "setting.sms_not_configured" MsgSettingSaved = "setting.saved" ) diff --git a/i18n/locales/en.yaml b/i18n/locales/en.yaml index e9fc80f248fb..35e87542ff46 100644 --- a/i18n/locales/en.yaml +++ b/i18n/locales/en.yaml @@ -208,6 +208,9 @@ setting.gotify_url_empty: "Gotify server URL cannot be empty" setting.gotify_token_empty: "Gotify token cannot be empty" setting.gotify_url_invalid: "Invalid Gotify server URL" setting.url_must_http: "URL must start with http:// or https://" +setting.sms_phone_empty: "Phone number cannot be empty" +setting.sms_phone_invalid: "Invalid phone number format" +setting.sms_not_configured: "SMS service is not configured, please contact the administrator" setting.saved: "Settings updated" # Deployment messages (io.net) diff --git a/i18n/locales/zh-CN.yaml b/i18n/locales/zh-CN.yaml index 58ba5007820d..dd8f1b6390c7 100644 --- a/i18n/locales/zh-CN.yaml +++ b/i18n/locales/zh-CN.yaml @@ -209,6 +209,9 @@ setting.gotify_url_empty: "Gotify服务器地址不能为空" setting.gotify_token_empty: "Gotify令牌不能为空" setting.gotify_url_invalid: "无效的Gotify服务器地址" setting.url_must_http: "URL必须以http://或https://开头" +setting.sms_phone_empty: "手机号码不能为空" +setting.sms_phone_invalid: "手机号码格式不正确" +setting.sms_not_configured: "系统未配置短信服务,请联系管理员" setting.saved: "设置已更新" # Deployment messages (io.net) diff --git a/i18n/locales/zh-TW.yaml b/i18n/locales/zh-TW.yaml index 5a24bff7762d..5c1102ad2eb7 100644 --- a/i18n/locales/zh-TW.yaml +++ b/i18n/locales/zh-TW.yaml @@ -209,6 +209,9 @@ setting.gotify_url_empty: "Gotify伺服器位址不能為空" setting.gotify_token_empty: "Gotify令牌不能為空" setting.gotify_url_invalid: "無效的Gotify伺服器位址" setting.url_must_http: "URL必須以http://或https://開頭" +setting.sms_phone_empty: "手機號碼不能為空" +setting.sms_phone_invalid: "手機號碼格式不正確" +setting.sms_not_configured: "系統未配置簡訊服務,請聯繫管理員" setting.saved: "設定已更新" # Deployment messages (io.net) diff --git a/model/option.go b/model/option.go index 783d970df735..94404de8c272 100644 --- a/model/option.go +++ b/model/option.go @@ -378,7 +378,15 @@ func updateOptionMap(key string, value string) (err error) { case "SMSCustomUrl": common.SMSCustomUrl = value case "SMSCustomMethod": - common.SMSCustomMethod = value + upperMethod := strings.ToUpper(value) + switch upperMethod { + case "GET", "POST", "PUT", "DELETE", "PATCH": + common.SMSCustomMethod = upperMethod + case "": + // 空值保留默认的 POST + default: + common.SMSCustomMethod = "POST" + } case "SMSCustomTemplate": common.SMSCustomTemplate = value case "ServerAddress": diff --git a/service/quota.go b/service/quota.go index 8a92352976bd..91e8b67c65cd 100644 --- a/service/quota.go +++ b/service/quota.go @@ -497,6 +497,10 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) { if notifyType == dto.NotifyTypeBark { content = "{{value}},剩余额度:{{value}},请及时充值" values = []interface{}{prompt, logger.FormatQuota(int(remaining))} + } else if notifyType == dto.NotifyTypeSms { + // SMS短信使用模板变量:values[0]=当前余额, values[1]=告警阈值 + content = "" + values = []interface{}{logger.FormatQuota(int(remaining)), logger.FormatQuota(threshold)} } else if notifyType == dto.NotifyTypeGotify { content = "{{value}},当前剩余额度为 {{value}},请及时充值。" values = []interface{}{prompt, logger.FormatQuota(int(remaining))} diff --git a/service/sms_notify.go b/service/sms_notify.go index 795a3a2fef2c..9c5af6a4d004 100644 --- a/service/sms_notify.go +++ b/service/sms_notify.go @@ -8,7 +8,6 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" - "encoding/json" "fmt" "io" "net/http" @@ -106,7 +105,7 @@ func sendAliyunSms(accessKeyId, accessKeySecret, signName, templateCode, phoneNu templateParamMap[name] = templateValues[i] } } - templateParam, _ := json.Marshal(templateParamMap) + templateParam, _ := common.Marshal(templateParamMap) params := map[string]string{ "AccessKeyId": accessKeyId, @@ -158,7 +157,7 @@ func sendAliyunSms(accessKeyId, accessKeySecret, signName, templateCode, phoneNu Code string `json:"Code"` Message string `json:"Message"` } - if err := json.Unmarshal(body, &result); err != nil { + if err := common.Unmarshal(body, &result); err != nil { return fmt.Errorf("failed to parse aliyun sms response: %v", err) } @@ -187,7 +186,7 @@ func sendSendCloudSms(smsUser, smsKey, templateId, phoneNumber string, templateV varsMap[name] = templateValues[i] } } - varsJSON, _ := json.Marshal(varsMap) + varsJSON, _ := common.Marshal(varsMap) params["vars"] = string(varsJSON) // 按 key 排序生成签名字符串 @@ -232,7 +231,7 @@ func sendSendCloudSms(smsUser, smsKey, templateId, phoneNumber string, templateV StatusCode int `json:"statusCode"` Message string `json:"message"` } - if err := json.Unmarshal(body, &result); err != nil { + if err := common.Unmarshal(body, &result); err != nil { return fmt.Errorf("failed to parse sendcloud sms response: %v", err) } @@ -284,7 +283,7 @@ func sendTencentSms(secretId, secretKey, smsSdkAppId, signName, templateId, phon TemplateParamSet: templateValues, } - payloadBytes, err := json.Marshal(payload) + payloadBytes, err := common.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal tencent sms payload: %v", err) } @@ -346,7 +345,7 @@ func sendTencentSms(secretId, secretKey, smsSdkAppId, signName, templateId, phon } `json:"Error"` } `json:"Response"` } - if err := json.Unmarshal(body, &result); err != nil { + if err := common.Unmarshal(body, &result); err != nil { return fmt.Errorf("failed to parse tencent sms response: %v", err) } @@ -363,8 +362,8 @@ func sendTencentSms(secretId, secretKey, smsSdkAppId, signName, templateId, phon // jsonEscapeString 对字符串进行 JSON 转义,防止模板注入 func jsonEscapeString(s string) string { - b, _ := json.Marshal(s) - // json.Marshal 返回带引号的字符串,去掉首尾引号 + b, _ := common.Marshal(s) + // common.Marshal 返回带引号的字符串,去掉首尾引号 return string(b[1 : len(b)-1]) } @@ -397,7 +396,7 @@ func sendCustomSms(smsUrl, method, template, phoneNumber, title, content string) Method: method, Headers: map[string]string{ "Content-Type": "application/json; charset=utf-8", - "User-Agent": "OneAPI-SMS-Notify/1.0", + "User-Agent": "NewAPI-SMS-Notify/1.0", }, } if method == "POST" { @@ -429,7 +428,7 @@ func sendCustomSms(smsUrl, method, template, phoneNumber, title, content string) } req.Header.Set("Content-Type", "application/json; charset=utf-8") - req.Header.Set("User-Agent", "OneAPI-SMS-Notify/1.0") + req.Header.Set("User-Agent", "NewAPI-SMS-Notify/1.0") client := GetHttpClient() resp, err = client.Do(req) diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 5736431fa2b1..f3cd010b3eaf 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3298,7 +3298,6 @@ "配置短信服务": "Configure SMS Service", "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "To support SMS notification push, users can select SMS notification in personal settings", "未配置": "Not configured", - "敏感信息不会发送到前端显示": "Sensitive information will not be sent to the frontend", "保存短信设置": "Save SMS Settings", "通过划转功能将奖励额度转入到您的账户余额中": "Transfer the reward amount to your account balance through the transfer function", "通过密码注册时需要进行邮箱验证": "Email verification is required when registering via password", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index df5abc628a32..88d0c9f64235 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -3264,7 +3264,6 @@ "配置短信服务": "Configurer le service SMS", "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "Pour prendre en charge les notifications SMS, les utilisateurs peuvent sélectionner les notifications SMS dans les paramètres personnels", "未配置": "Non configuré", - "敏感信息不会发送到前端显示": "Les informations sensibles ne seront pas envoyées au frontend", "保存短信设置": "Enregistrer les paramètres SMS", "通过划转功能将奖励额度转入到您的账户余额中": "Transférez le montant de la récompense sur le solde de votre compte via la fonction de virement", "通过密码注册时需要进行邮箱验证": "La vérification par e-mail est requise lors de l'inscription via mot de passe", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index fbb1fc3f6fcd..86d7001deeef 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -3245,7 +3245,6 @@ "配置短信服务": "SMSサービスの設定", "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "SMS通知をサポートします。ユーザーは個人設定でSMS通知を選択できます", "未配置": "未設定", - "敏感信息不会发送到前端显示": "機密情報はフロントエンドに表示されません", "保存短信设置": "SMS設定を保存", "通过划转功能将奖励额度转入到您的账户余额中": "振替機能を利用して、特典をアカウントの残高に振り替えることができます", "通过密码注册时需要进行邮箱验证": "パスワードでのサインアップ時にメールアドレスの確認を必須にする", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index ba7715fb64b6..f0dd1763bc66 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3278,7 +3278,6 @@ "配置短信服务": "Настройка SMS-сервиса", "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "Для поддержки SMS-уведомлений. Пользователи могут выбрать SMS в личных настройках", "未配置": "Не настроено", - "敏感信息不会发送到前端显示": "Конфиденциальная информация не будет отправлена на фронтенд", "保存短信设置": "Сохранить настройки SMS", "通过划转功能将奖励额度转入到您的账户余额中": "Через функцию перевода переведите вознаграждение на баланс вашей учётной записи", "通过密码注册时需要进行邮箱验证": "При регистрации через пароль требуется проверка электронной почты", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 3002868a666c..1b8c52e88e55 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -3746,7 +3746,6 @@ "配置短信服务": "Cấu hình dịch vụ SMS", "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "Hỗ trợ thông báo SMS. Người dùng có thể chọn thông báo SMS trong cài đặt cá nhân", "未配置": "Chưa cấu hình", - "敏感信息不会发送到前端显示": "Thông tin nhạy cảm sẽ không được gửi đến frontend", "保存短信设置": "Lưu cài đặt SMS", "通过": "Thông qua", "通过 GitHub 登录": "Đăng nhập qua GitHub", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 1de4e0775587..9ca40b9b2889 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -2532,7 +2532,6 @@ "配置短信服务": "配置短信服务", "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "用以支持短信通知推送,用户可在个人设置中选择短信通知方式", "未配置": "未配置", - "敏感信息不会发送到前端显示": "敏感信息不会发送到前端显示", "保存短信设置": "保存短信设置", "通过划转功能将奖励额度转入到您的账户余额中": "通过划转功能将奖励额度转入到您的账户余额中", "通过密码注册时需要进行邮箱验证": "通过密码注册时需要进行邮箱验证", From 5ccf67cf2dc56891b49484f7030908c8d3f5d3da Mon Sep 17 00:00:00 2001 From: chenjianyong Date: Wed, 29 Apr 2026 17:56:01 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20=E6=A0=B9=E6=8D=AEPR=20#4306?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E8=BD=AE=E8=AF=84=E5=AE=A1=E6=84=8F=E8=A7=81?= =?UTF-8?q?=E4=BF=AE=E5=A4=8DSMS=E9=80=9A=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 腾讯云SMS:空SendStatusSet时返回错误而非静默成功 - 自定义SMS:HTTP URL不走Worker(Worker会拒绝非HTTPS),回退到直连 - 额度通知SMS:content保留可读文本供自定义HTTP接口的{{content}}占位符使用 - Classic前端submitSMS:区分凭证字段和配置字段,允许清空非凭证项 --- service/quota.go | 6 +++-- service/sms_notify.go | 9 +++++-- .../src/components/settings/SystemSetting.jsx | 27 +++++++++++-------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/service/quota.go b/service/quota.go index 63b7610713e5..1d90d8150fd6 100644 --- a/service/quota.go +++ b/service/quota.go @@ -480,8 +480,9 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} } else if notifyType == dto.NotifyTypeSms { // SMS短信使用模板变量:values[0]=当前余额, values[1]=告警阈值 - content = "" + // content 保留可读文本供自定义HTTP接口的 {{content}} 占位符使用 remainQuota := relayInfo.UserQuota - consumeQuota + content = fmt.Sprintf("当前剩余额度 %s,告警阈值 %s,请及时充值", logger.FormatQuota(remainQuota), logger.FormatQuota(threshold)) values = []interface{}{logger.FormatQuota(remainQuota), logger.FormatQuota(threshold)} } else if notifyType == dto.NotifyTypeGotify { content = "{{value}},当前剩余额度为 {{value}},请及时充值。" @@ -536,7 +537,8 @@ func checkAndSendSubscriptionQuotaNotify(relayInfo *relaycommon.RelayInfo) { values = []interface{}{prompt, logger.FormatQuota(int(remaining))} } else if notifyType == dto.NotifyTypeSms { // SMS短信使用模板变量:values[0]=当前余额, values[1]=告警阈值 - content = "" + // content 保留可读文本供自定义HTTP接口的 {{content}} 占位符使用 + content = fmt.Sprintf("当前剩余额度 %s,告警阈值 %s,请及时充值", logger.FormatQuota(int(remaining)), logger.FormatQuota(threshold)) values = []interface{}{logger.FormatQuota(int(remaining)), logger.FormatQuota(threshold)} } else if notifyType == dto.NotifyTypeGotify { content = "{{value}},当前剩余额度为 {{value}},请及时充值。" diff --git a/service/sms_notify.go b/service/sms_notify.go index 9c5af6a4d004..f966f71e2b17 100644 --- a/service/sms_notify.go +++ b/service/sms_notify.go @@ -353,7 +353,11 @@ func sendTencentSms(secretId, secretKey, smsSdkAppId, signName, templateId, phon return fmt.Errorf("tencent sms failed: %s - %s", result.Response.Error.Code, result.Response.Error.Message) } - if len(result.Response.SendStatusSet) > 0 && result.Response.SendStatusSet[0].Code != "Ok" { + if len(result.Response.SendStatusSet) == 0 { + return fmt.Errorf("tencent sms failed: empty SendStatusSet, no delivery result returned") + } + + if result.Response.SendStatusSet[0].Code != "Ok" { return fmt.Errorf("tencent sms failed: %s - %s", result.Response.SendStatusSet[0].Code, result.Response.SendStatusSet[0].Message) } @@ -389,7 +393,8 @@ func sendCustomSms(smsUrl, method, template, phoneNumber, title, content string) var resp *http.Response var err error - if system_setting.EnableWorker() { + useWorker := system_setting.EnableWorker() && strings.HasPrefix(strings.ToLower(finalURL), "https://") + if useWorker { workerReq := &WorkerRequest{ URL: finalURL, Key: system_setting.WorkerValidKey, diff --git a/web/classic/src/components/settings/SystemSetting.jsx b/web/classic/src/components/settings/SystemSetting.jsx index 2e169b64ebce..88a8344a61e9 100644 --- a/web/classic/src/components/settings/SystemSetting.jsx +++ b/web/classic/src/components/settings/SystemSetting.jsx @@ -367,6 +367,17 @@ const SystemSetting = () => { const submitSMS = async () => { const options = []; + // 凭证字段:后端 GetOptions 会隐藏这些字段,前端拿到的是空值 + // 只有用户填写了新值才提交,避免用空串覆盖已有凭证 + const secretKeys = new Set([ + 'SMSAliyunAccessKeyId', + 'SMSAliyunAccessKeySecret', + 'SMSSendCloudSmsUser', + 'SMSSendCloudSmsKey', + 'SMSTencentSecretId', + 'SMSTencentSecretKey', + 'SMSTencentSmsSdkAppId', + ]); const smsKeys = [ 'SMSProvider', 'SMSAliyunAccessKeyId', @@ -386,20 +397,14 @@ const SystemSetting = () => { 'SMSCustomTemplate', ]; for (const key of smsKeys) { - if ( - originInputs[key] !== inputs[key] && - inputs[key] !== '' - ) { + if (originInputs[key] !== inputs[key]) { + // 凭证字段仅在有新值时提交,非凭证字段允许清空 + if (secretKeys.has(key) && inputs[key] === '') { + continue; + } options.push({ key, value: inputs[key] }); } } - // 允许清空 SMSProvider - if (originInputs['SMSProvider'] !== inputs['SMSProvider']) { - const exists = options.find((o) => o.key === 'SMSProvider'); - if (!exists) { - options.push({ key: 'SMSProvider', value: inputs['SMSProvider'] || '' }); - } - } if (options.length > 0) { await updateOptions(options); }