Explorar el Código

fix: 移植上游关键 bug 修复

从 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 <noreply@anthropic.com>
feat/alipay-payment
fengsilin hace 4 semanas
padre
commit
296edf0f71
Se han modificado 13 ficheros con 87 adiciones y 43 borrados
  1. +26
    -0
      controller/pricing.go
  2. +9
    -0
      controller/video_proxy.go
  3. +5
    -0
      dto/gemini.go
  4. +1
    -1
      dto/openai_request.go
  5. +10
    -1
      relay/channel/claude/relay-claude.go
  6. +3
    -3
      relay/channel/task/ali/adaptor.go
  7. +3
    -0
      relay/channel/zhipu_4v/adaptor.go
  8. +7
    -0
      relay/mjproxy_handler.go
  9. +2
    -1
      router/api-router.go
  10. +5
    -0
      setting/ratio_setting/model_ratio.go
  11. +1
    -1
      setting/system_setting/fetch_setting.go
  12. +14
    -35
      web/src/components/common/DocumentRenderer/index.jsx
  13. +1
    -1
      web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx

+ 26
- 0
controller/pricing.go Ver fichero

@@ -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 {


+ 9
- 0
controller/video_proxy.go Ver fichero

@@ -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()))


+ 5
- 0
dto/gemini.go Ver fichero

@@ -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
}



+ 1
- 1
dto/openai_request.go Ver fichero

@@ -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
}



+ 10
- 1
relay/channel/claude/relay-claude.go Ver fichero

@@ -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 {


+ 3
- 3
relay/channel/task/ali/adaptor.go Ver fichero

@@ -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 {


+ 3
- 0
relay/channel/zhipu_4v/adaptor.go Ver fichero

@@ -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 != "" {


+ 7
- 0
relay/mjproxy_handler.go Ver fichero

@@ -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{


+ 2
- 1
router/api-router.go Ver fichero

@@ -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)
}


+ 5
- 0
setting/ratio_setting/model_ratio.go Ver fichero

@@ -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 {


+ 1
- 1
setting/system_setting/fetch_setting.go Ver fichero

@@ -21,7 +21,7 @@ var defaultFetchSetting = FetchSetting{
DomainList: []string{},
IpList: []string{},
AllowedPorts: []string{"80", "443", "8080", "8443"},
ApplyIPFilterForDomain: false,
ApplyIPFilterForDomain: true,
}

func init() {


+ 14
- 35
web/src/components/common/DocumentRenderer/index.jsx Ver fichero

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
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 (
<div className='min-h-screen bg-gray-50'>
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
@@ -225,7 +204,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
</Title>
<div
className='prose prose-lg max-w-none'
dangerouslySetInnerHTML={{ __html: htmlContent }}
dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
/>
</div>
</div>


+ 1
- 1
web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx Ver fichero

@@ -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 }
: {}),


Cargando…
Cancelar
Guardar