diff --git a/common/constants.go b/common/constants.go index c4d2511ef357..1cb4d56e343d 100644 --- a/common/constants.go +++ b/common/constants.go @@ -104,6 +104,24 @@ var SMTPAccount = "" var SMTPFrom = "" var SMTPToken = "" +// SMS 短信通知系统级配置 +var SMSProvider = "" // 短信服务商: aliyun, sendcloud, 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 d42db346bdfd..10496d1b5f75 100644 --- a/controller/option.go +++ b/controller/option.go @@ -79,7 +79,12 @@ 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 敏感凭证字段(ID类也隐藏,防止凭证泄露) + k == "SMSAliyunAccessKeyId" || + k == "SMSSendCloudSmsUser" || + k == "SMSTencentSecretId" || + k == "SMSTencentSmsSdkAppId" if isSensitiveKey && !isVisiblePublicKeyOption(k) { continue } diff --git a/controller/user.go b/controller/user.go index b5722668632d..457f69b08b81 100644 --- a/controller/user.go +++ b/controller/user.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "regexp" "strconv" "strings" "sync" @@ -1120,6 +1121,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"` @@ -1133,7 +1135,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 } @@ -1206,6 +1208,24 @@ func UpdateUserSetting(c *gin.Context) { } } + // 如果是SMS类型,验证手机号和系统SMS配置 + if req.QuotaWarningType == dto.NotifyTypeSms { + if req.SmsPhoneNumber == "" { + common.ApiErrorI18n(c, i18n.MsgSettingSmsPhoneEmpty) + return + } + // 验证手机号格式:允许国际号码格式,7-15位数字,可选+前缀 + phoneRegex := regexp.MustCompile(`^\+?[0-9]{7,15}$`) + if !phoneRegex.MatchString(req.SmsPhoneNumber) { + common.ApiErrorI18n(c, i18n.MsgSettingSmsPhoneInvalid) + return + } + if common.SMSProvider == "" { + common.ApiErrorI18n(c, i18n.MsgSettingSmsNotConfigured) + return + } + } + userId := c.GetInt("id") user, err := model.GetUserById(userId, true) if err != nil { @@ -1257,6 +1277,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/i18n/keys.go b/i18n/keys.go index 8e551d2ea657..1bc98ad250d5 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 064abc708420..7a4a80b7ebad 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 b05ca743f33a..968aa01bc883 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 6ae222b711eb..fff9d6f5086b 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 e0a3048d34f2..684ede14d10d 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"] = "" @@ -345,6 +361,46 @@ 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": + 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": system_setting.ServerAddress = value case "WorkerUrl": diff --git a/service/quota.go b/service/quota.go index 398bd1b792d1..1d90d8150fd6 100644 --- a/service/quota.go +++ b/service/quota.go @@ -478,6 +478,12 @@ 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短信使用模板变量:values[0]=当前余额, values[1]=告警阈值 + // 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}},请及时充值。" values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)} @@ -529,6 +535,11 @@ 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 保留可读文本供自定义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}},请及时充值。" values = []interface{}{prompt, logger.FormatQuota(int(remaining))} diff --git a/service/sms_notify.go b/service/sms_notify.go new file mode 100644 index 000000000000..f966f71e2b17 --- /dev/null +++ b/service/sms_notify.go @@ -0,0 +1,451 @@ +package service + +import ( + "bytes" + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "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") + } + + // 提取模板变量值列表(用于模板类短信服务商) + 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) + } + + switch provider { + case "aliyun": + return sendAliyunSms( + common.SMSAliyunAccessKeyId, + common.SMSAliyunAccessKeySecret, + common.SMSAliyunSignName, + common.SMSAliyunTemplateCode, + phoneNumber, + templateValues, + ) + case "sendcloud": + return sendSendCloudSms( + common.SMSSendCloudSmsUser, + common.SMSSendCloudSmsKey, + common.SMSSendCloudTemplateId, + phoneNumber, + templateValues, + ) + case "tencent": + return sendTencentSms( + common.SMSTencentSecretId, + common.SMSTencentSecretKey, + common.SMSTencentSmsSdkAppId, + common.SMSTencentSignName, + common.SMSTencentTemplateId, + phoneNumber, + templateValues, + ) + 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) +// 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, _ := common.Marshal(templateParamMap) + + 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 := common.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 string, templateValues []string) error { + params := map[string]string{ + "smsUser": smsUser, + "templateId": templateId, + "phone": phoneNumber, + "msgType": "0", + } + + // 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, _ := common.Marshal(varsMap) + 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 := common.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 签名) +// templateValues: 模板变量值列表,按顺序对应模板中的 {1}, {2}, ... +func sendTencentSms(secretId, secretKey, smsSdkAppId, signName, templateId, phoneNumber string, templateValues []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: templateValues, + } + + payloadBytes, err := common.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 := common.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 { + 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) + } + + return nil +} + +// jsonEscapeString 对字符串进行 JSON 转义,防止模板注入 +func jsonEscapeString(s string) string { + b, _ := common.Marshal(s) + // common.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 + + useWorker := system_setting.EnableWorker() && strings.HasPrefix(strings.ToLower(finalURL), "https://") + if useWorker { + workerReq := &WorkerRequest{ + URL: finalURL, + Key: system_setting.WorkerValidKey, + Method: method, + Headers: map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "NewAPI-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", "NewAPI-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/classic/src/components/settings/PersonalSetting.jsx b/web/classic/src/components/settings/PersonalSetting.jsx index e735c877c49b..95e22529d62d 100644 --- a/web/classic/src/components/settings/PersonalSetting.jsx +++ b/web/classic/src/components/settings/PersonalSetting.jsx @@ -95,6 +95,7 @@ const PersonalSetting = () => { upstreamModelUpdateNotifyEnabled: false, acceptUnsetModelRatioModel: false, recordIpLog: false, + smsPhoneNumber: '', }); const { @@ -198,6 +199,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]); @@ -528,6 +530,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/classic/src/components/settings/SystemSetting.jsx b/web/classic/src/components/settings/SystemSetting.jsx index 63b20c70f4d1..88a8344a61e9 100644 --- a/web/classic/src/components/settings/SystemSetting.jsx +++ b/web/classic/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,51 @@ const SystemSetting = () => { } }; + const submitSMS = async () => { + const options = []; + // 凭证字段:后端 GetOptions 会隐藏这些字段,前端拿到的是空值 + // 只有用户填写了新值才提交,避免用空串覆盖已有凭证 + const secretKeys = new Set([ + 'SMSAliyunAccessKeyId', + 'SMSAliyunAccessKeySecret', + 'SMSSendCloudSmsUser', + 'SMSSendCloudSmsKey', + 'SMSTencentSecretId', + 'SMSTencentSecretKey', + 'SMSTencentSmsSdkAppId', + ]); + 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]) { + // 凭证字段仅在有新值时提交,非凭证字段允许清空 + if (secretKeys.has(key) && inputs[key] === '') { + continue; + } + options.push({ key, value: inputs[key] }); + } + } + if (options.length > 0) { + await updateOptions(options); + } + }; + const submitEmailDomainWhitelist = async () => { if (Array.isArray(emailDomainWhitelist)) { await updateOptions([ @@ -1351,6 +1412,154 @@ const SystemSetting = () => { + + + {t('用以支持短信通知推送,用户可在个人设置中选择短信通知方式')} + + + + + + {inputs.SMSProvider === 'aliyun' && ( + <> + + + + + + + + + + + + + + + + + + )} + {inputs.SMSProvider === 'sendcloud' && ( + + + + + + + + + + + + )} + {inputs.SMSProvider === 'tencent' && ( + <> + + + + + + + + + + + + + + + + + + + + + )} + {inputs.SMSProvider === 'custom' && ( + <> + + + + + + + + + + + + + + + )} + + + diff --git a/web/classic/src/components/settings/personal/cards/NotificationSettings.jsx b/web/classic/src/components/settings/personal/cards/NotificationSettings.jsx index 5e8d4fd8299f..870178363e1d 100644 --- a/web/classic/src/components/settings/personal/cards/NotificationSettings.jsx +++ b/web/classic/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/classic/src/i18n/locales/en.json b/web/classic/src/i18n/locales/en.json index dc8ad6cb9464..7de3883c5293 100644 --- a/web/classic/src/i18n/locales/en.json +++ b/web/classic/src/i18n/locales/en.json @@ -3423,6 +3423,39 @@ "通知邮箱": "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", + "保存短信设置": "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/classic/src/i18n/locales/fr.json b/web/classic/src/i18n/locales/fr.json index 8e7d143d0954..aa4b34b644ff 100644 --- a/web/classic/src/i18n/locales/fr.json +++ b/web/classic/src/i18n/locales/fr.json @@ -3402,6 +3402,39 @@ "通知邮箱": "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é", + "保存短信设置": "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/classic/src/i18n/locales/ja.json b/web/classic/src/i18n/locales/ja.json index 88d2899e17c6..330611ccfc11 100644 --- a/web/classic/src/i18n/locales/ja.json +++ b/web/classic/src/i18n/locales/ja.json @@ -3371,6 +3371,39 @@ "通知邮箱": "通知メールアドレス", "通知配置": "通知設定", "通过分组可以实现不同用户等级的差异化定价,例如 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/classic/src/i18n/locales/ru.json b/web/classic/src/i18n/locales/ru.json index 2980af179f6f..8a68fc39fca7 100644 --- a/web/classic/src/i18n/locales/ru.json +++ b/web/classic/src/i18n/locales/ru.json @@ -3422,6 +3422,39 @@ "通知邮箱": "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/classic/src/i18n/locales/vi.json b/web/classic/src/i18n/locales/vi.json index 4ca1a77f3122..7d46d246e9a4 100644 --- a/web/classic/src/i18n/locales/vi.json +++ b/web/classic/src/i18n/locales/vi.json @@ -3869,6 +3869,39 @@ "通知类型 (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", + "保存短信设置": "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/classic/src/i18n/locales/zh-CN.json b/web/classic/src/i18n/locales/zh-CN.json index e54a1c0f9114..e4fee0613061 100644 --- a/web/classic/src/i18n/locales/zh-CN.json +++ b/web/classic/src/i18n/locales/zh-CN.json @@ -3410,6 +3410,39 @@ "通知邮箱": "通知邮箱", "通知配置": "通知配置", "通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。": "通过分组可以实现不同用户等级的差异化定价,例如 VIP 用户享受更低的 API 调用费用。", + "短信通知": "短信通知", + "短信服务商": "短信服务商", + "阿里云短信": "阿里云短信", + "腾讯云短信": "腾讯云短信", + "通用HTTP接口": "通用HTTP接口", + "手机号码": "手机号码", + "请输入接收短信的手机号码": "请输入接收短信的手机号码", + "请输入手机号码": "请输入手机号码", + "手机号码格式不正确": "手机号码格式不正确", + "短信服务由管理员统一配置,您只需填写接收通知的手机号码": "短信服务由管理员统一配置,您只需填写接收通知的手机号码", + "短信签名": "短信签名", + "模板Code": "模板Code", + "请输入模板Code": "请输入模板Code", + "模板ID": "模板ID", + "请输入模板ID": "请输入模板ID", + "接口地址": "接口地址", + "请输入短信接口地址": "请输入短信接口地址", + "请求方法": "请求方法", + "请求模板": "请求模板", + "请输入请求模板": "请输入请求模板", + "短信接口地址必须以http://或https://开头": "短信接口地址必须以http://或https://开头", + "阿里云短信配置说明": "阿里云短信配置说明", + "腾讯云短信配置说明": "腾讯云短信配置说明", + "通用HTTP接口说明": "通用HTTP接口说明", + "请输入短信签名": "请输入短信签名", + "在阿里云短信服务控制台创建签名和模板后获取相关参数": "在阿里云短信服务控制台创建签名和模板后获取相关参数", + "在腾讯云短信控制台创建应用、签名和模板后获取相关参数": "在腾讯云短信控制台创建应用、签名和模板后获取相关参数", + "支持模板变量: {{phone}} (手机号), {{title}} (通知标题), {{content}} (通知内容)": "支持模板变量: {{phone}} (手机号), {{title}} (通知标题), {{content}} (通知内容)", + "短信模板中需包含一个变量用于接收通知内容": "短信模板中需包含一个变量用于接收通知内容", + "配置短信服务": "配置短信服务", + "用以支持短信通知推送,用户可在个人设置中选择短信通知方式": "用以支持短信通知推送,用户可在个人设置中选择短信通知方式", + "未配置": "未配置", + "保存短信设置": "保存短信设置", "通过划转功能将奖励额度转入到您的账户余额中": "通过划转功能将奖励额度转入到您的账户余额中", "通过密码注册时需要进行邮箱验证": "通过密码注册时需要进行邮箱验证", "通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。": "通过此功能,可以根据用户所在分组,为不同等级的用户展示不同的可选列表。",