Просмотр исходного кода

feat: add Gotify notification option for quota alerts

RedwindA 2 месяцев назад
Родитель
Сommit
85ff8b1422

+ 50 - 1
controller/user.go

@@ -1102,6 +1102,9 @@ type UpdateUserSettingRequest struct {
 	WebhookSecret              string  `json:"webhook_secret,omitempty"`
 	WebhookSecret              string  `json:"webhook_secret,omitempty"`
 	NotificationEmail          string  `json:"notification_email,omitempty"`
 	NotificationEmail          string  `json:"notification_email,omitempty"`
 	BarkUrl                    string  `json:"bark_url,omitempty"`
 	BarkUrl                    string  `json:"bark_url,omitempty"`
+	GotifyUrl                  string  `json:"gotify_url,omitempty"`
+	GotifyToken                string  `json:"gotify_token,omitempty"`
+	GotifyPriority             int     `json:"gotify_priority,omitempty"`
 	AcceptUnsetModelRatioModel bool    `json:"accept_unset_model_ratio_model"`
 	AcceptUnsetModelRatioModel bool    `json:"accept_unset_model_ratio_model"`
 	RecordIpLog                bool    `json:"record_ip_log"`
 	RecordIpLog                bool    `json:"record_ip_log"`
 }
 }
@@ -1117,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) {
 	}
 	}
 
 
 	// 验证预警类型
 	// 验证预警类型
-	if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
+	if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
 		c.JSON(http.StatusOK, gin.H{
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
 			"success": false,
 			"message": "无效的预警类型",
 			"message": "无效的预警类型",
@@ -1192,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) {
 		}
 		}
 	}
 	}
 
 
+	// 如果是Gotify类型,验证Gotify URL和Token
+	if req.QuotaWarningType == dto.NotifyTypeGotify {
+		if req.GotifyUrl == "" {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "Gotify服务器地址不能为空",
+			})
+			return
+		}
+		if req.GotifyToken == "" {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "Gotify令牌不能为空",
+			})
+			return
+		}
+		// 验证URL格式
+		if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "无效的Gotify服务器地址",
+			})
+			return
+		}
+		// 检查是否是HTTP或HTTPS
+		if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "Gotify服务器地址必须以http://或https://开头",
+			})
+			return
+		}
+	}
+
 	userId := c.GetInt("id")
 	userId := c.GetInt("id")
 	user, err := model.GetUserById(userId, true)
 	user, err := model.GetUserById(userId, true)
 	if err != nil {
 	if err != nil {
@@ -1225,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) {
 		settings.BarkUrl = req.BarkUrl
 		settings.BarkUrl = req.BarkUrl
 	}
 	}
 
 
+	// 如果是Gotify类型,添加Gotify配置到设置中
+	if req.QuotaWarningType == dto.NotifyTypeGotify {
+		settings.GotifyUrl = req.GotifyUrl
+		settings.GotifyToken = req.GotifyToken
+		// Gotify优先级范围0-10,超出范围则使用默认值5
+		if req.GotifyPriority < 0 || req.GotifyPriority > 10 {
+			settings.GotifyPriority = 5
+		} else {
+			settings.GotifyPriority = req.GotifyPriority
+		}
+	}
+
 	// 更新用户设置
 	// 更新用户设置
 	user.SetSetting(settings)
 	user.SetSetting(settings)
 	if err := user.Update(false); err != nil {
 	if err := user.Update(false); err != nil {

+ 4 - 0
dto/user_settings.go

@@ -7,6 +7,9 @@ type UserSetting struct {
 	WebhookSecret         string  `json:"webhook_secret,omitempty"`                 // WebhookSecret webhook密钥
 	WebhookSecret         string  `json:"webhook_secret,omitempty"`                 // WebhookSecret webhook密钥
 	NotificationEmail     string  `json:"notification_email,omitempty"`             // NotificationEmail 通知邮箱地址
 	NotificationEmail     string  `json:"notification_email,omitempty"`             // NotificationEmail 通知邮箱地址
 	BarkUrl               string  `json:"bark_url,omitempty"`                       // BarkUrl Bark推送URL
 	BarkUrl               string  `json:"bark_url,omitempty"`                       // BarkUrl Bark推送URL
+	GotifyUrl             string  `json:"gotify_url,omitempty"`                     // GotifyUrl Gotify服务器地址
+	GotifyToken           string  `json:"gotify_token,omitempty"`                   // GotifyToken Gotify应用令牌
+	GotifyPriority        int     `json:"gotify_priority"`                          // GotifyPriority Gotify消息优先级
 	AcceptUnsetRatioModel bool    `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
 	AcceptUnsetRatioModel bool    `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
 	RecordIpLog           bool    `json:"record_ip_log,omitempty"`                  // 是否记录请求和错误日志IP
 	RecordIpLog           bool    `json:"record_ip_log,omitempty"`                  // 是否记录请求和错误日志IP
 	SidebarModules        string  `json:"sidebar_modules,omitempty"`                // SidebarModules 左侧边栏模块配置
 	SidebarModules        string  `json:"sidebar_modules,omitempty"`                // SidebarModules 左侧边栏模块配置
@@ -16,4 +19,5 @@ var (
 	NotifyTypeEmail   = "email"   // Email 邮件
 	NotifyTypeEmail   = "email"   // Email 邮件
 	NotifyTypeWebhook = "webhook" // Webhook
 	NotifyTypeWebhook = "webhook" // Webhook
 	NotifyTypeBark    = "bark"    // Bark 推送
 	NotifyTypeBark    = "bark"    // Bark 推送
+	NotifyTypeGotify  = "gotify"  // Gotify 推送
 )
 )

+ 4 - 1
service/quota.go

@@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
 				// Bark推送使用简短文本,不支持HTML
 				// Bark推送使用简短文本,不支持HTML
 				content = "{{value}},剩余额度:{{value}},请及时充值"
 				content = "{{value}},剩余额度:{{value}},请及时充值"
 				values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
 				values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
+			} else if notifyType == dto.NotifyTypeGotify {
+				content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
+				values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
 			} else {
 			} else {
-				// 默认内容格式,适用于Email和Webhook
+				// 默认内容格式,适用于Email和Webhook(支持HTML)
 				content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
 				content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
 				values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
 				values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
 			}
 			}

+ 112 - 4
service/user_notify.go

@@ -1,6 +1,8 @@
 package service
 package service
 
 
 import (
 import (
+	"bytes"
+	"encoding/json"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
 	"net/url"
 	"net/url"
@@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
 
 
 	switch notifyType {
 	switch notifyType {
 	case dto.NotifyTypeEmail:
 	case dto.NotifyTypeEmail:
-		// check setting email
-		userEmail = userSetting.NotificationEmail
-		if userEmail == "" {
+		// 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱
+		emailToUse := userSetting.NotificationEmail
+		if emailToUse == "" {
+			emailToUse = userEmail
+		}
+		if emailToUse == "" {
 			common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId))
 			common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId))
 			return nil
 			return nil
 		}
 		}
-		return sendEmailNotify(userEmail, data)
+		return sendEmailNotify(emailToUse, data)
 	case dto.NotifyTypeWebhook:
 	case dto.NotifyTypeWebhook:
 		webhookURLStr := userSetting.WebhookUrl
 		webhookURLStr := userSetting.WebhookUrl
 		if webhookURLStr == "" {
 		if webhookURLStr == "" {
@@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
 			return nil
 			return nil
 		}
 		}
 		return sendBarkNotify(barkURL, data)
 		return sendBarkNotify(barkURL, data)
+	case dto.NotifyTypeGotify:
+		gotifyUrl := userSetting.GotifyUrl
+		gotifyToken := userSetting.GotifyToken
+		if gotifyUrl == "" || gotifyToken == "" {
+			common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId))
+			return nil
+		}
+		return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data)
 	}
 	}
 	return nil
 	return nil
 }
 }
@@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
 
 
 	return nil
 	return nil
 }
 }
+
+func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error {
+	// 处理占位符
+	content := data.Content
+	for _, value := range data.Values {
+		content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
+	}
+
+	// 构建完整的 Gotify API URL
+	// 确保 URL 以 /message 结尾
+	finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken)
+
+	// Gotify优先级范围0-10,如果超出范围则使用默认值5
+	if priority < 0 || priority > 10 {
+		priority = 5
+	}
+
+	// 构建 JSON payload
+	type GotifyMessage struct {
+		Title    string `json:"title"`
+		Message  string `json:"message"`
+		Priority int    `json:"priority"`
+	}
+
+	payload := GotifyMessage{
+		Title:    data.Title,
+		Message:  content,
+		Priority: priority,
+	}
+
+	// 序列化为 JSON
+	payloadBytes, err := json.Marshal(payload)
+	if err != nil {
+		return fmt.Errorf("failed to marshal gotify payload: %v", err)
+	}
+
+	var req *http.Request
+	var resp *http.Response
+
+	if system_setting.EnableWorker() {
+		// 使用worker发送请求
+		workerReq := &WorkerRequest{
+			URL:    finalURL,
+			Key:    system_setting.WorkerValidKey,
+			Method: http.MethodPost,
+			Headers: map[string]string{
+				"Content-Type": "application/json; charset=utf-8",
+				"User-Agent":   "OneAPI-Gotify-Notify/1.0",
+			},
+			Body: payloadBytes,
+		}
+
+		resp, err = DoWorkerRequest(workerReq)
+		if err != nil {
+			return fmt.Errorf("failed to send gotify request through worker: %v", err)
+		}
+		defer resp.Body.Close()
+
+		// 检查响应状态
+		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+			return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
+		}
+	} else {
+		// SSRF防护:验证Gotify URL(非Worker模式)
+		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)
+		}
+
+		// 直接发送请求
+		req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes))
+		if err != nil {
+			return fmt.Errorf("failed to create gotify request: %v", err)
+		}
+
+		// 设置请求头
+		req.Header.Set("Content-Type", "application/json; charset=utf-8")
+		req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0")
+
+		// 发送请求
+		client := GetHttpClient()
+		resp, err = client.Do(req)
+		if err != nil {
+			return fmt.Errorf("failed to send gotify request: %v", err)
+		}
+		defer resp.Body.Close()
+
+		// 检查响应状态
+		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+			return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
+		}
+	}
+
+	return nil
+}

+ 15 - 0
web/src/components/settings/PersonalSetting.jsx

@@ -81,6 +81,9 @@ const PersonalSetting = () => {
     webhookSecret: '',
     webhookSecret: '',
     notificationEmail: '',
     notificationEmail: '',
     barkUrl: '',
     barkUrl: '',
+    gotifyUrl: '',
+    gotifyToken: '',
+    gotifyPriority: 5,
     acceptUnsetModelRatioModel: false,
     acceptUnsetModelRatioModel: false,
     recordIpLog: false,
     recordIpLog: false,
   });
   });
@@ -149,6 +152,12 @@ const PersonalSetting = () => {
         webhookSecret: settings.webhook_secret || '',
         webhookSecret: settings.webhook_secret || '',
         notificationEmail: settings.notification_email || '',
         notificationEmail: settings.notification_email || '',
         barkUrl: settings.bark_url || '',
         barkUrl: settings.bark_url || '',
+        gotifyUrl: settings.gotify_url || '',
+        gotifyToken: settings.gotify_token || '',
+        gotifyPriority:
+          settings.gotify_priority !== undefined
+            ? settings.gotify_priority
+            : 5,
         acceptUnsetModelRatioModel:
         acceptUnsetModelRatioModel:
           settings.accept_unset_model_ratio_model || false,
           settings.accept_unset_model_ratio_model || false,
         recordIpLog: settings.record_ip_log || false,
         recordIpLog: settings.record_ip_log || false,
@@ -406,6 +415,12 @@ const PersonalSetting = () => {
         webhook_secret: notificationSettings.webhookSecret,
         webhook_secret: notificationSettings.webhookSecret,
         notification_email: notificationSettings.notificationEmail,
         notification_email: notificationSettings.notificationEmail,
         bark_url: notificationSettings.barkUrl,
         bark_url: notificationSettings.barkUrl,
+        gotify_url: notificationSettings.gotifyUrl,
+        gotify_token: notificationSettings.gotifyToken,
+        gotify_priority: (() => {
+          const parsed = parseInt(notificationSettings.gotifyPriority);
+          return isNaN(parsed) ? 5 : parsed;
+        })(),
         accept_unset_model_ratio_model:
         accept_unset_model_ratio_model:
           notificationSettings.acceptUnsetModelRatioModel,
           notificationSettings.acceptUnsetModelRatioModel,
         record_ip_log: notificationSettings.recordIpLog,
         record_ip_log: notificationSettings.recordIpLog,

+ 102 - 0
web/src/components/settings/personal/cards/NotificationSettings.jsx

@@ -400,6 +400,7 @@ const NotificationSettings = ({
                   <Radio value='email'>{t('邮件通知')}</Radio>
                   <Radio value='email'>{t('邮件通知')}</Radio>
                   <Radio value='webhook'>{t('Webhook通知')}</Radio>
                   <Radio value='webhook'>{t('Webhook通知')}</Radio>
                   <Radio value='bark'>{t('Bark通知')}</Radio>
                   <Radio value='bark'>{t('Bark通知')}</Radio>
+                  <Radio value='gotify'>{t('Gotify通知')}</Radio>
                 </Form.RadioGroup>
                 </Form.RadioGroup>
 
 
                 <Form.AutoComplete
                 <Form.AutoComplete
@@ -596,6 +597,107 @@ const NotificationSettings = ({
                     </div>
                     </div>
                   </>
                   </>
                 )}
                 )}
+
+                {/* Gotify推送设置 */}
+                {notificationSettings.warningType === 'gotify' && (
+                  <>
+                    <Form.Input
+                      field='gotifyUrl'
+                      label={t('Gotify服务器地址')}
+                      placeholder={t(
+                        '请输入Gotify服务器地址,例如: https://gotify.example.com',
+                      )}
+                      onChange={(val) => handleFormChange('gotifyUrl', val)}
+                      prefix={<IconLink />}
+                      extraText={t(
+                        '支持HTTP和HTTPS,填写Gotify服务器的完整URL地址',
+                      )}
+                      showClear
+                      rules={[
+                        {
+                          required:
+                            notificationSettings.warningType === 'gotify',
+                          message: t('请输入Gotify服务器地址'),
+                        },
+                        {
+                          pattern: /^https?:\/\/.+/,
+                          message: t('Gotify服务器地址必须以http://或https://开头'),
+                        },
+                      ]}
+                    />
+
+                    <Form.Input
+                      field='gotifyToken'
+                      label={t('Gotify应用令牌')}
+                      placeholder={t('请输入Gotify应用令牌')}
+                      onChange={(val) => handleFormChange('gotifyToken', val)}
+                      prefix={<IconKey />}
+                      extraText={t(
+                        '在Gotify服务器创建应用后获得的令牌,用于发送通知',
+                      )}
+                      showClear
+                      rules={[
+                        {
+                          required:
+                            notificationSettings.warningType === 'gotify',
+                          message: t('请输入Gotify应用令牌'),
+                        },
+                      ]}
+                    />
+
+                    <Form.AutoComplete
+                      field='gotifyPriority'
+                      label={t('消息优先级')}
+                      placeholder={t('请选择消息优先级')}
+                      data={[
+                        { value: 0, label: t('0 - 最低') },
+                        { value: 2, label: t('2 - 低') },
+                        { value: 5, label: t('5 - 正常(默认)') },
+                        { value: 8, label: t('8 - 高') },
+                        { value: 10, label: t('10 - 最高') },
+                      ]}
+                      onChange={(val) =>
+                        handleFormChange('gotifyPriority', val)
+                      }
+                      prefix={<IconBell />}
+                      extraText={t('消息优先级,范围0-10,默认为5')}
+                      style={{ width: '100%', maxWidth: '300px' }}
+                    />
+
+                    <div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
+                      <div className='text-sm text-gray-700 mb-3'>
+                        <strong>{t('配置说明')}</strong>
+                      </div>
+                      <div className='text-xs text-gray-500 space-y-2'>
+                        <div>
+                          1. {t('在Gotify服务器的应用管理中创建新应用')}
+                        </div>
+                        <div>
+                          2.{' '}
+                          {t(
+                            '复制应用的令牌(Token)并填写到上方的应用令牌字段',
+                          )}
+                        </div>
+                        <div>
+                          3. {t('填写Gotify服务器的完整URL地址')}
+                        </div>
+                        <div className='mt-3 pt-3 border-t border-gray-200'>
+                          <span className='text-gray-400'>
+                            {t('更多信息请参考')}
+                          </span>{' '}
+                          <a
+                            href='https://gotify.net/'
+                            target='_blank'
+                            rel='noopener noreferrer'
+                            className='text-blue-500 hover:text-blue-600 font-medium'
+                          >
+                            Gotify 官方文档
+                          </a>
+                        </div>
+                      </div>
+                    </div>
+                  </>
+                )}
               </div>
               </div>
             </TabPane>
             </TabPane>