mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
feat(simple): make startup default group creation optional
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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` 才允许启动
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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) 配置
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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:-}
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
|
|||||||
@@ -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:-}
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user