diff --git a/controller/user.go b/controller/user.go index c03afa32..33d4636b 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/channel_settings.go b/dto/channel_settings.go index d6d6e084..d57184b3 100644 --- a/dto/channel_settings.go +++ b/dto/channel_settings.go @@ -20,6 +20,9 @@ type ChannelOtherSettings struct { AzureResponsesVersion string `json:"azure_responses_version,omitempty"` VertexKeyType VertexKeyType `json:"vertex_key_type,omitempty"` // "json" or "api_key" OpenRouterEnterprise *bool `json:"openrouter_enterprise,omitempty"` + AllowServiceTier bool `json:"allow_service_tier,omitempty"` // 是否允许 service_tier 透传(默认过滤以避免额外计费) + DisableStore bool `json:"disable_store,omitempty"` // 是否禁用 store 透传(默认允许透传,禁用后可能导致 Codex 无法使用) + AllowSafetyIdentifier bool `json:"allow_safety_identifier,omitempty"` // 是否允许 safety_identifier 透传(默认过滤以保护用户隐私) } func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool { diff --git a/dto/claude.go b/dto/claude.go index 42774226..dfc5cfd4 100644 --- a/dto/claude.go +++ b/dto/claude.go @@ -195,12 +195,15 @@ type ClaudeRequest struct { Temperature *float64 `json:"temperature,omitempty"` TopP float64 `json:"top_p,omitempty"` TopK int `json:"top_k,omitempty"` - //ClaudeMetadata `json:"metadata,omitempty"` Stream bool `json:"stream,omitempty"` Tools any `json:"tools,omitempty"` ContextManagement json.RawMessage `json:"context_management,omitempty"` ToolChoice any `json:"tool_choice,omitempty"` Thinking *Thinking `json:"thinking,omitempty"` + McpServers json.RawMessage `json:"mcp_servers,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` } func (c *ClaudeRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/dto/openai_request.go b/dto/openai_request.go index 191fa638..dbdfad44 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -57,6 +57,18 @@ type GeneralOpenAIRequest struct { Dimensions int `json:"dimensions,omitempty"` Modalities json.RawMessage `json:"modalities,omitempty"` Audio json.RawMessage `json:"audio,omitempty"` + // 安全标识符,用于帮助 OpenAI 检测可能违反使用政策的应用程序用户 + // 注意:此字段会向 OpenAI 发送用户标识信息,默认过滤以保护用户隐私 + SafetyIdentifier string `json:"safety_identifier,omitempty"` + // Whether or not to store the output of this chat completion request for use in our model distillation or evals products. + // 是否存储此次请求数据供 OpenAI 用于评估和优化产品 + // 注意:默认过滤此字段以保护用户隐私,但过滤后可能导致 Codex 无法正常使用 + Store json.RawMessage `json:"store,omitempty"` + // Used by OpenAI to cache responses for similar requests to optimize your cache hit rates. Replaces the user field + PromptCacheKey string `json:"prompt_cache_key,omitempty"` + LogitBias json.RawMessage `json:"logit_bias,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Prediction json.RawMessage `json:"prediction,omitempty"` // gemini ExtraBody json.RawMessage `json:"extra_body,omitempty"` //xai @@ -775,19 +787,20 @@ type OpenAIResponsesRequest struct { ParallelToolCalls json.RawMessage `json:"parallel_tool_calls,omitempty"` PreviousResponseID string `json:"previous_response_id,omitempty"` Reasoning *Reasoning `json:"reasoning,omitempty"` - ServiceTier string `json:"service_tier,omitempty"` - Store json.RawMessage `json:"store,omitempty"` - PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` - Stream bool `json:"stream,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - Text json.RawMessage `json:"text,omitempty"` - ToolChoice json.RawMessage `json:"tool_choice,omitempty"` - Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map - TopP float64 `json:"top_p,omitempty"` - Truncation string `json:"truncation,omitempty"` - User string `json:"user,omitempty"` - MaxToolCalls uint `json:"max_tool_calls,omitempty"` - Prompt json.RawMessage `json:"prompt,omitempty"` + // 服务层级字段,用于指定 API 服务等级。允许透传可能导致实际计费高于预期,默认应过滤 + ServiceTier string `json:"service_tier,omitempty"` + Store json.RawMessage `json:"store,omitempty"` + PromptCacheKey json.RawMessage `json:"prompt_cache_key,omitempty"` + Stream bool `json:"stream,omitempty"` + Temperature float64 `json:"temperature,omitempty"` + Text json.RawMessage `json:"text,omitempty"` + ToolChoice json.RawMessage `json:"tool_choice,omitempty"` + Tools json.RawMessage `json:"tools,omitempty"` // 需要处理的参数很少,MCP 参数太多不确定,所以用 map + TopP float64 `json:"top_p,omitempty"` + Truncation string `json:"truncation,omitempty"` + User string `json:"user,omitempty"` + MaxToolCalls uint `json:"max_tool_calls,omitempty"` + Prompt json.RawMessage `json:"prompt,omitempty"` } func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta { diff --git a/dto/user_settings.go b/dto/user_settings.go index 89dd926e..16ce7b98 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/middleware/distributor.go b/middleware/distributor.go index 7fefeda4..3d929df4 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -169,6 +169,9 @@ func getModelRequest(c *gin.Context) (*ModelRequest, bool, error) { relayMode := relayconstant.RelayModeUnknown if c.Request.Method == http.MethodPost { err = common.UnmarshalBodyReusable(c, &modelRequest) + if err != nil { + return nil, false, errors.New("video无效的请求, " + err.Error()) + } relayMode = relayconstant.RelayModeVideoSubmit } else if c.Request.Method == http.MethodGet { relayMode = relayconstant.RelayModeVideoFetchByID diff --git a/relay/channel/vertex/adaptor.go b/relay/channel/vertex/adaptor.go index 91a7f88c..c4781813 100644 --- a/relay/channel/vertex/adaptor.go +++ b/relay/channel/vertex/adaptor.go @@ -91,7 +91,43 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s } a.AccountCredentials = *adc - if a.RequestMode == RequestModeLlama { + if a.RequestMode == RequestModeGemini { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeClaude { + if region == "global" { + return fmt.Sprintf( + "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/anthropic/models/%s:%s", + adc.ProjectID, + modelName, + suffix, + ), nil + } else { + return fmt.Sprintf( + "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/anthropic/models/%s:%s", + region, + adc.ProjectID, + region, + modelName, + suffix, + ), nil + } + } else if a.RequestMode == RequestModeLlama { return fmt.Sprintf( "https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions", region, @@ -99,42 +135,33 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s region, ), nil } - - if region == "global" { - return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/projects/%s/locations/global/publishers/google/models/%s:%s", - adc.ProjectID, - modelName, - suffix, - ), nil - } else { - return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/projects/%s/locations/%s/publishers/google/models/%s:%s", - region, - adc.ProjectID, - region, - modelName, - suffix, - ), nil - } } else { + var keyPrefix string + if strings.HasSuffix(suffix, "?alt=sse") { + keyPrefix = "&" + } else { + keyPrefix = "?" + } if region == "global" { return fmt.Sprintf( - "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + "https://aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s", modelName, suffix, + keyPrefix, info.ApiKey, ), nil } else { return fmt.Sprintf( - "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s?key=%s", + "https://%s-aiplatform.googleapis.com/v1/publishers/google/models/%s:%s%skey=%s", region, modelName, suffix, + keyPrefix, info.ApiKey, ), nil } } + return "", errors.New("unsupported request mode") } func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { @@ -188,7 +215,7 @@ func (a *Adaptor) SetupRequestHeader(c *gin.Context, req *http.Header, info *rel } req.Set("Authorization", "Bearer "+accessToken) } - if a.AccountCredentials.ProjectID != "" { + if a.AccountCredentials.ProjectID != "" { req.Set("x-goog-user-project", a.AccountCredentials.ProjectID) } return nil diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 59d12abe..3a739785 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -112,6 +112,12 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for Claude API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index b2905c57..cc860abd 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -509,3 +509,43 @@ type TaskInfo struct { CompletionTokens int `json:"completion_tokens,omitempty"` // 用于按倍率计费 TotalTokens int `json:"total_tokens,omitempty"` // 用于按倍率计费 } + +// RemoveDisabledFields 从请求 JSON 数据中移除渠道设置中禁用的字段 +// service_tier: 服务层级字段,可能导致额外计费(OpenAI、Claude、Responses API 支持) +// store: 数据存储授权字段,涉及用户隐私(仅 OpenAI、Responses API 支持,默认允许透传,禁用后可能导致 Codex 无法使用) +// safety_identifier: 安全标识符,用于向 OpenAI 报告违规用户(仅 OpenAI 支持,涉及用户隐私) +func RemoveDisabledFields(jsonData []byte, channelOtherSettings dto.ChannelOtherSettings) ([]byte, error) { + var data map[string]interface{} + if err := common.Unmarshal(jsonData, &data); err != nil { + common.SysError("RemoveDisabledFields Unmarshal error :" + err.Error()) + return jsonData, nil + } + + // 默认移除 service_tier,除非明确允许(避免额外计费风险) + if !channelOtherSettings.AllowServiceTier { + if _, exists := data["service_tier"]; exists { + delete(data, "service_tier") + } + } + + // 默认允许 store 透传,除非明确禁用(禁用可能影响 Codex 使用) + if channelOtherSettings.DisableStore { + if _, exists := data["store"]; exists { + delete(data, "store") + } + } + + // 默认移除 safety_identifier,除非明确允许(保护用户隐私,避免向 OpenAI 报告用户信息) + if !channelOtherSettings.AllowSafetyIdentifier { + if _, exists := data["safety_identifier"]; exists { + delete(data, "safety_identifier") + } + } + + jsonDataAfter, err := common.Marshal(data) + if err != nil { + common.SysError("RemoveDisabledFields Marshal error :" + err.Error()) + return jsonData, nil + } + return jsonDataAfter, nil +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 38b820f7..a3ddf6d4 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -135,6 +135,12 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types return types.NewError(err, types.ErrorCodeJsonMarshalFailed, types.ErrOptionWithSkipRetry()) } + // remove disabled fields for OpenAI API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/relay/responses_handler.go b/relay/responses_handler.go index 0c57a303..6958f96e 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -56,6 +56,13 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) } + + // remove disabled fields for OpenAI Responses API + jsonData, err = relaycommon.RemoveDisabledFields(jsonData, info.ChannelOtherSettings) + if err != nil { + return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) + } + // apply param override if len(info.ParamOverride) > 0 { jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride) diff --git a/service/quota.go b/service/quota.go index 12017e11..43c4024a 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 fba12d9d..0f92e7d7 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 01e7023a..c9934604 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 aad612d2..0c99e285 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 = ({ {t('邮件通知')} {t('Webhook通知')} {t('Bark通知')} + {t('Gotify通知')} - Bark 官方文档 + Bark {t('官方文档')} + + + + + + )} + + {/* Gotify推送设置 */} + {notificationSettings.warningType === 'gotify' && ( + <> + handleFormChange('gotifyUrl', val)} + prefix={} + extraText={t( + '支持HTTP和HTTPS,填写Gotify服务器的完整URL地址', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify服务器地址'), + }, + { + pattern: /^https?:\/\/.+/, + message: t('Gotify服务器地址必须以http://或https://开头'), + }, + ]} + /> + + handleFormChange('gotifyToken', val)} + prefix={} + extraText={t( + '在Gotify服务器创建应用后获得的令牌,用于发送通知', + )} + showClear + rules={[ + { + required: + notificationSettings.warningType === 'gotify', + message: t('请输入Gotify应用令牌'), + }, + ]} + /> + + + handleFormChange('gotifyPriority', val) + } + prefix={} + extraText={t('消息优先级,范围0-10,默认为5')} + style={{ width: '100%', maxWidth: '300px' }} + /> + +
+
+ {t('配置说明')} +
+
+
+ 1. {t('在Gotify服务器的应用管理中创建新应用')} +
+
+ 2.{' '} + {t( + '复制应用的令牌(Token)并填写到上方的应用令牌字段', + )} +
+
+ 3. {t('填写Gotify服务器的完整URL地址')} +
+
+ + {t('更多信息请参考')} + {' '} + + Gotify {t('官方文档')}
diff --git a/web/src/components/table/channels/modals/EditChannelModal.jsx b/web/src/components/table/channels/modals/EditChannelModal.jsx index f625ab14..09cfb0f0 100644 --- a/web/src/components/table/channels/modals/EditChannelModal.jsx +++ b/web/src/components/table/channels/modals/EditChannelModal.jsx @@ -68,6 +68,8 @@ import { IconCode, IconGlobe, IconBolt, + IconChevronUp, + IconChevronDown, } from '@douyinfe/semi-icons'; const { Text, Title } = Typography; @@ -169,6 +171,10 @@ const EditChannelModal = (props) => { vertex_key_type: 'json', // 企业账户设置 is_enterprise_account: false, + // 字段透传控制默认值 + allow_service_tier: false, + disable_store: false, // false = 允许透传(默认开启) + allow_safety_identifier: false, }; const [batch, setBatch] = useState(false); const [multiToSingle, setMultiToSingle] = useState(false); @@ -202,6 +208,27 @@ const EditChannelModal = (props) => { keyData: '', }); + // 专门的2FA验证状态(用于TwoFactorAuthModal) + const [show2FAVerifyModal, setShow2FAVerifyModal] = useState(false); + const [verifyCode, setVerifyCode] = useState(''); + const [verifyLoading, setVerifyLoading] = useState(false); + + // 表单块导航相关状态 + const formSectionRefs = useRef({ + basicInfo: null, + apiConfig: null, + modelConfig: null, + advancedSettings: null, + channelExtraSettings: null, + }); + const [currentSectionIndex, setCurrentSectionIndex] = useState(0); + const formSections = ['basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings']; + const formContainerRef = useRef(null); + + // 2FA状态更新辅助函数 + const updateTwoFAState = (updates) => { + setTwoFAState((prev) => ({ ...prev, ...updates })); + }; // 使用通用安全验证 Hook const { isModalVisible, @@ -241,6 +268,44 @@ const EditChannelModal = (props) => { }); }; + // 重置2FA验证状态 + const reset2FAVerifyState = () => { + setShow2FAVerifyModal(false); + setVerifyCode(''); + setVerifyLoading(false); + }; + + // 表单导航功能 + const scrollToSection = (sectionKey) => { + const sectionElement = formSectionRefs.current[sectionKey]; + if (sectionElement) { + sectionElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }); + } + }; + + const navigateToSection = (direction) => { + const availableSections = formSections.filter(section => { + if (section === 'apiConfig') { + return showApiConfigCard; + } + return true; + }); + + let newIndex; + if (direction === 'up') { + newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1; + } else { + newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0; + } + + setCurrentSectionIndex(newIndex); + scrollToSection(availableSections[newIndex]); + }; + // 渠道额外设置状态 const [channelSettings, setChannelSettings] = useState({ force_format: false, @@ -453,17 +518,27 @@ const EditChannelModal = (props) => { data.vertex_key_type = parsedSettings.vertex_key_type || 'json'; // 读取企业账户设置 data.is_enterprise_account = parsedSettings.openrouter_enterprise === true; + // 读取字段透传控制设置 + data.allow_service_tier = parsedSettings.allow_service_tier || false; + data.disable_store = parsedSettings.disable_store || false; + data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false; } catch (error) { console.error('解析其他设置失败:', error); data.azure_responses_version = ''; data.region = ''; data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } } else { // 兼容历史数据:老渠道没有 settings 时,默认按 json 展示 data.vertex_key_type = 'json'; data.is_enterprise_account = false; + data.allow_service_tier = false; + data.disable_store = false; + data.allow_safety_identifier = false; } if ( @@ -715,6 +790,8 @@ const EditChannelModal = (props) => { fetchModelGroups(); // 重置手动输入模式状态 setUseManualInput(false); + // 重置导航状态 + setCurrentSectionIndex(0); } else { // 统一的模态框关闭重置逻辑 resetModalState(); @@ -900,21 +977,33 @@ const EditChannelModal = (props) => { }; localInputs.setting = JSON.stringify(channelExtraSettings); - // 处理type === 20的企业账户设置 - if (localInputs.type === 20) { - let settings = {}; - if (localInputs.settings) { - try { - settings = JSON.parse(localInputs.settings); - } catch (error) { - console.error('解析settings失败:', error); - } + // 处理 settings 字段(包括企业账户设置和字段透传控制) + let settings = {}; + if (localInputs.settings) { + try { + settings = JSON.parse(localInputs.settings); + } catch (error) { + console.error('解析settings失败:', error); } - // 设置企业账户标识,无论是true还是false都要传到后端 - settings.openrouter_enterprise = localInputs.is_enterprise_account === true; - localInputs.settings = JSON.stringify(settings); } + // type === 20: 设置企业账户标识,无论是true还是false都要传到后端 + if (localInputs.type === 20) { + settings.openrouter_enterprise = localInputs.is_enterprise_account === true; + } + + // type === 1 (OpenAI) 或 type === 14 (Claude): 设置字段透传控制(显式保存布尔值) + if (localInputs.type === 1 || localInputs.type === 14) { + settings.allow_service_tier = localInputs.allow_service_tier === true; + // 仅 OpenAI 渠道需要 store 和 safety_identifier + if (localInputs.type === 1) { + settings.disable_store = localInputs.disable_store === true; + settings.allow_safety_identifier = localInputs.allow_safety_identifier === true; + } + } + + localInputs.settings = JSON.stringify(settings); + // 清理不需要发送到后端的字段 delete localInputs.force_format; delete localInputs.thinking_to_content; @@ -925,6 +1014,10 @@ const EditChannelModal = (props) => { delete localInputs.is_enterprise_account; // 顶层的 vertex_key_type 不应发送给后端 delete localInputs.vertex_key_type; + // 清理字段透传控制的临时字段 + delete localInputs.allow_service_tier; + delete localInputs.disable_store; + delete localInputs.allow_safety_identifier; let res; localInputs.auto_ban = localInputs.auto_ban ? 1 : 0; @@ -1240,7 +1333,41 @@ const EditChannelModal = (props) => { visible={props.visible} width={isMobile ? '100%' : 600} footer={ -
+
+
+
+ + + ), + }, + ]; + return ( -
(refForm.current = formAPI)} - style={{ marginBottom: 15 }} - > + - { - return verifyJSON(value); - }, - message: t('不是合法的 JSON 字符串'), - }, - ]} - onChange={(value) => - setInputs({ - ...inputs, - Chats: value, - }) - } - /> + + + +
+ + {t('编辑模式')}: + + { + const newMode = e.target.value; + setEditMode(newMode); + + // 确保模式切换时数据正确同步 + setTimeout(() => { + if (newMode === 'json' && refForm.current) { + refForm.current.setValues(inputs); + } + }, 100); + }} + > + {t('可视化编辑')} + {t('JSON编辑')} + +
+ + {editMode === 'visual' ? ( +
+ + + + } + placeholder={t('搜索聊天应用名称')} + value={searchText} + onChange={(value) => setSearchText(value)} + style={{ width: 250 }} + showClear + /> + + + + t('共 {{total}} 项,当前显示 {{start}}-{{end}} 项', { + total, + start: range[0], + end: range[1], + }), + }} + /> + + ) : ( + (refForm.current = formAPI)} + > + { + return verifyJSON(value); + }, + message: t('不是合法的 JSON 字符串'), + }, + ]} + onChange={(value) => + setInputs({ + ...inputs, + Chats: value, + }) + } + /> + + )} - - - + + {editMode === 'json' && ( + + + + )} + + +
(modalFormRef.current = api)}> + + + + +
); }