diff --git a/controller/user.go b/controller/user.go
index c03afa322..33d4636b7 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -1102,6 +1102,9 @@ type UpdateUserSettingRequest struct {
WebhookSecret string `json:"webhook_secret,omitempty"`
NotificationEmail string `json:"notification_email,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"`
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{
"success": false,
"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")
user, err := model.GetUserById(userId, true)
if err != nil {
@@ -1225,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) {
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)
if err := user.Update(false); err != nil {
diff --git a/dto/user_settings.go b/dto/user_settings.go
index 89dd926ef..16ce7b985 100644
--- a/dto/user_settings.go
+++ b/dto/user_settings.go
@@ -7,6 +7,9 @@ type UserSetting struct {
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
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 是否接受未设置价格的模型
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
@@ -16,4 +19,5 @@ var (
NotifyTypeEmail = "email" // Email 邮件
NotifyTypeWebhook = "webhook" // Webhook
NotifyTypeBark = "bark" // Bark 推送
+ NotifyTypeGotify = "gotify" // Gotify 推送
)
diff --git a/service/quota.go b/service/quota.go
index 12017e11e..43c4024ae 100644
--- a/service/quota.go
+++ b/service/quota.go
@@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
// Bark推送使用简短文本,不支持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)}
} else {
- // 默认内容格式,适用于Email和Webhook
+ // 默认内容格式,适用于Email和Webhook(支持HTML)
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。
充值链接:{{value}}"
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
}
diff --git a/service/user_notify.go b/service/user_notify.go
index fba12d9db..0f92e7d75 100644
--- a/service/user_notify.go
+++ b/service/user_notify.go
@@ -1,6 +1,8 @@
package service
import (
+ "bytes"
+ "encoding/json"
"fmt"
"net/http"
"net/url"
@@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
switch notifyType {
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))
return nil
}
- return sendEmailNotify(userEmail, data)
+ return sendEmailNotify(emailToUse, data)
case dto.NotifyTypeWebhook:
webhookURLStr := userSetting.WebhookUrl
if webhookURLStr == "" {
@@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
return nil
}
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
}
@@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
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
+}
diff --git a/web/src/components/settings/PersonalSetting.jsx b/web/src/components/settings/PersonalSetting.jsx
index 01e7023ad..c9934604c 100644
--- a/web/src/components/settings/PersonalSetting.jsx
+++ b/web/src/components/settings/PersonalSetting.jsx
@@ -81,6 +81,9 @@ const PersonalSetting = () => {
webhookSecret: '',
notificationEmail: '',
barkUrl: '',
+ gotifyUrl: '',
+ gotifyToken: '',
+ gotifyPriority: 5,
acceptUnsetModelRatioModel: false,
recordIpLog: false,
});
@@ -149,6 +152,12 @@ const PersonalSetting = () => {
webhookSecret: settings.webhook_secret || '',
notificationEmail: settings.notification_email || '',
barkUrl: settings.bark_url || '',
+ gotifyUrl: settings.gotify_url || '',
+ gotifyToken: settings.gotify_token || '',
+ gotifyPriority:
+ settings.gotify_priority !== undefined
+ ? settings.gotify_priority
+ : 5,
acceptUnsetModelRatioModel:
settings.accept_unset_model_ratio_model || false,
recordIpLog: settings.record_ip_log || false,
@@ -406,6 +415,12 @@ const PersonalSetting = () => {
webhook_secret: notificationSettings.webhookSecret,
notification_email: notificationSettings.notificationEmail,
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:
notificationSettings.acceptUnsetModelRatioModel,
record_ip_log: notificationSettings.recordIpLog,
diff --git a/web/src/components/settings/personal/cards/NotificationSettings.jsx b/web/src/components/settings/personal/cards/NotificationSettings.jsx
index aad612d2c..dc428f145 100644
--- a/web/src/components/settings/personal/cards/NotificationSettings.jsx
+++ b/web/src/components/settings/personal/cards/NotificationSettings.jsx
@@ -400,6 +400,7 @@ const NotificationSettings = ({