From 35c1d5e3b208eaa0b4d6614ee972ba8bfa9f9648 Mon Sep 17 00:00:00 2001 From: z23cc Date: Mon, 11 May 2026 11:25:36 +0800 Subject: [PATCH 01/38] fix channel affinity cache validation Add local Docker stack script for one-command builds. --- bin/docker-local.sh | 416 ++++++++++++++++++ middleware/distributor.go | 29 +- .../distributor_channel_affinity_test.go | 196 +++++++++ service/channel_affinity.go | 64 +++ 4 files changed, 679 insertions(+), 26 deletions(-) create mode 100755 bin/docker-local.sh create mode 100644 middleware/distributor_channel_affinity_test.go diff --git a/bin/docker-local.sh b/bin/docker-local.sh new file mode 100755 index 000000000000..fb1b3364b0ab --- /dev/null +++ b/bin/docker-local.sh @@ -0,0 +1,416 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${ROOT_DIR}" + +PROJECT_NAME="${PROJECT_NAME:-new-api-local}" +IMAGE_NAME="${IMAGE_NAME:-new-api:local}" +CONTAINER_NAME="${CONTAINER_NAME:-${PROJECT_NAME}-app}" +POSTGRES_CONTAINER_NAME="${POSTGRES_CONTAINER_NAME:-${PROJECT_NAME}-postgres}" +REDIS_CONTAINER_NAME="${REDIS_CONTAINER_NAME:-${PROJECT_NAME}-redis}" +NETWORK_NAME="${NETWORK_NAME:-${PROJECT_NAME}-network}" +POSTGRES_VOLUME="${POSTGRES_VOLUME:-${PROJECT_NAME}-postgres-data}" +REDIS_VOLUME="${REDIS_VOLUME:-${PROJECT_NAME}-redis-data}" +APP_DATA_VOLUME="${APP_DATA_VOLUME:-${PROJECT_NAME}-app-data}" + +HOST_PORT="${HOST_PORT:-${PORT:-3000}}" +APP_PORT="${APP_PORT:-3000}" +POSTGRES_HOST_PORT="${POSTGRES_HOST_PORT:-}" +REDIS_HOST_PORT="${REDIS_HOST_PORT:-}" +LOCAL_TZ="${TZ:-Asia/Shanghai}" +ENV_FILE="${ENV_FILE:-}" +FOLLOW_LOGS="${FOLLOW_LOGS:-0}" +NO_CACHE="${NO_CACHE:-0}" +PLATFORM="${PLATFORM:-}" +ACTION="${1:-up}" + +STATE_DIR="${STATE_DIR:-${ROOT_DIR}/data/docker-local}" +LOG_DIR="${LOG_DIR:-${ROOT_DIR}/logs/docker-local}" +SECRETS_FILE="${SECRETS_FILE:-${STATE_DIR}/.env.generated}" + +POSTGRES_IMAGE="${POSTGRES_IMAGE:-postgres:15-alpine}" +REDIS_IMAGE="${REDIS_IMAGE:-redis:7-alpine}" +POSTGRES_DB="${POSTGRES_DB:-new-api}" +POSTGRES_USER="${POSTGRES_USER:-newapi}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-}" +REDIS_PASSWORD="${REDIS_PASSWORD:-}" +SESSION_SECRET="${SESSION_SECRET:-}" +CRYPTO_SECRET="${CRYPTO_SECRET:-}" +NODE_NAME="${NODE_NAME:-${PROJECT_NAME}-node-1}" +BUILD_ON_UP="${BUILD_ON_UP:-1}" + +usage() { + cat <<'USAGE' +Usage: + bash bin/docker-local.sh [up|build|run|stop|logs|status|clean|help] + +Default action: + up Build app image, then start PostgreSQL, Redis, and new-api. + +No manual config is required. The script persists generated secrets in: + ./data/docker-local/.env.generated + +Common environment overrides: + PROJECT_NAME=new-api-local Prefix for containers/network/volumes + IMAGE_NAME=new-api:local Docker image tag for the app + HOST_PORT=3000 Host port for new-api + POSTGRES_HOST_PORT=5432 Optional host port for PostgreSQL + REDIS_HOST_PORT=6379 Optional host port for Redis + BUILD_ON_UP=0 Skip docker build during up + NO_CACHE=1 Build without Docker cache + PLATFORM=linux/amd64 Optional docker build --platform value + FOLLOW_LOGS=1 Follow app logs after starting + ENV_FILE=.env.local Optional extra env file for the app + +Advanced overrides: + POSTGRES_PASSWORD=... Override generated PostgreSQL password + REDIS_PASSWORD=... Override generated Redis password + SESSION_SECRET=... Override generated session secret + CRYPTO_SECRET=... Override generated crypto secret + +Examples: + bash bin/docker-local.sh + HOST_PORT=3001 bash bin/docker-local.sh up + BUILD_ON_UP=0 bash bin/docker-local.sh up + bash bin/docker-local.sh logs + bash bin/docker-local.sh status + bash bin/docker-local.sh stop + bash bin/docker-local.sh clean +USAGE +} + +log() { + printf '\033[1;34m==>\033[0m %s\n' "$*" +} + +warn() { + printf '\033[1;33mWARN\033[0m %s\n' "$*" >&2 +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Missing required command: $1" >&2 + exit 1 + fi +} + +random_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + else + LC_ALL=C tr -dc 'A-Za-z0-9' "${SECRETS_FILE}" </dev/null 2>&1 +} + +container_running() { + [[ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null || true)" == "true" ]] +} + +remove_container_if_exists() { + local name="$1" + if container_exists "${name}"; then + log "Removing existing container ${name}" + docker rm -f "${name}" >/dev/null + fi +} + +ensure_network() { + if ! docker network inspect "${NETWORK_NAME}" >/dev/null 2>&1; then + log "Creating Docker network ${NETWORK_NAME}" + docker network create "${NETWORK_NAME}" >/dev/null + fi +} + +build_image() { + require_cmd docker + + local build_args=() + if [[ "${NO_CACHE}" == "1" || "${NO_CACHE}" == "true" ]]; then + build_args+=(--no-cache) + fi + if [[ -n "${PLATFORM}" ]]; then + build_args+=(--platform "${PLATFORM}") + fi + + log "Building Docker image ${IMAGE_NAME}" + DOCKER_BUILDKIT="${DOCKER_BUILDKIT:-1}" docker build \ + "${build_args[@]}" \ + -f "${ROOT_DIR}/Dockerfile" \ + -t "${IMAGE_NAME}" \ + "${ROOT_DIR}" +} + +start_postgres() { + require_cmd docker + ensure_network + ensure_secrets + + if container_running "${POSTGRES_CONTAINER_NAME}"; then + log "PostgreSQL already running: ${POSTGRES_CONTAINER_NAME}" + return + fi + remove_container_if_exists "${POSTGRES_CONTAINER_NAME}" + + local port_args=() + if [[ -n "${POSTGRES_HOST_PORT}" ]]; then + port_args=(-p "${POSTGRES_HOST_PORT}:5432") + fi + + log "Starting PostgreSQL ${POSTGRES_CONTAINER_NAME}" + docker run -d \ + --name "${POSTGRES_CONTAINER_NAME}" \ + --restart unless-stopped \ + --network "${NETWORK_NAME}" \ + "${port_args[@]}" \ + -v "${POSTGRES_VOLUME}:/var/lib/postgresql/data" \ + -e "POSTGRES_DB=${POSTGRES_DB}" \ + -e "POSTGRES_USER=${POSTGRES_USER}" \ + -e "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" \ + -e "TZ=${LOCAL_TZ}" \ + "${POSTGRES_IMAGE}" >/dev/null +} + +start_redis() { + require_cmd docker + ensure_network + ensure_secrets + + if container_running "${REDIS_CONTAINER_NAME}"; then + log "Redis already running: ${REDIS_CONTAINER_NAME}" + return + fi + remove_container_if_exists "${REDIS_CONTAINER_NAME}" + + local port_args=() + if [[ -n "${REDIS_HOST_PORT}" ]]; then + port_args=(-p "${REDIS_HOST_PORT}:6379") + fi + + log "Starting Redis ${REDIS_CONTAINER_NAME}" + docker run -d \ + --name "${REDIS_CONTAINER_NAME}" \ + --restart unless-stopped \ + --network "${NETWORK_NAME}" \ + "${port_args[@]}" \ + -v "${REDIS_VOLUME}:/data" \ + -e "TZ=${LOCAL_TZ}" \ + "${REDIS_IMAGE}" \ + redis-server --appendonly yes --requirepass "${REDIS_PASSWORD}" >/dev/null +} + +wait_for_postgres() { + log "Waiting for PostgreSQL" + local i + for i in {1..60}; do + if docker exec "${POSTGRES_CONTAINER_NAME}" pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" >/dev/null 2>&1; then + return + fi + sleep 1 + done + echo "PostgreSQL did not become ready in time" >&2 + docker logs "${POSTGRES_CONTAINER_NAME}" >&2 || true + exit 1 +} + +wait_for_redis() { + log "Waiting for Redis" + local i + for i in {1..60}; do + if docker exec "${REDIS_CONTAINER_NAME}" redis-cli -a "${REDIS_PASSWORD}" ping >/dev/null 2>&1; then + return + fi + sleep 1 + done + echo "Redis did not become ready in time" >&2 + docker logs "${REDIS_CONTAINER_NAME}" >&2 || true + exit 1 +} + +run_container() { + require_cmd docker + ensure_network + ensure_secrets + start_postgres + start_redis + wait_for_postgres + wait_for_redis + mkdir -p "${LOG_DIR}" + + remove_container_if_exists "${CONTAINER_NAME}" + + local sql_dsn="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_CONTAINER_NAME}:5432/${POSTGRES_DB}" + local redis_dsn="redis://:${REDIS_PASSWORD}@${REDIS_CONTAINER_NAME}:6379/0" + local env_args=( + -e "TZ=${LOCAL_TZ}" + -e "PORT=${APP_PORT}" + -e "SQL_DSN=${SQL_DSN:-${sql_dsn}}" + -e "REDIS_CONN_STRING=${REDIS_CONN_STRING:-${redis_dsn}}" + -e "SESSION_SECRET=${SESSION_SECRET}" + -e "CRYPTO_SECRET=${CRYPTO_SECRET}" + -e "ERROR_LOG_ENABLED=${ERROR_LOG_ENABLED:-true}" + -e "BATCH_UPDATE_ENABLED=${BATCH_UPDATE_ENABLED:-true}" + -e "MEMORY_CACHE_ENABLED=${MEMORY_CACHE_ENABLED:-true}" + -e "SYNC_FREQUENCY=${SYNC_FREQUENCY:-60}" + -e "NODE_NAME=${NODE_NAME}" + ) + + local pass_env_vars=( + LOG_SQL_DSN + RELAY_TIMEOUT + STREAMING_TIMEOUT + CHANNEL_UPDATE_FREQUENCY + GENERATE_DEFAULT_TOKEN + FRONTEND_BASE_URL + TRUSTED_REDIRECT_DOMAINS + ) + local name + for name in "${pass_env_vars[@]}"; do + if [[ -n "${!name:-}" ]]; then + env_args+=(-e "${name}=${!name}") + fi + done + + if [[ -n "${ENV_FILE}" ]]; then + if [[ ! -f "${ENV_FILE}" ]]; then + echo "ENV_FILE does not exist: ${ENV_FILE}" >&2 + exit 1 + fi + env_args+=(--env-file "${ENV_FILE}") + fi + + log "Starting app ${CONTAINER_NAME} on http://localhost:${HOST_PORT}" + docker run -d \ + --name "${CONTAINER_NAME}" \ + --restart unless-stopped \ + --network "${NETWORK_NAME}" \ + -p "${HOST_PORT}:${APP_PORT}" \ + -v "${APP_DATA_VOLUME}:/data" \ + -v "${LOG_DIR}:/app/logs" \ + "${env_args[@]}" \ + "${IMAGE_NAME}" \ + --log-dir /app/logs >/dev/null + + log "Secrets file: ${SECRETS_FILE}" + log "PostgreSQL volume: ${POSTGRES_VOLUME}" + log "Redis volume: ${REDIS_VOLUME}" + log "App data volume: ${APP_DATA_VOLUME}" + log "Logs dir: ${LOG_DIR}" + log "Open: http://localhost:${HOST_PORT}" + + if [[ "${FOLLOW_LOGS}" == "1" || "${FOLLOW_LOGS}" == "true" ]]; then + docker logs -f "${CONTAINER_NAME}" + fi +} + +stop_container() { + require_cmd docker + remove_container_if_exists "${CONTAINER_NAME}" + remove_container_if_exists "${REDIS_CONTAINER_NAME}" + remove_container_if_exists "${POSTGRES_CONTAINER_NAME}" +} + +show_logs() { + require_cmd docker + local target="${2:-app}" + case "${target}" in + app) docker logs -f "${CONTAINER_NAME}" ;; + postgres|pg) docker logs -f "${POSTGRES_CONTAINER_NAME}" ;; + redis) docker logs -f "${REDIS_CONTAINER_NAME}" ;; + *) echo "Unknown logs target: ${target} (use app|postgres|redis)" >&2; exit 1 ;; + esac +} + +show_status() { + require_cmd docker + docker ps -a \ + --filter "name=^/${CONTAINER_NAME}$" \ + --filter "name=^/${POSTGRES_CONTAINER_NAME}$" \ + --filter "name=^/${REDIS_CONTAINER_NAME}$" +} + +clean_all() { + stop_container + log "Removing image ${IMAGE_NAME} if it exists" + docker image rm "${IMAGE_NAME}" >/dev/null 2>&1 || true + + if [[ "${KEEP_VOLUMES:-1}" == "0" || "${KEEP_VOLUMES:-1}" == "false" ]]; then + warn "Removing persistent volumes and generated secrets" + docker volume rm "${POSTGRES_VOLUME}" "${REDIS_VOLUME}" "${APP_DATA_VOLUME}" >/dev/null 2>&1 || true + rm -f "${SECRETS_FILE}" + else + log "Keeping volumes. Set KEEP_VOLUMES=0 bash bin/docker-local.sh clean to remove them." + fi +} + +case "${ACTION}" in + up) + if [[ "${BUILD_ON_UP}" == "1" || "${BUILD_ON_UP}" == "true" ]]; then + build_image + fi + run_container + ;; + build) + build_image + ;; + run) + run_container + ;; + stop) + stop_container + ;; + logs) + show_logs "$@" + ;; + status) + show_status + ;; + clean) + clean_all + ;; + help|-h|--help) + usage + ;; + *) + echo "Unknown action: ${ACTION}" >&2 + usage + exit 1 + ;; +esac diff --git a/middleware/distributor.go b/middleware/distributor.go index 2263fae3fae5..85ae53859b18 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -99,32 +99,9 @@ func Distribute() func(c *gin.Context) { } } - if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { - preferred, err := model.CacheGetChannel(preferredChannelID) - if err == nil && preferred != nil { - if preferred.Status != common.ChannelStatusEnabled { - if service.ShouldSkipRetryAfterChannelAffinityFailure(c) { - abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorAffinityChannelDisabled)) - return - } - } else if usingGroup == "auto" { - userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) - autoGroups := service.GetUserAutoGroup(userGroup) - for _, g := range autoGroups { - if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { - selectGroup = g - common.SetContextKey(c, constant.ContextKeyAutoGroup, g) - channel = preferred - service.MarkChannelAffinityUsed(c, g, preferred.Id) - break - } - } - } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) { - channel = preferred - selectGroup = usingGroup - service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id) - } - } + if preferred, selectedGroup, found := service.GetUsablePreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { + channel = preferred + selectGroup = selectedGroup } if channel == nil { diff --git a/middleware/distributor_channel_affinity_test.go b/middleware/distributor_channel_affinity_test.go new file mode 100644 index 000000000000..07f7f53cd40b --- /dev/null +++ b/middleware/distributor_channel_affinity_test.go @@ -0,0 +1,196 @@ +package middleware + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupDistributorAffinityTestDB(t *testing.T) *gorm.DB { + t.Helper() + + originalDB := model.DB + originalLogDB := model.LOG_DB + originalMemoryCacheEnabled := common.MemoryCacheEnabled + originalRedisEnabled := common.RedisEnabled + originalUsingSQLite := common.UsingSQLite + originalUsingMySQL := common.UsingMySQL + originalUsingPostgreSQL := common.UsingPostgreSQL + + gin.SetMode(gin.TestMode) + common.MemoryCacheEnabled = true + common.RedisEnabled = false + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + + model.DB = db + model.LOG_DB = db + require.NoError(t, db.AutoMigrate(&model.Channel{}, &model.Ability{})) + service.ClearChannelAffinityCacheAll() + + t.Cleanup(func() { + service.ClearChannelAffinityCacheAll() + _ = db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Ability{}).Error + _ = db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&model.Channel{}).Error + model.InitChannelCache() + + model.DB = originalDB + model.LOG_DB = originalLogDB + common.MemoryCacheEnabled = originalMemoryCacheEnabled + common.RedisEnabled = originalRedisEnabled + common.UsingSQLite = originalUsingSQLite + common.UsingMySQL = originalUsingMySQL + common.UsingPostgreSQL = originalUsingPostgreSQL + if originalMemoryCacheEnabled && originalDB != nil { + model.InitChannelCache() + } + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + }) + + return db +} + +func seedDistributorAffinityChannel(t *testing.T, db *gorm.DB, name string, status int, priority int64) *model.Channel { + t.Helper() + return seedDistributorAffinityChannelForModel(t, db, name, status, priority, "gpt-5") +} + +func seedDistributorAffinityChannelForModel(t *testing.T, db *gorm.DB, name string, status int, priority int64, modelName string) *model.Channel { + t.Helper() + + weight := uint(100) + autoBan := 1 + baseURL := "https://example.com" + channel := &model.Channel{ + Type: constant.ChannelTypeOpenAI, + Key: "sk-" + name, + Status: status, + Name: name, + Weight: &weight, + BaseURL: &baseURL, + Models: modelName, + Group: "default", + Priority: &priority, + AutoBan: &autoBan, + } + require.NoError(t, db.Create(channel).Error) + require.NoError(t, db.Create(&model.Ability{ + Group: "default", + Model: modelName, + ChannelId: channel.Id, + Enabled: status == common.ChannelStatusEnabled, + Priority: &priority, + Weight: weight, + }).Error) + return channel +} + +func buildAffinityRequestContext(t *testing.T, body string) *gin.Context { + t.Helper() + + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + ctx.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + ctx.Request.Header.Set("Content-Type", "application/json") + common.SetContextKey(ctx, constant.ContextKeyUsingGroup, "default") + common.SetContextKey(ctx, constant.ContextKeyUserGroup, "default") + return ctx +} + +func serveAffinityResponsesRequest(t *testing.T, body string) (int, int) { + t.Helper() + + var selectedChannelID int + router := gin.New() + router.Use(func(c *gin.Context) { + common.SetContextKey(c, constant.ContextKeyUsingGroup, "default") + common.SetContextKey(c, constant.ContextKeyUserGroup, "default") + common.SetContextKey(c, constant.ContextKeyTokenModelLimitEnabled, false) + c.Next() + }) + router.POST("/v1/responses", Distribute(), func(c *gin.Context) { + selectedChannelID = common.GetContextKeyInt(c, constant.ContextKeyChannelId) + c.Status(http.StatusOK) + }) + + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + return recorder.Code, selectedChannelID +} + +func TestDistributeInvalidatesDisabledAffinityChannelAndReselects(t *testing.T) { + db := setupDistributorAffinityTestDB(t) + + disabled := seedDistributorAffinityChannel(t, db, "affinity-disabled", common.ChannelStatusManuallyDisabled, 100) + available := seedDistributorAffinityChannel(t, db, "affinity-available", common.ChannelStatusEnabled, 90) + model.InitChannelCache() + + body := `{"model":"gpt-5","prompt_cache_key":"affinity-session-disabled"}` + bindCtx := buildAffinityRequestContext(t, body) + _, found := service.GetPreferredChannelByAffinity(bindCtx, "gpt-5", "default") + require.False(t, found) + service.RecordChannelAffinity(bindCtx, disabled.Id) + + checkCtx := buildAffinityRequestContext(t, body) + cachedChannelID, found := service.GetPreferredChannelByAffinity(checkCtx, "gpt-5", "default") + require.True(t, found) + require.Equal(t, disabled.Id, cachedChannelID) + + statusCode, selectedChannelID := serveAffinityResponsesRequest(t, body) + require.Equal(t, http.StatusOK, statusCode) + require.Equal(t, available.Id, selectedChannelID) + + refreshedCtx := buildAffinityRequestContext(t, body) + cachedChannelID, found = service.GetPreferredChannelByAffinity(refreshedCtx, "gpt-5", "default") + require.True(t, found) + require.Equal(t, available.Id, cachedChannelID) +} + +func TestDistributeInvalidatesModelMismatchedAffinityChannelAndReselects(t *testing.T) { + db := setupDistributorAffinityTestDB(t) + + mismatched := seedDistributorAffinityChannelForModel(t, db, "affinity-gpt5", common.ChannelStatusEnabled, 100, "gpt-5") + available := seedDistributorAffinityChannelForModel(t, db, "affinity-gpt4", common.ChannelStatusEnabled, 90, "gpt-4") + model.InitChannelCache() + + cacheBody := `{"model":"gpt-5","prompt_cache_key":"affinity-session-model-mismatch"}` + bindCtx := buildAffinityRequestContext(t, cacheBody) + _, found := service.GetPreferredChannelByAffinity(bindCtx, "gpt-5", "default") + require.False(t, found) + service.RecordChannelAffinity(bindCtx, mismatched.Id) + + requestBody := `{"model":"gpt-4","prompt_cache_key":"affinity-session-model-mismatch"}` + checkCtx := buildAffinityRequestContext(t, requestBody) + cachedChannelID, found := service.GetPreferredChannelByAffinity(checkCtx, "gpt-4", "default") + require.True(t, found) + require.Equal(t, mismatched.Id, cachedChannelID) + + statusCode, selectedChannelID := serveAffinityResponsesRequest(t, requestBody) + require.Equal(t, http.StatusOK, statusCode) + require.Equal(t, available.Id, selectedChannelID) + + refreshedCtx := buildAffinityRequestContext(t, requestBody) + cachedChannelID, found = service.GetPreferredChannelByAffinity(refreshedCtx, "gpt-4", "default") + require.True(t, found) + require.Equal(t, available.Id, cachedChannelID) +} diff --git a/service/channel_affinity.go b/service/channel_affinity.go index f16c350bb14e..93e12369dfd4 100644 --- a/service/channel_affinity.go +++ b/service/channel_affinity.go @@ -10,7 +10,9 @@ import ( "time" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/pkg/cachex" "github.com/QuantumNous/new-api/setting/operation_setting" "github.com/QuantumNous/new-api/types" @@ -623,6 +625,68 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup return 0, false } +func GetUsablePreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (*model.Channel, string, bool) { + channelID, found := GetPreferredChannelByAffinity(c, modelName, usingGroup) + if !found { + return nil, "", false + } + + preferred, err := model.CacheGetChannel(channelID) + if err != nil || preferred == nil { + DiscardChannelAffinityCacheForContext(c) + return nil, "", false + } + + selectedGroup, ok := validateChannelAffinityHit(c, preferred, modelName, usingGroup) + if !ok { + DiscardChannelAffinityCacheForContext(c) + return nil, "", false + } + + MarkChannelAffinityUsed(c, selectedGroup, preferred.Id) + return preferred, selectedGroup, true +} + +func validateChannelAffinityHit(c *gin.Context, channel *model.Channel, modelName string, usingGroup string) (string, bool) { + if channel == nil || channel.Id <= 0 { + return "", false + } + if channel.Status != common.ChannelStatusEnabled { + return "", false + } + + if usingGroup == "auto" { + userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) + autoGroups := GetUserAutoGroup(userGroup) + for _, group := range autoGroups { + if model.IsChannelEnabledForGroupModel(group, modelName, channel.Id) { + common.SetContextKey(c, constant.ContextKeyAutoGroup, group) + return group, true + } + } + return "", false + } + + if model.IsChannelEnabledForGroupModel(usingGroup, modelName, channel.Id) { + return usingGroup, true + } + return "", false +} + +func DiscardChannelAffinityCacheForContext(c *gin.Context) bool { + cacheKey, _, ok := getChannelAffinityContext(c) + if !ok || cacheKey == "" { + return false + } + cache := getChannelAffinityCache() + deleted, err := cache.DeleteMany([]string{cacheKey}) + if err != nil { + common.SysError(fmt.Sprintf("channel affinity cache delete failed: key=%s, err=%v", cacheKey, err)) + return false + } + return deleted[cacheKey] +} + func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool { if c == nil { return false From 4135155cbb75b33d36f52c7dced37a1c03af041a Mon Sep 17 00:00:00 2001 From: z23cc Date: Thu, 28 May 2026 13:43:25 +0800 Subject: [PATCH 02/38] chore: default local docker deployment to new frontend --- bin/docker-local.sh | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/bin/docker-local.sh b/bin/docker-local.sh index fb1b3364b0ab..313571643b8b 100755 --- a/bin/docker-local.sh +++ b/bin/docker-local.sh @@ -40,6 +40,9 @@ SESSION_SECRET="${SESSION_SECRET:-}" CRYPTO_SECRET="${CRYPTO_SECRET:-}" NODE_NAME="${NODE_NAME:-${PROJECT_NAME}-node-1}" BUILD_ON_UP="${BUILD_ON_UP:-1}" +if [[ -z "${FRONTEND_THEME+x}" ]]; then + FRONTEND_THEME="default" +fi usage() { cat <<'USAGE' @@ -63,6 +66,7 @@ Common environment overrides: PLATFORM=linux/amd64 Optional docker build --platform value FOLLOW_LOGS=1 Follow app logs after starting ENV_FILE=.env.local Optional extra env file for the app + FRONTEND_THEME=default Frontend theme for local deployment (default|classic; empty to skip) Advanced overrides: POSTGRES_PASSWORD=... Override generated PostgreSQL password @@ -264,6 +268,37 @@ wait_for_redis() { exit 1 } +wait_for_options_table() { + local i + for i in {1..60}; do + if docker exec "${POSTGRES_CONTAINER_NAME}" psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -tAc "SELECT to_regclass('public.options') IS NOT NULL" 2>/dev/null | grep -q "t"; then + return + fi + sleep 1 + done + echo "options table did not become ready in time" >&2 + docker logs "${CONTAINER_NAME}" >&2 || true + exit 1 +} + +apply_frontend_theme() { + if [[ -z "${FRONTEND_THEME}" ]]; then + return + fi + if [[ "${FRONTEND_THEME}" != "default" && "${FRONTEND_THEME}" != "classic" ]]; then + echo "Invalid FRONTEND_THEME: ${FRONTEND_THEME} (use default|classic, or empty to skip)" >&2 + exit 1 + fi + + log "Setting frontend theme to ${FRONTEND_THEME}" + wait_for_options_table + docker exec "${POSTGRES_CONTAINER_NAME}" psql -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=1 -c \ + "INSERT INTO options (\"key\", \"value\") VALUES ('theme.frontend','${FRONTEND_THEME}') ON CONFLICT (\"key\") DO UPDATE SET \"value\" = EXCLUDED.\"value\";" >/dev/null + + log "Restarting app to apply frontend theme" + docker restart "${CONTAINER_NAME}" >/dev/null +} + run_container() { require_cmd docker ensure_network @@ -328,6 +363,8 @@ run_container() { "${IMAGE_NAME}" \ --log-dir /app/logs >/dev/null + apply_frontend_theme + log "Secrets file: ${SECRETS_FILE}" log "PostgreSQL volume: ${POSTGRES_VOLUME}" log "Redis volume: ${REDIS_VOLUME}" From 8b8052e1bd1ae5546a266d5ef4159fe0b82e50d5 Mon Sep 17 00:00:00 2001 From: z23cc Date: Fri, 29 May 2026 21:09:10 +0800 Subject: [PATCH 03/38] feat(nav): dynamic database-backed navigation system with multi-lingual fallback and visibility rules --- .gitignore | 1 + controller/navigation.go | 400 +++++++++ docker-compose.yml | 2 +- middleware/auth.go | 10 + model/main.go | 143 ++++ model/navigation.go | 77 ++ router/api-router.go | 19 + service/navigation.go | 342 ++++++++ service/navigation_test.go | 242 ++++++ service/waffo_pancake_test.go | 5 + .../layout/components/public-navigation.tsx | 133 ++- .../components/layout/components/top-nav.tsx | 226 +++-- web/default/src/components/layout/types.ts | 2 + .../maintenance/header-navigation-section.tsx | 777 ++++++++++++------ .../system-settings/site/section-registry.tsx | 11 +- .../components/usage-logs-mobile-card.tsx | 4 +- web/default/src/hooks/use-top-nav-links.ts | 137 ++- web/default/src/lib/nav-modules.ts | 23 + 18 files changed, 2127 insertions(+), 427 deletions(-) create mode 100644 controller/navigation.go create mode 100644 model/navigation.go create mode 100644 service/navigation.go create mode 100644 service/navigation_test.go diff --git a/.gitignore b/.gitignore index bbc5717e4727..32cb4c653f9a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ data/ .test token_estimator_test.go skills-lock.json +query-newapi/* \ No newline at end of file diff --git a/controller/navigation.go b/controller/navigation.go new file mode 100644 index 000000000000..d7b3cc7e6d9f --- /dev/null +++ b/controller/navigation.go @@ -0,0 +1,400 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +package controller + +import ( + "net/http" + "strconv" + + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/service" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// GetNavigationTree 用户侧获取导航树 API +func GetNavigationTree(c *gin.Context) { + menuKey := c.DefaultQuery("menu_key", "default_web_top") + + // 从 Context 中提取语言,默认为 zh-CN + locale := c.GetString("lang") + if locale == "" { + locale = c.DefaultQuery("lang", "zh-CN") + } + + // 提取用户登录态与权限 + userID := c.GetInt("id") + userRole := c.GetInt("role") + userGroup := c.GetString("group") + + isAuthenticated := userID > 0 + + tree, err := service.NavService.GetVisibleNavigationTree(menuKey, locale, userRole, userGroup, isAuthenticated) + if err != nil { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": tree, + }) +} + +// ================= 管理侧菜单 CRUD 接口 ================= + +// AdminGetMenus 获取所有菜单容器列表 +func AdminGetMenus(c *gin.Context) { + var menus []model.NavigationMenu + if err := model.DB.Order("id asc").Find(&menus).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": menus}) +} + +// AdminCreateMenu 创建新的菜单配置 +func AdminCreateMenu(c *gin.Context) { + var menu model.NavigationMenu + if err := c.ShouldBindJSON(&menu); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + menu.IsSystem = false // 管理员手工创建的绝非系统菜单 + if err := model.DB.Create(&menu).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + service.NavService.InvalidateCache() + c.JSON(http.StatusOK, gin.H{"success": true, "data": menu}) +} + +// AdminUpdateMenu 更新菜单元数据 +func AdminUpdateMenu(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid menu id"}) + return + } + + var menu model.NavigationMenu + if err := model.DB.First(&menu, id).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "menu not found"}) + return + } + + var input model.NavigationMenu + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + // 仅允许修改名称、启用状态 + menu.Name = input.Name + menu.Enabled = input.Enabled + + if err := model.DB.Save(&menu).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + service.NavService.InvalidateCache() + c.JSON(http.StatusOK, gin.H{"success": true, "data": menu}) +} + +// AdminDeleteMenu 删除非系统级菜单 +func AdminDeleteMenu(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid menu id"}) + return + } + + var menu model.NavigationMenu + if err := model.DB.First(&menu, id).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "menu not found"}) + return + } + + if menu.IsSystem { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "system menu cannot be deleted"}) + return + } + + // 在事务中级联清理 Menu 关联的所有 Item + err = model.DB.Transaction(func(tx *gorm.DB) error { + var items []model.NavigationItem + if err := tx.Where("menu_id = ?", menu.ID).Find(&items).Error; err != nil { + return err + } + + for _, item := range items { + // 触发级联物理删除 Translations & Rules + if err := tx.Delete(&item).Error; err != nil { + return err + } + } + + return tx.Delete(&menu).Error + }) + + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + service.NavService.InvalidateCache() + c.JSON(http.StatusOK, gin.H{"success": true, "message": "menu deleted successfully"}) +} + +// ================= 管理侧菜单节点 CRUD 接口 ================= + +// AdminGetItems 获取某个菜单下所有的平铺节点(含级联预加载翻译和规则,由前端还原树) +func AdminGetItems(c *gin.Context) { + menuIDStr := c.Query("menu_id") + if menuIDStr == "" { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "menu_id query parameter is required"}) + return + } + + menuID, err := strconv.Atoi(menuIDStr) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid menu_id"}) + return + } + + var items []model.NavigationItem + err = model.DB.Where("menu_id = ?", menuID). + Order("sort_order asc, id asc"). + Preload("Translations"). + Preload("Rules"). + Find(&items).Error + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "data": items}) +} + +// AdminCreateItem 创建菜单节点(包含多语言和可见性规则的一体化保存) +func AdminCreateItem(c *gin.Context) { + var item model.NavigationItem + if err := c.ShouldBindJSON(&item); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + // 1. URL 协议安全性拦截校验(防 XSS 注入) + if err := service.NavService.ValidateItemURL(item.Type, item.URL); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + // 2. 事务级联创建节点及其子集合 + err := model.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Omit("Translations", "Rules").Create(&item).Error; err != nil { + return err + } + + // 保存多语言 + for i := range item.Translations { + item.Translations[i].ItemID = item.ID + if err := tx.Create(&item.Translations[i]).Error; err != nil { + return err + } + } + + // 保存可见性规则 + for i := range item.Rules { + item.Rules[i].ItemID = item.ID + if err := tx.Create(&item.Rules[i]).Error; err != nil { + return err + } + } + + return nil + }) + + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + service.NavService.InvalidateCache() + c.JSON(http.StatusOK, gin.H{"success": true, "data": item}) +} + +// AdminUpdateItem 更新菜单节点及其子属性(采用 FullSaveAssociations 完整事务更新) +func AdminUpdateItem(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid item id"}) + return + } + + var item model.NavigationItem + if err := model.DB.Preload("Translations").Preload("Rules").First(&item, id).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "item not found"}) + return + } + + var input model.NavigationItem + if err := c.ShouldBindJSON(&input); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + // 1. 安全性 URL 校验 + if err := service.NavService.ValidateItemURL(input.Type, input.URL); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + // 2. 级联事务全量覆盖更新 + err = model.DB.Transaction(func(tx *gorm.DB) error { + // 清理旧的多语言翻译和可见性规则,避免级联更新产生废弃记录 + if err := tx.Where("item_id = ?", item.ID).Delete(&model.NavigationItemTranslation{}).Error; err != nil { + return err + } + if err := tx.Where("item_id = ?", item.ID).Delete(&model.NavigationVisibilityRule{}).Error; err != nil { + return err + } + + // 更新字段 + item.ParentID = input.ParentID + item.Type = input.Type + item.ModuleKey = input.ModuleKey + item.Path = input.Path + item.URL = input.URL + item.IconKey = input.IconKey + item.SortOrder = input.SortOrder + item.Enabled = input.Enabled + item.OpenInNewTab = input.OpenInNewTab + item.ExactActive = input.ExactActive + + // 保存主体,忽略关联表的自动保存,避免与接下来的手动保存发生冲突 + if err := tx.Omit("Translations", "Rules").Save(&item).Error; err != nil { + return err + } + + // 创建新的 Translations + for i := range input.Translations { + input.Translations[i].ItemID = item.ID + input.Translations[i].ID = 0 // 重置 ID 确保插入 + if err := tx.Create(&input.Translations[i]).Error; err != nil { + return err + } + } + + // 创建新的 Rules + for i := range input.Rules { + input.Rules[i].ItemID = item.ID + input.Rules[i].ID = 0 // 重置 ID 确保插入 + if err := tx.Create(&input.Rules[i]).Error; err != nil { + return err + } + } + + return nil + }) + + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + service.NavService.InvalidateCache() + c.JSON(http.StatusOK, gin.H{"success": true, "data": item}) +} + +// AdminDeleteItem 删除节点(级联物理删除关联的 Translations 和 Rules) +func AdminDeleteItem(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "invalid item id"}) + return + } + + var item model.NavigationItem + if err := model.DB.First(&item, id).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": "item not found"}) + return + } + + err = model.DB.Transaction(func(tx *gorm.DB) error { + // 如果有子节点,将其父节点引用置为空,使子节点不致变成废弃不可达孤儿节点(或者可以选择级联删除子项) + // 在这里,按严谨级联规则,我们将子节点的 parent_id 设为 nil + if err := tx.Model(&model.NavigationItem{}).Where("parent_id = ?", item.ID).Update("parent_id", nil).Error; err != nil { + return err + } + + // 删除主体,触发外键约束自动级联删除 translations 和 rules + return tx.Delete(&item).Error + }) + + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + service.NavService.InvalidateCache() + c.JSON(http.StatusOK, gin.H{"success": true, "message": "item deleted successfully"}) +} + +type ReorderInput struct { + ItemID uint `json:"item_id"` + SortOrder int `json:"sort_order"` +} + +// AdminReorderItems 批量节点重新排序接口 +func AdminReorderItems(c *gin.Context) { + var inputs []ReorderInput + if err := c.ShouldBindJSON(&inputs); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + err := model.DB.Transaction(func(tx *gorm.DB) error { + for _, input := range inputs { + if err := tx.Model(&model.NavigationItem{}).Where("id = ?", input.ItemID).Update("sort_order", input.SortOrder).Error; err != nil { + return err + } + } + return nil + }) + + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "message": err.Error()}) + return + } + + service.NavService.InvalidateCache() + c.JSON(http.StatusOK, gin.H{"success": true, "message": "items reordered successfully"}) +} diff --git a/docker-compose.yml b/docker-compose.yml index be8c885b186a..b9dd120d4f5b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,7 +21,7 @@ services: restart: always command: --log-dir /app/logs ports: - - "3000:3000" + - "3001:3000" volumes: - ./data:/data - ./logs:/app/logs diff --git a/middleware/auth.go b/middleware/auth.go index 23d933fbe0c1..72eec75bd8b3 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -162,6 +162,16 @@ func TryUserAuth() func(c *gin.Context) { id := session.Get("id") if id != nil { c.Set("id", id) + if role := session.Get("role"); role != nil { + c.Set("role", role) + } + if username := session.Get("username"); username != nil { + c.Set("username", username) + } + if group := session.Get("group"); group != nil { + c.Set("group", group) + c.Set("user_group", group) + } } c.Next() } diff --git a/model/main.go b/model/main.go index 9083ee57ab90..bcd207841892 100644 --- a/model/main.go +++ b/model/main.go @@ -281,10 +281,15 @@ func migrateDB() error { &CustomOAuthProvider{}, &UserOAuthBinding{}, &PerfMetric{}, + &NavigationMenu{}, + &NavigationItem{}, + &NavigationItemTranslation{}, + &NavigationVisibilityRule{}, ) if err != nil { return err } + go seedDefaultNavigation() if common.UsingSQLite { if err := ensureSubscriptionPlanTableSQLite(); err != nil { return err @@ -330,6 +335,10 @@ func migrateDBFast() error { {&CustomOAuthProvider{}, "CustomOAuthProvider"}, {&UserOAuthBinding{}, "UserOAuthBinding"}, {&PerfMetric{}, "PerfMetric"}, + {&NavigationMenu{}, "NavigationMenu"}, + {&NavigationItem{}, "NavigationItem"}, + {&NavigationItemTranslation{}, "NavigationItemTranslation"}, + {&NavigationVisibilityRule{}, "NavigationVisibilityRule"}, } // 动态计算migration数量,确保errChan缓冲区足够大 errChan := make(chan error, len(migrations)) @@ -706,3 +715,137 @@ func PingDB() error { common.SysLog("Database pinged successfully") return nil } + +func seedDefaultNavigation() { + var count int64 + err := DB.Model(&NavigationMenu{}).Where("key = ?", "default_web_top").Count(&count).Error + if err != nil { + common.SysError("failed to query default_web_top menu: " + err.Error()) + return + } + if count > 0 { + return // 已经初始化过了,无需重复初始化 + } + + common.SysLog("Initializing default top navigation menu database records...") + + // 1. 创建默认顶部导航菜单 + menu := NavigationMenu{ + Key: "default_web_top", + Name: "默认顶部导航栏", + Client: "web_default", + Surface: "top", + Enabled: true, + IsSystem: true, + } + // 2. 初始内置模块定义 + type itemDef struct { + ModuleKey string + SortOrder int + IconKey string + Locales map[string]string + } + + defaultItems := []itemDef{ + { + ModuleKey: "home", + SortOrder: 1, + IconKey: "home", + Locales: map[string]string{ + "en": "Home", + "zh-CN": "首页", + "zh-TW": "首頁", + }, + }, + { + ModuleKey: "console", + SortOrder: 2, + IconKey: "layout-dashboard", + Locales: map[string]string{ + "en": "Console", + "zh-CN": "控制台", + "zh-TW": "控制台", + }, + }, + { + ModuleKey: "pricing", + SortOrder: 3, + IconKey: "credit-card", + Locales: map[string]string{ + "en": "Model Square", + "zh-CN": "模型广场", + "zh-TW": "模型廣場", + }, + }, + { + ModuleKey: "rankings", + SortOrder: 4, + IconKey: "trophy", + Locales: map[string]string{ + "en": "Rankings", + "zh-CN": "排行榜", + "zh-TW": "排行榜", + }, + }, + { + ModuleKey: "docs", + SortOrder: 5, + IconKey: "book-open", + Locales: map[string]string{ + "en": "Docs", + "zh-CN": "文档", + "zh-TW": "文檔", + }, + }, + { + ModuleKey: "about", + SortOrder: 6, + IconKey: "info", + Locales: map[string]string{ + "en": "About", + "zh-CN": "关于", + "zh-TW": "关于", + }, + }, + } + + // 开启事务进行菜单和菜单项的原子化创建 + err = DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&menu).Error; err != nil { + return err + } + + for _, def := range defaultItems { + item := NavigationItem{ + MenuID: menu.ID, + Type: "builtin_module", + ModuleKey: def.ModuleKey, + IconKey: def.IconKey, + SortOrder: def.SortOrder, + Enabled: true, + } + if err := tx.Create(&item).Error; err != nil { + return err + } + + // 插入多语言翻译 + for locale, label := range def.Locales { + trans := NavigationItemTranslation{ + ItemID: item.ID, + Locale: locale, + Label: label, + } + if err := tx.Create(&trans).Error; err != nil { + return err + } + } + } + return nil + }) + + if err != nil { + common.SysError("failed to seed default navigation items: " + err.Error()) + } else { + common.SysLog("Default top navigation menu initialized successfully") + } +} diff --git a/model/navigation.go b/model/navigation.go new file mode 100644 index 000000000000..26eda28359c1 --- /dev/null +++ b/model/navigation.go @@ -0,0 +1,77 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +package model + +// NavigationMenu 定义导航菜单的集合类型(如顶部导航栏、侧边栏等) +type NavigationMenu struct { + ID uint `json:"id" gorm:"primaryKey"` + Key string `json:"key" gorm:"type:varchar(64);uniqueIndex;not null"` // 例如 "default_web_top" + Name string `json:"name" gorm:"type:varchar(128);not null"` // 菜单名称 + Client string `json:"client" gorm:"type:varchar(64);not null"` // "web_default", "mobile" 等 + Surface string `json:"surface" gorm:"type:varchar(64);not null"` // "top", "sidebar", "footer" 等 + Enabled bool `json:"enabled" gorm:"not null;default:true"` + IsSystem bool `json:"is_system" gorm:"not null;default:false"` // 系统置顶菜单,禁止删除 + CreatedAt int64 `json:"created_at" gorm:"type:bigint;autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"type:bigint;autoUpdateTime"` +} + +// NavigationItem 树状嵌套的菜单节点 +type NavigationItem struct { + ID uint `json:"id" gorm:"primaryKey"` + MenuID uint `json:"menu_id" gorm:"index;not null"` + ParentID *uint `json:"parent_id" gorm:"index"` // 父节点ID,允许为 nil 表示顶级节点 + Type string `json:"type" gorm:"type:varchar(64);not null"` // builtin_module, internal_path, external_url, group, divider + ModuleKey string `json:"module_key" gorm:"type:varchar(128)"` // 内置模块对应的 Key(例如 "pricing") + Path string `json:"path" gorm:"type:varchar(255)"` // 站内路径 + URL string `json:"url" gorm:"type:text"` // 外部链接 + IconKey string `json:"icon_key" gorm:"type:varchar(128)"` // Lucide/LobeHub 的图标对应键 + SortOrder int `json:"sort_order" gorm:"not null;default:0"` // 排序权重 + Enabled bool `json:"enabled" gorm:"not null;default:true"` + OpenInNewTab bool `json:"open_in_new_tab" gorm:"not null;default:false"` // 是否在新标签页打开 + ExactActive bool `json:"exact_active" gorm:"not null;default:false"` // 路由匹配时是否精确匹配 + CreatedAt int64 `json:"created_at" gorm:"type:bigint;autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"type:bigint;autoUpdateTime"` + + // 关联字段,不写入数据库,由 GORM 自动处理级联操作 + Children []NavigationItem `json:"children,omitempty" gorm:"foreignKey:ParentID"` + Translations []NavigationItemTranslation `json:"translations,omitempty" gorm:"foreignKey:ItemID;constraint:OnDelete:CASCADE"` + Rules []NavigationVisibilityRule `json:"rules,omitempty" gorm:"foreignKey:ItemID;constraint:OnDelete:CASCADE"` +} + +// NavigationItemTranslation 支持导航节点多语言翻译的数据表 +type NavigationItemTranslation struct { + ID uint `json:"id" gorm:"primaryKey"` + ItemID uint `json:"item_id" gorm:"uniqueIndex:idx_item_locale;not null"` + Locale string `json:"locale" gorm:"type:varchar(32);uniqueIndex:idx_item_locale;not null"` // 区域标识,如 "zh-CN", "en-US", "zh-TW" + Label string `json:"label" gorm:"type:varchar(255);not null"` // 显示给用户的文字 + CreatedAt int64 `json:"created_at" gorm:"type:bigint;autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"type:bigint;autoUpdateTime"` +} + +// NavigationVisibilityRule 控制导航节点精细可见性(如登录状态、角色等)的权限规则表 +type NavigationVisibilityRule struct { + ID uint `json:"id" gorm:"primaryKey"` + ItemID uint `json:"item_id" gorm:"index;not null"` + Effect string `json:"effect" gorm:"type:varchar(32);not null;default:'allow'"` // 作用效力:"allow" 或 "deny" + SubjectType string `json:"subject_type" gorm:"type:varchar(64);not null"` // 主体类型:everyone, anonymous, authenticated, role, user_group + SubjectValue string `json:"subject_value" gorm:"type:varchar(255);not null"` // 主体对应的值(例如:role 时对应 "admin", "root") + CreatedAt int64 `json:"created_at" gorm:"type:bigint;autoCreateTime"` + UpdatedAt int64 `json:"updated_at" gorm:"type:bigint;autoUpdateTime"` +} diff --git a/router/api-router.go b/router/api-router.go index 381d2ccd0fbf..32b7d469124e 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -53,6 +53,25 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), controller.HandleOAuth) apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) + // 动态菜单树接口 + apiRouter.GET("/navigation/tree", middleware.TryUserAuth(), controller.GetNavigationTree) + + // 菜单管理后台路由(必须管理员及以上权限) + navigationAdminRoute := apiRouter.Group("/navigation/admin") + navigationAdminRoute.Use(middleware.AdminAuth()) + { + navigationAdminRoute.GET("/menus", controller.AdminGetMenus) + navigationAdminRoute.POST("/menus", controller.AdminCreateMenu) + navigationAdminRoute.PUT("/menus/:id", controller.AdminUpdateMenu) + navigationAdminRoute.DELETE("/menus/:id", controller.AdminDeleteMenu) + + navigationAdminRoute.GET("/items", controller.AdminGetItems) + navigationAdminRoute.POST("/items", controller.AdminCreateItem) + navigationAdminRoute.PUT("/items/:id", controller.AdminUpdateItem) + navigationAdminRoute.DELETE("/items/:id", controller.AdminDeleteItem) + navigationAdminRoute.POST("/items/reorder", controller.AdminReorderItems) + } + apiRouter.POST("/stripe/webhook", controller.StripeWebhook) apiRouter.POST("/creem/webhook", controller.CreemWebhook) apiRouter.POST("/waffo/webhook", controller.WaffoWebhook) diff --git a/service/navigation.go b/service/navigation.go new file mode 100644 index 000000000000..43090a94448a --- /dev/null +++ b/service/navigation.go @@ -0,0 +1,342 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +package service + +import ( + "errors" + "fmt" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "gorm.io/gorm" +) + +// NavigationItemDTO 下发给前端的统一菜单节点格式 +type NavigationItemDTO struct { + ID uint `json:"id"` + Type string `json:"type"` + ModuleKey string `json:"module_key,omitempty"` + Label string `json:"label"` + Path string `json:"path,omitempty"` + URL string `json:"url,omitempty"` + IconKey string `json:"icon_key,omitempty"` + OpenInNewTab bool `json:"open_in_new_tab"` + ExactActive bool `json:"exact_active"` + Children []NavigationItemDTO `json:"children,omitempty"` +} + +type NavigationService struct { + cache map[string][]NavigationItemDTO + cacheMu sync.RWMutex +} + +var NavService = &NavigationService{ + cache: make(map[string][]NavigationItemDTO), +} + +// GetVisibleNavigationTree 获取指定菜单可见的过滤树(线程安全,基于内存缓存) +func (s *NavigationService) GetVisibleNavigationTree(menuKey string, locale string, userRole int, userGroup string, isAuthenticated bool) ([]NavigationItemDTO, error) { + cacheKey := s.buildCacheKey(menuKey, locale, userRole, userGroup, isAuthenticated) + + // 1. 读缓存 + s.cacheMu.RLock() + if cachedData, ok := s.cache[cacheKey]; ok { + s.cacheMu.RUnlock() + return cachedData, nil + } + s.cacheMu.RUnlock() + + // 2. 查数据库并拼装 + var menu model.NavigationMenu + err := model.DB.Where("key = ? AND enabled = ?", menuKey, true).First(&menu).Error + if err != nil { + return nil, fmt.Errorf("menu not found: %w", err) + } + + var items []model.NavigationItem + err = model.DB.Where("menu_id = ? AND enabled = ?", menu.ID, true). + Order("sort_order asc, id asc"). + Preload("Translations"). + Preload("Rules"). + Find(&items).Error + if err != nil { + return nil, fmt.Errorf("failed to fetch menu items: %w", err) + } + + // 3. 过滤并翻译 + visibleItems := make([]model.NavigationItem, 0, len(items)) + for _, item := range items { + if s.checkVisibility(item.Rules, userRole, userGroup, isAuthenticated) { + visibleItems = append(visibleItems, item) + } + } + + // 4. 构建树形结构 + tree := s.buildTree(visibleItems, locale) + + // 5. 写入缓存 + s.cacheMu.Lock() + s.cache[cacheKey] = tree + s.cacheMu.Unlock() + + return tree, nil +} + +// InvalidateCache 清空所有缓存(在管理端 CRUD 修改导航后调用) +func (s *NavigationService) InvalidateCache() { + s.cacheMu.Lock() + s.cache = make(map[string][]NavigationItemDTO) + s.cacheMu.Unlock() + common.SysLog("Navigation memory cache invalidated") +} + +// buildCacheKey 构造唯一的缓存 Key +func (s *NavigationService) buildCacheKey(menuKey, locale string, userRole int, userGroup string, isAuthenticated bool) string { + return fmt.Sprintf("%s:%s:%d:%s:%t", menuKey, locale, userRole, userGroup, isAuthenticated) +} + +// checkVisibility 验证节点权限,实施 RBAC 可见性过滤规则 +func (s *NavigationService) checkVisibility(rules []model.NavigationVisibilityRule, userRole int, userGroup string, isAuthenticated bool) bool { + if len(rules) == 0 { + return true // 无规则限制,默认所有人可见 + } + + hasAllowRules := false + allowMatched := false + + for _, rule := range rules { + matched := s.evaluateRuleSubject(rule.SubjectType, rule.SubjectValue, userRole, userGroup, isAuthenticated) + + if rule.Effect == "deny" { + if matched { + return false // 只要命中任何一条 deny 规则,立即不可见 + } + } else if rule.Effect == "allow" { + hasAllowRules = true + if matched { + allowMatched = true + } + } + } + + // 如果配置了 allow 规则,必须命中至少一条 allow 规则才可见 + if hasAllowRules { + return allowMatched + } + + return true +} + +// evaluateRuleSubject 判断用户是否符合规则主体 +func (s *NavigationService) evaluateRuleSubject(subjectType, subjectValue string, userRole int, userGroup string, isAuthenticated bool) bool { + switch subjectType { + case "everyone": + return true + case "anonymous": + return !isAuthenticated + case "authenticated": + return isAuthenticated + case "role": + if !isAuthenticated { + return false + } + // 角色判断规范: + // "root" (100) -> 仅 root 匹配 + // "admin" (10) -> admin (10) 和 root (100) 匹配 + // "user" (1) -> 所有登录用户匹配 + switch strings.ToLower(subjectValue) { + case "root": + return userRole == common.RoleRootUser + case "admin": + return userRole >= common.RoleAdminUser + case "user": + return userRole >= common.RoleCommonUser + default: + return false + } + case "user_group": + if !isAuthenticated { + return false + } + return userGroup == subjectValue + default: + return false + } +} + +// buildTree 一次性遍历将扁平列表组装为树形结构,并应用翻译 fallback 规则 +func (s *NavigationService) buildTree(items []model.NavigationItem, locale string) []NavigationItemDTO { + // 初始化节点映射表 + dtoMap := make(map[uint]*NavigationItemDTO) + for _, item := range items { + dto := &NavigationItemDTO{ + ID: item.ID, + Type: item.Type, + ModuleKey: item.ModuleKey, + Path: item.Path, + URL: item.URL, + IconKey: item.IconKey, + OpenInNewTab: item.OpenInNewTab, + ExactActive: item.ExactActive, + Label: s.translateLabel(item.Translations, item.ModuleKey, locale), + Children: []NavigationItemDTO{}, + } + dtoMap[item.ID] = dto + } + + var rootDTOs []NavigationItemDTO + + // 二次遍历组装树状父子层级 + for _, item := range items { + dto := dtoMap[item.ID] + if dto == nil { + continue + } + + if item.ParentID == nil { + // 顶级菜单 + rootDTOs = append(rootDTOs, *dto) + } else { + // 子菜单,挂载到父节点下 + parentDTO := dtoMap[*item.ParentID] + if parentDTO != nil { + parentDTO.Children = append(parentDTO.Children, *dto) + } else { + // 父节点已在权限过滤中被裁剪或被禁用,降级作为顶级项(这里按严谨重构规范:无父节点的子项如果无有效父节点,不显示) + // 或者可以选择放入 rootDTOs。在此设计中,如果父节点被权限过滤掉,其子节点在软件工程规范中应该同步不可见 + } + } + } + + // 重新深拷贝或扁平复制以消除多级嵌套中由于引用的子对象在 map 树组装时的错乱 + var result []NavigationItemDTO + for _, rootItem := range rootDTOs { + result = append(result, s.deepCopyDTO(rootItem, dtoMap)) + } + + return result +} + +// deepCopyDTO 保证树的深拷贝以维持嵌套结构的正确格式 +func (s *NavigationService) deepCopyDTO(node NavigationItemDTO, dtoMap map[uint]*NavigationItemDTO) NavigationItemDTO { + actualNode := dtoMap[node.ID] + if actualNode == nil { + return node + } + + var copiedChildren []NavigationItemDTO + for _, child := range actualNode.Children { + copiedChildren = append(copiedChildren, s.deepCopyDTO(child, dtoMap)) + } + + node.Children = copiedChildren + return node +} + +// translateLabel 多语言 Fallback 精准解析 +func (s *NavigationService) translateLabel(translations []model.NavigationItemTranslation, moduleKey string, targetLocale string) string { + if len(translations) == 0 { + return moduleKey // 极端无翻译记录下的兜底,展示内置模块键名 + } + + transMap := make(map[string]string) + for _, t := range translations { + transMap[strings.ToLower(t.Locale)] = t.Label + } + + target := strings.ToLower(targetLocale) + + // 1. 精确匹配(如 zh-cn) + if label, ok := transMap[target]; ok { + return label + } + + // 2. 去除区域后缀的模糊匹配(如 zh-tw -> zh) + if parts := strings.Split(target, "-"); len(parts) > 1 { + if label, ok := transMap[parts[0]]; ok { + return label + } + } + + // 2.5 基础语言前缀模糊匹配(如 target="zh",则匹配 "zh-cn" 或 "zh-tw") + for k, v := range transMap { + if strings.HasPrefix(k, target+"-") { + return v + } + } + + // 3. Fallback 到英语 "en" + if label, ok := transMap["en"]; ok { + return label + } + if label, ok := transMap["en-us"]; ok { + return label + } + + // 4. Fallback 到中文 "zh-cn" + if label, ok := transMap["zh-cn"]; ok { + return label + } + if label, ok := transMap["zh"]; ok { + return label + } + + // 5. Fallback 到第一条已有翻译 + return translations[0].Label +} + +// SaveMenuWithTransaction 用于管理端安全保存(包含子节点和翻译等的事务性级联保存) +func (s *NavigationService) SaveMenuWithTransaction(menu *model.NavigationMenu) error { + // 可在此实现需要强事务绑定的复杂业务逻辑 + return model.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Save(menu).Error; err != nil { + return err + } + s.InvalidateCache() + return nil + }) +} + +// ValidateItemURL 拦截恶意 URL 并防范 XSS 漏洞 +func (s *NavigationService) ValidateItemURL(itemType string, itemURL string) error { + if itemType != "external_url" { + return nil + } + + trimmedURL := strings.TrimSpace(itemURL) + if trimmedURL == "" { + return errors.New("external URL cannot be empty") + } + + lowerURL := strings.ToLower(trimmedURL) + // 拦截包含 javascript: 等具有运行脚本能力的恶意协议 + if strings.HasPrefix(lowerURL, "javascript:") || strings.HasPrefix(lowerURL, "data:") { + return errors.New("malicious URL protocol detected") + } + + // 必须以 http:// 或 https:// 开头 + if !strings.HasPrefix(lowerURL, "http://") && !strings.HasPrefix(lowerURL, "https://") { + return errors.New("external URL must start with http:// or https://") + } + + return nil +} diff --git a/service/navigation_test.go b/service/navigation_test.go new file mode 100644 index 000000000000..76c19f9a600b --- /dev/null +++ b/service/navigation_test.go @@ -0,0 +1,242 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ + +package service + +import ( + "fmt" + "strings" + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupNavigationTestDB(t *testing.T) *gorm.DB { + t.Helper() + + oldDB := model.DB + oldLogDB := model.LOG_DB + + common.UsingSQLite = true + common.UsingMySQL = false + common.UsingPostgreSQL = false + common.RedisEnabled = false + + dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_")) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + + model.DB = db + model.LOG_DB = db + + require.NoError(t, db.AutoMigrate( + &model.NavigationMenu{}, + &model.NavigationItem{}, + &model.NavigationItemTranslation{}, + &model.NavigationVisibilityRule{}, + )) + + t.Cleanup(func() { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } + model.DB = oldDB + model.LOG_DB = oldLogDB + }) + + return db +} + +func TestValidateItemURL(t *testing.T) { + tests := []struct { + name string + itemType string + url string + expectErr bool + }{ + {"Valid HTTPS URL", "external_url", "https://google.com/path?query=1", false}, + {"Valid HTTP URL", "external_url", "http://localhost:8080", false}, + {"Empty URL", "external_url", "", true}, + {"Malicious Javascript URL", "external_url", "javascript:alert(1)", true}, + {"Malicious Data URL", "external_url", "data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==", true}, + {"No Protocol URL", "external_url", "www.google.com", true}, + {"Non-external type skipped", "builtin_module", "javascript:alert(1)", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NavService.ValidateItemURL(tt.itemType, tt.url) + if tt.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGetVisibleNavigationTree(t *testing.T) { + db := setupNavigationTestDB(t) + + // Create test menu + menu := model.NavigationMenu{ + Key: "test_menu", + Name: "Test Menu", + Client: "web_default", + Surface: "top", + Enabled: true, + IsSystem: false, + } + require.NoError(t, db.Create(&menu).Error) + + // Create test items + // Item 1: Builtin module - Everyone + item1 := model.NavigationItem{ + MenuID: menu.ID, + Type: "builtin_module", + ModuleKey: "home", + SortOrder: 1, + Enabled: true, + } + require.NoError(t, db.Create(&item1).Error) + require.NoError(t, db.Create(&model.NavigationItemTranslation{ + ItemID: item1.ID, + Locale: "zh-CN", + Label: "首页", + }).Error) + require.NoError(t, db.Create(&model.NavigationItemTranslation{ + ItemID: item1.ID, + Locale: "en", + Label: "Home", + }).Error) + + // Item 2: Admin only + item2 := model.NavigationItem{ + MenuID: menu.ID, + Type: "internal_path", + Path: "/admin/users", + SortOrder: 2, + Enabled: true, + } + require.NoError(t, db.Create(&item2).Error) + require.NoError(t, db.Create(&model.NavigationItemTranslation{ + ItemID: item2.ID, + Locale: "zh-CN", + Label: "用户管理", + }).Error) + require.NoError(t, db.Create(&model.NavigationVisibilityRule{ + ItemID: item2.ID, + Effect: "allow", + SubjectType: "role", + SubjectValue: "admin", + }).Error) + + // Item 3: VIP Group only + item3 := model.NavigationItem{ + MenuID: menu.ID, + Type: "external_url", + URL: "https://vip.example.com", + SortOrder: 3, + Enabled: true, + } + require.NoError(t, db.Create(&item3).Error) + require.NoError(t, db.Create(&model.NavigationItemTranslation{ + ItemID: item3.ID, + Locale: "zh-CN", + Label: "VIP专属", + }).Error) + require.NoError(t, db.Create(&model.NavigationVisibilityRule{ + ItemID: item3.ID, + Effect: "allow", + SubjectType: "user_group", + SubjectValue: "VIP", + }).Error) + + // Invalidate service cache to ensure fresh DB query + NavService.InvalidateCache() + + // Case 1: Anonymous user + t.Run("Anonymous visibility", func(t *testing.T) { + NavService.InvalidateCache() + tree, err := NavService.GetVisibleNavigationTree("test_menu", "zh-CN", 0, "", false) + require.NoError(t, err) + require.Len(t, tree, 1) + require.Equal(t, "首页", tree[0].Label) + }) + + // Case 2: Ordinary user (not admin, not VIP) + t.Run("Ordinary user visibility", func(t *testing.T) { + NavService.InvalidateCache() + tree, err := NavService.GetVisibleNavigationTree("test_menu", "zh-CN", common.RoleCommonUser, "default", true) + require.NoError(t, err) + require.Len(t, tree, 1) + require.Equal(t, "首页", tree[0].Label) + }) + + // Case 3: Admin user (role admin) + t.Run("Admin user visibility", func(t *testing.T) { + NavService.InvalidateCache() + tree, err := NavService.GetVisibleNavigationTree("test_menu", "zh-CN", common.RoleAdminUser, "default", true) + require.NoError(t, err) + require.Len(t, tree, 2) + require.Equal(t, "首页", tree[0].Label) + require.Equal(t, "用户管理", tree[1].Label) + }) + + // Case 4: VIP Group user (ordinary role) + t.Run("VIP group visibility", func(t *testing.T) { + NavService.InvalidateCache() + tree, err := NavService.GetVisibleNavigationTree("test_menu", "zh-CN", common.RoleCommonUser, "VIP", true) + require.NoError(t, err) + require.Len(t, tree, 2) + require.Equal(t, "首页", tree[0].Label) + require.Equal(t, "VIP专属", tree[1].Label) + }) +} + +func TestTranslateLabelFallback(t *testing.T) { + translations := []model.NavigationItemTranslation{ + {Locale: "zh-CN", Label: "中文简体"}, + {Locale: "zh-TW", Label: "中文繁體"}, + {Locale: "en", Label: "English"}, + } + + tests := []struct { + locale string + expected string + }{ + {"zh-CN", "中文简体"}, + {"zh-tw", "中文繁體"}, + {"zh-HK", "English"}, + {"en-US", "English"}, + {"fr-FR", "English"}, + } + + for _, tt := range tests { + t.Run(tt.locale, func(t *testing.T) { + label := NavService.translateLabel(translations, "fallback_module_key", tt.locale) + require.Equal(t, tt.expected, label) + }) + } +} diff --git a/service/waffo_pancake_test.go b/service/waffo_pancake_test.go index 41c91a15ae23..fe285315727a 100644 --- a/service/waffo_pancake_test.go +++ b/service/waffo_pancake_test.go @@ -17,6 +17,9 @@ import ( func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB { t.Helper() + oldDB := model.DB + oldLogDB := model.LOG_DB + common.UsingSQLite = true common.UsingMySQL = false common.UsingPostgreSQL = false @@ -36,6 +39,8 @@ func setupWaffoPancakeTestDB(t *testing.T) *gorm.DB { if err == nil { _ = sqlDB.Close() } + model.DB = oldDB + model.LOG_DB = oldLogDB }) return db diff --git a/web/default/src/components/layout/components/public-navigation.tsx b/web/default/src/components/layout/components/public-navigation.tsx index 4e8cb752fddb..a2ed39964e62 100644 --- a/web/default/src/components/layout/components/public-navigation.tsx +++ b/web/default/src/components/layout/components/public-navigation.tsx @@ -16,11 +16,22 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ + import { Link } from '@tanstack/react-router' +import { ChevronDown } from 'lucide-react' import { cn } from '@/lib/utils' import { useTopNavLinks } from '@/hooks/use-top-nav-links' +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} from '@/components/ui/dropdown-menu' import { defaultTopNavLinks } from '../config/top-nav.config' -import type { TopNavLink } from '../types' +import { type TopNavLink } from '../types' interface PublicNavigationProps { /** @@ -42,45 +53,111 @@ export function PublicNavigation({ links: providedLinks, className, }: PublicNavigationProps = {}) { - // Use the same logic as AppHeader: prioritize dynamic links from backend const dynamicLinks = useTopNavLinks() const defaultLinks = providedLinks || defaultTopNavLinks const links = dynamicLinks.length > 0 ? dynamicLinks : defaultLinks - return ( - ) diff --git a/web/default/src/components/layout/types.ts b/web/default/src/components/layout/types.ts index 087ff2e54090..ff24f07c4384 100644 --- a/web/default/src/components/layout/types.ts +++ b/web/default/src/components/layout/types.ts @@ -91,6 +91,8 @@ export type TopNavLink = { disabled?: boolean requiresAuth?: boolean external?: boolean + openInNewTab?: boolean + children?: TopNavLink[] } /** diff --git a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx index 7a4bd04ce587..5da80a4f19bd 100644 --- a/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx +++ b/web/default/src/features/system-settings/maintenance/header-navigation-section.tsx @@ -16,284 +16,559 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ -import { useEffect, useMemo } from 'react' -import * as z from 'zod' -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' + +import { useState, useMemo } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' import { - Form, - FormControl, - FormDescription, - FormField, - FormLabel, - FormMessage, -} from '@/components/ui/form' + Plus, + Edit2, + Trash2, + ArrowUp, + ArrowDown, + Globe, + Lock, + ExternalLink, + FolderPlus, +} from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { - SettingsControlChildren, - SettingsForm, - SettingsSwitchContent, - SettingsControlGroup, - SettingsSwitchItem, -} from '../components/settings-form-layout' -import { SettingsPageFormActions } from '../components/settings-page-context' + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' import { SettingsSection } from '../components/settings-section' -import { useUpdateOption } from '../hooks/use-update-option' -import { - HEADER_NAV_DEFAULT, - type HeaderNavModulesConfig, - serializeHeaderNavModules, -} from './config' - -const headerNavSchema = z.object({ - home: z.boolean(), - console: z.boolean(), - pricingEnabled: z.boolean(), - pricingRequireAuth: z.boolean(), - rankingsEnabled: z.boolean(), - rankingsRequireAuth: z.boolean(), - docs: z.boolean(), - about: z.boolean(), -}) - -type HeaderNavFormValues = z.infer - -type HeaderNavigationSectionProps = { - config: HeaderNavModulesConfig - initialSerialized: string + +type NavigationItemTranslation = { + id?: number + locale: string + label: string } -const toFormValues = (config: HeaderNavModulesConfig): HeaderNavFormValues => ({ - home: - config.home === undefined ? HEADER_NAV_DEFAULT.home : Boolean(config.home), - console: - config.console === undefined - ? HEADER_NAV_DEFAULT.console - : Boolean(config.console), - pricingEnabled: - config.pricing?.enabled === undefined - ? HEADER_NAV_DEFAULT.pricing.enabled - : Boolean(config.pricing.enabled), - pricingRequireAuth: - config.pricing?.requireAuth === undefined - ? HEADER_NAV_DEFAULT.pricing.requireAuth - : Boolean(config.pricing.requireAuth), - rankingsEnabled: - config.rankings?.enabled === undefined - ? HEADER_NAV_DEFAULT.rankings.enabled - : Boolean(config.rankings.enabled), - rankingsRequireAuth: - config.rankings?.requireAuth === undefined - ? HEADER_NAV_DEFAULT.rankings.requireAuth - : Boolean(config.rankings.requireAuth), - docs: - config.docs === undefined ? HEADER_NAV_DEFAULT.docs : Boolean(config.docs), - about: - config.about === undefined - ? HEADER_NAV_DEFAULT.about - : Boolean(config.about), -}) - -export function HeaderNavigationSection({ - config, - initialSerialized, -}: HeaderNavigationSectionProps) { - const { t } = useTranslation() - const updateOption = useUpdateOption() - const formDefaults = useMemo(() => toFormValues(config), [config]) +type NavigationVisibilityRule = { + id?: number + effect: 'allow' | 'deny' + subject_type: 'everyone' | 'anonymous' | 'authenticated' | 'role' | 'user_group' + subject_value: string +} - const form = useForm({ - resolver: zodResolver(headerNavSchema), - defaultValues: formDefaults, +type NavigationItem = { + id: number + menu_id: number + parent_id?: number + type: 'builtin_module' | 'internal_path' | 'external_url' | 'group' | 'divider' + module_key?: string + path?: string + url?: string + icon_key?: string + sort_order: number + enabled: boolean + open_in_new_tab: boolean + exact_active: boolean + translations: NavigationItemTranslation[] + rules: NavigationVisibilityRule[] +} + +export function HeaderNavigationSection() { + const { t } = useTranslation() + const queryClient = useQueryClient() + + // 编辑弹窗状态 + const [editDialogOpen, setEditDialogOpen] = useState(false) + const [editingItem, setEditingItem] = useState | null>(null) + + // 1. 获取菜单容器列表,定位顶级 web top 菜单 + const { data: menus = [] } = useQuery({ + queryKey: ['admin-navigation-menus'], + queryFn: async () => { + const res = await api.get('/api/navigation/admin/menus') + return res.data?.data || [] + }, }) - useEffect(() => { - form.reset(formDefaults) - }, [formDefaults, form]) - - const onSubmit = async (values: HeaderNavFormValues) => { - const payload: HeaderNavModulesConfig = { - ...config, - home: values.home, - console: values.console, - docs: values.docs, - about: values.about, - pricing: { - ...(config.pricing ?? HEADER_NAV_DEFAULT.pricing), - enabled: values.pricingEnabled, - requireAuth: values.pricingRequireAuth, - }, - rankings: { - ...(config.rankings ?? HEADER_NAV_DEFAULT.rankings), - enabled: values.rankingsEnabled, - requireAuth: values.rankingsRequireAuth, - }, - } + // 派生出 activeMenuID,避免在异步 queryFn 中调用 setState 造成的缓存及 React 状态异步更新不一致问题 + const activeMenuID = useMemo(() => { + const defaultMenu = menus.find((m: any) => m.key === 'default_web_top') + return defaultMenu ? defaultMenu.id : null + }, [menus]) - const serialized = serializeHeaderNavModules(payload) - if (serialized === initialSerialized) { - return + // 2. 获取该菜单下所有节点列表 + const { data: flatItems = [], refetch: refetchItems } = useQuery({ + queryKey: ['admin-navigation-items', activeMenuID], + queryFn: async () => { + if (!activeMenuID) return [] + const res = await api.get('/api/navigation/admin/items', { + params: { menu_id: activeMenuID }, + }) + return res.data?.data || [] + }, + enabled: !!activeMenuID, + }) + + // 3. 构建多级缩进排序好的展示列表 + const displayItems = useMemo(() => { + const list: Array<{ item: NavigationItem; depth: number }> = [] + + const recurse = (parentID: number | undefined, depth: number) => { + const children = flatItems.filter((it) => { + if (!parentID) return !it.parent_id + return it.parent_id === parentID + }) + + children.forEach((child) => { + list.push({ item: child, depth }) + recurse(child.id, depth + 1) + }) } - await updateOption.mutateAsync({ - key: 'HeaderNavModules', - value: serialized, - }) - } + recurse(undefined, 0) + return list + }, [flatItems]) - const resetToDefault = () => { - form.reset(toFormValues(HEADER_NAV_DEFAULT)) - } + // ================= 级联 CRUD 修改的 Mutations ================= - const simpleModules: Array<{ - key: keyof HeaderNavFormValues - title: string - description: string - }> = [ - { - key: 'home', - title: t('Home'), - description: t('Landing page with system overview.'), + // 创建/更新节点 + const saveMutation = useMutation({ + mutationFn: async (item: Partial) => { + if (item.id) { + return api.put(`/api/navigation/admin/items/${item.id}`, item) + } else { + return api.post('/api/navigation/admin/items', item) + } }, - { - key: 'console', - title: t('Console'), - description: t('User dashboard and quota controls.'), + onSuccess: (res) => { + if (res.data?.success) { + toast.success(t('Navigation settings saved successfully')) + setEditDialogOpen(false) + refetchItems() + // 同步刷新用户侧导航栏缓存 + queryClient.invalidateQueries({ queryKey: ['navigation-tree'] }) + } }, - { - key: 'docs', - title: t('Docs'), - description: t('Documentation or external knowledge base.'), + }) + + // 删除节点 + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + return api.delete(`/api/navigation/admin/items/${id}`) }, - { - key: 'about', - title: t('About'), - description: t('Static page describing the platform.'), + onSuccess: (res) => { + if (res.data?.success) { + toast.success(t('Menu item deleted')) + refetchItems() + queryClient.invalidateQueries({ queryKey: ['navigation-tree'] }) + } }, - ] - - const accessModules: Array<{ - enabledKey: keyof HeaderNavFormValues - requireAuthKey: keyof HeaderNavFormValues - requireAuthDependsOn: 'pricingEnabled' | 'rankingsEnabled' - title: string - description: string - requireAuthTitle: string - requireAuthDescription: string - }> = [ - { - enabledKey: 'pricingEnabled', - requireAuthKey: 'pricingRequireAuth', - requireAuthDependsOn: 'pricingEnabled', - title: t('Model Square'), - description: t('Public model catalog and pricing page.'), - requireAuthTitle: t('Require login to view models'), - requireAuthDescription: t( - 'Visitors must authenticate before accessing the pricing directory.' - ), + }) + + // 重新排序 + const reorderMutation = useMutation({ + mutationFn: async (reorderList: Array<{ item_id: number; sort_order: number }>) => { + return api.post('/api/navigation/admin/items/reorder', reorderList) }, - { - enabledKey: 'rankingsEnabled', - requireAuthKey: 'rankingsRequireAuth', - requireAuthDependsOn: 'rankingsEnabled', - title: t('Rankings'), - description: t('Public rankings page based on live usage data.'), - requireAuthTitle: t('Require login to view rankings'), - requireAuthDescription: t( - 'Visitors must authenticate before accessing the rankings page.' - ), + onSuccess: () => { + refetchItems() + queryClient.invalidateQueries({ queryKey: ['navigation-tree'] }) }, - ] + }) + + // ================= 辅助操作 ================= + + const handleOpenCreate = (parentID?: number) => { + setEditingItem({ + menu_id: activeMenuID || 1, + parent_id: parentID, + type: 'builtin_module', + module_key: 'home', + enabled: true, + open_in_new_tab: false, + exact_active: false, + sort_order: flatItems.length + 1, + translations: [ + { locale: 'zh-CN', label: '' }, + { locale: 'en', label: '' }, + { locale: 'zh-TW', label: '' }, + ], + rules: [], + }) + setEditDialogOpen(true) + } + + const handleOpenEdit = (item: NavigationItem) => { + // 拷贝多语言配置,防修改污染 + const translations = ['zh-CN', 'en', 'zh-TW'].map((locale) => { + const found = (item.translations || []).find((t) => t.locale === locale) + return { locale, label: found ? found.label : '' } + }) + + setEditingItem({ + ...item, + translations, + }) + setEditDialogOpen(true) + } + + // 排序上移/下移 + const handleMove = (index: number, direction: 'up' | 'down') => { + const siblingItems = displayItems.filter( + (it) => it.item.parent_id === displayItems[index].item.parent_id + ) + const currentSiblingIdx = siblingItems.findIndex( + (it) => it.item.id === displayItems[index].item.id + ) + + let targetSiblingIdx = direction === 'up' ? currentSiblingIdx - 1 : currentSiblingIdx + 1 + if (targetSiblingIdx < 0 || targetSiblingIdx >= siblingItems.length) return + + const currentItem = siblingItems[currentSiblingIdx].item + const targetItem = siblingItems[targetSiblingIdx].item + + // 互换权重并保存 + reorderMutation.mutate([ + { item_id: currentItem.id, sort_order: targetItem.sort_order }, + { item_id: targetItem.id, sort_order: currentItem.sort_order }, + ]) + } + + const handleSaveItem = () => { + if (!editingItem) return + const cnTrans = editingItem.translations?.find((t) => t.locale === 'zh-CN') + if (!cnTrans || !cnTrans.label.trim()) { + toast.error(t('Chinese label is required')) + return + } + + saveMutation.mutate(editingItem) + } + + const updateTranslation = (locale: string, val: string) => { + if (!editingItem || !editingItem.translations) return + const updated = editingItem.translations.map((t) => { + if (t.locale === locale) return { ...t, label: val } + return t + }) + setEditingItem({ ...editingItem, translations: updated }) + } return ( -
- - -
- {simpleModules.map((module) => ( - ( - - - {module.title} - {module.description} - - - +
+

+ 自定义顶部导航菜单管理。支持树形自关联与二级子触发器,允许外链/内置组合。 +

+ +
+ + {/* 动态链接列表 */} +
+ {displayItems.length === 0 ? ( +
+ 暂无配置节点,点击右上角添加。 +
+ ) : ( + displayItems.map(({ item, depth }, index) => { + const cnLabel = + (item.translations || []).find((t) => t.locale === 'zh-CN')?.label || item.module_key + const enLabel = + (item.translations || []).find((t) => t.locale === 'en')?.label || item.module_key + + return ( +
+
+ {/* 图标与阶梯指示 */} +
+ {depth > 0 && └─} + + {item.type} + +
+ +
+ + {cnLabel} + + ({enLabel}) + + + + {item.type === 'builtin_module' + ? `模块键: ${item.module_key}` + : item.type === 'internal_path' + ? `内部路径: ${item.path}` + : `链接: ${item.url}`} + +
+
+ +
+ {/* 权限及外链指示 */} + {(item.rules || []).length > 0 && } + {item.open_in_new_tab && } + + {/* 排序微调 */} + + + + {/* 增加子节点 (只允许二级,限制 depth = 0 时) */} + {depth === 0 && item.type !== 'divider' && ( + + )} + + {/* 编辑与删除 */} + + +
+
+ ) + }) + )} +
+
+ + {/* 属性编辑 Dialog */} + + + + + {editingItem?.id ? '编辑导航项' : '新增导航链接'} + + + + {editingItem && ( +
+ {/* 类型选择 */} +
+ 类型 + +
+ + {/* 针对不同类型的附加输入框 */} + {editingItem.type === 'builtin_module' && ( +
+ 内置模块 + +
+ )} + + {editingItem.type === 'internal_path' && ( +
+ 站内路径 + + setEditingItem({ ...editingItem, path: e.target.value }) + } + placeholder='例如: /dashboard/billing' + className='col-span-3' + /> +
+ )} + + {editingItem.type === 'external_url' && ( +
+ 外部链接 + + setEditingItem({ ...editingItem, url: e.target.value }) + } + placeholder='例如: https://github.com' + className='col-span-3' + /> +
+ )} + + {editingItem.type !== 'divider' && ( + <> + {/* 多语言标签输入 */} +
+ + + 国际化多语言翻译 (Labels) + +
+
+ + 简体中文 + + t.locale === 'zh-CN')?.label || '' + } + onChange={(e) => updateTranslation('zh-CN', e.target.value)} + className='col-span-3' + /> +
+
+ + English + + t.locale === 'en')?.label || '' + } + onChange={(e) => updateTranslation('en', e.target.value)} + className='col-span-3' + /> +
+
+ + 繁體中文 + + t.locale === 'zh-TW')?.label || '' + } + onChange={(e) => updateTranslation('zh-TW', e.target.value)} + className='col-span-3' + /> +
+
+
+ + {/* 图标与行为开关 */} +
+
+ 图标标识 + + setEditingItem({ ...editingItem, icon_key: e.target.value }) + } + placeholder='例如: star' + className='col-span-3' /> - - - - )} - /> - ))} -
- -
- {accessModules.map((module) => ( - - ( - - - {module.title} - {module.description} - - +
+ +
+ 新标签页打开 +
+ setEditingItem({ + ...editingItem, + open_in_new_tab: checked, + }) + } /> - - - - )} - /> - - ( - - - - {module.requireAuthTitle} - - {module.requireAuthDescription} - - - - - - - - - )} - /> - - ))} -
- - +
+
+ +
+ 路由精确激活 +
+ + setEditingItem({ + ...editingItem, + exact_active: checked, + }) + } + /> +
+
+
+ + )} + + )} + + + + + +
+
) } diff --git a/web/default/src/features/system-settings/site/section-registry.tsx b/web/default/src/features/system-settings/site/section-registry.tsx index 6cea57a672e0..4fd4874caf01 100644 --- a/web/default/src/features/system-settings/site/section-registry.tsx +++ b/web/default/src/features/system-settings/site/section-registry.tsx @@ -18,9 +18,7 @@ For commercial licensing, please contact support@quantumnous.com */ import { SystemInfoSection } from '../general/system-info-section' import { - parseHeaderNavModules, parseSidebarModulesAdmin, - serializeHeaderNavModules, serializeSidebarModulesAdmin, } from '../maintenance/config' import { HeaderNavigationSection } from '../maintenance/header-navigation-section' @@ -63,14 +61,9 @@ const SITE_SECTIONS = [ { id: 'header-navigation', titleKey: 'Header navigation', - build: (settings: SiteSettings) => { - const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules) - const headerNavSerialized = serializeHeaderNavModules(headerNavConfig) + build: () => { return ( - + ) }, }, diff --git a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx index 4a6eb0eb723b..8f37a3727971 100644 --- a/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx +++ b/web/default/src/features/usage-logs/components/usage-logs-mobile-card.tsx @@ -200,8 +200,8 @@ function CommonLogsCard({ {t('Time')} . For commercial licensing, please contact support@quantumnous.com */ -import { useMemo } from 'react' + +import { useQuery } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' +import { api } from '@/lib/api' +import { BuiltinModulesRegistry } from '@/lib/nav-modules' import { useAuthStore } from '@/stores/auth-store' -import { parseHeaderNavModulesFromStatus } from '@/lib/nav-modules' -import { useStatus } from '@/hooks/use-status' - -export type TopNavLink = { - title: string - href: string - disabled?: boolean - requiresAuth?: boolean - external?: boolean -} +import { type TopNavLink } from '@/components/layout/types' /** - * Generate top navigation links based on HeaderNavModules configuration from backend /api/status - * Backend format example (stringified JSON): - * { - * home: true, - * console: true, - * pricing: { enabled: true, requireAuth: false }, - * rankings: { enabled: true, requireAuth: false }, - * docs: true, - * about: true - * } + * 动态加载并拼装顶部导航树 Hook */ export function useTopNavLinks(): TopNavLink[] { - const { t } = useTranslation() - const { status } = useStatus() + const { i18n } = useTranslation() const { auth } = useAuthStore() - // Parse HeaderNavModules - const modules = useMemo(() => { - return parseHeaderNavModulesFromStatus( - status as Record | null - ) - }, [status]) - - // Documentation link (may be external) - const docsLink: string | undefined = status?.docs_link as string | undefined - - const isAuthed = !!auth?.user - - const links: TopNavLink[] = [] - - // Home - if (modules?.home !== false) { - links.push({ title: t('Home'), href: '/' }) - } - - // Console -> /dashboard (new console path) - if (modules?.console !== false) { - links.push({ title: t('Console'), href: '/dashboard' }) - } + // 区分 i18n 语言环境 + const currentLang = i18n.language || 'zh-CN' + + // 利用 React Query 获取可见的菜单树 + const { data: rawTree } = useQuery({ + queryKey: ['navigation-tree', 'default_web_top', currentLang, auth?.user?.id], + queryFn: async () => { + const res = await api.get('/api/navigation/tree', { + params: { + menu_key: 'default_web_top', + lang: currentLang, + }, + skipErrorHandler: true, // 避免加载失败弹窗影响全局交互,实施静默重试/加载 + }) + return res.data?.data || [] + }, + }) + + // 将后端动态返回的菜单节点转换为前端标准的顶级及多级嵌套路由格式 + const links: TopNavLink[] = (rawTree || []).map(mapNavigationItemToLink) - // Pricing - const pricing = modules?.pricing - if (pricing && typeof pricing === 'object' && pricing.enabled) { - const requiresAuth = pricing.requireAuth && !isAuthed - links.push({ title: t('Model Square'), href: '/pricing', requiresAuth }) - } - - // Rankings - const rankings = modules?.rankings - if (rankings && typeof rankings === 'object' && rankings.enabled) { - const requiresAuth = rankings.requireAuth && !isAuthed - links.push({ title: t('Rankings'), href: '/rankings', requiresAuth }) - } + return links +} - // Docs (supports external links) - if (modules?.docs !== false) { - if (docsLink) { - links.push({ title: t('Docs'), href: docsLink, external: true }) - } else { - links.push({ title: t('Docs'), href: '/docs' }) - } +/** + * 映射后端 DTO 格式节点到前端导航项 + */ +function mapNavigationItemToLink(item: any): TopNavLink { + let href = '' + let isExternal = false + + switch (item.type) { + case 'builtin_module': + // 引用内置注册表的 SPA 路径 + const meta = BuiltinModulesRegistry[item.module_key] + href = meta ? meta.to : '/' + break + case 'internal_path': + href = item.path || '/' + break + case 'external_url': + href = item.url || '' + isExternal = true + break + case 'group': + href = '#' + break + default: + href = '#' } - // About - if (modules?.about !== false) { - links.push({ title: t('About'), href: '/about' }) + // 递归转换子菜单节点 + const children = + item.children && item.children.length > 0 + ? item.children.map(mapNavigationItemToLink) + : undefined + + return { + title: item.label, + href, + external: isExternal, + openInNewTab: item.open_in_new_tab, + children, } - - return links } diff --git a/web/default/src/lib/nav-modules.ts b/web/default/src/lib/nav-modules.ts index 2e8611d2218c..c8cfb3897329 100644 --- a/web/default/src/lib/nav-modules.ts +++ b/web/default/src/lib/nav-modules.ts @@ -16,8 +16,31 @@ along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ + import { getStatus } from '@/lib/api' +// ================= 前端内置模块注册表 (Registry) ================= + +export interface BuiltinModuleMeta { + moduleKey: string + defaultLabelKey: string // i18n 对应的多语言翻译键值 + to: string // SPA 路由跳转路径 + iconKey: string // 图标键名 + activeMatch?: 'exact' | 'prefix' // 路由高亮匹配模式 +} + +// BuiltinModulesRegistry 声明了系统内所有支持注册为内置导航的页面元数据 +export const BuiltinModulesRegistry: Record = { + home: { moduleKey: 'home', defaultLabelKey: 'Home', to: '/', iconKey: 'home', activeMatch: 'exact' }, + console: { moduleKey: 'console', defaultLabelKey: 'Console', to: '/dashboard', iconKey: 'layout-dashboard', activeMatch: 'prefix' }, + pricing: { moduleKey: 'pricing', defaultLabelKey: 'Model Square', to: '/pricing', iconKey: 'credit-card', activeMatch: 'prefix' }, + rankings: { moduleKey: 'rankings', defaultLabelKey: 'Rankings', to: '/rankings', iconKey: 'trophy', activeMatch: 'prefix' }, + docs: { moduleKey: 'docs', defaultLabelKey: 'Docs', to: '/docs', iconKey: 'book-open', activeMatch: 'prefix' }, + about: { moduleKey: 'about', defaultLabelKey: 'About', to: '/about', iconKey: 'info', activeMatch: 'prefix' } +} + +// ================= 向下兼容的历史解析逻辑 ================= + export type ModuleAccess = { enabled: boolean; requireAuth: boolean } export type HeaderNavModule = 'rankings' | 'pricing' From f2213df9b5fb524747b0445cce335be2c64c696b Mon Sep 17 00:00:00 2001 From: z23cc Date: Sun, 31 May 2026 13:32:01 +0800 Subject: [PATCH 04/38] fix: reuse upstream model fetch for channel preview --- controller/channel.go | 108 +++++++----------------------------------- 1 file changed, 16 insertions(+), 92 deletions(-) diff --git a/controller/channel.go b/controller/channel.go index c59e492a5a02..ed5d8185a424 100644 --- a/controller/channel.go +++ b/controller/channel.go @@ -14,7 +14,6 @@ import ( "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/model" relaychannel "github.com/QuantumNous/new-api/relay/channel" - "github.com/QuantumNous/new-api/relay/channel/gemini" "github.com/QuantumNous/new-api/relay/channel/ollama" "github.com/QuantumNous/new-api/service" @@ -993,9 +992,10 @@ func UpdateChannel(c *gin.Context) { func FetchModels(c *gin.Context) { var req struct { - BaseURL string `json:"base_url"` - Type int `json:"type"` - Key string `json:"key"` + BaseURL string `json:"base_url"` + Type int `json:"type"` + Key string `json:"key"` + HeaderOverride string `json:"header_override"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -1006,105 +1006,29 @@ func FetchModels(c *gin.Context) { return } - baseURL := req.BaseURL - if baseURL == "" { - baseURL = constant.ChannelBaseURLs[req.Type] + // 预览/新建渠道时还没有入库的 Channel,复用已保存渠道的上游拉取逻辑, + // 避免各渠道在鉴权头、特殊模型地址、代理等细节上出现分叉。 + channel := &model.Channel{ + Type: req.Type, + Key: strings.TrimSpace(strings.Split(strings.TrimSpace(req.Key), "\n")[0]), } - // remove line breaks and extra spaces. - key := strings.TrimSpace(req.Key) - key = strings.Split(key, "\n")[0] - - if req.Type == constant.ChannelTypeOllama { - models, err := ollama.FetchOllamaModels(baseURL, key) - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("获取Ollama模型失败: %s", err.Error()), - }) - return - } - - names := make([]string, 0, len(models)) - for _, modelInfo := range models { - names = append(names, modelInfo.Name) - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": names, - }) - return + if baseURL := strings.TrimSpace(req.BaseURL); baseURL != "" { + channel.BaseURL = &baseURL } - - if req.Type == constant.ChannelTypeGemini { - models, err := gemini.FetchGeminiModels(baseURL, key, "") - if err != nil { - c.JSON(http.StatusOK, gin.H{ - "success": false, - "message": fmt.Sprintf("获取Gemini模型失败: %s", err.Error()), - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "data": models, - }) - return - } - - client := &http.Client{} - url := fmt.Sprintf("%s/v1/models", baseURL) - - request, err := http.NewRequest("GET", url, nil) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": err.Error(), - }) - return + if headerOverride := strings.TrimSpace(req.HeaderOverride); headerOverride != "" { + channel.HeaderOverride = &headerOverride } - request.Header.Set("Authorization", "Bearer "+key) - - response, err := client.Do(request) + models, err := fetchChannelUpstreamModelIDs(channel) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": err.Error(), - }) - return - } - //check status code - if response.StatusCode != http.StatusOK { - c.JSON(http.StatusInternalServerError, gin.H{ - "success": false, - "message": "Failed to fetch models", - }) - return - } - defer response.Body.Close() - - var result struct { - Data []struct { - ID string `json:"id"` - } `json:"data"` - } - - if err := json.NewDecoder(response.Body).Decode(&result); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ + c.JSON(http.StatusOK, gin.H{ "success": false, - "message": err.Error(), + "message": fmt.Sprintf("获取模型列表失败: %s", err.Error()), }) return } - var models []string - for _, model := range result.Data { - models = append(models, model.ID) - } - c.JSON(http.StatusOK, gin.H{ "success": true, "data": models, From fc75746d66622cdbf6122fe7edb807d0e5c2f2a8 Mon Sep 17 00:00:00 2001 From: z23cc Date: Sun, 31 May 2026 14:01:10 +0800 Subject: [PATCH 05/38] fix: add claude opus 4-8 defaults --- relay/channel/claude/constants.go | 1 + setting/ratio_setting/cache_ratio.go | 2 ++ setting/ratio_setting/model_ratio.go | 1 + .../channels/components/drawers/channel-mutate-drawer.tsx | 5 +++++ 4 files changed, 9 insertions(+) diff --git a/relay/channel/claude/constants.go b/relay/channel/claude/constants.go index 3c516aefb7db..bbf6a07627ac 100644 --- a/relay/channel/claude/constants.go +++ b/relay/channel/claude/constants.go @@ -33,6 +33,7 @@ var ModelList = []string{ "claude-opus-4-7-medium", "claude-opus-4-7-low", "claude-opus-4-7-thinking", + "claude-opus-4-8", } var ChannelName = "claude" diff --git a/setting/ratio_setting/cache_ratio.go b/setting/ratio_setting/cache_ratio.go index fe6e3b3262a4..18c760490af1 100644 --- a/setting/ratio_setting/cache_ratio.go +++ b/setting/ratio_setting/cache_ratio.go @@ -71,6 +71,7 @@ var defaultCacheRatio = map[string]float64{ "claude-opus-4-7-high": 0.1, "claude-opus-4-7-medium": 0.1, "claude-opus-4-7-low": 0.1, + "claude-opus-4-8": 0.1, } var defaultCreateCacheRatio = map[string]float64{ @@ -106,6 +107,7 @@ var defaultCreateCacheRatio = map[string]float64{ "claude-opus-4-7-high": 1.25, "claude-opus-4-7-medium": 1.25, "claude-opus-4-7-low": 1.25, + "claude-opus-4-8": 1.25, } //var defaultCreateCacheRatio = map[string]float64{} diff --git a/setting/ratio_setting/model_ratio.go b/setting/ratio_setting/model_ratio.go index 80702ee42ad2..e2a2b200ad69 100644 --- a/setting/ratio_setting/model_ratio.go +++ b/setting/ratio_setting/model_ratio.go @@ -152,6 +152,7 @@ var defaultModelRatio = map[string]float64{ "claude-opus-4-7-high": 2.5, "claude-opus-4-7-medium": 2.5, "claude-opus-4-7-low": 2.5, + "claude-opus-4-8": 2.5, "claude-3-opus-20240229": 7.5, // $15 / 1M tokens "claude-opus-4-20250514": 7.5, "claude-opus-4-1-20250805": 7.5, diff --git a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx index 6b26cd171505..722439d35a41 100644 --- a/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx +++ b/web/default/src/features/channels/components/drawers/channel-mutate-drawer.tsx @@ -415,6 +415,11 @@ export function ChannelMutateDrawer({ (model) => model.startsWith('gpt-') || model.startsWith('text-') ) } + if (currentType === 14) { + return allModelsList.filter((model) => + model.toLowerCase().startsWith('claude-') + ) + } return allModelsList }, [allModelsList, currentType]) From f52a7b316f42374e14af11c8e6544cd2d4315ff7 Mon Sep 17 00:00:00 2001 From: z23cc Date: Sun, 31 May 2026 14:24:46 +0800 Subject: [PATCH 06/38] fix: align classic playground route --- web/classic/src/App.jsx | 8 ++++++++ web/classic/src/components/layout/SiderBar.jsx | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/web/classic/src/App.jsx b/web/classic/src/App.jsx index a5d1ebc00b32..fe8a6dda7917 100644 --- a/web/classic/src/App.jsx +++ b/web/classic/src/App.jsx @@ -155,6 +155,14 @@ function App() { } /> + + + + } + /> {} }) => { { text: t('操练场'), itemKey: 'playground', - to: '/playground', + to: '/console/playground', }, { text: t('聊天'), From 1359d601936ed77f88f684bf5f20a4b37597e0a3 Mon Sep 17 00:00:00 2001 From: z23cc Date: Mon, 1 Jun 2026 12:29:08 +0800 Subject: [PATCH 07/38] =?UTF-8?q?feat:=20=E6=89=B9=E9=87=8F=E5=AF=BC?= =?UTF-8?q?=E5=85=A5Claude=E6=B8=A0=E9=81=93=20&=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E7=B1=BB=E5=9E=8B=E6=94=B9=E4=B8=BAAnthropic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增批量导入功能(classic/default双主题) - 支持 余额密钥 格式,每行一条 - 自动命名:YYYYMMDDHHmm-余额-标签 - 自动使用Claude默认模型 - 逐条创建,实时显示进度和结果 - 创建渠道默认类型从OpenAI(1)改为Anthropic Claude(14) - docker-local.sh 默认前端主题改为classic --- bin/docker-local.sh | 2 +- .../table/channels/ChannelsFilters.jsx | 15 +- .../src/components/table/channels/index.jsx | 6 + .../channels/modals/BatchImportModal.jsx | 480 ++++++++++++++++ .../channels/modals/EditChannelModal.jsx | 2 +- .../src/hooks/channels/useChannelsData.jsx | 3 + .../channels/components/channels-dialogs.tsx | 7 + .../components/channels-primary-buttons.tsx | 11 + .../channels/components/channels-provider.tsx | 1 + .../dialogs/batch-import-dialog.tsx | 529 ++++++++++++++++++ .../src/features/channels/lib/channel-form.ts | 2 +- 11 files changed, 1054 insertions(+), 4 deletions(-) create mode 100644 web/classic/src/components/table/channels/modals/BatchImportModal.jsx create mode 100644 web/default/src/features/channels/components/dialogs/batch-import-dialog.tsx diff --git a/bin/docker-local.sh b/bin/docker-local.sh index 313571643b8b..608409a21155 100755 --- a/bin/docker-local.sh +++ b/bin/docker-local.sh @@ -41,7 +41,7 @@ CRYPTO_SECRET="${CRYPTO_SECRET:-}" NODE_NAME="${NODE_NAME:-${PROJECT_NAME}-node-1}" BUILD_ON_UP="${BUILD_ON_UP:-1}" if [[ -z "${FRONTEND_THEME+x}" ]]; then - FRONTEND_THEME="default" + FRONTEND_THEME="classic" fi usage() { diff --git a/web/classic/src/components/table/channels/ChannelsFilters.jsx b/web/classic/src/components/table/channels/ChannelsFilters.jsx index e97a1e3e37f0..483dfeb8caed 100644 --- a/web/classic/src/components/table/channels/ChannelsFilters.jsx +++ b/web/classic/src/components/table/channels/ChannelsFilters.jsx @@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com import React from 'react'; import { Button, Form } from '@douyinfe/semi-ui'; -import { IconSearch } from '@douyinfe/semi-icons'; +import { IconSearch, IconUpload } from '@douyinfe/semi-icons'; const ChannelsFilters = ({ setEditingChannel, @@ -34,6 +34,7 @@ const ChannelsFilters = ({ groupOptions, loading, searching, + setShowBatchImport, t, }) => { return ( @@ -54,6 +55,18 @@ const ChannelsFilters = ({ {t('添加渠道')} + + + {importState !== 'done' && ( + + )} + + } + > +
+ {/* Name Tag */} +
+
{t('名称标签')}
+ +
+ {t('渠道命名格式:{{format}}').replace( + '{{format}}', + `${timestamp}-{余额}-{标签}`, + )} +
+
+ + {/* Group */} +
+
{t('分组')}
+ +
+ + {/* Input Data */} +
+
+ {t('导入数据')}{' '} + + ({t('余额密钥,每行一条')}) + +
+