diff --git a/.env.example b/.env.example
index 43c61b8..c659abf 100644
--- a/.env.example
+++ b/.env.example
@@ -40,7 +40,6 @@ ZHINIAN_GO_EMBEDDED_WORKER=true
# Temporary production bootstrap only: allows the Go API to start before provider
# credentials are available. Generation and quote requests remain unavailable
# until credentials are saved and the Go API is restarted. Keep false/empty after setup.
-ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=false
# Data layer. Production and Docker Compose use PostgreSQL; set local only for
# an explicitly non-production single-process development run.
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..73f69e0
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
+# Editor-based HTTP Client requests
+/httpRequests/
diff --git a/.idea/NianAIGC.iml b/.idea/NianAIGC.iml
new file mode 100644
index 0000000..d6ebd48
--- /dev/null
+++ b/.idea/NianAIGC.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..6560a98
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..639900d
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..ea2dcab
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.project-docs/30-worklog/tasks/20260818-db-provider-settings-4f6a2c9d.md b/.project-docs/30-worklog/tasks/20260818-db-provider-settings-4f6a2c9d.md
new file mode 100644
index 0000000..f7c5d97
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260818-db-provider-settings-4f6a2c9d.md
@@ -0,0 +1,102 @@
+# Task: Persist provider settings in PostgreSQL
+
+## Identity
+
+- Task ID: 20260818-db-provider-settings-4f6a2c9d
+- Mode: Feature
+- Branch: main
+- Worktree: /Users/andy/IdeaProjects/NianAIGC
+- Base commit: 22b504e
+- Owner: Codex /root
+- Status: Ready for Integration
+
+## Scope
+
+- Replace production file persistence for the four provider setting groups
+ (Volcengine visual, EvoLink, Seedance, and Bailian) plus image/video engine
+ assignments with PostgreSQL-backed runtime settings.
+- Keep environment variables as fallback values and leave authentication,
+ database, billing, and OSS deployment configuration outside this migration.
+- Make provider submission, job construction, capability reporting, and health
+ reporting observe successful setting changes without a process restart.
+- Change the settings UI to submit only edited fields and accurately report
+ that a successful save is immediately effective.
+- Add migration and regression coverage for persistence, precedence, secret
+ preservation, and runtime consumption.
+
+## Intent And Constraints
+
+- Preserve the accepted static Web plus Go-only API architecture and the
+ existing super-administrator authorization boundary on `/api/settings`.
+- PostgreSQL is the production source of truth; saved database values override
+ environment fallback values, while unset values continue to use environment
+ configuration and existing defaults.
+- Store values in plaintext for this first functional delivery, as explicitly
+ requested; encryption and secret rotation remain follow-up hardening.
+- Do not modify the pre-existing untracked `.idea/` directory.
+- The repository-required `task_context.py` coordination helper is not present
+ in this checkout, and `.git` is read-only in this environment, so this task
+ records ownership here and works in the user-authorized current worktree
+ without creating a feature branch.
+- Feature work updates only this task-scoped record; canonical architecture and
+ decision documents remain integration-owned.
+
+## Outcome
+
+- Added migration `0003_platform_runtime_settings.sql` and explicit
+ application-role/readiness coverage for the new PostgreSQL table. Provider
+ settings are upserted transactionally with a revision and update timestamp.
+- The production settings service now persists the four provider groups plus
+ image/video engine assignments in PostgreSQL. Database rows override
+ environment and legacy file fallback values; blank secret submissions still
+ preserve the existing value and secret values remain blank in API payloads.
+- Provider configuration is refreshed at both runtime seams: quote/job
+ construction resolves current engine/model/defaults, and Worker submit/query
+ resolves a fresh adapter with the current credential and Base URL. Capability
+ and health projections also observe the mutable provider state.
+- Removed the production startup credential gate and obsolete bootstrap flag.
+ The API can start before providers are configured, while a request selecting
+ an unconfigured provider still fails with 503 and never falls back to Mock.
+- The settings client tracks dirty keys and posts only edited values, preventing
+ a provider save from also attempting writes for unrelated file-backed groups.
+ Successful provider saves report immediate application without a restart.
+- Provider Secret injection is now an optional environment fallback in ACK;
+ deployment guidance documents migration 0003, database precedence, and the
+ no-restart behavior. Values are intentionally plaintext for this delivery.
+
+## Verification
+
+- RED: `npx vitest run tests/settings-go-contract.test.ts` failed because the
+ compatibility fixture still required restart and migration 0003 did not yet
+ exist.
+- PASS: official Go 1.26.6 archive checksum matched the published SHA-256;
+ the toolchain was extracted only under `/private/tmp` and removed after
+ verification.
+- PASS: `gofmt -l backend` returned no files.
+- PASS: `go test ./...` across all Go packages.
+- PASS: `go vet ./...`.
+- PASS: `go build -o /private/tmp/nianaigc-zhinian-api ./cmd/zhinian-api`.
+- PASS: affected Go package regression rerun for `postgres`, `settings`,
+ `application`, `jobs`, and `httpapi` after final transaction cleanup.
+- PASS: `npm test` (`55` files, `176` tests).
+- PASS: `npx tsc --noEmit --incremental false`.
+- PASS: `npm run build` (all routes statically exported).
+- PASS: `npm run deploy:check` (`7` ACK manifest files).
+- PASS: `git diff --check`.
+- The repository-referenced `check_project_docs.py`, `check_doc_drift.py`, and
+ `task_context.py` helpers are not present in this checkout, so those specific
+ project-document/coordination commands could not be run.
+
+## Follow-ups
+
+- Consider encrypting provider secrets at rest and recording secret-access
+ audit metadata after functional persistence is deployed and verified.
+- Before rolling out the new Go image, execute migration 0003 and re-provision
+ application-role grants, then smoke save each provider group and submit one
+ real request through every configured engine against the target RDS/ACK
+ environment. No live database or cluster mutation was performed in this task.
+
+## Promotion Candidates
+
+- Record PostgreSQL-backed mutable provider settings and the database-over-env
+ precedence rule in canonical architecture during integration.
diff --git a/README.md b/README.md
index 9dc794d..7d62241 100644
--- a/README.md
+++ b/README.md
@@ -222,11 +222,10 @@ cp .env.example .env.local
- `SEEDANCE_RESOLUTION`:支持 `480p`、`720p`、`1080p`、`4k`;Seedance 2.0 fast 不支持 `1080p`
- `ALI_OSS_*`:用于上传素材和生成结果转存
- `ZHINIAN_DATA_BACKEND`:生产使用 `postgres`,开发可使用 `local`
-- `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true`:仅用于临时生产引导;允许 Go API 先启动,但未配置服务商的报价/生成请求会返回 503,配置后必须重启并恢复为 `false`
- `DATABASE_URL`:仅服务端读取的 PostgreSQL 连接串
- PostgreSQL 客户端强制使用 `sslmode=disable` 且不读取 CA。ACK 到 RDS 的数据库链路为明文,只应使用 RDS 内网地址,并通过 VPC、安全组和白名单限制访问。
-当 `ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层。生产 `postgres` 模式默认在启动时校验真实服务商凭据;临时引导可设置 `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true`,但未配置服务商的报价和生成请求会返回 503,不会静默切换到 Mock,配置保存后还必须重启 Go 后端。如果 OSS 未配置,上传和生成结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Go 路由提供访问。
+当 `ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层。生产 `postgres` 模式允许先启动再由超级管理员配置服务商;设置页把四类服务商配置和图片/视频引擎选择写入 PostgreSQL,数据库值优先于环境变量且保存后立即用于报价、任务构建和 Worker 调用。未配置服务商的请求返回 503,不会静默切换到 Mock。如果 OSS 未配置,上传和生成结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Go 路由提供访问。
## 数据库
@@ -236,7 +235,7 @@ cp .env.example .env.local
database/migrations/
```
-首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(先 0001 后 0002,再加角色授权语句),不部署迁移 Job Pod;执行完成后再滚动工作负载。
+首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(依次执行 0001、0002、0003,再加角色授权语句),不部署迁移 Job Pod;执行完成后再滚动工作负载。
当前仍保留必要数据表,供上传、生成任务和用量记录使用:
@@ -246,6 +245,7 @@ database/migrations/
- `billing_price_rules`
- `billing_wallets`
- `billing_ledger`
+- `platform_runtime_settings`
## 任务管理与开放 API
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 9ab7c93..8c45537 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -141,7 +141,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码
## 组织账号管理
-组织、账号、角色、停用、密码重置和归档均由平台本地接口处理,不再调用外部组织服务。生产部署前由部署负责人手工执行 [`database/migrations`](./database/migrations) 中的版本化 SQL 文件(先 0001 后 0002)及角色授权语句完成建库建表,不部署迁移 Job Pod;超级管理员由 Go 后端首次启动时通过 `ZHINIAN_BOOTSTRAP_ADMIN_*` 环境变量自动创建一次。
+组织、账号、角色、停用、密码重置和归档均由平台本地接口处理,不再调用外部组织服务。生产部署前由部署负责人手工执行 [`database/migrations`](./database/migrations) 中的版本化 SQL 文件(依次执行 0001、0002、0003)及角色授权语句完成建库建表,不部署迁移 Job Pod;超级管理员由 Go 后端首次启动时通过 `ZHINIAN_BOOTSTRAP_ADMIN_*` 环境变量自动创建一次。
## 账号、组织用量与计费
@@ -272,7 +272,6 @@ cp .env.example .env.local
| `ZHINIAN_WEBHOOK_SECRET` | Webhook 签名密钥,可选 |
| `ZHINIAN_WORKER_*` | Worker 间隔、批量、锁超时、重试配置 |
| `ZHINIAN_GO_EMBEDDED_WORKER` | Go 内嵌 Worker 开关,生产设为 `true` |
-| `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS` | 临时生产引导开关;设为 `true` 可先启动后配置,但未配置服务商的报价/生成请求返回 503,配置后必须重启 Go 后端 |
| `IMAGE_GENERATE_ENGINE` | 图片生成引擎:`jimeng`、`evolink` 或 `bailian` |
| `BAILIAN_API_KEY` | 阿里云百炼 API Key |
| `BAILIAN_BASE_URL` | 百炼业务空间兼容地址;系统自动派生原生异步接口 |
@@ -292,7 +291,7 @@ cp .env.example .env.local
| `DATABASE_URL` | PostgreSQL 连接串(仅放 Secret) |
| PostgreSQL 传输 | 客户端强制 `sslmode=disable` 且不读取 CA;ACK 到 RDS 的链路为明文,只应走内网并通过 VPC、安全组和白名单限制访问 |
-`ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层;生产 `postgres` 模式默认会在启动时校验真实服务商凭据。若上线引导阶段暂时没有凭据,可将 `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true` 注入 Go API,让服务先启动;此时未配置服务商的报价和生成请求会返回 503,不会切换到 Mock。通过设置页保存凭据后必须重启 Go 后端,并将该开关恢复为 `false`。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads` 与 `.runtime/generated-results`。
+`ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层;生产 `postgres` 模式允许先启动再由超级管理员配置服务商。设置页把四类服务商配置和图片/视频引擎选择写入 PostgreSQL,数据库值优先于环境变量且保存后立即用于报价、任务构建和 Worker 调用。未配置服务商的请求返回 503,不会切换到 Mock。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads` 与 `.runtime/generated-results`。
## 项目结构
diff --git a/backend/cmd/zhinian-api/main_test.go b/backend/cmd/zhinian-api/main_test.go
index 2386013..9207bed 100644
--- a/backend/cmd/zhinian-api/main_test.go
+++ b/backend/cmd/zhinian-api/main_test.go
@@ -26,7 +26,7 @@ func TestRuntimeGetenvLoadsWhitelistedSettingsAndPreservesProcessPrecedence(t *t
if err != nil {
t.Fatal(err)
}
- if getenv("IMAGE_GENERATE_ENGINE") != "evolink" || getenv("EVOLINK_API_KEY") != "process-secret" || getenv("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS") != "true" || getenv("DATABASE_URL") != "" || getenv("ZHINIAN_DATA_BACKEND") != "local" {
+ if getenv("IMAGE_GENERATE_ENGINE") != "evolink" || getenv("EVOLINK_API_KEY") != "process-secret" || getenv("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS") != "" || getenv("DATABASE_URL") != "" || getenv("ZHINIAN_DATA_BACKEND") != "local" {
t.Fatalf("loaded engine=%q key=%q bootstrap=%q database=%q backend=%q", getenv("IMAGE_GENERATE_ENGINE"), getenv("EVOLINK_API_KEY"), getenv("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS"), getenv("DATABASE_URL"), getenv("ZHINIAN_DATA_BACKEND"))
}
}
diff --git a/backend/internal/application/application.go b/backend/internal/application/application.go
index d52a7d8..f2ad247 100644
--- a/backend/internal/application/application.go
+++ b/backend/internal/application/application.go
@@ -79,14 +79,6 @@ func New(options Options) (*App, error) {
if err != nil {
return nil, err
}
- if config.Backend == postgres.BackendPostgres && options.ProviderRegistry == nil {
- if err := validateProductionProviderConfiguration(getenv); err != nil {
- return nil, err
- }
- if allowUnconfiguredProviders(getenv) {
- log.Printf("WARNING: %s is enabled; the Go API will start with unconfigured providers, and generation/quote requests will remain unavailable until credentials are added and the process is restarted", "ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS")
- }
- }
database, err := postgres.Open(ctx, config)
if err != nil {
return nil, err
@@ -245,6 +237,9 @@ func New(options Options) (*App, error) {
billingService := billing.NewService(billingStore, nil).SetEnabled(strings.TrimSpace(getenv("ZHINIAN_BILLING_REQUIRED")) != "0")
runtimeSettings := defaultSettingsService(getenv)
+ if config.Backend == postgres.BackendPostgres {
+ runtimeSettings = databaseSettingsService(getenv, database.Store)
+ }
billingAccounts := settingsBillingAccountStore{service: runtimeSettings}
templateService := templates.NewService(templateCatalog, nil, nil)
logService := options.Logs
@@ -258,26 +253,20 @@ func New(options Options) (*App, error) {
miscHandler, err := httpapi.NewMiscHandler(httpapi.MiscDependencies{
Platform: platformAuthorizer, Templates: templateService, PromptAssembler: prompt.Assemble,
Settings: runtimeSettings, Logs: logService, Public: publicAuthenticator,
- Capabilities: capabilitySummary(getenv), PublicOrigin: publicBaseURL,
+ Capabilities: runtimeCapabilitySummary(getenv, runtimeSettings), PublicOrigin: publicBaseURL,
})
if err != nil {
return nil, err
}
- providerRegistry := options.ProviderRegistry
- if providerRegistry == nil {
- providerRegistry = buildProviderRegistry(getenv)
- }
- var unavailableProviders map[string]string
- if config.Backend == postgres.BackendPostgres {
- unavailableProviders = providerUnavailableMessages(getenv)
+ var providerResolver jobs.ProviderResolver
+ if options.ProviderRegistry != nil {
+ providerResolver = options.ProviderRegistry
+ } else {
+ providerResolver = newRuntimeProviderResolver(getenv, runtimeSettings)
}
jobService := jobs.NewService(jobStore, nil)
- jobBuilder := jobs.ProviderJobBuilder{
- ImageProvider: imageProvider(getenv), VideoProvider: videoProvider(getenv),
- ImageModel: imageModel(getenv), VideoModel: videoModel(getenv), ImageEngine: imageEngine(getenv), VideoEngine: videoEngine(getenv),
- ImageEngines: providerImageTargets(getenv), VideoEngines: providerVideoTargets(getenv), UnavailableProviders: unavailableProviders, NewID: applicationJobID,
- }
+ jobBuilder := runtimeProviderJobBuilder{fallback: getenv, settings: runtimeSettings, enforceAvailability: config.Backend == postgres.BackendPostgres}
usageService := usage.Service{
Repository: usageRepository,
OrganizationOptions: usage.OrganizationOptionSourceFunc(func(ctx context.Context, requester usage.Requester) ([]usage.Option, error) {
@@ -306,7 +295,7 @@ func New(options Options) (*App, error) {
}
webhookBridge := orchestration.NewWebhookBridge(webhook.NewDeliverer(webhookSender, getenv("ZHINIAN_WEBHOOK_SECRET"), nil))
outputs := orchestration.NewAssetOutputRegistrar(assetService, orchestration.ResolveProviderOutputURLs)
- providerProcessor := jobs.ProviderProcessor{Providers: providerRegistry, Store: jobStore}
+ providerProcessor := jobs.ProviderProcessor{Providers: providerResolver, Store: jobStore}
settlementProcessor := orchestration.NewSettlementProcessor(providerProcessor, ledger, settlementState, nil)
processor := orchestration.NewOutputRegisteringProcessor(settlementProcessor, outputs, jobState)
artifacts := orchestration.NewAssetArtifacts(assetService)
@@ -330,7 +319,7 @@ func New(options Options) (*App, error) {
workerLoop.Start(ctx)
}
- foundation := httpapi.NewHandler(readiness, httpapi.WithHealthDetails(runtimeHealthDetails(getenv)))
+ foundation := httpapi.NewHandler(readiness, httpapi.WithHealthDetailsProvider(runtimeHealthDetailsProvider(getenv, runtimeSettings)))
mux := http.NewServeMux()
mux.Handle("/api/auth/me", authMe)
mux.Handle("/api/auth/password", authPassword)
diff --git a/backend/internal/application/application_test.go b/backend/internal/application/application_test.go
index a6e3617..170e33c 100644
--- a/backend/internal/application/application_test.go
+++ b/backend/internal/application/application_test.go
@@ -77,15 +77,14 @@ func TestApplicationRejectsInvalidProductionDatabaseConfiguration(t *testing.T)
}
}
-func TestProductionProviderBootstrapFlagAllowsApplicationComposition(t *testing.T) {
+func TestProductionApplicationCanStartBeforeProviderSettingsAreConfigured(t *testing.T) {
app, err := application.New(application.Options{Getenv: applicationEnv(map[string]string{
- "NODE_ENV": "production",
- "ZHINIAN_DATA_BACKEND": "postgres",
- "DATABASE_URL": "postgres://user:password@127.0.0.1:5432/zhinian",
- "ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS": "true",
+ "NODE_ENV": "production",
+ "ZHINIAN_DATA_BACKEND": "postgres",
+ "DATABASE_URL": "postgres://user:password@127.0.0.1:5432/zhinian",
})})
if err != nil {
- t.Fatalf("New() with provider bootstrap flag = %v", err)
+ t.Fatalf("New() without provider credentials = %v", err)
}
app.Close()
}
diff --git a/backend/internal/application/runtime.go b/backend/internal/application/runtime.go
index 4aea0d2..188d3b6 100644
--- a/backend/internal/application/runtime.go
+++ b/backend/internal/application/runtime.go
@@ -59,10 +59,6 @@ func parseBool(value string) bool {
}
}
-func allowUnconfiguredProviders(getenv postgres.Getenv) bool {
- return parseBool(getenv("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS"))
-}
-
func applicationJobID() string {
return applicationID("job")
}
@@ -143,6 +139,10 @@ func bailianNativeBaseURL(getenv postgres.Getenv) string {
func buildProviderRegistry(getenv postgres.Getenv) jobs.ProviderRegistry {
client := &http.Client{Timeout: durationEnv(getenv, "ZHINIAN_PROVIDER_TIMEOUT_MS", 30*time.Second)}
maximum := positiveInt64Env(getenv, "ZHINIAN_PROVIDER_MAX_RESPONSE_BYTES", 2<<20)
+ return buildProviderRegistryWithClient(getenv, client, maximum)
+}
+
+func buildProviderRegistryWithClient(getenv postgres.Getenv, client *http.Client, maximum int64) jobs.ProviderRegistry {
return jobs.ProviderRegistry{
"volcengine-visual": providers.NewVolcengine(providers.Config{
BaseURL: firstNonEmpty(getenv("VOLCENGINE_VISUAL_ENDPOINT"), "https://visual.volcengineapi.com"),
@@ -164,6 +164,7 @@ func providerImageTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget
"evolink": {
Provider: "evolink",
Model: firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2"),
+ Settings: map[string]any{"quality": firstNonEmpty(getenv("EVOLINK_IMAGE_QUALITY"), "medium")},
},
"bailian": {
Provider: "bailian",
@@ -207,29 +208,6 @@ func providerUnavailableMessages(getenv postgres.Getenv) map[string]string {
return missing
}
-func validateProductionProviderConfiguration(getenv postgres.Getenv) error {
- if allowUnconfiguredProviders(getenv) {
- return nil
- }
- missing := make([]string, 0, 4)
- if strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) == "" || strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) == "" {
- missing = append(missing, "即梦 VOLCENGINE_ACCESS_KEY_ID/VOLCENGINE_SECRET_ACCESS_KEY")
- }
- if strings.TrimSpace(getenv("EVOLINK_API_KEY")) == "" {
- missing = append(missing, "EvoLink EVOLINK_API_KEY")
- }
- if bailianAPIKey(getenv) == "" {
- missing = append(missing, "百炼 BAILIAN_API_KEY/DASHSCOPE_API_KEY")
- }
- if strings.TrimSpace(getenv("SEEDANCE_API_KEY")) == "" {
- missing = append(missing, "Seedance SEEDANCE_API_KEY")
- }
- if len(missing) == 0 {
- return nil
- }
- return fmt.Errorf("生产环境缺少真实服务商凭据:%s", strings.Join(missing, ";"))
-}
-
func defaultWebhookSender(getenv postgres.Getenv) (*webhook.HTTPSender, error) {
return webhook.NewPublicHTTPSender(
durationEnv(getenv, "ZHINIAN_WEBHOOK_TIMEOUT_MS", 10*time.Second),
@@ -272,6 +250,8 @@ func bailianVideoCapabilityLimits() map[string]any {
func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails {
image := imageEngine(getenv)
imageLabel := map[string]string{"jimeng": "即梦", "evolink": "EvoLink", "bailian": "阿里云百炼"}[image]
+ video := videoEngine(getenv)
+ videoLabel := map[string]string{"seedance": "Seedance", "bailian": "阿里云百炼"}[video]
visualConfigured := strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) != "" && strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) != ""
auth, _ := ParseAuthConfig(getenv)
bailianKey := bailianAPIKey(getenv)
@@ -290,7 +270,7 @@ func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails {
AuthMode: authMode,
Capabilities: []any{
map[string]any{"id": "image.generate", "label": "图片生成 4.6", "engine": image, "engineLabel": imageLabel, "reqKey": imageModel(getenv)},
- map[string]any{"id": "video.generate", "label": "Seedance 视频生成", "engine": "seedance", "engineLabel": "Seedance", "reqKey": firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128")},
+ map[string]any{"id": "video.generate", "label": "视频生成", "engine": video, "engineLabel": videoLabel, "reqKey": videoModel(getenv)},
},
}
}
@@ -330,6 +310,103 @@ func defaultSettingsService(getenv postgres.Getenv) *settings.Service {
return settings.New(path, runtimeEnvironment(getenv), nil)
}
+func databaseSettingsService(getenv postgres.Getenv, repository settings.RuntimeSettingsRepository) *settings.Service {
+ path := firstNonEmpty(getenv("ZHINIAN_SETTINGS_FILE"), ".env.local")
+ return settings.NewWithRuntimeRepository(path, runtimeEnvironment(getenv), repository, nil)
+}
+
+func runtimeSettingsGetenv(ctx context.Context, fallback postgres.Getenv, service *settings.Service) (postgres.Getenv, error) {
+ values, err := service.RuntimeValues(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return mergedSettingsGetenv(values, fallback), nil
+}
+
+func cachedRuntimeSettingsGetenv(fallback postgres.Getenv, service *settings.Service) postgres.Getenv {
+ return mergedSettingsGetenv(service.CachedRuntimeValues(), fallback)
+}
+
+func mergedSettingsGetenv(values map[string]string, fallback postgres.Getenv) postgres.Getenv {
+ return func(name string) string {
+ if value, ok := values[name]; ok {
+ return value
+ }
+ return fallback(name)
+ }
+}
+
+type runtimeProviderResolver struct {
+ fallback postgres.Getenv
+ settings *settings.Service
+ client *http.Client
+ maximum int64
+}
+
+func newRuntimeProviderResolver(fallback postgres.Getenv, service *settings.Service) *runtimeProviderResolver {
+ return &runtimeProviderResolver{
+ fallback: fallback, settings: service,
+ client: &http.Client{Timeout: durationEnv(fallback, "ZHINIAN_PROVIDER_TIMEOUT_MS", 30*time.Second)},
+ maximum: positiveInt64Env(fallback, "ZHINIAN_PROVIDER_MAX_RESPONSE_BYTES", 2<<20),
+ }
+}
+
+func (resolver *runtimeProviderResolver) Resolve(ctx context.Context, name string) (providers.Adapter, error) {
+ getenv, err := runtimeSettingsGetenv(ctx, resolver.fallback, resolver.settings)
+ if err != nil {
+ return nil, err
+ }
+ return buildProviderRegistryWithClient(getenv, resolver.client, resolver.maximum)[name], nil
+}
+
+type runtimeProviderJobBuilder struct {
+ fallback postgres.Getenv
+ settings *settings.Service
+ enforceAvailability bool
+}
+
+func (builder runtimeProviderJobBuilder) Build(ctx context.Context, owner, client, capability, idempotency string, body map[string]any) (jobs.CreateCommand, error) {
+ getenv, err := runtimeSettingsGetenv(ctx, builder.fallback, builder.settings)
+ if err != nil {
+ return jobs.CreateCommand{}, fmt.Errorf("load runtime provider settings: %w", err)
+ }
+ return configuredProviderJobBuilder(getenv, builder.enforceAvailability).Build(ctx, owner, client, capability, idempotency, body)
+}
+
+func configuredProviderJobBuilder(getenv postgres.Getenv, enforceAvailability bool) jobs.ProviderJobBuilder {
+ var unavailable map[string]string
+ if enforceAvailability {
+ unavailable = providerUnavailableMessages(getenv)
+ }
+ return jobs.ProviderJobBuilder{
+ ImageProvider: imageProvider(getenv), VideoProvider: videoProvider(getenv),
+ ImageModel: imageModel(getenv), VideoModel: videoModel(getenv), ImageEngine: imageEngine(getenv), VideoEngine: videoEngine(getenv),
+ ImageEngines: providerImageTargets(getenv), VideoEngines: providerVideoTargets(getenv), UnavailableProviders: unavailable, NewID: applicationJobID,
+ }
+}
+
+func runtimeCapabilitySummary(fallback postgres.Getenv, service *settings.Service) func(context.Context) (any, error) {
+ return func(ctx context.Context) (any, error) {
+ getenv, err := runtimeSettingsGetenv(ctx, fallback, service)
+ if err != nil {
+ return nil, fmt.Errorf("load runtime provider settings: %w", err)
+ }
+ return capabilitySummary(getenv)(ctx)
+ }
+}
+
+func runtimeHealthDetailsProvider(fallback postgres.Getenv, service *settings.Service) func(context.Context) httpapi.HealthDetails {
+ return func(ctx context.Context) httpapi.HealthDetails {
+ refreshContext, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
+ defer cancel()
+ getenv, err := runtimeSettingsGetenv(refreshContext, fallback, service)
+ if err != nil {
+ getenv = cachedRuntimeSettingsGetenv(fallback, service)
+ }
+ return runtimeHealthDetails(getenv)
+ }
+}
+
type settingsBillingAccountStore struct{ service *settings.Service }
func (store settingsBillingAccountStore) Load(ctx context.Context) (billing.AccountConfig, error) {
diff --git a/backend/internal/application/runtime_test.go b/backend/internal/application/runtime_test.go
index c391e73..cf16042 100644
--- a/backend/internal/application/runtime_test.go
+++ b/backend/internal/application/runtime_test.go
@@ -3,7 +3,10 @@ package application
import (
"bytes"
"context"
+ "encoding/json"
"io"
+ "net/http"
+ "net/http/httptest"
"os"
"path/filepath"
"reflect"
@@ -12,10 +15,11 @@ import (
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing"
+ "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/settings"
)
-func TestDefaultSettingsServicePersistsRestartRequiredWithoutPartialHotReload(t *testing.T) {
+func TestDefaultSettingsServiceAppliesProviderSettingsWithoutRestart(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env.local")
key := "IMAGE_GENERATE_ENGINE"
service := defaultSettingsService(func(name string) string {
@@ -29,7 +33,7 @@ func TestDefaultSettingsServicePersistsRestartRequiredWithoutPartialHotReload(t
t.Fatal(err)
}
payload, ok := value.(settings.Payload)
- if !ok || !payload.RestartRequired {
+ if !ok || payload.RestartRequired {
t.Fatalf("payload=%#v", value)
}
}
@@ -78,6 +82,7 @@ func TestRuntimeHealthDetailsMatchTypeScriptDefaultsAndConfiguredModes(t *testin
"VOLCENGINE_ACCESS_KEY_ID": "access",
"VOLCENGINE_SECRET_ACCESS_KEY": "secret",
"SEEDANCE_API_KEY": "seedance-key",
+ "VIDEO_GENERATE_ENGINE": "seedance",
"ZHINIAN_AUTH_REQUIRED": "true",
"ZHINIAN_AUTH_SESSION_SECRET": "session-secret",
}
@@ -95,6 +100,88 @@ func TestRuntimeHealthDetailsMatchTypeScriptDefaultsAndConfiguredModes(t *testin
}
}
+func TestRuntimeProviderJobBuilderRefreshesDatabaseSettings(t *testing.T) {
+ settingsPath := filepath.Join(t.TempDir(), ".env.local")
+ fallback := func(name string) string {
+ if name == "ZHINIAN_SETTINGS_FILE" {
+ return settingsPath
+ }
+ return ""
+ }
+ repository := &applicationRuntimeSettingsRepository{values: map[string]string{
+ "EVOLINK_API_KEY": "evolink-secret",
+ "EVOLINK_IMAGE_MODEL": "database-image-model",
+ "EVOLINK_IMAGE_QUALITY": "high",
+ "IMAGE_GENERATE_ENGINE": "evolink",
+ }}
+ service := databaseSettingsService(fallback, repository)
+ builder := runtimeProviderJobBuilder{fallback: fallback, settings: service, enforceAvailability: true}
+ command, err := builder.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{"prompt": "draw"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var request providers.Request
+ if err := json.Unmarshal(command.Job.RequestPayload, &request); err != nil {
+ t.Fatal(err)
+ }
+ if command.Job.Provider != "evolink" || command.Job.ReqKey != "database-image-model" || request.Settings["quality"] != "high" {
+ t.Fatalf("job=%#v request=%#v", command.Job, request)
+ }
+ repository.values = map[string]string{
+ "BAILIAN_API_KEY": "bailian-secret",
+ "BAILIAN_IMAGE_MODEL": "database-bailian-model",
+ "IMAGE_GENERATE_ENGINE": "bailian",
+ }
+ command, err = builder.Build(context.Background(), "owner", "", "image.generate", "", map[string]any{"prompt": "draw again"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if command.Job.Provider != "bailian" || command.Job.ReqKey != "database-bailian-model" {
+ t.Fatalf("refreshed job=%#v", command.Job)
+ }
+}
+
+func TestRuntimeProviderResolverUsesLatestDatabaseCredential(t *testing.T) {
+ var authorizations []string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
+ authorizations = append(authorizations, request.Header.Get("Authorization"))
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"id":"task-1","status":"queued"}`))
+ }))
+ defer server.Close()
+ settingsPath := filepath.Join(t.TempDir(), ".env.local")
+ fallback := func(name string) string {
+ if name == "ZHINIAN_SETTINGS_FILE" {
+ return settingsPath
+ }
+ return ""
+ }
+ repository := &applicationRuntimeSettingsRepository{values: map[string]string{
+ "EVOLINK_API_KEY": "database-secret-1",
+ "EVOLINK_BASE_URL": server.URL,
+ }}
+ service := databaseSettingsService(fallback, repository)
+ resolver := newRuntimeProviderResolver(fallback, service)
+ adapter, err := resolver.Resolve(context.Background(), "evolink")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := adapter.Submit(context.Background(), providers.Request{Capability: "image.generate", Prompt: "draw"}); err != nil {
+ t.Fatal(err)
+ }
+ repository.values["EVOLINK_API_KEY"] = "database-secret-2"
+ adapter, err = resolver.Resolve(context.Background(), "evolink")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := adapter.Submit(context.Background(), providers.Request{Capability: "image.generate", Prompt: "draw again"}); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(authorizations, []string{"Bearer database-secret-1", "Bearer database-secret-2"}) {
+ t.Fatalf("authorizations=%#v", authorizations)
+ }
+}
+
func TestRuntimeHealthDetailsReportConfiguredProvidersAndImageEngine(t *testing.T) {
values := map[string]string{
"IMAGE_GENERATE_ENGINE": "bailian",
@@ -110,31 +197,6 @@ func TestRuntimeHealthDetailsReportConfiguredProvidersAndImageEngine(t *testing.
}
}
-func TestValidateProductionProviderConfigurationRequiresAllRealCredentials(t *testing.T) {
- if err := validateProductionProviderConfiguration(func(string) string { return "" }); err == nil {
- t.Fatal("validateProductionProviderConfiguration() unexpectedly succeeded")
- } else if !strings.Contains(err.Error(), "EVOLINK_API_KEY") || !strings.Contains(err.Error(), "SEEDANCE_API_KEY") {
- t.Fatalf("error = %v", err)
- }
- values := map[string]string{
- "VOLCENGINE_ACCESS_KEY_ID": "access",
- "VOLCENGINE_SECRET_ACCESS_KEY": "secret",
- "EVOLINK_API_KEY": "evolink",
- "BAILIAN_API_KEY": "bailian",
- "SEEDANCE_API_KEY": "seedance",
- }
- if err := validateProductionProviderConfiguration(func(name string) string { return values[name] }); err != nil {
- t.Fatalf("validateProductionProviderConfiguration() error = %v", err)
- }
-}
-
-func TestValidateProductionProviderConfigurationCanBeSkippedForBootstrap(t *testing.T) {
- values := map[string]string{"ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS": "true"}
- if err := validateProductionProviderConfiguration(func(name string) string { return values[name] }); err != nil {
- t.Fatalf("validateProductionProviderConfiguration() with bootstrap flag = %v", err)
- }
-}
-
func TestProviderUnavailableMessagesIdentifyOnlyMissingCredentials(t *testing.T) {
values := map[string]string{
"VOLCENGINE_ACCESS_KEY_ID": "access",
@@ -214,6 +276,26 @@ func TestPrefixedBlobStoreKeepsApplicationStoragePathStable(t *testing.T) {
type recordingBlobStore struct{ putKey, readKey, deleteKey string }
+type applicationRuntimeSettingsRepository struct{ values map[string]string }
+
+func (repository *applicationRuntimeSettingsRepository) LoadRuntimeSettings(context.Context, []string) (map[string]string, error) {
+ values := map[string]string{}
+ for key, value := range repository.values {
+ values[key] = value
+ }
+ return values, nil
+}
+
+func (repository *applicationRuntimeSettingsRepository) SaveRuntimeSettings(_ context.Context, values map[string]string) error {
+ if repository.values == nil {
+ repository.values = map[string]string{}
+ }
+ for key, value := range values {
+ repository.values[key] = value
+ }
+ return nil
+}
+
func (s *recordingBlobStore) Put(_ context.Context, key string, _ io.Reader, _ int64, _ string) (assets.StoredObject, error) {
s.putKey = key
return assets.StoredObject{Key: key, URL: "https://cdn.example/" + key}, nil
diff --git a/backend/internal/httpapi/billing.go b/backend/internal/httpapi/billing.go
index 5178b68..f286d36 100644
--- a/backend/internal/httpapi/billing.go
+++ b/backend/internal/httpapi/billing.go
@@ -10,7 +10,6 @@ import (
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
- "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers"
)
@@ -21,14 +20,14 @@ type billingHandler struct {
authorizer *PlatformAuthorizer
service BillingHTTPService
accounts BillingAccountStore
- builder jobs.ProviderJobBuilder
+ builder ProviderCommandBuilder
}
func NewBillingHandler(authorizer *PlatformAuthorizer, service BillingHTTPService, accounts BillingAccountStore) http.Handler {
return &billingHandler{authorizer: authorizer, service: service, accounts: accounts}
}
-func NewBillingHandlerWithBuilder(authorizer *PlatformAuthorizer, service BillingHTTPService, accounts BillingAccountStore, builder jobs.ProviderJobBuilder) http.Handler {
+func NewBillingHandlerWithBuilder(authorizer *PlatformAuthorizer, service BillingHTTPService, accounts BillingAccountStore, builder ProviderCommandBuilder) http.Handler {
return &billingHandler{authorizer: authorizer, service: service, accounts: accounts, builder: builder}
}
@@ -112,7 +111,7 @@ func (h *billingHandler) quote(w http.ResponseWriter, r *http.Request) {
}
provider, reqKey := billingString(body["provider"]), billingString(body["reqKey"])
parameters := billing.Parameters{}
- if h.builder.NewID != nil {
+ if h.builder != nil {
prepared, err := h.builder.Build(r.Context(), session.User.ID, "", capability, "", body)
if err != nil {
writeJobError(w, err, false)
diff --git a/backend/internal/httpapi/httpapi.go b/backend/internal/httpapi/httpapi.go
index d6cce6a..578ef57 100644
--- a/backend/internal/httpapi/httpapi.go
+++ b/backend/internal/httpapi/httpapi.go
@@ -30,6 +30,7 @@ type handler struct {
readiness Readiness
readinessTimeout time.Duration
healthDetails HealthDetails
+ healthProvider func(context.Context) HealthDetails
}
// HealthDetails contains runtime compatibility fields assembled by the
@@ -67,6 +68,14 @@ func WithHealthDetails(details HealthDetails) Option {
}
}
+// WithHealthDetailsProvider evaluates mutable provider state for every health
+// request while preserving /api/health as a non-blocking liveness endpoint.
+func WithHealthDetailsProvider(provider func(context.Context) HealthDetails) Option {
+ return func(h *handler) {
+ h.healthProvider = provider
+ }
+}
+
// NewHandler returns the foundation health/readiness HTTP handler.
func NewHandler(readiness Readiness, options ...Option) http.Handler {
h := &handler{readiness: readiness, readinessTimeout: defaultReadinessTimeout, healthDetails: HealthDetails{Capabilities: []any{}}}
@@ -96,6 +105,13 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if h.readiness != nil {
status = h.readiness.Status()
}
+ details := h.healthDetails
+ if h.healthProvider != nil {
+ details = h.healthProvider(r.Context())
+ if details.Capabilities == nil {
+ details.Capabilities = []any{}
+ }
+ }
writeJSON(w, http.StatusOK, struct {
OK bool `json:"ok"`
AppID string `json:"appId"`
@@ -106,7 +122,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
OK: true,
AppID: appID,
WebOnly: true,
- HealthDetails: h.healthDetails,
+ HealthDetails: details,
Database: status,
})
}
diff --git a/backend/internal/httpapi/jobs.go b/backend/internal/httpapi/jobs.go
index abbf979..5be4c5a 100644
--- a/backend/internal/httpapi/jobs.go
+++ b/backend/internal/httpapi/jobs.go
@@ -34,6 +34,12 @@ type JobBuilder interface {
Build(context.Context, JobBuildInput) (jobs.CreateCommand, error)
}
+// ProviderCommandBuilder is the shared seam used by creation, retry, and quote
+// paths. Implementations may resolve runtime provider settings per request.
+type ProviderCommandBuilder interface {
+ Build(context.Context, string, string, string, string, map[string]any) (jobs.CreateCommand, error)
+}
+
type JobCreationCoordinator interface {
CreatePlatform(context.Context, identity.Session, orchestration.CreationInput) (jobs.Job, bool, error)
CreatePublic(context.Context, orchestration.CreationInput) (jobs.Job, bool, error)
@@ -48,7 +54,7 @@ type JobTickLimiter interface {
}
// ProviderBuilderAdapter exposes jobs.ProviderJobBuilder at the HTTP seam.
-type ProviderBuilderAdapter struct{ Builder jobs.ProviderJobBuilder }
+type ProviderBuilderAdapter struct{ Builder ProviderCommandBuilder }
func (a ProviderBuilderAdapter) Build(ctx context.Context, in JobBuildInput) (jobs.CreateCommand, error) {
return a.Builder.Build(ctx, in.Scope.OwnerID, in.Scope.ExternalClientID, in.Capability, in.IdempotencyKey, in.Body)
diff --git a/backend/internal/jobs/provider.go b/backend/internal/jobs/provider.go
index a0bd971..c5a34de 100644
--- a/backend/internal/jobs/provider.go
+++ b/backend/internal/jobs/provider.go
@@ -16,8 +16,16 @@ import (
type ProviderRegistry map[string]providers.Adapter
+type ProviderResolver interface {
+ Resolve(context.Context, string) (providers.Adapter, error)
+}
+
+func (registry ProviderRegistry) Resolve(_ context.Context, name string) (providers.Adapter, error) {
+ return registry[name], nil
+}
+
type ProviderProcessor struct {
- Providers ProviderRegistry
+ Providers ProviderResolver
Store Store
}
@@ -27,7 +35,13 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
if job.Status.Terminal() {
return job, nil
}
- adapter := p.Providers[job.Provider]
+ if p.Providers == nil {
+ return Job{}, fmt.Errorf("generation provider %q is unavailable", job.Provider)
+ }
+ adapter, err := p.Providers.Resolve(ctx, job.Provider)
+ if err != nil {
+ return Job{}, fmt.Errorf("resolve generation provider %q: %w", job.Provider, err)
+ }
if adapter == nil {
return Job{}, fmt.Errorf("generation provider %q is unavailable", job.Provider)
}
@@ -36,7 +50,6 @@ func (p ProviderProcessor) Advance(ctx context.Context, job Job) (Job, error) {
return Job{}, errors.New("invalid provider request payload")
}
var result providers.Result
- var err error
expectedStatus := job.Status
if job.ProviderTaskID == "" {
if job.ProviderDispatchStartedAt != nil {
@@ -187,7 +200,11 @@ func (b ProviderJobBuilder) target(capability string, rawEngine any) (ProviderTa
}
return target, engine, nil
}
- return ProviderTarget{Provider: b.ImageProvider, Model: b.ImageModel}, firstConfiguredEngine(b.ImageEngine, b.ImageProvider, "image"), nil
+ configured := firstConfiguredEngine(b.ImageEngine, b.ImageProvider, "image")
+ if target, ok := b.ImageEngines[configured]; ok && target.Provider == b.ImageProvider && target.Model == b.ImageModel {
+ return target, configured, nil
+ }
+ return ProviderTarget{Provider: b.ImageProvider, Model: b.ImageModel}, configured, nil
}
if rawEngine != nil && engine == "" {
return ProviderTarget{}, "", invalidPreparation("unsupported video engine")
@@ -202,7 +219,11 @@ func (b ProviderJobBuilder) target(capability string, rawEngine any) (ProviderTa
}
return target, engine, nil
}
- return ProviderTarget{Provider: b.VideoProvider, Model: b.VideoModel}, firstConfiguredEngine(b.VideoEngine, b.VideoProvider, "video"), nil
+ configured := firstConfiguredEngine(b.VideoEngine, b.VideoProvider, "video")
+ if target, ok := b.VideoEngines[configured]; ok && target.Provider == b.VideoProvider && target.Model == b.VideoModel {
+ return target, configured, nil
+ }
+ return ProviderTarget{Provider: b.VideoProvider, Model: b.VideoModel}, configured, nil
}
func firstConfiguredEngine(configured, provider, capabilityKind string) string {
@@ -263,6 +284,9 @@ func prepareProviderRequest(capability, engine string, defaults map[string]any,
return preparedProviderRequest{}, err
}
settings, err = imageSettings(body)
+ if err == nil {
+ applyImageSettingDefaults(settings, defaults)
+ }
if err == nil && engine == "bailian" {
err = validateBailianImage(urls, settings)
}
@@ -294,6 +318,17 @@ func prepareProviderRequest(capability, engine string, defaults map[string]any,
return preparedProviderRequest{request: providers.Request{Capability: capability, Prompt: text, InputURLs: urls, Materials: providerMaterials, Settings: settings}, assetIDs: assetIDs}, nil
}
+func applyImageSettingDefaults(settings, defaults map[string]any) {
+ if _, configured := settings["quality"]; configured {
+ return
+ }
+ raw, _ := defaults["quality"].(string)
+ quality := strings.ToLower(strings.TrimSpace(raw))
+ if quality == "low" || quality == "medium" || quality == "high" {
+ settings["quality"] = quality
+ }
+}
+
func validateImageCoverage(text string, materials []prompt.Material, imageURLCount int) error {
required := prompt.ExtractRequirements(text)
if required.Video > 0 || required.Audio > 0 {
diff --git a/backend/internal/postgres/database.go b/backend/internal/postgres/database.go
index a666040..6ccb13f 100644
--- a/backend/internal/postgres/database.go
+++ b/backend/internal/postgres/database.go
@@ -18,6 +18,7 @@ WITH required_table_privileges(table_name, privilege_name) AS (
('platform_organizations', 'SELECT'), ('platform_organizations', 'INSERT'), ('platform_organizations', 'UPDATE'), ('platform_organizations', 'DELETE'),
('platform_users', 'SELECT'), ('platform_users', 'INSERT'), ('platform_users', 'UPDATE'), ('platform_users', 'DELETE'),
('platform_account_migrations', 'SELECT'), ('platform_account_migrations', 'INSERT'), ('platform_account_migrations', 'UPDATE'),
+ ('platform_runtime_settings', 'SELECT'), ('platform_runtime_settings', 'INSERT'), ('platform_runtime_settings', 'UPDATE'),
('billing_price_rules', 'SELECT'), ('billing_price_rules', 'INSERT'), ('billing_price_rules', 'UPDATE'),
('billing_wallets', 'SELECT'), ('billing_wallets', 'INSERT'), ('billing_wallets', 'UPDATE'),
('billing_ledger', 'SELECT'), ('billing_ledger', 'INSERT')
diff --git a/backend/internal/postgres/database_test.go b/backend/internal/postgres/database_test.go
index faf1b81..912cd22 100644
--- a/backend/internal/postgres/database_test.go
+++ b/backend/internal/postgres/database_test.go
@@ -13,6 +13,7 @@ func TestReadinessSQLFreezesPrivilegeMatrixAndFunctionSignatures(t *testing.T) {
wantTables := []string{
"assets", "generation_jobs", "usage_events", "projects", "image_templates",
"platform_organizations", "platform_users", "platform_account_migrations",
+ "platform_runtime_settings",
"billing_price_rules", "billing_wallets", "billing_ledger",
}
for _, table := range wantTables {
diff --git a/backend/internal/postgres/runtime_settings.go b/backend/internal/postgres/runtime_settings.go
new file mode 100644
index 0000000..084fcbc
--- /dev/null
+++ b/backend/internal/postgres/runtime_settings.go
@@ -0,0 +1,88 @@
+package postgres
+
+import (
+ "context"
+ "fmt"
+ "sort"
+)
+
+const LoadRuntimeSettingsSQL = `
+SELECT setting_key, setting_value
+FROM platform_runtime_settings
+WHERE setting_key = ANY($1::text[])
+ORDER BY setting_key
+`
+
+const UpsertRuntimeSettingSQL = `
+INSERT INTO platform_runtime_settings (setting_key, setting_value)
+VALUES ($1, $2)
+ON CONFLICT (setting_key) DO UPDATE SET
+ setting_value = EXCLUDED.setting_value,
+ revision = platform_runtime_settings.revision + 1,
+ updated_at = now()
+`
+
+// LoadRuntimeSettings returns only the requested runtime setting keys. The
+// caller owns the editable-key whitelist; the database adapter owns storage.
+func (db *Database) LoadRuntimeSettings(ctx context.Context, keys []string) (map[string]string, error) {
+ if db.config.Backend != BackendPostgres || db.querier == nil {
+ return nil, fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend)
+ }
+ if len(keys) == 0 {
+ return map[string]string{}, nil
+ }
+ rows, err := db.querier.Query(ctx, LoadRuntimeSettingsSQL, keys)
+ if err != nil {
+ return nil, fmt.Errorf("load runtime settings: %w", err)
+ }
+ defer rows.Close()
+ values := make(map[string]string, len(keys))
+ for rows.Next() {
+ var key, value string
+ if err := rows.Scan(&key, &value); err != nil {
+ return nil, fmt.Errorf("scan runtime setting: %w", err)
+ }
+ values[key] = value
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("read runtime settings: %w", err)
+ }
+ return values, nil
+}
+
+// SaveRuntimeSettings atomically upserts a partial set of settings. Sorting
+// makes lock acquisition deterministic when concurrent requests touch several
+// keys at once.
+func (db *Database) SaveRuntimeSettings(ctx context.Context, values map[string]string) error {
+ if db.config.Backend != BackendPostgres || db.transactions == nil {
+ return fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend)
+ }
+ if len(values) == 0 {
+ return nil
+ }
+ keys := make([]string, 0, len(values))
+ for key := range values {
+ keys = append(keys, key)
+ }
+ sort.Strings(keys)
+ tx, err := db.transactions.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("begin runtime settings update: %w", err)
+ }
+ finished := false
+ defer func() {
+ if !finished {
+ _ = tx.Rollback(ctx)
+ }
+ }()
+ for _, key := range keys {
+ if err := tx.Exec(ctx, UpsertRuntimeSettingSQL, key, values[key]); err != nil {
+ return fmt.Errorf("save runtime setting %s: %w", key, err)
+ }
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return fmt.Errorf("commit runtime settings update: %w", err)
+ }
+ finished = true
+ return nil
+}
diff --git a/backend/internal/postgres/runtime_settings_test.go b/backend/internal/postgres/runtime_settings_test.go
new file mode 100644
index 0000000..961e47c
--- /dev/null
+++ b/backend/internal/postgres/runtime_settings_test.go
@@ -0,0 +1,121 @@
+package postgres
+
+import (
+ "context"
+ "errors"
+ "reflect"
+ "testing"
+)
+
+func TestLoadRuntimeSettingsUsesRequestedKeys(t *testing.T) {
+ pool := &runtimeSettingsPool{rows: [][]any{{"EVOLINK_API_KEY", "secret"}, {"IMAGE_GENERATE_ENGINE", "evolink"}}}
+ database := NewDatabase(Config{Backend: BackendPostgres}, pool)
+ keys := []string{"EVOLINK_API_KEY", "IMAGE_GENERATE_ENGINE"}
+ values, err := database.LoadRuntimeSettings(context.Background(), keys)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pool.query != LoadRuntimeSettingsSQL || !reflect.DeepEqual(pool.args, []any{keys}) {
+ t.Fatalf("query=%q args=%#v", pool.query, pool.args)
+ }
+ want := map[string]string{"EVOLINK_API_KEY": "secret", "IMAGE_GENERATE_ENGINE": "evolink"}
+ if !reflect.DeepEqual(values, want) {
+ t.Fatalf("values=%#v want=%#v", values, want)
+ }
+}
+
+func TestSaveRuntimeSettingsUpsertsSortedKeysInOneTransaction(t *testing.T) {
+ transaction := &runtimeSettingsTransaction{}
+ pool := &runtimeSettingsPool{transaction: transaction}
+ database := NewDatabase(Config{Backend: BackendPostgres}, pool)
+ err := database.SaveRuntimeSettings(context.Background(), map[string]string{
+ "VIDEO_GENERATE_ENGINE": "seedance",
+ "BAILIAN_API_KEY": "secret",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := [][]any{{"BAILIAN_API_KEY", "secret"}, {"VIDEO_GENERATE_ENGINE", "seedance"}}
+ if !reflect.DeepEqual(transaction.execArgs, want) {
+ t.Fatalf("exec args=%#v want=%#v", transaction.execArgs, want)
+ }
+ if transaction.commits != 1 {
+ t.Fatalf("commits=%d", transaction.commits)
+ }
+ if transaction.rollbacks != 0 {
+ t.Fatalf("rollbacks=%d", transaction.rollbacks)
+ }
+}
+
+type runtimeSettingsPool struct {
+ rows [][]any
+ query string
+ args []any
+ transaction *runtimeSettingsTransaction
+}
+
+func (pool *runtimeSettingsPool) Query(_ context.Context, query string, args ...any) (Rows, error) {
+ pool.query, pool.args = query, args
+ return &runtimeSettingsRows{rows: pool.rows}, nil
+}
+
+func (pool *runtimeSettingsPool) Begin(context.Context) (Transaction, error) {
+ if pool.transaction == nil {
+ return nil, errors.New("transaction unavailable")
+ }
+ return pool.transaction, nil
+}
+
+type runtimeSettingsTransaction struct {
+ execArgs [][]any
+ commits int
+ rollbacks int
+}
+
+func (*runtimeSettingsTransaction) Query(context.Context, string, ...any) (Rows, error) {
+ return &runtimeSettingsRows{}, nil
+}
+
+func (transaction *runtimeSettingsTransaction) Exec(_ context.Context, query string, args ...any) error {
+ if query != UpsertRuntimeSettingSQL {
+ return errors.New("unexpected runtime settings query")
+ }
+ transaction.execArgs = append(transaction.execArgs, args)
+ return nil
+}
+
+func (transaction *runtimeSettingsTransaction) Commit(context.Context) error {
+ transaction.commits++
+ return nil
+}
+
+func (transaction *runtimeSettingsTransaction) Rollback(context.Context) error {
+ transaction.rollbacks++
+ return nil
+}
+
+type runtimeSettingsRows struct {
+ rows [][]any
+ index int
+}
+
+func (*runtimeSettingsRows) Close() {}
+
+func (*runtimeSettingsRows) Err() error { return nil }
+
+func (rows *runtimeSettingsRows) Next() bool { return rows.index < len(rows.rows) }
+
+func (rows *runtimeSettingsRows) Scan(dest ...any) error {
+ if rows.index >= len(rows.rows) || len(dest) != len(rows.rows[rows.index]) {
+ return errors.New("invalid runtime settings row")
+ }
+ for index, value := range rows.rows[rows.index] {
+ pointer, ok := dest[index].(*string)
+ if !ok {
+ return errors.New("unexpected runtime settings destination")
+ }
+ *pointer = value.(string)
+ }
+ rows.index++
+ return nil
+}
diff --git a/backend/internal/settings/service.go b/backend/internal/settings/service.go
index ee3688f..8bda471 100644
--- a/backend/internal/settings/service.go
+++ b/backend/internal/settings/service.go
@@ -13,6 +13,14 @@ import (
)
type RuntimeUpdater func(context.Context, map[string]string) error
+
+// RuntimeSettingsRepository persists the subset of settings that can be
+// applied to provider calls without restarting the Go process.
+type RuntimeSettingsRepository interface {
+ LoadRuntimeSettings(context.Context, []string) (map[string]string, error)
+ SaveRuntimeSettings(context.Context, map[string]string) error
+}
+
type Option struct {
Label string `json:"label"`
Value string `json:"value"`
@@ -72,6 +80,8 @@ type Service struct {
mu sync.Mutex
path string
environment map[string]string
+ provider map[string]string
+ repository RuntimeSettingsRepository
update RuntimeUpdater
billing BillingAccountWriter
}
@@ -85,7 +95,14 @@ type BillingAccountWriter interface {
}
func New(path string, environment map[string]string, updater RuntimeUpdater) *Service {
- return &Service{path: path, environment: cloneStrings(environment), update: updater}
+ return NewWithRuntimeRepository(path, environment, nil, updater)
+}
+
+func NewWithRuntimeRepository(path string, environment map[string]string, repository RuntimeSettingsRepository, updater RuntimeUpdater) *Service {
+ return &Service{
+ path: path, environment: cloneStrings(environment), provider: map[string]string{},
+ repository: repository, update: updater,
+ }
}
// LoadEnvironment reads only the settings whitelist. Values already supplied
@@ -108,11 +125,17 @@ func LoadEnvironment(path string, environment map[string]string) (map[string]str
}
func RuntimeSettingKeys() []string {
- keys := make([]string, 0, len(fieldIndex)+len(runtimeOnlyKeys))
+ keys := make([]string, 0, len(fieldIndex))
for key := range fieldIndex {
keys = append(keys, key)
}
- for key := range runtimeOnlyKeys {
+ sort.Strings(keys)
+ return keys
+}
+
+func ProviderSettingKeys() []string {
+ keys := make([]string, 0, len(providerSettingKeys))
+ for key := range providerSettingKeys {
keys = append(keys, key)
}
sort.Strings(keys)
@@ -120,10 +143,7 @@ func RuntimeSettingKeys() []string {
}
func allowedEnvironmentKey(key string) bool {
- if _, allowed := fieldIndex[key]; allowed {
- return true
- }
- _, allowed := runtimeOnlyKeys[key]
+ _, allowed := fieldIndex[key]
return allowed
}
@@ -137,14 +157,53 @@ func (s *Service) Get(ctx context.Context) (any, error) {
}
s.mu.Lock()
defer s.mu.Unlock()
+ if err := s.refreshProviderSettings(ctx); err != nil {
+ return nil, err
+ }
return s.get()
}
+
+// RuntimeValues returns one coherent provider configuration snapshot. Database
+// values override process/file values, including an explicitly saved empty
+// non-secret value. Callers use a fresh snapshot per request or worker action
+// so changes made through another replica are also observed.
+func (s *Service) RuntimeValues(ctx context.Context) (map[string]string, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if err := s.refreshProviderSettings(ctx); err != nil {
+ return nil, err
+ }
+ return s.runtimeValues()
+}
+
+// CachedRuntimeValues is the non-blocking fallback used by liveness reporting
+// if PostgreSQL is temporarily unavailable. Mutating/provider request paths use
+// RuntimeValues and therefore still fail closed on a refresh error.
+func (s *Service) CachedRuntimeValues() map[string]string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ values, err := s.runtimeValues()
+ if err != nil {
+ values = cloneStrings(s.environment)
+ for key, value := range s.provider {
+ values[key] = value
+ }
+ }
+ return values
+}
+
func (s *Service) Save(ctx context.Context, values map[string]any) (any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
s.mu.Lock()
defer s.mu.Unlock()
+ if err := s.refreshProviderSettings(ctx); err != nil {
+ return nil, err
+ }
updates := map[string]string{}
for key, raw := range values {
field, ok := fieldIndex[key]
@@ -159,24 +218,46 @@ func (s *Service) Save(ctx context.Context, values map[string]any) (any, error)
updates[key] = next
}
if len(updates) > 0 {
- // Capture the complete persisted view before writing so a partial billing
- // account update cannot blank sibling fields that only exist in the file.
- persisted, err := readEnv(s.path)
- if err != nil {
- return nil, err
+ providerUpdates, fileUpdates := map[string]string{}, map[string]string{}
+ for key, value := range updates {
+ if s.repository != nil && isProviderSetting(key) {
+ providerUpdates[key] = value
+ } else {
+ fileUpdates[key] = value
+ }
}
- if err := s.write(updates); err != nil {
- return nil, err
+ // Capture the complete file-backed view before writing so a partial billing
+ // account update cannot blank sibling fields.
+ persisted := map[string]string{}
+ if len(fileUpdates) > 0 || containsBillingAccountUpdate(updates) {
+ var err error
+ persisted, err = readEnv(s.path)
+ if err != nil {
+ return nil, err
+ }
+ }
+ if len(fileUpdates) > 0 {
+ if err := s.write(fileUpdates); err != nil {
+ return nil, err
+ }
+ }
+ if len(providerUpdates) > 0 {
+ if err := s.repository.SaveRuntimeSettings(ctx, cloneStrings(providerUpdates)); err != nil {
+ return nil, fmt.Errorf("persist provider settings: %w", err)
+ }
}
if s.update != nil {
if err := s.update(ctx, cloneStrings(updates)); err != nil {
return nil, fmt.Errorf("apply runtime settings: %w", err)
}
}
- for key, value := range updates {
+ for key, value := range fileUpdates {
s.environment[key] = value
persisted[key] = value
}
+ for key, value := range providerUpdates {
+ s.provider[key] = value
+ }
if s.billing != nil && containsBillingAccountUpdate(updates) {
if err := s.billing.SaveBillingAccount(ctx, BillingAccount{
AccountName: billingSetting(s.environment, persisted, "ZHINIAN_BILLING_ACCOUNT_NAME"),
@@ -214,6 +295,9 @@ func containsBillingAccountUpdate(updates map[string]string) bool {
func requiresRestart(updates map[string]string) bool {
for key := range updates {
+ if isProviderSetting(key) {
+ continue
+ }
switch key {
case "ZHINIAN_BILLING_ACCOUNT_NAME", "ZHINIAN_BILLING_ACCOUNT_BANK", "ZHINIAN_BILLING_ACCOUNT_NUMBER", "ZHINIAN_BILLING_CONTACT":
continue
@@ -229,6 +313,9 @@ func (s *Service) get() (Payload, error) {
return Payload{}, err
}
current := func(field Field) string {
+ if value, ok := s.provider[field.Key]; ok {
+ return value
+ }
if value, ok := s.environment[field.Key]; ok {
return value
}
@@ -250,19 +337,56 @@ func (s *Service) get() (Payload, error) {
}
image := normalizeImage(current(fieldIndex["IMAGE_GENERATE_ENGINE"]))
video := normalizeVideo(current(fieldIndex["VIDEO_GENERATE_ENGINE"]))
- imageModel := map[string]string{"jimeng": lookup(s.environment, file, "JIMENG_IMAGE_GENERATE_46_REQ_KEY", "jimeng_seedream46_cvtob"), "evolink": lookup(s.environment, file, "EVOLINK_IMAGE_MODEL", "gpt-image-2"), "bailian": lookup(s.environment, file, "BAILIAN_IMAGE_MODEL", "wan2.7-image-pro")}[image]
- videoModel := lookup(s.environment, file, "SEEDANCE_MODEL", "doubao-seedance-2-0-260128")
+ imageModel := map[string]string{"jimeng": lookup(s.provider, s.environment, file, "JIMENG_IMAGE_GENERATE_46_REQ_KEY", "jimeng_seedream46_cvtob"), "evolink": lookup(s.provider, s.environment, file, "EVOLINK_IMAGE_MODEL", "gpt-image-2"), "bailian": lookup(s.provider, s.environment, file, "BAILIAN_IMAGE_MODEL", "wan2.7-image-pro")}[image]
+ videoModel := lookup(s.provider, s.environment, file, "SEEDANCE_MODEL", "doubao-seedance-2-0-260128")
if video == "bailian" {
- videoModel = lookup(s.environment, file, "BAILIAN_VIDEO_MODEL", "wan2.7-i2v-2026-04-25")
+ videoModel = lookup(s.provider, s.environment, file, "BAILIAN_VIDEO_MODEL", "wan2.7-i2v-2026-04-25")
}
- imageConnected := connected(image, s.environment, file)
- videoConnected := connected(video, s.environment, file)
- imageField := project(fieldIndex["IMAGE_GENERATE_ENGINE"], image, s.environment, file)
- videoField := project(fieldIndex["VIDEO_GENERATE_ENGINE"], video, s.environment, file)
+ imageConnected := connected(image, s.provider, s.environment, file)
+ videoConnected := connected(video, s.provider, s.environment, file)
+ imageField := project(fieldIndex["IMAGE_GENERATE_ENGINE"], image, s.provider, s.environment, file)
+ videoField := project(fieldIndex["VIDEO_GENERATE_ENGINE"], video, s.provider, s.environment, file)
assignments := []EngineAssignment{{ID: "image.generate", Label: "图片生成", Engine: image, EngineLabel: label(image), Connected: imageConnected, ConnectionLabel: connection(imageConnected), ReqKey: imageModel, Configurable: true, Field: &imageField}, {ID: "video.generate", Label: "视频生成", Engine: video, EngineLabel: label(video), Connected: videoConnected, ConnectionLabel: connection(videoConnected), ReqKey: videoModel, Configurable: true, Field: &videoField}}
- services := Services{Visual: connected("jimeng", s.environment, file), Evolink: connected("evolink", s.environment, file), Seedance: connected("seedance", s.environment, file), Bailian: connected("bailian", s.environment, file), Auth: lookup(s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != "", Organization: lookup(s.environment, file, "DATABASE_URL", "") != "" || lookup(s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != ""}
+ services := Services{Visual: connected("jimeng", s.provider, s.environment, file), Evolink: connected("evolink", s.provider, s.environment, file), Seedance: connected("seedance", s.provider, s.environment, file), Bailian: connected("bailian", s.provider, s.environment, file), Auth: lookup(s.provider, s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != "", Organization: lookup(s.provider, s.environment, file, "DATABASE_URL", "") != "" || lookup(s.provider, s.environment, file, "ZHINIAN_AUTH_SESSION_SECRET", "") != ""}
return Payload{Services: services, Capabilities: []Capability{{ID: "image.generate", Label: "图片生成 4.6", ReqKey: imageModel, Engine: image, EngineLabel: label(image), Enabled: true}, {ID: "video.generate", Label: "视频生成", ReqKey: videoModel, Engine: video, EngineLabel: label(video), Enabled: true}}, EngineAssignments: assignments, Groups: groups}, nil
}
+
+func (s *Service) refreshProviderSettings(ctx context.Context) error {
+ if s.repository == nil {
+ return nil
+ }
+ values, err := s.repository.LoadRuntimeSettings(ctx, ProviderSettingKeys())
+ if err != nil {
+ return fmt.Errorf("load provider settings: %w", err)
+ }
+ s.provider = map[string]string{}
+ for key, value := range values {
+ if isProviderSetting(key) {
+ s.provider[key] = value
+ }
+ }
+ return nil
+}
+
+func (s *Service) runtimeValues() (map[string]string, error) {
+ file, err := readEnv(s.path)
+ if err != nil {
+ return nil, err
+ }
+ values := map[string]string{}
+ for key, value := range file {
+ if allowedEnvironmentKey(key) {
+ values[key] = value
+ }
+ }
+ for key, value := range s.environment {
+ values[key] = value
+ }
+ for key, value := range s.provider {
+ values[key] = value
+ }
+ return values, nil
+}
func (s *Service) write(updates map[string]string) error {
data, err := os.ReadFile(s.path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
@@ -413,7 +537,10 @@ func cloneStrings(input map[string]string) map[string]string {
}
return out
}
-func lookup(environment, file map[string]string, key, fallback string) string {
+func lookup(provider, environment, file map[string]string, key, fallback string) string {
+ if value, ok := provider[key]; ok {
+ return value
+ }
if value, ok := environment[key]; ok {
return value
}
@@ -422,9 +549,9 @@ func lookup(environment, file map[string]string, key, fallback string) string {
}
return fallback
}
-func project(field Field, value string, environment, file map[string]string) Field {
+func project(field Field, value string, provider, environment, file map[string]string) Field {
field.Value = value
- field.Configured = lookup(environment, file, field.Key, "") != ""
+ field.Configured = lookup(provider, environment, file, field.Key, "") != ""
return field
}
func normalizeImage(value string) string {
@@ -458,16 +585,16 @@ func connection(ok bool) string {
}
return "待配置"
}
-func connected(engine string, environment, file map[string]string) bool {
+func connected(engine string, provider, environment, file map[string]string) bool {
switch engine {
case "evolink":
- return strings.TrimSpace(lookup(environment, file, "EVOLINK_API_KEY", "")) != ""
+ return strings.TrimSpace(lookup(provider, environment, file, "EVOLINK_API_KEY", "")) != ""
case "seedance":
- return strings.TrimSpace(lookup(environment, file, "SEEDANCE_API_KEY", "")) != ""
+ return strings.TrimSpace(lookup(provider, environment, file, "SEEDANCE_API_KEY", "")) != ""
case "bailian":
- return strings.TrimSpace(lookup(environment, file, "BAILIAN_API_KEY", "")) != "" || strings.TrimSpace(lookup(environment, file, "DASHSCOPE_API_KEY", "")) != ""
+ return strings.TrimSpace(lookup(provider, environment, file, "BAILIAN_API_KEY", "")) != "" || strings.TrimSpace(lookup(provider, environment, file, "DASHSCOPE_API_KEY", "")) != ""
default:
- return strings.TrimSpace(lookup(environment, file, "VOLCENGINE_ACCESS_KEY_ID", "")) != "" && strings.TrimSpace(lookup(environment, file, "VOLCENGINE_SECRET_ACCESS_KEY", "")) != ""
+ return strings.TrimSpace(lookup(provider, environment, file, "VOLCENGINE_ACCESS_KEY_ID", "")) != "" && strings.TrimSpace(lookup(provider, environment, file, "VOLCENGINE_SECRET_ACCESS_KEY", "")) != ""
}
}
@@ -495,8 +622,23 @@ var fieldIndex = func() map[string]Field {
return result
}()
-// runtimeOnlyKeys are read from the process/settings file by the Go startup
-// loader but are intentionally not exposed as editable settings-panel fields.
-var runtimeOnlyKeys = map[string]struct{}{
- "ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS": {},
+var providerSettingKeys = map[string]struct{}{
+ "VOLCENGINE_ACCESS_KEY_ID": {},
+ "VOLCENGINE_SECRET_ACCESS_KEY": {},
+ "EVOLINK_API_KEY": {},
+ "EVOLINK_BASE_URL": {},
+ "EVOLINK_IMAGE_MODEL": {},
+ "EVOLINK_IMAGE_QUALITY": {},
+ "SEEDANCE_API_KEY": {},
+ "BAILIAN_API_KEY": {},
+ "BAILIAN_BASE_URL": {},
+ "BAILIAN_IMAGE_MODEL": {},
+ "BAILIAN_VIDEO_MODEL": {},
+ "IMAGE_GENERATE_ENGINE": {},
+ "VIDEO_GENERATE_ENGINE": {},
+}
+
+func isProviderSetting(key string) bool {
+ _, ok := providerSettingKeys[key]
+ return ok
}
diff --git a/backend/internal/settings/service_test.go b/backend/internal/settings/service_test.go
index 727a632..713f2e9 100644
--- a/backend/internal/settings/service_test.go
+++ b/backend/internal/settings/service_test.go
@@ -65,12 +65,61 @@ func TestServiceSavePreservesSecretsAndUnrelatedEnvLines(t *testing.T) {
if !reflect.DeepEqual(applied, map[string]string{"ZHINIAN_BILLING_CONTACT": "", "EVOLINK_BASE_URL": "https://new.example/v1"}) {
t.Fatalf("applied = %#v", applied)
}
- if payload := value.(Payload); !payload.RestartRequired {
- t.Fatal("provider setting update must declare restartRequired")
+ if payload := value.(Payload); payload.RestartRequired {
+ t.Fatal("provider setting update must be applied without restart")
}
assertSecretProjection(t, value, "ZHINIAN_AUTH_SESSION_SECRET", true)
}
+func TestRuntimeRepositoryOverridesEnvironmentAndProjectsSecrets(t *testing.T) {
+ repository := &runtimeSettingsRepositoryStub{values: map[string]string{
+ "EVOLINK_API_KEY": "database-secret",
+ "EVOLINK_BASE_URL": "https://database.example",
+ "IMAGE_GENERATE_ENGINE": "evolink",
+ }}
+ service := NewWithRuntimeRepository(
+ filepath.Join(t.TempDir(), ".env.local"),
+ map[string]string{"EVOLINK_API_KEY": "environment-secret", "EVOLINK_BASE_URL": "https://environment.example"},
+ repository,
+ nil,
+ )
+ value, err := service.Get(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := fieldValue(t, value, "EVOLINK_BASE_URL"); got != "https://database.example" {
+ t.Fatalf("base URL = %q", got)
+ }
+ assertSecretProjection(t, value, "EVOLINK_API_KEY", true)
+ runtime, err := service.RuntimeValues(context.Background())
+ if err != nil || runtime["EVOLINK_API_KEY"] != "database-secret" {
+ t.Fatalf("runtime=%#v err=%v", runtime, err)
+ }
+}
+
+func TestRuntimeRepositorySaveIsPartialImmediateAndDoesNotCreateSettingsFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "nested", ".env.local")
+ repository := &runtimeSettingsRepositoryStub{values: map[string]string{"EVOLINK_API_KEY": "keep-secret"}}
+ service := NewWithRuntimeRepository(path, nil, repository, nil)
+ value, err := service.Save(context.Background(), map[string]any{
+ "EVOLINK_API_KEY": " ",
+ "EVOLINK_BASE_URL": " https://saved.example ",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(repository.saved, map[string]string{"EVOLINK_BASE_URL": "https://saved.example"}) {
+ t.Fatalf("saved=%#v", repository.saved)
+ }
+ if _, err := os.Stat(path); !os.IsNotExist(err) {
+ t.Fatalf("provider save unexpectedly created local settings file: %v", err)
+ }
+ if value.(Payload).RestartRequired {
+ t.Fatal("provider database update should apply immediately")
+ }
+ assertSecretProjection(t, value, "EVOLINK_API_KEY", true)
+}
+
func TestServiceGetUsesInjectedEnvironmentWithoutMutatingProcess(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env.local")
if err := os.WriteFile(path, []byte("EVOLINK_BASE_URL=file-value\nSEEDANCE_API_KEY=file-secret\n"), 0o600); err != nil {
@@ -100,7 +149,7 @@ func TestServiceGetUsesInjectedEnvironmentWithoutMutatingProcess(t *testing.T) {
assertSecretProjection(t, value, "SEEDANCE_API_KEY", true)
}
-func TestLoadEnvironmentAllowsBootstrapRuntimeFlagButIgnoresUnknownKeys(t *testing.T) {
+func TestLoadEnvironmentIgnoresUnknownRuntimeFlags(t *testing.T) {
path := filepath.Join(t.TempDir(), ".env.local")
if err := os.WriteFile(path, []byte("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true\nUNKNOWN_RUNTIME_FLAG=true\n"), 0o600); err != nil {
t.Fatal(err)
@@ -109,8 +158,8 @@ func TestLoadEnvironmentAllowsBootstrapRuntimeFlagButIgnoresUnknownKeys(t *testi
if err != nil {
t.Fatal(err)
}
- if values["ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS"] != "true" {
- t.Fatalf("bootstrap flag = %q", values["ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS"])
+ if _, exists := values["ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS"]; exists {
+ t.Fatal("obsolete provider bootstrap flag was loaded")
}
if _, exists := values["UNKNOWN_RUNTIME_FLAG"]; exists {
t.Fatal("unknown runtime flag was loaded")
@@ -160,6 +209,26 @@ func (writer *billingWriterStub) SaveBillingAccount(_ context.Context, value Bil
return nil
}
+type runtimeSettingsRepositoryStub struct {
+ values map[string]string
+ saved map[string]string
+}
+
+func (repository *runtimeSettingsRepositoryStub) LoadRuntimeSettings(_ context.Context, _ []string) (map[string]string, error) {
+ return clone(repository.values), nil
+}
+
+func (repository *runtimeSettingsRepositoryStub) SaveRuntimeSettings(_ context.Context, values map[string]string) error {
+ repository.saved = clone(values)
+ if repository.values == nil {
+ repository.values = map[string]string{}
+ }
+ for key, value := range values {
+ repository.values[key] = value
+ }
+ return nil
+}
+
func fieldValue(t *testing.T, value any, key string) string {
t.Helper()
payload := value.(Payload)
diff --git a/components/settings-panel.tsx b/components/settings-panel.tsx
index 1b13b53..89ad1e2 100644
--- a/components/settings-panel.tsx
+++ b/components/settings-panel.tsx
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Loader2, RefreshCw, Save } from "lucide-react";
+import { selectDirtySettingsValues } from "@/lib/settings-client";
import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
type SettingsField = {
@@ -23,7 +24,7 @@ type SettingsGroup = {
};
type SettingsPayload = {
- restartRequired?: boolean;
+ restartRequired?: boolean;
services: {
visual: boolean;
evolink: boolean;
@@ -57,6 +58,7 @@ type SettingsPayload = {
export function SettingsPanel() {
const [payload, setPayload] = useState(null);
const [values, setValues] = useState>({});
+ const [dirtyKeys, setDirtyKeys] = useState>(() => new Set());
const [activeTab, setActiveTab] = useState("status");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -92,12 +94,14 @@ export function SettingsPanel() {
async function loadSettings() {
setLoading(true);
setError(null);
+ setMessage(null);
try {
const response = await fetch("/api/settings", { cache: "no-store" });
const nextPayload = await response.json();
if (!response.ok) throw new Error(nextPayload.error || "读取设置失败");
setPayload(nextPayload);
setValues(valuesFromPayload(nextPayload));
+ setDirtyKeys(new Set());
if (activeTab !== "status" && !nextPayload.groups.some((group: SettingsGroup) => group.id === activeTab)) {
setActiveTab(nextPayload.groups[0]?.id || "status");
}
@@ -109,6 +113,7 @@ export function SettingsPanel() {
}
async function saveSettings() {
+ if (dirtyKeys.size === 0) return;
setSaving(true);
setError(null);
setMessage(null);
@@ -116,13 +121,14 @@ export function SettingsPanel() {
const response = await fetch("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ values })
+ body: JSON.stringify({ values: selectDirtySettingsValues(values, dirtyKeys) })
});
const nextPayload = await response.json();
if (!response.ok) throw new Error(nextPayload.error || "保存设置失败");
setPayload(nextPayload);
setValues(valuesFromPayload(nextPayload));
- setMessage(nextPayload.restartRequired ? "配置已保存,重启 Go 服务后生效。" : "配置已保存并应用到当前服务。");
+ setDirtyKeys(new Set());
+ setMessage(nextPayload.restartRequired ? "配置已保存,重启 Go 服务后生效。" : "配置已保存并应用到当前服务。");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -130,6 +136,15 @@ export function SettingsPanel() {
}
}
+ function updateValue(key: string, value: string) {
+ setValues((items) => ({ ...items, [key]: value }));
+ setDirtyKeys((keys) => {
+ const next = new Set(keys);
+ next.add(key);
+ return next;
+ });
+ }
+
if (loading && !payload) {
return (
@@ -164,7 +179,7 @@ export function SettingsPanel() {
className="button primary"
type="button"
onClick={saveSettings}
- disabled={saving || (!fields.length && !payload?.engineAssignments.length)}
+ disabled={saving || dirtyKeys.size === 0 || (!fields.length && !payload?.engineAssignments.length)}
>
{saving ? : }
保存
@@ -194,7 +209,7 @@ export function SettingsPanel() {
{field.secret ? {field.configured ? "已配置" : "未配置"} : null}
{field.type === "select" ? (
-