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
| @@ -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 { | ||||
| @@ -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 | ||||
| @@ -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())) | ||||
| @@ -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 | ||||
| } | } | ||||
| @@ -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 | ||||
| } | } | ||||
| @@ -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 { | ||||
| @@ -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 { | ||||
| @@ -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 != "" { | ||||
| @@ -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()) | ||||
| } | } | ||||
| @@ -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()) | ||||
| } | } | ||||
| @@ -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 | ||||
| @@ -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()) | ||||
| } | } | ||||
| @@ -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()) | ||||
| } | } | ||||
| @@ -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()) | ||||
| } | } | ||||
| @@ -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()) | ||||
| } | } | ||||
| @@ -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{ | ||||
| @@ -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()) | ||||
| } | } | ||||
| @@ -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()) | ||||
| } | } | ||||
| @@ -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) | ||||
| } | } | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | ||||
| } | } | ||||
| @@ -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"` | |||||
| } | |||||
| @@ -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 { | ||||
| @@ -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() { | ||||
| @@ -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 | |||||
| @@ -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> | ||||
| @@ -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 } | ||||
| : {}), | : {}), | ||||