feat(simple): make startup default group creation optional

This commit is contained in:
wucm667
2026-09-19 16:47:17 +08:00
parent 1a9d49e16f
commit 68aa26477f
13 changed files with 193 additions and 14 deletions
+1
View File
@@ -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. 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` - 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 - Difference: Hides SaaS-related features and skips billing process
- Security note: In production, you must also set `SIMPLE_MODE_CONFIRM=true` to allow startup - Security note: In production, you must also set `SIMPLE_MODE_CONFIRM=true` to allow startup
+1
View File
@@ -735,6 +735,7 @@ OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_gener
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。 简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
- 启用方式:设置环境变量 `RUN_MODE=simple` - 启用方式:设置环境变量 `RUN_MODE=simple`
- 默认每次启动会补齐默认分组。设置 `SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=false`(或 YAML `simple_mode.auto_create_default_groups: false`)可自行管理分组。默认值为 `true`;关闭后不删除已有分组,不改变运行时自动绑定或管理员并发设置。
- 功能差异:隐藏 SaaS 相关功能,跳过计费流程 - 功能差异:隐藏 SaaS 相关功能,跳过计费流程
- 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动 - 安全注意事项:生产环境需同时设置 `SIMPLE_MODE_CONFIRM=true` 才允许启动
+7
View File
@@ -96,6 +96,7 @@ type Config struct {
UsageCleanup UsageCleanupConfig `mapstructure:"usage_cleanup"` UsageCleanup UsageCleanupConfig `mapstructure:"usage_cleanup"`
Concurrency ConcurrencyConfig `mapstructure:"concurrency"` Concurrency ConcurrencyConfig `mapstructure:"concurrency"`
TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"` TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"`
SimpleMode SimpleModeConfig `mapstructure:"simple_mode" yaml:"simple_mode"`
RunMode string `mapstructure:"run_mode" yaml:"run_mode"` RunMode string `mapstructure:"run_mode" yaml:"run_mode"`
Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC"
Gemini GeminiConfig `mapstructure:"gemini"` Gemini GeminiConfig `mapstructure:"gemini"`
@@ -106,6 +107,11 @@ type Config struct {
Plugins PluginConfig `mapstructure:"plugins"` 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 控制管理员手动上传的本地进程插件。 // PluginConfig 控制管理员手动上传的本地进程插件。
// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。 // 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。
type PluginConfig struct { type PluginConfig struct {
@@ -1992,6 +1998,7 @@ func configureConfigSource(setConfigFile, addConfigPath func(string)) {
func setDefaults() { func setDefaults() {
viper.SetDefault("run_mode", RunModeStandard) viper.SetDefault("run_mode", RunModeStandard)
viper.SetDefault("simple_mode.auto_create_default_groups", true)
// Server // Server
viper.SetDefault("server.host", "0.0.0.0") viper.SetDefault("server.host", "0.0.0.0")
+39
View File
@@ -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) 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)
})
}
})
}
}
+18 -14
View File
@@ -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) return nil, nil, fmt.Errorf("validate config after secret bootstrap: %w", err)
} }
// SIMPLE 模式:启动时补齐各平台默认分组。 seedCtx, seedCancel := context.WithTimeout(context.Background(), 30*time.Second)
// - anthropic/openai/gemini: 确保存在 <platform>-default defer seedCancel()
// - antigravity: 仅要求存在 >=2 个未软删除分组(用于 claude/gemini 混合调度场景) if err := ensureSimpleModeStartup(seedCtx, client, cfg); err != nil {
if cfg.RunMode == config.RunModeSimple { _ = client.Close()
seedCtx, seedCancel := context.WithTimeout(context.Background(), 30*time.Second) return nil, nil, err
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
}
} }
return client, drv.DB(), nil 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)
}
@@ -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())
}
+3
View File
@@ -119,6 +119,9 @@ SERVER_H2C_MAX_UPLOAD_BUFFER_PER_STREAM=524288
# 运行模式: standard (默认) 或 simple (内部自用) # 运行模式: standard (默认) 或 simple (内部自用)
# standard: 完整 SaaS 功能,包含计费/余额校验;simple: 隐藏 SaaS 功能并跳过计费/余额校验 # standard: 完整 SaaS 功能,包含计费/余额校验;simple: 隐藏 SaaS 功能并跳过计费/余额校验
RUN_MODE=standard RUN_MODE=standard
# 简易模式启动时自动补齐默认分组;留空或未设置时使用 YAML 配置,未配置时应用默认 true
# 显式 true/false 会覆盖 YAML;false 由用户自行管理,已有分组和运行时行为不变
SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=
# Timezone # Timezone
TZ=Asia/Shanghai TZ=Asia/Shanghai
+5
View File
@@ -100,6 +100,11 @@ webauthn:
# - simple: 隐藏 SaaS 功能,跳过计费和余额校验 # - simple: 隐藏 SaaS 功能,跳过计费和余额校验
run_mode: "standard" run_mode: "standard"
simple_mode:
# 仅控制简易模式启动时补齐默认分组;false 不删除已有分组,不改变运行时自动绑定或管理员并发设置。
# 环境变量:SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS
auto_create_default_groups: true
# ============================================================================= # =============================================================================
# CORS Configuration # CORS Configuration
# 跨域资源共享 (CORS) 配置 # 跨域资源共享 (CORS) 配置
+1
View File
@@ -30,6 +30,7 @@ services:
- SERVER_MODE=debug - SERVER_MODE=debug
- ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false}
- RUN_MODE=${RUN_MODE:-standard} - RUN_MODE=${RUN_MODE:-standard}
- SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-}
- DATABASE_HOST=postgres - DATABASE_HOST=postgres
- DATABASE_PORT=5432 - DATABASE_PORT=5432
- DATABASE_USER=${POSTGRES_USER:-sub2api} - DATABASE_USER=${POSTGRES_USER:-sub2api}
+1
View File
@@ -55,6 +55,7 @@ services:
- SERVER_MODE=${SERVER_MODE:-release} - SERVER_MODE=${SERVER_MODE:-release}
- ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false}
- RUN_MODE=${RUN_MODE:-standard} - RUN_MODE=${RUN_MODE:-standard}
- SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-}
- UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-}
# ======================================================================= # =======================================================================
+1
View File
@@ -41,6 +41,7 @@ services:
- SERVER_MODE=${SERVER_MODE:-release} - SERVER_MODE=${SERVER_MODE:-release}
- ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false}
- RUN_MODE=${RUN_MODE:-standard} - RUN_MODE=${RUN_MODE:-standard}
- SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-}
- UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-}
# ======================================================================= # =======================================================================
+1
View File
@@ -51,6 +51,7 @@ services:
- SERVER_MODE=${SERVER_MODE:-release} - SERVER_MODE=${SERVER_MODE:-release}
- ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false} - ENABLE_SERVER_TIMING=${ENABLE_SERVER_TIMING:-false}
- RUN_MODE=${RUN_MODE:-standard} - RUN_MODE=${RUN_MODE:-standard}
- SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS=${SIMPLE_MODE_AUTO_CREATE_DEFAULT_GROUPS:-}
- UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-} - UPDATE_GITHUB_TOKEN=${UPDATE_GITHUB_TOKEN:-}
- ALIPAY_MOBILE_PRECREATE_DEEP_LINK=${ALIPAY_MOBILE_PRECREATE_DEEP_LINK:-} - ALIPAY_MOBILE_PRECREATE_DEEP_LINK=${ALIPAY_MOBILE_PRECREATE_DEEP_LINK:-}
@@ -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