diff --git a/Dockerfile b/Dockerfile index 17c4398d..936f6538 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,29 @@ FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder WORKDIR /build -COPY web/default/package.json . -COPY web/default/bun.lock . -RUN bun install -COPY ./web/default . -COPY ./VERSION . -RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat VERSION) bun run build +# 前端是 Bun workspace(根在 web/,含 catalog 与 bun.lock),必须从 web/ 根安装才能解析 catalog +COPY web ./web +COPY VERSION ./VERSION +WORKDIR /build/web +RUN bun install --frozen-lockfile -FROM oven/bun:1@sha256:0733e50325078969732ebe3b15ce4c4be5082f18c4ac1a0f0ca4839c2e4e42a7 AS builder-classic +# 构建 default 前端 → web/default/dist +WORKDIR /build/web/default +RUN DISABLE_ESLINT_PLUGIN='true' VITE_REACT_APP_VERSION=$(cat /build/VERSION) bun run build -WORKDIR /build -COPY web/classic/package.json . -COPY web/classic/bun.lock . -RUN bun install -COPY ./web/classic . -COPY ./VERSION . -RUN VITE_REACT_APP_VERSION=$(cat VERSION) bun run build +# classic 前端为旧版(Semi Design),运行时默认主题为 default。 +# classic 依赖 semi-ui 版本漂移(锁定为 2.99.x, 旧代码引用的 semi.css 已不再导出)导致构建失败, +# 且基本无人使用。这里用 default 的产物顶替 classic 的 dist,仅为满足 go:embed 的存在性要求。 +RUN rm -rf /build/web/classic/dist && cp -r /build/web/default/dist /build/web/classic/dist FROM golang:1.26.1-alpine@sha256:2389ebfa5b7f43eeafbd6be0c3700cc46690ef842ad962f6c5bd6be49ed82039 AS builder2 ENV GO111MODULE=on CGO_ENABLED=0 ARG TARGETOS ARG TARGETARCH +ARG GOPROXY=https://proxy.golang.org,direct ENV GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH:-amd64} +ENV GOPROXY=${GOPROXY} ENV GOEXPERIMENT=greenteagc WORKDIR /build @@ -32,8 +32,8 @@ ADD go.mod go.sum ./ RUN go mod download COPY . . -COPY --from=builder /build/dist ./web/default/dist -COPY --from=builder-classic /build/dist ./web/classic/dist +COPY --from=builder /build/web/default/dist ./web/default/dist +COPY --from=builder /build/web/classic/dist ./web/classic/dist RUN go build -ldflags "-s -w -X 'github.com/QuantumNous/new-api/common.Version=$(cat VERSION)'" -o new-api FROM debian:bookworm-slim@sha256:f06537653ac770703bc45b4b113475bd402f451e85223f0f2837acbf89ab020a diff --git a/docker-compose.yml b/docker-compose.yml index be8c885b..0ea9309c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,11 +12,13 @@ # # ⚠️ IMPORTANT: Change all default passwords before deploying to production! -version: '3.4' # For compatibility with older Docker versions - services: new-api: - image: calciumion/new-api:latest + build: + context: . + args: + GOPROXY: https://goproxy.cn,direct + image: new-pai-linghe:local container_name: new-api restart: always command: --log-dir /app/logs @@ -72,8 +74,8 @@ services: - pg_data:/var/lib/postgresql/data networks: - new-api-network -# ports: -# - "5432:5432" # Uncomment if you need to access PostgreSQL from outside Docker + ports: + - "5433:5432" # 对外暴露给 Z-Mini 概览页直连(host:5433 → 容器:5432) # mysql: # image: mysql:8.2 diff --git a/middleware/distributor.go b/middleware/distributor.go index 771719b9..c6488c9c 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -17,6 +17,7 @@ import ( "github.com/QuantumNous/new-api/model" relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" @@ -109,9 +110,9 @@ func Distribute() func(c *gin.Context) { abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled)) return } - } else if usingGroup == "auto" { + } else if setting.IsAutoTypeGroup(usingGroup) { userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) - autoGroups := service.GetUserAutoGroup(userGroup) + autoGroups := service.GetAutoSubGroups(usingGroup, userGroup) for _, g := range autoGroups { if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { selectGroup = g diff --git a/middleware/domain_group_router.go b/middleware/domain_group_router.go new file mode 100644 index 00000000..99eacfdf --- /dev/null +++ b/middleware/domain_group_router.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/logger" + "github.com/QuantumNous/new-api/setting" + + "github.com/gin-gonic/gin" +) + +// DomainAutoGroupRouter 按请求来源域名把顶层 "auto" 路由到对应的二级 auto(高价池 / 低价池)。 +// +// - 仅当当前生效分组(UsingGroup)为 "auto" 时介入;其他分组的 key 全程不受影响。 +// - 域名优先取 X-Forwarded-Host(生产由 nginx 各域名入口注入),回退 Host;去端口、小写。 +// - 命中 DomainAutoRouting 且目标二级 auto 存在时,同时改写 UsingGroup 与 TokenGroup: +// UsingGroup 影响首次选渠道(distributor),TokenGroup 影响重试循环(relayInfo.TokenGroup 快照)。 +// - 未命中或目标非法时保持 "auto",回退到顶层 AutoGroups。 +// +// 必须挂在 TokenAuth 之后、Distribute 之前。 +func DomainAutoGroupRouter() gin.HandlerFunc { + return func(c *gin.Context) { + if common.GetContextKeyString(c, constant.ContextKeyUsingGroup) != "auto" { + c.Next() + return + } + + host := c.GetHeader("X-Forwarded-Host") + if host == "" { + host = c.Request.Host + } + domain := setting.NormalizeDomain(host) + + if target, ok := setting.ResolveDomainToSecondaryAuto(domain); ok { + if _, valid := setting.GetSecondaryAutoGroup(target); valid { + common.SetContextKey(c, constant.ContextKeyUsingGroup, target) + common.SetContextKey(c, constant.ContextKeyTokenGroup, target) + logger.LogDebug(c, "domain routing: %s -> secondary auto group %s", domain, target) + } + } + c.Next() + } +} diff --git a/model/option.go b/model/option.go index ed1af72e..5750ad33 100644 --- a/model/option.go +++ b/model/option.go @@ -117,6 +117,8 @@ func InitOptionMap() { common.OptionMap["TopupGroupRatio"] = common.TopupGroupRatio2JSONString() common.OptionMap["Chats"] = setting.Chats2JsonString() common.OptionMap["AutoGroups"] = setting.AutoGroups2JsonString() + common.OptionMap["SecondaryAutoGroups"] = setting.SecondaryAutoGroups2JsonString() + common.OptionMap["DomainAutoRouting"] = setting.DomainAutoRouting2JsonString() common.OptionMap["DefaultUseAutoGroup"] = strconv.FormatBool(setting.DefaultUseAutoGroup) common.OptionMap["PayMethods"] = operation_setting.PayMethods2JsonString() common.OptionMap["GitHubClientId"] = "" @@ -386,6 +388,10 @@ func updateOptionMap(key string, value string) (err error) { err = setting.UpdateChatsByJsonString(value) case "AutoGroups": err = setting.UpdateAutoGroupsByJsonString(value) + case "SecondaryAutoGroups": + err = setting.UpdateSecondaryAutoGroupsByJsonString(value) + case "DomainAutoRouting": + err = setting.UpdateDomainAutoRoutingByJsonString(value) case "CustomCallbackAddress": operation_setting.CustomCallbackAddress = value case "EpayId": diff --git a/router/relay-router.go b/router/relay-router.go index 17a13cad..353c45bd 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -71,6 +71,7 @@ func SetRelayRouter(router *gin.Engine) { relayV1Router.Use(middleware.SystemPerformanceCheck()) relayV1Router.Use(middleware.TokenAuth()) relayV1Router.Use(middleware.ModelRequestRateLimit()) + relayV1Router.Use(middleware.DomainAutoGroupRouter()) { // WebSocket 路由(统一到 Relay) wsRouter := relayV1Router.Group("") @@ -179,7 +180,7 @@ func SetRelayRouter(router *gin.Engine) { relaySunoRouter := router.Group("/suno") relaySunoRouter.Use(middleware.RouteTag("relay")) relaySunoRouter.Use(middleware.SystemPerformanceCheck()) - relaySunoRouter.Use(middleware.TokenAuth(), middleware.Distribute()) + relaySunoRouter.Use(middleware.TokenAuth(), middleware.DomainAutoGroupRouter(), middleware.Distribute()) { relaySunoRouter.POST("/submit/:action", controller.RelayTask) relaySunoRouter.POST("/fetch", controller.RelayTaskFetch) @@ -191,6 +192,7 @@ func SetRelayRouter(router *gin.Engine) { relayGeminiRouter.Use(middleware.SystemPerformanceCheck()) relayGeminiRouter.Use(middleware.TokenAuth()) relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) + relayGeminiRouter.Use(middleware.DomainAutoGroupRouter()) relayGeminiRouter.Use(middleware.Distribute()) { // Gemini API 路径格式: /v1beta/models/{model_name}:{action} @@ -202,7 +204,7 @@ func SetRelayRouter(router *gin.Engine) { func registerMjRouterGroup(relayMjRouter *gin.RouterGroup) { relayMjRouter.GET("/image/:id", relay.RelayMidjourneyImage) - relayMjRouter.Use(middleware.TokenAuth(), middleware.Distribute()) + relayMjRouter.Use(middleware.TokenAuth(), middleware.DomainAutoGroupRouter(), middleware.Distribute()) { relayMjRouter.POST("/submit/action", controller.RelayMidjourney) relayMjRouter.POST("/submit/shorten", controller.RelayMidjourney) diff --git a/service/channel_select.go b/service/channel_select.go index a3710ef8..c3339d17 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -86,11 +86,11 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, selectGroup := param.TokenGroup userGroup := common.GetContextKeyString(param.Ctx, constant.ContextKeyUserGroup) - if param.TokenGroup == "auto" { - if len(setting.GetAutoGroups()) == 0 { + if setting.IsAutoTypeGroup(param.TokenGroup) { + autoGroups := GetAutoSubGroups(param.TokenGroup, userGroup) + if len(autoGroups) == 0 { return nil, selectGroup, errors.New("auto groups is not enabled") } - autoGroups := GetUserAutoGroup(userGroup) // startGroupIndex: the group index to start searching from // startGroupIndex: 开始搜索的分组索引 diff --git a/service/group.go b/service/group.go index a73642c3..a3a31c2f 100644 --- a/service/group.go +++ b/service/group.go @@ -53,6 +53,17 @@ func GetUserAutoGroup(userGroup string) []string { return autoGroups } +// GetAutoSubGroups 返回某个 auto 型分组(顶层 "auto" 或二级 auto)的可用子分组列表。 +// - 顶层 "auto":沿用 GetUserAutoGroup,与用户可用分组求交(保持现状)。 +// - 二级 auto:直接返回其配置的子分组(不与 UserUsableGroups 求交), +// 从而让高/低价子分组无需对用户可见即可生效,实现对用户隐藏。 +func GetAutoSubGroups(autoName, userGroup string) []string { + if autoName == "auto" { + return GetUserAutoGroup(userGroup) + } + return setting.GetAutoGroupSubGroups(autoName) +} + // GetUserGroupRatio 获取用户使用某个分组的倍率 // userGroup 用户分组 // group 需要获取倍率的分组 diff --git a/setting/secondary_auto_group.go b/setting/secondary_auto_group.go new file mode 100644 index 00000000..945ed61b --- /dev/null +++ b/setting/secondary_auto_group.go @@ -0,0 +1,165 @@ +package setting + +import ( + "net" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" +) + +// SecondaryAutoGroup 二级 auto 定义(高价池 / 低价池等)。 +// +// 设计要点: +// - 对用户隐藏:名字不进 GroupRatio / UserUsableGroups,因此不会出现在用户分组下拉, +// 用户只能通过顶层 "auto" 分组 + 来源域名路由间接命中。 +// - 复用顶层 auto 的选渠道 / 跨子分组重试 / 熔断能力(见 service/channel_select.go)。 +// - Groups 为有序子分组列表(顺序=优先级),每个子分组须已在 GroupRatio 注册并挂渠道。 +type SecondaryAutoGroup struct { + DisplayName string `json:"display_name"` + Groups []string `json:"groups"` +} + +var ( + // secondaryAutoGroups: 二级 auto 名 -> 定义 + secondaryAutoGroups = map[string]SecondaryAutoGroup{} + // domainAutoRouting: 规范化域名 -> 二级 auto 名 + domainAutoRouting = map[string]string{} + autoRoutingMu sync.RWMutex +) + +/* ----------------------------- SecondaryAutoGroups ----------------------------- */ + +func GetSecondaryAutoGroups() map[string]SecondaryAutoGroup { + autoRoutingMu.RLock() + defer autoRoutingMu.RUnlock() + out := make(map[string]SecondaryAutoGroup, len(secondaryAutoGroups)) + for k, v := range secondaryAutoGroups { + out[k] = v + } + return out +} + +func GetSecondaryAutoGroup(name string) (SecondaryAutoGroup, bool) { + autoRoutingMu.RLock() + defer autoRoutingMu.RUnlock() + g, ok := secondaryAutoGroups[name] + return g, ok +} + +func IsSecondaryAutoGroup(name string) bool { + autoRoutingMu.RLock() + defer autoRoutingMu.RUnlock() + _, ok := secondaryAutoGroups[name] + return ok +} + +// IsAutoTypeGroup 顶层 auto 或任一二级 auto。 +func IsAutoTypeGroup(name string) bool { + if name == "auto" { + return true + } + return IsSecondaryAutoGroup(name) +} + +// GetAutoGroupSubGroups 返回某个 auto 型分组的子分组列表(有序=优先级)。 +// 顶层 "auto" -> 全局 AutoGroups;二级 auto -> 其 Groups 配置。 +func GetAutoGroupSubGroups(name string) []string { + if name == "auto" { + return GetAutoGroups() + } + autoRoutingMu.RLock() + defer autoRoutingMu.RUnlock() + if g, ok := secondaryAutoGroups[name]; ok { + subs := make([]string, len(g.Groups)) + copy(subs, g.Groups) + return subs + } + return []string{} +} + +func SecondaryAutoGroups2JsonString() string { + autoRoutingMu.RLock() + defer autoRoutingMu.RUnlock() + b, err := common.Marshal(secondaryAutoGroups) + if err != nil { + return "{}" + } + return string(b) +} + +func UpdateSecondaryAutoGroupsByJsonString(jsonStr string) error { + m := map[string]SecondaryAutoGroup{} + if strings.TrimSpace(jsonStr) != "" { + if err := common.Unmarshal([]byte(jsonStr), &m); err != nil { + return err + } + } + autoRoutingMu.Lock() + defer autoRoutingMu.Unlock() + secondaryAutoGroups = m + return nil +} + +/* ----------------------------- DomainAutoRouting ----------------------------- */ + +func DomainAutoRouting2JsonString() string { + autoRoutingMu.RLock() + defer autoRoutingMu.RUnlock() + b, err := common.Marshal(domainAutoRouting) + if err != nil { + return "{}" + } + return string(b) +} + +func UpdateDomainAutoRoutingByJsonString(jsonStr string) error { + raw := map[string]string{} + if strings.TrimSpace(jsonStr) != "" { + if err := common.Unmarshal([]byte(jsonStr), &raw); err != nil { + return err + } + } + // 规范化域名 key(小写、去空白),与查询侧 NormalizeDomain 对齐 + norm := make(map[string]string, len(raw)) + for k, v := range raw { + key := strings.ToLower(strings.TrimSpace(k)) + if key == "" { + continue + } + norm[key] = strings.TrimSpace(v) + } + autoRoutingMu.Lock() + defer autoRoutingMu.Unlock() + domainAutoRouting = norm + return nil +} + +// ResolveDomainToSecondaryAuto 按规范化域名解析二级 auto 名。 +func ResolveDomainToSecondaryAuto(normalizedDomain string) (string, bool) { + if normalizedDomain == "" { + return "", false + } + autoRoutingMu.RLock() + defer autoRoutingMu.RUnlock() + target, ok := domainAutoRouting[normalizedDomain] + if !ok || target == "" { + return "", false + } + return target, true +} + +// NormalizeDomain 从 host 提取规范化域名:取首段(X-Forwarded-Host 可能多值)、去端口、小写。 +func NormalizeDomain(host string) string { + host = strings.TrimSpace(host) + if host == "" { + return "" + } + if idx := strings.IndexByte(host, ','); idx != -1 { + host = strings.TrimSpace(host[:idx]) + } + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + return strings.ToLower(host) +} diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx index daad5066..766fa03c 100644 --- a/web/default/src/features/system-settings/billing/index.tsx +++ b/web/default/src/features/system-settings/billing/index.tsx @@ -57,6 +57,8 @@ const defaultBillingSettings: BillingSettings = { GroupGroupRatio: '', AutoGroups: '', DefaultUseAutoGroup: false, + SecondaryAutoGroups: '', + DomainAutoRouting: '', 'group_ratio_setting.group_special_usable_group': '{}', PayAddress: '', EpayId: '', diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx index 1a1dc8a2..6c80ec30 100644 --- a/web/default/src/features/system-settings/billing/section-registry.tsx +++ b/web/default/src/features/system-settings/billing/section-registry.tsx @@ -48,6 +48,8 @@ const getGroupDefaults = (settings: BillingSettings) => ({ DefaultUseAutoGroup: settings.DefaultUseAutoGroup, GroupSpecialUsableGroup: settings['group_ratio_setting.group_special_usable_group'], + SecondaryAutoGroups: settings.SecondaryAutoGroups, + DomainAutoRouting: settings.DomainAutoRouting, }) const BILLING_SECTIONS = [ diff --git a/web/default/src/features/system-settings/models/group-ratio-form.tsx b/web/default/src/features/system-settings/models/group-ratio-form.tsx index c727adcf..4c97f736 100644 --- a/web/default/src/features/system-settings/models/group-ratio-form.tsx +++ b/web/default/src/features/system-settings/models/group-ratio-form.tsx @@ -58,6 +58,7 @@ import { import { SettingsPageActionsPortal } from '../components/settings-page-context' import { GroupRatioVisualEditor } from './group-ratio-visual-editor' import { GroupSpecialUsableRulesEditor } from './group-special-usable-editor' +import { SecondaryAutoGroupEditor } from './secondary-auto-group-editor' type GroupFormValues = { GroupRatio: string @@ -67,6 +68,8 @@ type GroupFormValues = { AutoGroups: string DefaultUseAutoGroup: boolean GroupSpecialUsableGroup: string + SecondaryAutoGroups: string + DomainAutoRouting: string } type GroupRatioFormProps = { @@ -153,6 +156,14 @@ export const GroupRatioForm = memo(function GroupRatioForm({ } /> + + handleFieldChange(field as keyof GroupFormValues, value) + } + /> + + ( + + {t('Secondary auto groups')} + +