From 296edf0f716635ac39cd7f30351c111e10e5c7f2 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Fri, 10 Apr 2026 13:47:47 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=A7=BB=E6=A4=8D=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E5=85=B3=E9=94=AE=20bug=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从 QuantumNous/new-api 上游移植以下修复: - fix(ssrf): 加固视频代理和 MJ 图片代理的 SSRF 防护,FetchModels 提权至 RootAuth - fix(claude): 流式断流时不再整份覆盖 usage,保留 cache 计费字段 - fix(pricing): 定价接口增加用户可用分组过滤 - fix(ratio): compact 模型优先匹配精确定价再回退通配符 - fix(ali): 接受 task polling 返回的字符串类型 usage 值 - fix(gemini): 从 URL 路径 :streamGenerateContent 检测流式请求 - fix(zhipu_4v): coding plan 图片生成使用正确的 OpenAI endpoint - fix(openai): MessageImageUrl.Detail 字段添加 omitempty - fix(channel-affinity): skip_retry_on_failure 配置项为 false 时不再丢失 - fix(document-renderer): 使用 useMemo 替代 useState 处理 HTML payload - fix(fetch-setting): ApplyIPFilterForDomain 默认值改为 true Co-Authored-By: Claude Opus 4.6 --- controller/pricing.go | 26 ++++++++++ controller/video_proxy.go | 9 ++++ dto/gemini.go | 5 ++ dto/openai_request.go | 2 +- relay/channel/claude/relay-claude.go | 11 ++++- relay/channel/task/ali/adaptor.go | 6 +-- relay/channel/zhipu_4v/adaptor.go | 3 ++ relay/mjproxy_handler.go | 7 +++ router/api-router.go | 3 +- setting/ratio_setting/model_ratio.go | 5 ++ setting/system_setting/fetch_setting.go | 2 +- .../common/DocumentRenderer/index.jsx | 49 ++++++------------- .../Operation/SettingsChannelAffinity.jsx | 2 +- 13 files changed, 87 insertions(+), 43 deletions(-) diff --git a/controller/pricing.go b/controller/pricing.go index b6537e4..9d1191f 100644 --- a/controller/pricing.go +++ b/controller/pricing.go @@ -1,6 +1,7 @@ package controller import ( + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting/ratio_setting" @@ -8,6 +9,30 @@ import ( "github.com/gin-gonic/gin" ) +func filterPricingByUsableGroups(pricing []model.Pricing, usableGroup map[string]string) []model.Pricing { + if len(pricing) == 0 { + return pricing + } + if len(usableGroup) == 0 { + return []model.Pricing{} + } + + filtered := make([]model.Pricing, 0, len(pricing)) + for _, item := range pricing { + if common.StringsContains(item.EnableGroup, "all") { + filtered = append(filtered, item) + continue + } + for _, group := range item.EnableGroup { + if _, ok := usableGroup[group]; ok { + filtered = append(filtered, item) + break + } + } + } + return filtered +} + func GetPricing(c *gin.Context) { pricing := model.GetPricing() userId, exists := c.Get("id") @@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) { } usableGroup = service.GetUserUsableGroups(group) + pricing = filterPricingByUsableGroups(pricing, usableGroup) // check groupRatio contains usableGroup for group := range ratio_setting.GetGroupRatioCopy() { if _, ok := usableGroup[group]; !ok { diff --git a/controller/video_proxy.go b/controller/video_proxy.go index f1dd2bc..fe8b3fc 100644 --- a/controller/video_proxy.go +++ b/controller/video_proxy.go @@ -8,10 +8,12 @@ import ( "net/url" "time" + "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting/system_setting" "github.com/gin-gonic/gin" ) @@ -109,6 +111,13 @@ func VideoProxy(c *gin.Context) { return } + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(videoURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + logger.LogError(c.Request.Context(), fmt.Sprintf("Video URL blocked for task %s: %v", taskID, err)) + videoProxyError(c, http.StatusForbidden, "server_error", fmt.Sprintf("request blocked: %v", err)) + return + } + resp, err := client.Do(req) if err != nil { logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error())) diff --git a/dto/gemini.go b/dto/gemini.go index b97f19e..cc1b502 100644 --- a/dto/gemini.go +++ b/dto/gemini.go @@ -121,6 +121,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool { if c.Query("alt") == "sse" { return true } + // Native Gemini API uses URL action to indicate streaming: + // /v1beta/models/{model}:streamGenerateContent + if strings.Contains(c.Request.URL.Path, "streamGenerateContent") { + return true + } return false } diff --git a/dto/openai_request.go b/dto/openai_request.go index c0a69a3..ef6dbed 100644 --- a/dto/openai_request.go +++ b/dto/openai_request.go @@ -387,7 +387,7 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl { type MessageImageUrl struct { Url string `json:"url"` - Detail string `json:"detail"` + Detail string `json:"detail,omitempty"` MimeType string } diff --git a/relay/channel/claude/relay-claude.go b/relay/channel/claude/relay-claude.go index 069c784..be2e2a8 100644 --- a/relay/channel/claude/relay-claude.go +++ b/relay/channel/claude/relay-claude.go @@ -743,7 +743,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau if common.DebugEnabled { common.SysLog("claude response usage is not complete, maybe upstream error") } - claudeInfo.Usage = service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, claudeInfo.Usage.PromptTokens) + // 只补缺失字段,不整份覆盖——保留 message_start 已拿到的 cache 字段 + fallback := service.ResponseText2Usage(c, claudeInfo.ResponseText.String(), info.UpstreamModelName, info.GetEstimatePromptTokens()) + if claudeInfo.Usage.CompletionTokens == 0 || + (!claudeInfo.Done && fallback.CompletionTokens > claudeInfo.Usage.CompletionTokens) { + claudeInfo.Usage.CompletionTokens = fallback.CompletionTokens + } + if claudeInfo.Usage.PromptTokens == 0 { + claudeInfo.Usage.PromptTokens = fallback.PromptTokens + } + claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens } if info.RelayFormat == types.RelayFormatClaude { diff --git a/relay/channel/task/ali/adaptor.go b/relay/channel/task/ali/adaptor.go index f698fc9..5b6b01d 100644 --- a/relay/channel/task/ali/adaptor.go +++ b/relay/channel/task/ali/adaptor.go @@ -80,9 +80,9 @@ type AliVideoOutput struct { // AliUsage 使用统计 type AliUsage struct { - Duration int `json:"duration,omitempty"` - VideoCount int `json:"video_count,omitempty"` - SR int `json:"SR,omitempty"` + Duration dto.IntValue `json:"duration,omitempty"` + VideoCount dto.IntValue `json:"video_count,omitempty"` + SR dto.IntValue `json:"SR,omitempty"` } type AliMetadata struct { diff --git a/relay/channel/zhipu_4v/adaptor.go b/relay/channel/zhipu_4v/adaptor.go index 597c485..6e547f6 100644 --- a/relay/channel/zhipu_4v/adaptor.go +++ b/relay/channel/zhipu_4v/adaptor.go @@ -63,6 +63,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) { } return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil case relayconstant.RelayModeImagesGenerations: + if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { + return fmt.Sprintf("%s/images/generations", specialPlan.OpenAIBaseURL), nil + } return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil default: if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go index 8e7c61e..63bc907 100644 --- a/relay/mjproxy_handler.go +++ b/relay/mjproxy_handler.go @@ -49,6 +49,13 @@ func RelayMidjourneyImage(c *gin.Context) { if httpClient == nil { httpClient = service.GetHttpClient() } + fetchSetting := system_setting.GetFetchSetting() + if err := common.ValidateURLWithFetchSetting(midjourneyTask.ImageUrl, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil { + c.JSON(http.StatusForbidden, gin.H{ + "error": fmt.Sprintf("request blocked: %v", err), + }) + return + } resp, err := httpClient.Get(midjourneyTask.ImageUrl) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/router/api-router.go b/router/api-router.go index 5b80d5c..9e87f8d 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -246,7 +246,7 @@ func SetApiRouter(router *gin.Engine) { channelRoute.POST("/batch", controller.DeleteChannelBatch) channelRoute.POST("/fix", controller.FixChannelsAbilities) channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) - channelRoute.POST("/fetch_models", controller.FetchModels) + channelRoute.POST("/fetch_models", middleware.RootAuth(), controller.FetchModels) channelRoute.POST("/codex/oauth/start", controller.StartCodexOAuth) channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth) channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel) @@ -396,6 +396,7 @@ func SetApiRouter(router *gin.Engine) { syncRoute.POST("/user/create", controller.ReceiveSyncedUserCreate) syncRoute.POST("/quota/update", controller.ReceiveQuotaUpdate) syncRoute.POST("/quota/query", controller.QueryUserQuota) + syncRoute.POST("/quota/batch-query", controller.BatchQueryUserQuota) syncRoute.POST("/quota/batch-deduct", controller.BatchDeductQuota) syncRoute.GET("/config", controller.GetSyncConfig) } diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 62a0010..6014f3f 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -357,6 +357,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error { func GetModelPrice(name string, printErr bool) (float64, bool) { name = FormatMatchingModelName(name) + // 优先匹配精确模型名称 + if price, ok := modelPriceMap.Get(name); ok { + return price, true + } + if strings.HasSuffix(name, CompactModelSuffix) { price, ok := modelPriceMap.Get(CompactWildcardModelKey) if !ok { diff --git a/setting/system_setting/fetch_setting.go b/setting/system_setting/fetch_setting.go index 0786961..c71be03 100644 --- a/setting/system_setting/fetch_setting.go +++ b/setting/system_setting/fetch_setting.go @@ -21,7 +21,7 @@ var defaultFetchSetting = FetchSetting{ DomainList: []string{}, IpList: []string{}, AllowedPorts: []string{"80", "443", "8080", "8443"}, - ApplyIPFilterForDomain: false, + ApplyIPFilterForDomain: true, } func init() { diff --git a/web/src/components/common/DocumentRenderer/index.jsx b/web/src/components/common/DocumentRenderer/index.jsx index 68e868c..8c15c07 100644 --- a/web/src/components/common/DocumentRenderer/index.jsx +++ b/web/src/components/common/DocumentRenderer/index.jsx @@ -17,7 +17,7 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { API, showError } from '../../../helpers'; import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui'; const { Title } = Typography; @@ -28,7 +28,7 @@ import { import { useTranslation } from 'react-i18next'; import MarkdownRenderer from '../markdown/MarkdownRenderer'; -// 检查是否为 URL +// Check whether content is a URL. const isUrl = (content) => { try { new URL(content.trim()); @@ -38,27 +38,23 @@ const isUrl = (content) => { } }; -// 检查是否为 HTML 内容 +// Check whether content contains HTML. const isHtmlContent = (content) => { if (!content || typeof content !== 'string') return false; - // 检查是否包含HTML标签 const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; return htmlTagRegex.test(content); }; -// 安全地渲染HTML内容 +// Parse HTML content and extract inline styles. const sanitizeHtml = (html) => { - // 创建一个临时元素来解析HTML const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; - // 提取样式 const styles = Array.from(tempDiv.querySelectorAll('style')) .map((style) => style.innerHTML) .join('\n'); - // 提取body内容,如果没有body标签则使用全部内容 const bodyContent = tempDiv.querySelector('body'); const content = bodyContent ? bodyContent.innerHTML : html; @@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { const { t } = useTranslation(); const [content, setContent] = useState(''); const [loading, setLoading] = useState(true); - const [htmlStyles, setHtmlStyles] = useState(''); - const [processedHtmlContent, setProcessedHtmlContent] = useState(''); const loadContent = async () => { - // 先从缓存中获取 const cachedContent = localStorage.getItem(cacheKey) || ''; if (cachedContent) { setContent(cachedContent); - processContent(cachedContent); setLoading(false); } @@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { const { success, message, data } = res.data; if (success && data) { setContent(data); - processContent(data); localStorage.setItem(cacheKey, data); } else { if (!cachedContent) { @@ -111,16 +102,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { } }; - const processContent = (rawContent) => { - if (isHtmlContent(rawContent)) { - const { content: htmlContent, styles } = sanitizeHtml(rawContent); - setProcessedHtmlContent(htmlContent); - setHtmlStyles(styles); - } else { - setProcessedHtmlContent(''); - setHtmlStyles(''); + const htmlPayload = useMemo(() => { + if (!isHtmlContent(content)) { + return { content: '', styles: '' }; } - }; + return sanitizeHtml(content); + }, [content]); useEffect(() => { loadContent(); @@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { // 处理HTML样式注入 useEffect(() => { const styleId = `document-renderer-styles-${cacheKey}`; + const { styles } = htmlPayload; - if (htmlStyles) { + if (styles) { let styleEl = document.getElementById(styleId); if (!styleEl) { styleEl = document.createElement('style'); @@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { styleEl.type = 'text/css'; document.head.appendChild(styleEl); } - styleEl.innerHTML = htmlStyles; + styleEl.innerHTML = styles; } else { const el = document.getElementById(styleId); if (el) el.remove(); @@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { const el = document.getElementById(styleId); if (el) el.remove(); }; - }, [htmlStyles, cacheKey]); + }, [cacheKey, htmlPayload]); // 显示加载状态 if (loading) { @@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => { // 如果是 HTML 内容,直接渲染 if (isHtmlContent(content)) { - const { content: htmlContent, styles } = sanitizeHtml(content); - - // 设置样式(如果有的话) - useEffect(() => { - if (styles && styles !== htmlStyles) { - setHtmlStyles(styles); - } - }, [content, styles, htmlStyles]); - return (
@@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
diff --git a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx index 18e2cfb..93873b8 100644 --- a/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx +++ b/web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx @@ -595,7 +595,7 @@ export default function SettingsChannelAffinity(props) { include_rule_name: !!values.include_rule_name, ...(values.skip_retry_on_failure ? { skip_retry_on_failure: true } - : {}), + : { skip_retry_on_failure: false }), ...(userAgentInclude.length > 0 ? { user_agent_include: userAgentInclude } : {}),