diff --git a/README.md b/README.md index 3d6fd891be..82d0a4d7ae 100644 --- a/README.md +++ b/README.md @@ -725,6 +725,7 @@ go generate ./cmd/server Simple Mode is designed for individual developers or internal teams who want quick access without full SaaS features. - Enable: Set environment variable `RUN_MODE=simple` +- Default groups are seeded on each startup. Set `SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=false` (or YAML `simple_mode.auto_create_default_groups: false`) to manage groups yourself. The default is `true`; disabling it does not delete existing groups or change runtime auto-binding or admin concurrency setup. - Difference: Hides SaaS-related features and skips billing process - Security note: In production, you must also set `SIMPLE_MODE_CONFIRM=true` to allow startup diff --git a/README_CN.md b/README_CN.md index 9a4907c461..6289d0c92b 100644 --- a/README_CN.md +++ b/README_CN.md @@ -735,6 +735,7 @@ OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_gener 简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。 - 启用方式:设置环境变量 `RUN_MODE=simple` +- 默认每次启动会补齐默认分组。设置 `SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=false`(或 YAML `simple_mode.auto_create_default_groups: false`)可自行管理分组。默认值为 `true`;关闭后不删除已有分组,不改变运行时自动绑定或管理员并发设置。 - 功能差异:隐藏 SaaS 相关功能,跳过计费流程 - 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 1d7e41305f..2195c74ae5 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -96,6 +96,7 @@ type Config struct { UsageCleanup UsageCleanupConfig `mapstructure:"usage_cleanup"` Concurrency ConcurrencyConfig `mapstructure:"concurrency"` TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"` + SimpleMode SimpleModeConfig `mapstructure:"simple_mode" yaml:"simple_mode"` RunMode string `mapstructure:"run_mode" yaml:"run_mode"` Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" Gemini GeminiConfig `mapstructure:"gemini"` @@ -106,6 +107,11 @@ type Config struct { Plugins PluginConfig `mapstructure:"plugins"` } +// SimpleModeConfig controls startup behavior in simple mode. +type SimpleModeConfig struct { + AutoCreateDefaultGroups bool `mapstructure:"auto_create_default_groups" yaml:"auto_create_default_groups"` +} + // PluginConfig 控制管理员手动上传的本地进程插件。 // 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。 type PluginConfig struct { @@ -1992,6 +1998,7 @@ func configureConfigSource(setConfigFile, addConfigPath func(string)) { func setDefaults() { viper.SetDefault("run_mode", RunModeStandard) + viper.SetDefault("simple_mode.auto_create_default_groups", true) // Server viper.SetDefault("server.host", "0.0.0.0") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index d0ed2d6993..8783092b7e 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -2630,3 +2630,42 @@ func TestLoad_DefaultGatewayImageStreamConfig(t *testing.T) { t.Fatalf("image stream timeout = %d, want greater than ordinary stream timeout %d", cfg.Gateway.ImageStreamDataIntervalTimeout, cfg.Gateway.StreamDataIntervalTimeout) } } + +func TestLoadSimpleModeAutoCreateDefaultGroups(t *testing.T) { + for _, loader := range []struct { + name string + load func() (*Config, error) + }{{"Load", Load}, {"LoadForBootstrap", LoadForBootstrap}} { + t.Run(loader.name, func(t *testing.T) { + for _, tt := range []struct { + name string + yaml string + env string + unset bool + want bool + }{ + {name: "unset env uses application default", unset: true, want: true}, + {name: "empty env uses application default", want: true}, + {name: "unset env preserves yaml false", unset: true, yaml: "simple_mode:\n auto_create_default_groups: false\n", want: false}, + {name: "empty env preserves yaml false", yaml: "simple_mode:\n auto_create_default_groups: false\n", want: false}, + {name: "env false overrides yaml true", yaml: "simple_mode:\n auto_create_default_groups: true\n", env: "false", want: false}, + {name: "env false without yaml", env: "false", want: false}, + {name: "env true overrides yaml false", yaml: "simple_mode:\n auto_create_default_groups: false\n", env: "true", want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS", tt.env) + if tt.unset { + require.NoError(t, os.Unsetenv("SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS")) + } + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("run_mode: simple\n"+tt.yaml), 0o600)) + t.Setenv("CONFIG_FILE", path) + cfg, err := loader.load() + require.NoError(t, err) + require.Equal(t, tt.want, cfg.SimpleMode.AutoCreateDefaultGroups) + }) + } + }) + } +} diff --git a/backend/internal/repository/ent.go b/backend/internal/repository/ent.go index 165fea4baf..d8f4ed5639 100644 --- a/backend/internal/repository/ent.go +++ b/backend/internal/repository/ent.go @@ -157,21 +157,25 @@ func InitEnt(cfg *config.Config) (*ent.Client, *sql.DB, error) { return nil, nil, fmt.Errorf("validate config after secret bootstrap: %w", err) } - // SIMPLE 模式:启动时补齐各平台默认分组。 - // - anthropic/openai/gemini: 确保存在 -default - // - antigravity: 仅要求存在 >=2 个未软删除分组(用于 claude/gemini 混合调度场景) - if cfg.RunMode == config.RunModeSimple { - seedCtx, seedCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer seedCancel() - if err := ensureSimpleModeDefaultGroups(seedCtx, client); err != nil { - _ = client.Close() - return nil, nil, err - } - if err := ensureSimpleModeAdminConcurrency(seedCtx, client); err != nil { - _ = client.Close() - return nil, nil, err - } + seedCtx, seedCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer seedCancel() + if err := ensureSimpleModeStartup(seedCtx, client, cfg); err != nil { + _ = client.Close() + return nil, nil, err } return client, drv.DB(), nil } + +// ensureSimpleModeStartup keeps admin concurrency setup independent of group seeding. +func ensureSimpleModeStartup(ctx context.Context, client *ent.Client, cfg *config.Config) error { + if cfg.RunMode != config.RunModeSimple { + return nil + } + if cfg.SimpleMode.AutoCreateDefaultGroups { + if err := ensureSimpleModeDefaultGroups(ctx, client); err != nil { + return err + } + } + return ensureSimpleModeAdminConcurrency(ctx, client) +} diff --git a/backend/internal/repository/simple_mode_startup_test.go b/backend/internal/repository/simple_mode_startup_test.go new file mode 100644 index 0000000000..d62a6a2ee3 --- /dev/null +++ b/backend/internal/repository/simple_mode_startup_test.go @@ -0,0 +1,69 @@ +package repository + +import ( + "context" + "errors" + "testing" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/stretchr/testify/require" +) + +func TestEnsureSimpleModeStartup(t *testing.T) { + for _, tt := range []struct { + name string + mode string + seed bool + }{ + {"simple enabled", config.RunModeSimple, true}, + {"simple disabled preserves admin setup", config.RunModeSimple, false}, + {"standard enabled", config.RunModeStandard, true}, + {"standard disabled", config.RunModeStandard, false}, + } { + t.Run(tt.name, func(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + client := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + if tt.mode == config.RunModeSimple { + if tt.seed { + // The first seeding operation backfills auto-created Grok groups. + mock.ExpectExec(`UPDATE "groups"`).WillReturnResult(sqlmock.NewResult(0, 0)) + // Existing groups exercise the complete seeding path without inserts. + for i := 0; i < 5; i++ { + mock.ExpectQuery(`SELECT COUNT\(.*FROM "groups"`).WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) + } + // Antigravity only counts; other platforms check the default name. + for i := 0; i < 4; i++ { + mock.ExpectQuery(`SELECT "groups"\."id" FROM "groups"`).WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + } + // Platform map iteration order is unspecified. + mock.MatchExpectationsInOrder(false) + } + mock.ExpectQuery(`SELECT .*FROM "settings"`). + WithArgs(simpleModeAdminConcurrencyUpgradeKey). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(1)) + } + cfg := &config.Config{RunMode: tt.mode, SimpleMode: config.SimpleModeConfig{AutoCreateDefaultGroups: tt.seed}} + require.NoError(t, ensureSimpleModeStartup(context.Background(), client, cfg)) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} + +func TestEnsureSimpleModeStartupSeedingFailure(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + client := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + seedErr := errors.New("group seeding failed") + mock.ExpectExec(`UPDATE "groups"`).WillReturnError(seedErr) + cfg := &config.Config{RunMode: config.RunModeSimple, SimpleMode: config.SimpleModeConfig{AutoCreateDefaultGroups: true}} + // No admin setup query is expected after a seeding failure. + require.ErrorIs(t, ensureSimpleModeStartup(context.Background(), client, cfg), seedErr) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/deploy/.env.example b/deploy/.env.example index 47f249b6aa..31b913e1cd 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -119,6 +119,9 @@ SERVER_H2C_MAX_UPLOAD_BUFFER_PER_STREAM=524288 # 运行模式: standard (默认) 或 simple (内部自用) # standard: 完整 SaaS 功能,包含计费/余额校验;simple: 隐藏 SaaS 功能并跳过计费/余额校验 RUN_MODE=standard +# 简易模式启动时自动补齐默认分组;留空或未设置时使用 YAML 配置,未配置时应用默认 true +# 显式 true/false 会覆盖 YAML;false 由用户自行管理,已有分组和运行时行为不变 +SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS= # Timezone TZ=Asia/Shanghai diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index e93f6c96f1..3507cbc03d 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -100,6 +100,11 @@ webauthn: # - simple: 隐藏 SaaS 功能,跳过计费和余额校验 run_mode: "standard" +simple_mode: + # 仅控制简易模式启动时补齐默认分组;false 不删除已有分组,不改变运行时自动绑定或管理员并发设置。 + # 环境变量:SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS + auto_create_default_groups: true + # ============================================================================= # CORS Configuration # 跨域资源共享 (CORS) 配置 diff --git a/deploy/docker-compose.dev.yml b/deploy/docker-compose.dev.yml index c2a2ac9246..92a075ceb7 100644 --- a/deploy/docker-compose.dev.yml +++ b/deploy/docker-compose.dev.yml @@ -30,6 +30,7 @@ services: - SERVER_MODE=debug - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - RUN_MODE=${RUN_MODE:-standard} + - SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-} - DATABASE_HOST=postgres - DATABASE_PORT=5432 - DATABASE_USER=${POSTGRES_USER:-sub2api} diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 8a14f4d7aa..8e8956cd69 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -55,6 +55,7 @@ services: - SERVER_MODE=${SERVER_MODE:-release} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - RUN_MODE=${RUN_MODE:-standard} + - SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-} - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} # ======================================================================= diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index 7273dd0fed..af98c34a60 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -41,6 +41,7 @@ services: - SERVER_MODE=${SERVER_MODE:-release} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - RUN_MODE=${RUN_MODE:-standard} + - SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-} - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} # ======================================================================= diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index e6b84a54d3..f9086dda4a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -51,6 +51,7 @@ services: - SERVER_MODE=${SERVER_MODE:-release} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - RUN_MODE=${RUN_MODE:-standard} + - SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-} - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} - ALIPAY_MOBILE_PRECREATE_DEEP_LINK=${ALIPAY_MOBILE_PRECREATE_DEEP_LINK:-} diff --git a/deploy/tests/docker-compose-simple-mode-env-test.sh b/deploy/tests/docker-compose-simple-mode-env-test.sh new file mode 100644 index 0000000000..28f4eec14a --- /dev/null +++ b/deploy/tests/docker-compose-simple-mode-env-test.sh @@ -0,0 +1,46 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +cd "$repo_root" + +python3 - <<'PY' +import json +import os +from pathlib import Path +import subprocess + +key = "SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS" +sample = Path("deploy/.env.example").read_text().splitlines() +assert [line for line in sample if line.startswith(key + "=")] == [key + "="], \ + ".env.example must preserve YAML configuration by leaving the override empty" + +for filename in ( + "docker-compose.yml", + "docker-compose.local.yml", + "docker-compose.standalone.yml", + "docker-compose.dev.yml", +): + path = Path("deploy") / filename + expected = " - " + key + "=${" + key + ":-}" + assert path.read_text().splitlines().count(expected) == 1, \ + f"{path} must pass the override with an empty fallback exactly once" + for value in (None, "", "true", "false"): + env = dict( + os.environ, POSTGRES_PASSWORD="compose-test-password", + DATABASE_HOST="postgres", DATABASE_PASSWORD="compose-test-password", + REDIS_HOST="redis", + ) + env.pop(key, None) + if value is not None: + env[key] = value + result = subprocess.run( + ["docker", "compose", "--env-file", "/dev/null", "-f", str(path), + "config", "--format", "json"], + env=env, check=True, capture_output=True, text=True, + ) + actual = json.loads(result.stdout)["services"]["sub2api"]["environment"][key] + assert actual == (value or ""), f"{path}: override {value!r} rendered as {actual!r}" + +print("docker compose simple mode environment test passed") +PY