Browse Source

merge: 上游 bug 修复 + Param Override 增强系统 + 批量余额查询

fix/cherry-pick-upstream-bugfixes 包含 3 个提交:
- fix: 移植上游关键 bug 修复(SSRF、Claude cache、定价过滤等 11 项)
- feat: 移植上游 Param Override 增强系统(9 种新 mode、Header Override、Audit 等)
- feat(region-sync): 批量查询用户余额接口,优化同步性能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat/alipay-payment
fengsilin 4 weeks ago
parent
commit
62f49a2bf7
29 changed files with 3230 additions and 242 deletions
  1. +26
    -0
      controller/pricing.go
  2. +35
    -0
      controller/region_sync.go
  3. +9
    -0
      controller/video_proxy.go
  4. +5
    -0
      dto/gemini.go
  5. +1
    -1
      dto/openai_request.go
  6. +10
    -1
      relay/channel/claude/relay-claude.go
  7. +3
    -3
      relay/channel/task/ali/adaptor.go
  8. +3
    -0
      relay/channel/zhipu_4v/adaptor.go
  9. +1
    -2
      relay/chat_completions_via_responses.go
  10. +1
    -1
      relay/claude_handler.go
  11. +1584
    -130
      relay/common/override.go
  12. +1368
    -47
      relay/common/override_test.go
  13. +28
    -0
      relay/common/relay_info.go
  14. +1
    -1
      relay/compatible_handler.go
  15. +1
    -1
      relay/embedding_handler.go
  16. +1
    -1
      relay/gemini_handler.go
  17. +1
    -1
      relay/image_handler.go
  18. +7
    -0
      relay/mjproxy_handler.go
  19. +1
    -1
      relay/rerank_handler.go
  20. +1
    -1
      relay/responses_handler.go
  21. +2
    -1
      router/api-router.go
  22. +12
    -0
      service/region_sync/sync_client.go
  23. +31
    -13
      service/region_sync/sync_manager.go
  24. +18
    -0
      service/region_sync/sync_types.go
  25. +5
    -0
      setting/ratio_setting/model_ratio.go
  26. +1
    -1
      setting/system_setting/fetch_setting.go
  27. +59
    -0
      update-image.sh
  28. +14
    -35
      web/src/components/common/DocumentRenderer/index.jsx
  29. +1
    -1
      web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx

+ 26
- 0
controller/pricing.go View File

@@ -1,6 +1,7 @@
package controller package controller


import ( import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -8,6 +9,30 @@ import (
"github.com/gin-gonic/gin" "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) { func GetPricing(c *gin.Context) {
pricing := model.GetPricing() pricing := model.GetPricing()
userId, exists := c.Get("id") userId, exists := c.Get("id")
@@ -31,6 +56,7 @@ func GetPricing(c *gin.Context) {
} }


usableGroup = service.GetUserUsableGroups(group) usableGroup = service.GetUserUsableGroups(group)
pricing = filterPricingByUsableGroups(pricing, usableGroup)
// check groupRatio contains usableGroup // check groupRatio contains usableGroup
for group := range ratio_setting.GetGroupRatioCopy() { for group := range ratio_setting.GetGroupRatioCopy() {
if _, ok := usableGroup[group]; !ok { if _, ok := usableGroup[group]; !ok {


+ 35
- 0
controller/region_sync.go View File

@@ -145,6 +145,41 @@ func QueryUserQuota(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "quota": user.Quota}) c.JSON(http.StatusOK, gin.H{"success": true, "quota": user.Quota})
} }


// BatchQueryUserQuota 批量查询用户余额(供 slave 节点调用)
func BatchQueryUserQuota(c *gin.Context) {
var req region_sync.BatchQueryQuotaRequest
if err := c.ShouldBindJSON(&req); err != nil {
syncErrorResponse(c, http.StatusBadRequest, "invalid request body")
return
}

if len(req.UserIds) == 0 {
c.JSON(http.StatusOK, region_sync.BatchQueryQuotaResponse{Success: true, Quotas: []region_sync.QuotaEntry{}})
return
}

if len(req.UserIds) > 500 {
syncErrorResponse(c, http.StatusBadRequest, "too many user ids (max 500)")
return
}

var users []model.User
if err := model.DB.Where("id IN ?", req.UserIds).Find(&users).Error; err != nil {
syncErrorResponse(c, http.StatusInternalServerError, "database error")
return
}

quotas := make([]region_sync.QuotaEntry, 0, len(users))
for _, user := range users {
quotas = append(quotas, region_sync.QuotaEntry{UserId: user.Id, Quota: user.Quota})
}

c.JSON(http.StatusOK, region_sync.BatchQueryQuotaResponse{
Success: true,
Quotas: quotas,
})
}

// BatchDeductQuota 批量扣费(供 slave 节点调用 master 节点) // BatchDeductQuota 批量扣费(供 slave 节点调用 master 节点)
func BatchDeductQuota(c *gin.Context) { func BatchDeductQuota(c *gin.Context) {
var req region_sync.BatchDeductRequest var req region_sync.BatchDeductRequest


+ 9
- 0
controller/video_proxy.go View File

@@ -8,10 +8,12 @@ import (
"net/url" "net/url"
"time" "time"


"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/model"
"github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/system_setting"


"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -109,6 +111,13 @@ func VideoProxy(c *gin.Context) {
return 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) resp, err := client.Do(req)
if err != nil { if err != nil {
logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error())) logger.LogError(c.Request.Context(), fmt.Sprintf("Failed to fetch video from %s: %s", videoURL, err.Error()))


+ 5
- 0
dto/gemini.go View File

@@ -121,6 +121,11 @@ func (r *GeminiChatRequest) IsStream(c *gin.Context) bool {
if c.Query("alt") == "sse" { if c.Query("alt") == "sse" {
return true 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 return false
} }




+ 1
- 1
dto/openai_request.go View File

@@ -387,7 +387,7 @@ func (m *MediaContent) GetVideoUrl() *MessageVideoUrl {


type MessageImageUrl struct { type MessageImageUrl struct {
Url string `json:"url"` Url string `json:"url"`
Detail string `json:"detail"`
Detail string `json:"detail,omitempty"`
MimeType string MimeType string
} }




+ 10
- 1
relay/channel/claude/relay-claude.go View File

@@ -743,7 +743,16 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if common.DebugEnabled { if common.DebugEnabled {
common.SysLog("claude response usage is not complete, maybe upstream error") 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 { if info.RelayFormat == types.RelayFormatClaude {


+ 3
- 3
relay/channel/task/ali/adaptor.go View File

@@ -80,9 +80,9 @@ type AliVideoOutput struct {


// AliUsage 使用统计 // AliUsage 使用统计
type AliUsage struct { 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 { type AliMetadata struct {


+ 3
- 0
relay/channel/zhipu_4v/adaptor.go View File

@@ -63,6 +63,9 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
} }
return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil return fmt.Sprintf("%s/api/paas/v4/embeddings", baseURL), nil
case relayconstant.RelayModeImagesGenerations: 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 return fmt.Sprintf("%s/api/paas/v4/images/generations", baseURL), nil
default: default:
if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" { if hasSpecialPlan && specialPlan.OpenAIBaseURL != "" {


+ 1
- 2
relay/chat_completions_via_responses.go View File

@@ -70,7 +70,6 @@ func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, requ
} }


func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) { func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) {
overrideCtx := relaycommon.BuildParamOverrideContext(info)
chatJSON, err := common.Marshal(request) chatJSON, err := common.Marshal(request)
if err != nil { if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
@@ -82,7 +81,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
} }


if len(info.ParamOverride) > 0 { if len(info.ParamOverride) > 0 {
chatJSON, err = relaycommon.ApplyParamOverride(chatJSON, info.ParamOverride, overrideCtx)
chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info)
if err != nil { if err != nil {
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
} }


+ 1
- 1
relay/claude_handler.go View File

@@ -153,7 +153,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ


// apply param override // apply param override
if len(info.ParamOverride) > 0 { if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
} }


+ 1584
- 130
relay/common/override.go
File diff suppressed because it is too large
View File


+ 1368
- 47
relay/common/override_test.go
File diff suppressed because it is too large
View File


+ 28
- 0
relay/common/relay_info.go View File

@@ -144,6 +144,12 @@ type RelayInfo struct {
SubscriptionAmountUsedAfterPreConsume int64 SubscriptionAmountUsedAfterPreConsume int64
IsClaudeBetaQuery bool // /v1/messages?beta=true IsClaudeBetaQuery bool // /v1/messages?beta=true
IsChannelTest bool // channel test request IsChannelTest bool // channel test request
RetryIndex int
LastError *types.NewAPIError
RequestHeaders map[string]string
RuntimeHeadersOverride map[string]interface{}
UseRuntimeHeadersOverride bool
ParamOverrideAudit []string


PriceData types.PriceData PriceData types.PriceData


@@ -473,6 +479,7 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
//promptTokens: common.GetContextKeyInt(c, constant.ContextKeyPromptTokens), //promptTokens: common.GetContextKeyInt(c, constant.ContextKeyPromptTokens),
estimatePromptTokens: common.GetContextKeyInt(c, constant.ContextKeyEstimatedTokens), estimatePromptTokens: common.GetContextKeyInt(c, constant.ContextKeyEstimatedTokens),
}, },
RequestHeaders: cloneRequestHeaders(c),
} }


if info.RelayMode == relayconstant.RelayModeUnknown { if info.RelayMode == relayconstant.RelayModeUnknown {
@@ -493,6 +500,27 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
return info return info
} }


func cloneRequestHeaders(c *gin.Context) map[string]string {
if c == nil || c.Request == nil {
return nil
}
if len(c.Request.Header) == 0 {
return nil
}
headers := make(map[string]string, len(c.Request.Header))
for key := range c.Request.Header {
value := strings.TrimSpace(c.Request.Header.Get(key))
if value == "" {
continue
}
headers[key] = value
}
if len(headers) == 0 {
return nil
}
return headers
}

func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) { func GenRelayInfo(c *gin.Context, relayFormat types.RelayFormat, request dto.Request, ws *websocket.Conn) (*RelayInfo, error) {
var info *RelayInfo var info *RelayInfo
var err error var err error


+ 1
- 1
relay/compatible_handler.go View File

@@ -172,7 +172,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types


// apply param override // apply param override
if len(info.ParamOverride) > 0 { if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
} }


+ 1
- 1
relay/embedding_handler.go View File

@@ -52,7 +52,7 @@ func EmbeddingHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
} }


if len(info.ParamOverride) > 0 { if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
} }


+ 1
- 1
relay/gemini_handler.go View File

@@ -157,7 +157,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ


// apply param override // apply param override
if len(info.ParamOverride) > 0 { if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
} }


+ 1
- 1
relay/image_handler.go View File

@@ -70,7 +70,7 @@ func ImageHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *type


// apply param override // apply param override
if len(info.ParamOverride) > 0 { if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
} }


+ 7
- 0
relay/mjproxy_handler.go View File

@@ -49,6 +49,13 @@ func RelayMidjourneyImage(c *gin.Context) {
if httpClient == nil { if httpClient == nil {
httpClient = service.GetHttpClient() 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) resp, err := httpClient.Get(midjourneyTask.ImageUrl)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{


+ 1
- 1
relay/rerank_handler.go View File

@@ -61,7 +61,7 @@ func RerankHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ


// apply param override // apply param override
if len(info.ParamOverride) > 0 { if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
} }


+ 1
- 1
relay/responses_handler.go View File

@@ -96,7 +96,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *


// apply param override // apply param override
if len(info.ParamOverride) > 0 { if len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverride(jsonData, info.ParamOverride, relaycommon.BuildParamOverrideContext(info))
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil { if err != nil {
return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry()) return types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
} }


+ 2
- 1
router/api-router.go View File

@@ -246,7 +246,7 @@ func SetApiRouter(router *gin.Engine) {
channelRoute.POST("/batch", controller.DeleteChannelBatch) channelRoute.POST("/batch", controller.DeleteChannelBatch)
channelRoute.POST("/fix", controller.FixChannelsAbilities) channelRoute.POST("/fix", controller.FixChannelsAbilities)
channelRoute.GET("/fetch_models/:id", controller.FetchUpstreamModels) 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/start", controller.StartCodexOAuth)
channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth) channelRoute.POST("/codex/oauth/complete", controller.CompleteCodexOAuth)
channelRoute.POST("/:id/codex/oauth/start", controller.StartCodexOAuthForChannel) 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("/user/create", controller.ReceiveSyncedUserCreate)
syncRoute.POST("/quota/update", controller.ReceiveQuotaUpdate) syncRoute.POST("/quota/update", controller.ReceiveQuotaUpdate)
syncRoute.POST("/quota/query", controller.QueryUserQuota) syncRoute.POST("/quota/query", controller.QueryUserQuota)
syncRoute.POST("/quota/batch-query", controller.BatchQueryUserQuota)
syncRoute.POST("/quota/batch-deduct", controller.BatchDeductQuota) syncRoute.POST("/quota/batch-deduct", controller.BatchDeductQuota)
syncRoute.GET("/config", controller.GetSyncConfig) syncRoute.GET("/config", controller.GetSyncConfig)
} }


+ 12
- 0
service/region_sync/sync_client.go View File

@@ -142,3 +142,15 @@ func (c *SyncClient) FetchConfig() (*SyncConfigResponse, error) {
} }
return &resp, nil return &resp, nil
} }

func (c *SyncClient) BatchQueryQuota(userIds []int) (*BatchQueryQuotaResponse, error) {
data, err := c.doRequest("POST", "/api/internal/sync/quota/batch-query", &BatchQueryQuotaRequest{UserIds: userIds})
if err != nil {
return nil, err
}
var resp BatchQueryQuotaResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return &resp, nil
}

+ 31
- 13
service/region_sync/sync_manager.go View File

@@ -143,7 +143,7 @@ func (m *SyncManager) QueryMasterQuota(remoteUserId int) (int, error) {
return resp.Quota, nil return resp.Quota, nil
} }


// RunQuotaSync 从 Master 拉取所有同步用户的最新余额,更新本地 synced_quota
// RunQuotaSync 从 Master 批量拉取所有同步用户的最新余额,更新本地 synced_quota
func (m *SyncManager) RunQuotaSync() int { func (m *SyncManager) RunQuotaSync() int {
settings := system_setting.GetRegionSyncSettings() settings := system_setting.GetRegionSyncSettings()
if !settings.Enabled || settings.IsMaster { if !settings.Enabled || settings.IsMaster {
@@ -157,24 +157,42 @@ func (m *SyncManager) RunQuotaSync() int {


logger.LogDebug(nil, "[RegionSync] RunQuotaSync: syncing %d users", len(users)) logger.LogDebug(nil, "[RegionSync] RunQuotaSync: syncing %d users", len(users))


// 建立 remoteUserId -> local User 映射
userMap := make(map[int]*model.User, len(users))
remoteIds := make([]int, len(users))
for i := range users {
userMap[users[i].RemoteUserId] = &users[i]
remoteIds[i] = users[i].RemoteUserId
}

syncedCount := 0 syncedCount := 0
for _, user := range users {
resp, err := m.client.QueryQuota(user.RemoteUserId)
if err != nil {
common.SysError(fmt.Sprintf("[SyncManager] QueryQuota failed for user %d: %v", user.Id, err))
continue
batchSize := 500
for i := 0; i < len(remoteIds); i += batchSize {
end := i + batchSize
if end > len(remoteIds) {
end = len(remoteIds)
} }
if !resp.Success {
batch := remoteIds[i:end]

resp, err := m.client.BatchQueryQuota(batch)
if err != nil {
common.SysError(fmt.Sprintf("[SyncManager] BatchQueryQuota failed (batch %d-%d): %v", i, end, err))
continue continue
} }
oldQuota := user.SyncedQuota
if err := model.UpdateSyncedQuota(user.Id, resp.Quota); err != nil {
common.SysError(fmt.Sprintf("[SyncManager] UpdateSyncedQuota failed for user %d: %v", user.Id, err))
continue

for _, entry := range resp.Quotas {
if user, ok := userMap[entry.UserId]; ok {
oldQuota := user.SyncedQuota
if err := model.UpdateSyncedQuota(user.Id, entry.Quota); err != nil {
common.SysError(fmt.Sprintf("[SyncManager] UpdateSyncedQuota failed for user %d: %v", user.Id, err))
continue
}
logger.LogDebug(nil, "[RegionSync] RunQuotaSync: userId=%d, syncedQuota %d -> %d", user.Id, oldQuota, entry.Quota)
syncedCount++
}
} }
logger.LogDebug(nil, "[RegionSync] RunQuotaSync: userId=%d, syncedQuota %d -> %d", user.Id, oldQuota, resp.Quota)
syncedCount++
} }

logger.LogDebug(nil, "[RegionSync] RunQuotaSync: completed, synced=%d/%d", syncedCount, len(users)) logger.LogDebug(nil, "[RegionSync] RunQuotaSync: completed, synced=%d/%d", syncedCount, len(users))
return syncedCount return syncedCount
} }


+ 18
- 0
service/region_sync/sync_types.go View File

@@ -81,3 +81,21 @@ type SyncConfigResponse struct {
SyncIntervalSeconds int `json:"sync_interval_seconds"` SyncIntervalSeconds int `json:"sync_interval_seconds"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
} }

// QuotaEntry 单个用户的余额信息
type QuotaEntry struct {
UserId int `json:"user_id"`
Quota int `json:"quota"`
}

// BatchQueryQuotaRequest 批量查询余额请求
type BatchQueryQuotaRequest struct {
UserIds []int `json:"user_ids"`
}

// BatchQueryQuotaResponse 批量查询余额响应
type BatchQueryQuotaResponse struct {
Success bool `json:"success"`
Quotas []QuotaEntry `json:"quotas"`
Error string `json:"error,omitempty"`
}

+ 5
- 0
setting/ratio_setting/model_ratio.go View File

@@ -357,6 +357,11 @@ func UpdateModelPriceByJSONString(jsonStr string) error {
func GetModelPrice(name string, printErr bool) (float64, bool) { func GetModelPrice(name string, printErr bool) (float64, bool) {
name = FormatMatchingModelName(name) name = FormatMatchingModelName(name)


// 优先匹配精确模型名称
if price, ok := modelPriceMap.Get(name); ok {
return price, true
}

if strings.HasSuffix(name, CompactModelSuffix) { if strings.HasSuffix(name, CompactModelSuffix) {
price, ok := modelPriceMap.Get(CompactWildcardModelKey) price, ok := modelPriceMap.Get(CompactWildcardModelKey)
if !ok { if !ok {


+ 1
- 1
setting/system_setting/fetch_setting.go View File

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


func init() { func init() {


+ 59
- 0
update-image.sh View File

@@ -0,0 +1,59 @@
#!/bin/bash
# 用法: ./update-image.sh [目录1] [目录2] ... <new-tag>
# 示例: ./update-image.sh /opt/ov /opt/cn 202604071354-feat-region-sync-glm
#
# 脚本会修改指定目录下 docker-compose.yml 中 new-api 服务的 image tag。
# 如果不传目录参数,默认修改当前目录。

set -e

if [ $# -lt 1 ]; then
echo "错误: 请提供新的 tag"
echo "用法: $0 [目录1] [目录2] ... <new-tag>"
echo "示例: $0 /opt/ov /opt/cn 202604071354-feat-region-sync-glm"
exit 1
fi

TAG="${@: -1}"

if [ $# -gt 1 ]; then
DIRS=("${@:1:$#-1}")
else
DIRS=(".")
fi

update_file() {
local FILE="$1"
local TAG="$2"

if [ ! -f "$FILE" ]; then
echo "跳过: $FILE 不存在"
return
fi

OLD_IMAGE=$(grep -E '^\s+image:.*new-api' "$FILE" | head -1 | sed 's/.*image: *//')
OLD_TAG="${OLD_IMAGE##*:}"
REPO="${OLD_IMAGE%:*}"

if [ -z "$OLD_TAG" ] || [ -z "$REPO" ]; then
echo "错误: 无法从 $FILE 中解析 new-api 的 image 配置"
return 1
fi

NEW_IMAGE="${REPO}:${TAG}"

if [[ "$OSTYPE" == "darwin"* ]]; then
perl -i -pe "s|(\s+image:) *${OLD_IMAGE}|\$1 ${NEW_IMAGE}|" "$FILE"
else
sed -i "s|${OLD_IMAGE}|${NEW_IMAGE}|" "$FILE"
fi

echo "已更新 $FILE:"
echo " 旧: ${OLD_IMAGE}"
echo " 新: ${NEW_IMAGE}"
}

for DIR in "${DIRS[@]}"; do
FILE="${DIR%/}/docker-compose.yml"
update_file "$FILE" "$TAG"
done

+ 14
- 35
web/src/components/common/DocumentRenderer/index.jsx View File

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com 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 { API, showError } from '../../../helpers';
import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui'; import { Empty, Card, Spin, Typography } from '@douyinfe/semi-ui';
const { Title } = Typography; const { Title } = Typography;
@@ -28,7 +28,7 @@ import {
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import MarkdownRenderer from '../markdown/MarkdownRenderer'; import MarkdownRenderer from '../markdown/MarkdownRenderer';


// 检查是否为 URL
// Check whether content is a URL.
const isUrl = (content) => { const isUrl = (content) => {
try { try {
new URL(content.trim()); new URL(content.trim());
@@ -38,27 +38,23 @@ const isUrl = (content) => {
} }
}; };


// 检查是否为 HTML 内容
// Check whether content contains HTML.
const isHtmlContent = (content) => { const isHtmlContent = (content) => {
if (!content || typeof content !== 'string') return false; if (!content || typeof content !== 'string') return false;


// 检查是否包含HTML标签
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i; const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
return htmlTagRegex.test(content); return htmlTagRegex.test(content);
}; };


// 安全地渲染HTML内容
// Parse HTML content and extract inline styles.
const sanitizeHtml = (html) => { const sanitizeHtml = (html) => {
// 创建一个临时元素来解析HTML
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = html; tempDiv.innerHTML = html;


// 提取样式
const styles = Array.from(tempDiv.querySelectorAll('style')) const styles = Array.from(tempDiv.querySelectorAll('style'))
.map((style) => style.innerHTML) .map((style) => style.innerHTML)
.join('\n'); .join('\n');


// 提取body内容,如果没有body标签则使用全部内容
const bodyContent = tempDiv.querySelector('body'); const bodyContent = tempDiv.querySelector('body');
const content = bodyContent ? bodyContent.innerHTML : html; const content = bodyContent ? bodyContent.innerHTML : html;


@@ -76,15 +72,11 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [htmlStyles, setHtmlStyles] = useState('');
const [processedHtmlContent, setProcessedHtmlContent] = useState('');


const loadContent = async () => { const loadContent = async () => {
// 先从缓存中获取
const cachedContent = localStorage.getItem(cacheKey) || ''; const cachedContent = localStorage.getItem(cacheKey) || '';
if (cachedContent) { if (cachedContent) {
setContent(cachedContent); setContent(cachedContent);
processContent(cachedContent);
setLoading(false); setLoading(false);
} }


@@ -93,7 +85,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const { success, message, data } = res.data; const { success, message, data } = res.data;
if (success && data) { if (success && data) {
setContent(data); setContent(data);
processContent(data);
localStorage.setItem(cacheKey, data); localStorage.setItem(cacheKey, data);
} else { } else {
if (!cachedContent) { 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(() => { useEffect(() => {
loadContent(); loadContent();
@@ -129,8 +116,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
// 处理HTML样式注入 // 处理HTML样式注入
useEffect(() => { useEffect(() => {
const styleId = `document-renderer-styles-${cacheKey}`; const styleId = `document-renderer-styles-${cacheKey}`;
const { styles } = htmlPayload;


if (htmlStyles) {
if (styles) {
let styleEl = document.getElementById(styleId); let styleEl = document.getElementById(styleId);
if (!styleEl) { if (!styleEl) {
styleEl = document.createElement('style'); styleEl = document.createElement('style');
@@ -138,7 +126,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
styleEl.type = 'text/css'; styleEl.type = 'text/css';
document.head.appendChild(styleEl); document.head.appendChild(styleEl);
} }
styleEl.innerHTML = htmlStyles;
styleEl.innerHTML = styles;
} else { } else {
const el = document.getElementById(styleId); const el = document.getElementById(styleId);
if (el) el.remove(); if (el) el.remove();
@@ -148,7 +136,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
const el = document.getElementById(styleId); const el = document.getElementById(styleId);
if (el) el.remove(); if (el) el.remove();
}; };
}, [htmlStyles, cacheKey]);
}, [cacheKey, htmlPayload]);


// 显示加载状态 // 显示加载状态
if (loading) { if (loading) {
@@ -207,15 +195,6 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {


// 如果是 HTML 内容,直接渲染 // 如果是 HTML 内容,直接渲染
if (isHtmlContent(content)) { if (isHtmlContent(content)) {
const { content: htmlContent, styles } = sanitizeHtml(content);

// 设置样式(如果有的话)
useEffect(() => {
if (styles && styles !== htmlStyles) {
setHtmlStyles(styles);
}
}, [content, styles, htmlStyles]);

return ( return (
<div className='min-h-screen bg-gray-50'> <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'> <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> </Title>
<div <div
className='prose prose-lg max-w-none' className='prose prose-lg max-w-none'
dangerouslySetInnerHTML={{ __html: htmlContent }}
dangerouslySetInnerHTML={{ __html: htmlPayload.content }}
/> />
</div> </div>
</div> </div>


+ 1
- 1
web/src/pages/Setting/Operation/SettingsChannelAffinity.jsx View File

@@ -595,7 +595,7 @@ export default function SettingsChannelAffinity(props) {
include_rule_name: !!values.include_rule_name, include_rule_name: !!values.include_rule_name,
...(values.skip_retry_on_failure ...(values.skip_retry_on_failure
? { skip_retry_on_failure: true } ? { skip_retry_on_failure: true }
: {}),
: { skip_retry_on_failure: false }),
...(userAgentInclude.length > 0 ...(userAgentInclude.length > 0
? { user_agent_include: userAgentInclude } ? { user_agent_include: userAgentInclude }
: {}), : {}),


Loading…
Cancel
Save