From 0749931fd17aeb83b7a61c091421d4ada938888f Mon Sep 17 00:00:00 2001 From: peakchao Date: Sun, 28 Jun 2026 12:12:05 +0800 Subject: [PATCH 1/2] feat(config): seed server address from environment Add SERVER_ADDRESS as a first-run seed for the ServerAddress option. When the database does not yet contain a ServerAddress row, startup persists the environment value through the existing option update path so runtime state and the options table stay in sync. Existing database/admin settings continue to take precedence and are never overwritten by the environment variable. Document the new variable in README files and docker-compose examples, and cover both seed and no-overwrite behavior with model tests. Refs QuantumNous#5549 --- README.en.md | 1 + README.fr.md | 1 + README.ja.md | 1 + README.md | 1 + README.zh_CN.md | 1 + README.zh_TW.md | 1 + docker-compose.dev.yml | 1 + docker-compose.yml | 1 + docs/installation/BT.md | 2 +- model/option.go | 13 ++++++++ model/option_test.go | 68 +++++++++++++++++++++++++++++++++++++++++ 11 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 model/option_test.go diff --git a/README.en.md b/README.en.md index e62d9b7f314..53a4d69dca5 100644 --- a/README.en.md +++ b/README.en.md @@ -309,6 +309,7 @@ docker run --name new-api -d --restart always \ | `CRYPTO_SECRET` | Encryption secret (required for Redis) | - | | `SQL_DSN` | Database connection string | - | | `REDIS_CONN_STRING` | Redis connection string | - | +| `SERVER_ADDRESS` | First-run seed for the server address. Used only when the database has no `ServerAddress` option; existing admin settings are not overwritten | - | | `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` | | `MAX_REQUEST_BODY_MB` | Max request body size (MB, counted **after decompression**; prevents huge requests/zip bombs from exhausting memory). Exceeding it returns `413` | `32` | diff --git a/README.fr.md b/README.fr.md index 2d0d4e32be8..bb543f9b661 100644 --- a/README.fr.md +++ b/README.fr.md @@ -316,6 +316,7 @@ docker run --name new-api -d --restart always \ | `CRYPTO_SECRET` | Secret de chiffrement (requis pour Redis) | - | | `SQL_DSN` | Chaine de connexion à la base de données | - | | `REDIS_CONN_STRING` | Chaine de connexion Redis | - | +| `SERVER_ADDRESS` | Valeur initiale de l'adresse du serveur au premier démarrage. Utilisée uniquement si l'option `ServerAddress` n'existe pas en base ; les réglages administrateur existants ne sont pas écrasés | - | | `STREAMING_TIMEOUT` | Délai d'expiration du streaming (secondes) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | Taille max du buffer par ligne (Mo) pour le scanner SSE ; à augmenter quand les sorties image/base64 sont très volumineuses (ex. images 4K) | `64` | | `MAX_REQUEST_BODY_MB` | Taille maximale du corps de requête (Mo, comptée **après décompression** ; évite les requêtes énormes/zip bombs qui saturent la mémoire). Dépassement ⇒ `413` | `32` | diff --git a/README.ja.md b/README.ja.md index 9978c768a80..c49d08f122e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -318,6 +318,7 @@ docker run --name new-api -d --restart always \ | `CRYPTO_SECRET` | 暗号化シークレット(Redisに必須) | - | | `SQL_DSN** | データベース接続文字列 | - | | `REDIS_CONN_STRING` | Redis接続文字列 | - | +| `SERVER_ADDRESS` | サーバーアドレスの初回起動シード。データベースに `ServerAddress` オプションがない場合のみ書き込み、既存の管理者設定は上書きしません | - | | `STREAMING_TIMEOUT` | ストリーミング応答のタイムアウト時間(秒) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | ストリームスキャナの1行あたりバッファ上限(MB)。4K画像など巨大なbase64 `data:` ペイロードを扱う場合は値を増加させてください | `64` | | `MAX_REQUEST_BODY_MB` | リクエストボディ最大サイズ(MB、**解凍後**に計測。巨大リクエスト/zip bomb によるメモリ枯渇を防止)。超過時は `413` | `32` | diff --git a/README.md b/README.md index c5b5e322ae1..7e39d895c28 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,7 @@ docker run --name new-api -d --restart always \ | `CRYPTO_SECRET` | Encryption secret (required for Redis) | - | | `SQL_DSN` | Database connection string | - | | `REDIS_CONN_STRING` | Redis connection string | - | +| `SERVER_ADDRESS` | First-run seed for the server address. Used only when the database has no `ServerAddress` option; existing admin settings are not overwritten | - | | `RELAY_IDLE_CONN_TIMEOUT` | Idle keep-alive timeout for relay HTTP clients, seconds. Defaults to Go standard library behavior; set `0` to disable | `90` | | `STREAMING_TIMEOUT` | Streaming timeout (seconds) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | Max per-line buffer (MB) for the stream scanner; increase when upstream sends huge image/base64 payloads | `64` | diff --git a/README.zh_CN.md b/README.zh_CN.md index 1e3f5f683aa..d58f4152acc 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -316,6 +316,7 @@ docker run --name new-api -d --restart always \ | `CRYPTO_SECRET` | 加密密钥(Redis 必须) | - | | `SQL_DSN` | 数据库连接字符串 | - | | `REDIS_CONN_STRING` | Redis 连接字符串 | - | +| `SERVER_ADDRESS` | 服务器地址的首次启动种子;仅当数据库中没有 `ServerAddress` 配置项时写入,已有管理员配置不会被覆盖 | - | | `STREAMING_TIMEOUT` | 流式超时时间(秒) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | 流式扫描器单行最大缓冲(MB),图像生成等超大 `data:` 片段(如 4K 图片 base64)需适当调大 | `64` | | `MAX_REQUEST_BODY_MB` | 请求体最大大小(MB,**解压后**计;防止超大请求/zip bomb 导致内存暴涨),超过将返回 `413` | `32` | diff --git a/README.zh_TW.md b/README.zh_TW.md index 29c4fd91b3f..0404f1940e3 100644 --- a/README.zh_TW.md +++ b/README.zh_TW.md @@ -316,6 +316,7 @@ docker run --name new-api -d --restart always \ | `CRYPTO_SECRET` | 加密密鑰(Redis 必須) | - | | `SQL_DSN` | 資料庫連接字符串 | - | | `REDIS_CONN_STRING` | Redis 連接字符串 | - | +| `SERVER_ADDRESS` | 伺服器地址的首次啟動種子;僅當資料庫中沒有 `ServerAddress` 設定項時寫入,既有管理員設定不會被覆蓋 | - | | `STREAMING_TIMEOUT` | 流式超時時間(秒) | `300` | | `STREAM_SCANNER_MAX_BUFFER_MB` | 流式掃描器單行最大緩衝(MB),圖像生成等超大 `data:` 片段(如 4K 圖片 base64)需適當調大 | `64` | | `MAX_REQUEST_BODY_MB` | 請求體最大大小(MB,**解壓縮後**計;防止超大請求/zip bomb 導致記憶體暴漲),超過將返回 `413` | `32` | diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e75befaeea8..f8e3e744c3c 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -29,6 +29,7 @@ services: environment: - SQL_DSN=postgresql://root:123456@postgres:5432/new-api - REDIS_CONN_STRING=redis://redis +# - SERVER_ADDRESS=http://localhost:3000 # Optional first-run seed; existing DB/admin setting wins - TZ=Asia/Shanghai - BATCH_UPDATE_ENABLED=true depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index afaf82d75d1..0babe8cc91f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,7 @@ services: # - LOG_SQL_DSN=clickhouse://default:123456@clickhouse:9000/new_api_logs # OPTIONAL: Use ClickHouse for logs only; also uncomment clickhouse in depends_on and the clickhouse service below # - LOG_SQL_CLICKHOUSE_TTL_DAYS=0 # OPTIONAL: ClickHouse log retention days. Unset or 0 disables automatic deletion; set to e.g. 30 to keep 30 days - REDIS_CONN_STRING=redis://:123456@redis:6379 # ⚠️ IMPORTANT: Change the password in production! +# - SERVER_ADDRESS=https://your-domain.example.com # OPTIONAL: First-run seed for server address; DB/admin setting wins after it exists - TZ=Asia/Shanghai - ERROR_LOG_ENABLED=true # 是否启用错误日志记录 (Whether to enable error log recording) - BATCH_UPDATE_ENABLED=true # 是否启用批量更新 (Whether to enable batch update) diff --git a/docs/installation/BT.md b/docs/installation/BT.md index 8579b354725..e895e4259bd 100644 --- a/docs/installation/BT.md +++ b/docs/installation/BT.md @@ -88,6 +88,7 @@ docker-compose up -d | `CRYPTO_SECRET` | 加密密钥,使用 Redis 时必填 | 条件必填 | | `SQL_DSN` | 数据库连接字符串(使用外部数据库时) | 可选 | | `REDIS_CONN_STRING` | Redis 连接字符串 | 可选 | +| `SERVER_ADDRESS` | 首次启动时写入服务器地址;仅在数据库没有 `ServerAddress` 配置项时生效 | 可选 | ### 生成随机密钥 @@ -148,4 +149,3 @@ docker-compose down && docker-compose up -d ![宝塔面板 Docker 安装](https://github.com/user-attachments/assets/7a6fc03e-c457-45e4-b8f9-184508fc26b0) > ⚠️ 注意:密钥为环境变量 `SESSION_SECRET`,请务必设置! - diff --git a/model/option.go b/model/option.go index 8e8587f271c..a2a88af03f2 100644 --- a/model/option.go +++ b/model/option.go @@ -1,6 +1,7 @@ package model import ( + "os" "strconv" "strings" "time" @@ -188,12 +189,24 @@ func InitOptionMap() { func loadOptionsFromDatabase() { options, _ := AllOption() + serverAddressExists := false for _, option := range options { + if option.Key == "ServerAddress" { + serverAddressExists = true + } err := updateOptionMap(option.Key, option.Value) if err != nil { common.SysLog("failed to update option map: " + err.Error()) } } + if !serverAddressExists { + serverAddress := strings.TrimSpace(os.Getenv("SERVER_ADDRESS")) + if serverAddress != "" { + if err := UpdateOption("ServerAddress", serverAddress); err != nil { + common.SysLog("failed to seed ServerAddress from SERVER_ADDRESS: " + err.Error()) + } + } + } } func SyncOptions(frequency int) { diff --git a/model/option_test.go b/model/option_test.go new file mode 100644 index 00000000000..912923c49b1 --- /dev/null +++ b/model/option_test.go @@ -0,0 +1,68 @@ +package model + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/setting/system_setting" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func setupOptionTestDB(t *testing.T) { + t.Helper() + + previousDB := DB + previousLogDB := LOG_DB + previousServerAddress := system_setting.ServerAddress + + common.OptionMapRWMutex.RLock() + previousOptionMap := common.OptionMap + common.OptionMapRWMutex.RUnlock() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&Option{})) + + DB = db + LOG_DB = db + + t.Cleanup(func() { + if sqlDB, err := db.DB(); err == nil { + _ = sqlDB.Close() + } + DB = previousDB + LOG_DB = previousLogDB + system_setting.ServerAddress = previousServerAddress + common.OptionMapRWMutex.Lock() + common.OptionMap = previousOptionMap + common.OptionMapRWMutex.Unlock() + }) +} + +func TestInitOptionMapSeedsServerAddressFromEnvWhenMissing(t *testing.T) { + setupOptionTestDB(t) + t.Setenv("SERVER_ADDRESS", "https://example.com") + + InitOptionMap() + + var option Option + require.NoError(t, DB.First(&option, "`key` = ?", "ServerAddress").Error) + assert.Equal(t, "https://example.com", option.Value) + assert.Equal(t, "https://example.com", system_setting.ServerAddress) +} + +func TestInitOptionMapDoesNotOverrideExistingServerAddress(t *testing.T) { + setupOptionTestDB(t) + t.Setenv("SERVER_ADDRESS", "https://env.example.com") + require.NoError(t, DB.Create(&Option{Key: "ServerAddress", Value: "https://db.example.com"}).Error) + + InitOptionMap() + + var option Option + require.NoError(t, DB.First(&option, "`key` = ?", "ServerAddress").Error) + assert.Equal(t, "https://db.example.com", option.Value) + assert.Equal(t, "https://db.example.com", system_setting.ServerAddress) +} From 990f5cb9a971be064c54261221f8ce7b06e16b6c Mon Sep 17 00:00:00 2001 From: peakchao Date: Sun, 28 Jun 2026 12:34:04 +0800 Subject: [PATCH 2/2] fix(config): address server address review feedback Handle option load errors before seeding SERVER_ADDRESS, avoid dialect-specific test predicates, and clone OptionMap snapshots in tests. Refs #5549 --- model/option.go | 6 +++++- model/option_test.go | 19 +++++++++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/model/option.go b/model/option.go index a2a88af03f2..9cfbc105f86 100644 --- a/model/option.go +++ b/model/option.go @@ -188,7 +188,11 @@ func InitOptionMap() { } func loadOptionsFromDatabase() { - options, _ := AllOption() + options, err := AllOption() + if err != nil { + common.SysLog("failed to load options from database: " + err.Error()) + return + } serverAddressExists := false for _, option := range options { if option.Key == "ServerAddress" { diff --git a/model/option_test.go b/model/option_test.go index 912923c49b1..a73b4faea29 100644 --- a/model/option_test.go +++ b/model/option_test.go @@ -19,7 +19,7 @@ func setupOptionTestDB(t *testing.T) { previousServerAddress := system_setting.ServerAddress common.OptionMapRWMutex.RLock() - previousOptionMap := common.OptionMap + previousOptionMap := cloneOptionMap(common.OptionMap) common.OptionMapRWMutex.RUnlock() db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) @@ -37,11 +37,22 @@ func setupOptionTestDB(t *testing.T) { LOG_DB = previousLogDB system_setting.ServerAddress = previousServerAddress common.OptionMapRWMutex.Lock() - common.OptionMap = previousOptionMap + common.OptionMap = cloneOptionMap(previousOptionMap) common.OptionMapRWMutex.Unlock() }) } +func cloneOptionMap(source map[string]string) map[string]string { + if source == nil { + return nil + } + cloned := make(map[string]string, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} + func TestInitOptionMapSeedsServerAddressFromEnvWhenMissing(t *testing.T) { setupOptionTestDB(t) t.Setenv("SERVER_ADDRESS", "https://example.com") @@ -49,7 +60,7 @@ func TestInitOptionMapSeedsServerAddressFromEnvWhenMissing(t *testing.T) { InitOptionMap() var option Option - require.NoError(t, DB.First(&option, "`key` = ?", "ServerAddress").Error) + require.NoError(t, DB.Where(&Option{Key: "ServerAddress"}).First(&option).Error) assert.Equal(t, "https://example.com", option.Value) assert.Equal(t, "https://example.com", system_setting.ServerAddress) } @@ -62,7 +73,7 @@ func TestInitOptionMapDoesNotOverrideExistingServerAddress(t *testing.T) { InitOptionMap() var option Option - require.NoError(t, DB.First(&option, "`key` = ?", "ServerAddress").Error) + require.NoError(t, DB.Where(&Option{Key: "ServerAddress"}).First(&option).Error) assert.Equal(t, "https://db.example.com", option.Value) assert.Equal(t, "https://db.example.com", system_setting.ServerAddress) }