From 2a2f78c81e1c2cb5a1565367a47f6ae40c0103d8 Mon Sep 17 00:00:00 2001 From: andy Date: Tue, 18 Aug 2026 12:41:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=85=8D=E7=BD=AE=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E4=B8=8D=E4=BA=86=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 - .idea/.gitignore | 8 + .idea/NianAIGC.iml | 9 + .idea/inspectionProfiles/Project_Default.xml | 36 +++ .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + .../20260818-db-provider-settings-4f6a2c9d.md | 102 +++++++++ README.md | 6 +- README.zh-CN.md | 5 +- backend/cmd/zhinian-api/main_test.go | 2 +- backend/internal/application/application.go | 35 +-- .../internal/application/application_test.go | 11 +- backend/internal/application/runtime.go | 133 ++++++++--- backend/internal/application/runtime_test.go | 136 ++++++++--- backend/internal/httpapi/billing.go | 7 +- backend/internal/httpapi/httpapi.go | 18 +- backend/internal/httpapi/jobs.go | 8 +- backend/internal/jobs/provider.go | 45 +++- backend/internal/postgres/database.go | 1 + backend/internal/postgres/database_test.go | 1 + backend/internal/postgres/runtime_settings.go | 88 ++++++++ .../postgres/runtime_settings_test.go | 121 ++++++++++ backend/internal/settings/service.go | 212 +++++++++++++++--- backend/internal/settings/service_test.go | 79 ++++++- components/settings-panel.tsx | 29 ++- contracts/settings/runtime-v1.json | 19 +- .../0003_platform_runtime_settings.sql | 12 + deploy/ack/configmap.yaml | 7 +- deploy/ack/go-api.yaml | 1 + deploy/ack/secrets.example.yaml | 7 +- docs/DEPLOYMENT.md | 24 +- lib/server/database.ts | 1 + lib/settings-client.ts | 6 + scripts/check-ack-manifests.mjs | 5 +- scripts/deploy.sh | 2 +- scripts/ensure-deploy-env.mjs | 2 +- scripts/migrate-postgres.mjs | 1 + tests/database-readiness-contract.test.ts | 1 + tests/postgres-privilege-contract.test.ts | 1 + tests/settings-client.test.ts | 17 ++ tests/settings-go-contract.test.ts | 41 +++- 42 files changed, 1082 insertions(+), 178 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/NianAIGC.iml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .project-docs/30-worklog/tasks/20260818-db-provider-settings-4f6a2c9d.md create mode 100644 backend/internal/postgres/runtime_settings.go create mode 100644 backend/internal/postgres/runtime_settings_test.go create mode 100644 database/migrations/0003_platform_runtime_settings.sql create mode 100644 lib/settings-client.ts create mode 100644 tests/settings-client.test.ts 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" ? ( - updateValue(field.key, event.target.value)}> {(field.options || []).map((option) => ( ))} @@ -204,7 +219,7 @@ export function SettingsPanel() { type={field.secret ? "password" : field.type === "number" ? "number" : "text"} value={values[field.key] ?? ""} placeholder={field.secret && field.configured ? "留空保留当前密钥" : field.description || ""} - onChange={(event) => setValues((items) => ({ ...items, [field.key]: event.target.value }))} + onChange={(event) => updateValue(field.key, event.target.value)} /> )} {field.key} @@ -248,7 +263,7 @@ export function SettingsPanel() {