From 22fc7a56d8dcd90faf6cb16070ee94e37bd7f9fa Mon Sep 17 00:00:00 2001 From: inman Date: Tue, 18 Aug 2026 00:36:05 +0800 Subject: [PATCH] feat: allow production provider bootstrap --- .env.example | 4 ++++ README.md | 3 ++- README.zh-CN.md | 3 ++- backend/cmd/zhinian-api/main_test.go | 6 ++--- backend/internal/application/application.go | 9 ++++++- .../internal/application/application_test.go | 13 ++++++++++ backend/internal/application/runtime.go | 24 +++++++++++++++++++ backend/internal/application/runtime_test.go | 20 ++++++++++++++++ backend/internal/jobs/job.go | 7 +++--- backend/internal/jobs/provider.go | 4 ++++ backend/internal/jobs/provider_test.go | 17 +++++++++++++ backend/internal/settings/service.go | 21 ++++++++++++++-- backend/internal/settings/service_test.go | 17 +++++++++++++ deploy/ack/configmap.yaml | 3 +++ deploy/ack/secrets.example.yaml | 6 +++-- docs/DEPLOYMENT.md | 10 +++++--- findings.md | 6 +++++ progress.md | 19 +++++++++++++++ scripts/deploy.sh | 2 +- scripts/ensure-deploy-env.mjs | 2 +- task_plan.md | 14 +++++++++++ 21 files changed, 192 insertions(+), 18 deletions(-) diff --git a/.env.example b/.env.example index b8c059c..43c61b8 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,10 @@ ZHINIAN_WORKER_RETRY_BASE_MS=10000 ZHINIAN_WORKER_RETRY_MAX_MS=300000 ZHINIAN_WORKER_REQUEST_TIMEOUT_MS=120000 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/README.md b/README.md index a7eef6e..9dc794d 100644 --- a/README.md +++ b/README.md @@ -222,10 +222,11 @@ 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` 模式缺少连接配置或真实服务商凭据会在启动时直接失败,不会静默写入本地数据。如果 OSS 未配置,上传和生成结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Go 路由提供访问。 +当 `ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层。生产 `postgres` 模式默认在启动时校验真实服务商凭据;临时引导可设置 `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true`,但未配置服务商的报价和生成请求会返回 503,不会静默切换到 Mock,配置保存后还必须重启 Go 后端。如果 OSS 未配置,上传和生成结果会保存到 `.runtime/uploads` 和 `.runtime/generated-results`,并通过 Go 路由提供访问。 ## 数据库 diff --git a/README.zh-CN.md b/README.zh-CN.md index 56c8718..9ab7c93 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -272,6 +272,7 @@ 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` | 百炼业务空间兼容地址;系统自动派生原生异步接口 | @@ -291,7 +292,7 @@ cp .env.example .env.local | `DATABASE_URL` | PostgreSQL 连接串(仅放 Secret) | | PostgreSQL 传输 | 客户端强制 `sslmode=disable` 且不读取 CA;ACK 到 RDS 的链路为明文,只应走内网并通过 VPC、安全组和白名单限制访问 | -`ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层;生产 `postgres` 模式配置错误或缺少真实服务商凭据会直接失败。未配置 OSS 时,上传和生成结果会写入 `.runtime/uploads` 与 `.runtime/generated-results`。 +`ZHINIAN_DATA_BACKEND=local` 时,应用使用进程内单实例开发数据层;生产 `postgres` 模式默认会在启动时校验真实服务商凭据。若上线引导阶段暂时没有凭据,可将 `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true` 注入 Go API,让服务先启动;此时未配置服务商的报价和生成请求会返回 503,不会切换到 Mock。通过设置页保存凭据后必须重启 Go 后端,并将该开关恢复为 `false`。未配置 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 0777a97..2386013 100644 --- a/backend/cmd/zhinian-api/main_test.go +++ b/backend/cmd/zhinian-api/main_test.go @@ -18,7 +18,7 @@ func TestListenAddressDefaultsToLoopbackAndSupportsExplicitBinding(t *testing.T) func TestRuntimeGetenvLoadsWhitelistedSettingsAndPreservesProcessPrecedence(t *testing.T) { path := filepath.Join(t.TempDir(), ".env.local") - if err := os.WriteFile(path, []byte("IMAGE_GENERATE_ENGINE=evolink\nEVOLINK_API_KEY=file-secret\nDATABASE_URL=postgres://must-not-load\n"), 0o600); err != nil { + if err := os.WriteFile(path, []byte("IMAGE_GENERATE_ENGINE=evolink\nEVOLINK_API_KEY=file-secret\nZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true\nDATABASE_URL=postgres://must-not-load\n"), 0o600); err != nil { t.Fatal(err) } process := map[string]string{"ZHINIAN_SETTINGS_FILE": path, "EVOLINK_API_KEY": "process-secret", "ZHINIAN_DATA_BACKEND": "local"} @@ -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("DATABASE_URL") != "" || getenv("ZHINIAN_DATA_BACKEND") != "local" { - t.Fatalf("loaded engine=%q key=%q database=%q backend=%q", getenv("IMAGE_GENERATE_ENGINE"), getenv("EVOLINK_API_KEY"), getenv("DATABASE_URL"), getenv("ZHINIAN_DATA_BACKEND")) + 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" { + 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 c332246..d52a7d8 100644 --- a/backend/internal/application/application.go +++ b/backend/internal/application/application.go @@ -83,6 +83,9 @@ func New(options Options) (*App, error) { 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 { @@ -265,11 +268,15 @@ func New(options Options) (*App, error) { if providerRegistry == nil { providerRegistry = buildProviderRegistry(getenv) } + var unavailableProviders map[string]string + if config.Backend == postgres.BackendPostgres { + unavailableProviders = providerUnavailableMessages(getenv) + } 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), NewID: applicationJobID, + ImageEngines: providerImageTargets(getenv), VideoEngines: providerVideoTargets(getenv), UnavailableProviders: unavailableProviders, NewID: applicationJobID, } usageService := usage.Service{ Repository: usageRepository, diff --git a/backend/internal/application/application_test.go b/backend/internal/application/application_test.go index db650fb..a6e3617 100644 --- a/backend/internal/application/application_test.go +++ b/backend/internal/application/application_test.go @@ -77,6 +77,19 @@ func TestApplicationRejectsInvalidProductionDatabaseConfiguration(t *testing.T) } } +func TestProductionProviderBootstrapFlagAllowsApplicationComposition(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", + })}) + if err != nil { + t.Fatalf("New() with provider bootstrap flag = %v", err) + } + app.Close() +} + func TestProductionLocalBackendNeverGrantsAnonymousAdministrator(t *testing.T) { app, err := application.New(application.Options{Getenv: applicationEnv(map[string]string{ "NODE_ENV": "production", diff --git a/backend/internal/application/runtime.go b/backend/internal/application/runtime.go index 52cc730..4aea0d2 100644 --- a/backend/internal/application/runtime.go +++ b/backend/internal/application/runtime.go @@ -59,6 +59,10 @@ func parseBool(value string) bool { } } +func allowUnconfiguredProviders(getenv postgres.Getenv) bool { + return parseBool(getenv("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS")) +} + func applicationJobID() string { return applicationID("job") } @@ -186,7 +190,27 @@ func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget } } +func providerUnavailableMessages(getenv postgres.Getenv) map[string]string { + missing := map[string]string{} + if strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) == "" || strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) == "" { + missing["volcengine-visual"] = "即梦服务商未配置,请先配置 VOLCENGINE_ACCESS_KEY_ID 和 VOLCENGINE_SECRET_ACCESS_KEY。" + } + if strings.TrimSpace(getenv("EVOLINK_API_KEY")) == "" { + missing["evolink"] = "EvoLink 服务商未配置,请先配置 EVOLINK_API_KEY。" + } + if bailianAPIKey(getenv) == "" { + missing["bailian"] = "百炼服务商未配置,请先配置 BAILIAN_API_KEY 或 DASHSCOPE_API_KEY。" + } + if strings.TrimSpace(getenv("SEEDANCE_API_KEY")) == "" { + missing["seedance"] = "Seedance 服务商未配置,请先配置 SEEDANCE_API_KEY。" + } + 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") diff --git a/backend/internal/application/runtime_test.go b/backend/internal/application/runtime_test.go index 9ce4f7b..c391e73 100644 --- a/backend/internal/application/runtime_test.go +++ b/backend/internal/application/runtime_test.go @@ -128,6 +128,26 @@ func TestValidateProductionProviderConfigurationRequiresAllRealCredentials(t *te } } +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", + "VOLCENGINE_SECRET_ACCESS_KEY": "secret", + "EVOLINK_API_KEY": "evolink", + "BAILIAN_API_KEY": "bailian", + } + missing := providerUnavailableMessages(func(name string) string { return values[name] }) + if len(missing) != 1 || !strings.Contains(missing["seedance"], "SEEDANCE_API_KEY") { + t.Fatalf("missing = %#v", missing) + } +} + func TestProviderTargetsNeverSelectRemovedProvider(t *testing.T) { getenv := func(string) string { return "" } for engine, target := range providerImageTargets(getenv) { diff --git a/backend/internal/jobs/job.go b/backend/internal/jobs/job.go index 194f613..ae3e4c9 100644 --- a/backend/internal/jobs/job.go +++ b/backend/internal/jobs/job.go @@ -92,9 +92,10 @@ func (scope Scope) Owns(job Job) bool { type ErrorKind string const ( - ErrorInvalid ErrorKind = "invalid" - ErrorNotFound ErrorKind = "not_found" - ErrorConflict ErrorKind = "conflict" + ErrorInvalid ErrorKind = "invalid" + ErrorNotFound ErrorKind = "not_found" + ErrorConflict ErrorKind = "conflict" + ErrorUnavailable ErrorKind = "unavailable" ) type Error struct { diff --git a/backend/internal/jobs/provider.go b/backend/internal/jobs/provider.go index e774465..a0bd971 100644 --- a/backend/internal/jobs/provider.go +++ b/backend/internal/jobs/provider.go @@ -121,6 +121,7 @@ type ProviderJobBuilder struct { ImageEngine, VideoEngine string ImageEngines map[string]ProviderTarget VideoEngines map[string]ProviderTarget + UnavailableProviders map[string]string NewID func() string } @@ -141,6 +142,9 @@ func (b ProviderJobBuilder) Build(_ context.Context, owner, client, capability, if err != nil { return CreateCommand{}, err } + if message := strings.TrimSpace(b.UnavailableProviders[target.Provider]); message != "" { + return CreateCommand{}, &Error{Kind: ErrorUnavailable, Status: 503, Message: message} + } if target.Provider == "" || target.Model == "" || b.NewID == nil { return CreateCommand{}, errors.New("provider job builder is not configured") } diff --git a/backend/internal/jobs/provider_test.go b/backend/internal/jobs/provider_test.go index 76e3400..11ffa18 100644 --- a/backend/internal/jobs/provider_test.go +++ b/backend/internal/jobs/provider_test.go @@ -40,6 +40,23 @@ func TestProviderJobBuilderPreparesImageRequestAndEngineOverride(t *testing.T) { } } +func TestProviderJobBuilderRejectsUnconfiguredProviderBeforeCreation(t *testing.T) { + b := testProviderBuilder() + b.UnavailableProviders = map[string]string{ + "evolink": "EvoLink 服务商未配置,请先配置 EVOLINK_API_KEY。", + } + _, err := b.Build(context.Background(), "owner", "client", "image.generate", "idem", map[string]any{ + "engine": "evolink", "prompt": "hello", + }) + if err == nil || err.Error() != "EvoLink 服务商未配置,请先配置 EVOLINK_API_KEY。" { + t.Fatalf("error = %v", err) + } + var unavailable *Error + if !errors.As(err, &unavailable) || unavailable.Kind != ErrorUnavailable || unavailable.Status != 503 { + t.Fatalf("error = %#v, want unavailable 503", err) + } +} + func TestProviderJobBuilderUsesPublicInputURLsForImageMaterialCoverage(t *testing.T) { b := testProviderBuilder() cmd, err := b.Build(context.Background(), "owner", "client", "image.generate", "", map[string]any{ diff --git a/backend/internal/settings/service.go b/backend/internal/settings/service.go index 6bfd79e..ee3688f 100644 --- a/backend/internal/settings/service.go +++ b/backend/internal/settings/service.go @@ -97,7 +97,7 @@ func LoadEnvironment(path string, environment map[string]string) (map[string]str } merged := cloneStrings(environment) for key, value := range file { - if _, allowed := fieldIndex[key]; !allowed { + if !allowedEnvironmentKey(key) { continue } if _, exists := merged[key]; !exists { @@ -108,14 +108,25 @@ func LoadEnvironment(path string, environment map[string]string) (map[string]str } func RuntimeSettingKeys() []string { - keys := make([]string, 0, len(fieldIndex)) + keys := make([]string, 0, len(fieldIndex)+len(runtimeOnlyKeys)) for key := range fieldIndex { keys = append(keys, key) } + for key := range runtimeOnlyKeys { + keys = append(keys, key) + } sort.Strings(keys) return keys } +func allowedEnvironmentKey(key string) bool { + if _, allowed := fieldIndex[key]; allowed { + return true + } + _, allowed := runtimeOnlyKeys[key] + return allowed +} + func (s *Service) WithBillingAccountWriter(writer BillingAccountWriter) *Service { s.billing = writer return s @@ -483,3 +494,9 @@ var fieldIndex = func() map[string]Field { result["VIDEO_GENERATE_ENGINE"] = Field{Key: "VIDEO_GENERATE_ENGINE", Label: "视频生成", Type: "select", DefaultValue: "bailian", Options: []Option{{Label: "Seedance", Value: "seedance"}, {Label: "阿里云百炼 Wan 2.7", Value: "bailian"}}} 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": {}, +} diff --git a/backend/internal/settings/service_test.go b/backend/internal/settings/service_test.go index 3a40510..727a632 100644 --- a/backend/internal/settings/service_test.go +++ b/backend/internal/settings/service_test.go @@ -100,6 +100,23 @@ func TestServiceGetUsesInjectedEnvironmentWithoutMutatingProcess(t *testing.T) { assertSecretProjection(t, value, "SEEDANCE_API_KEY", true) } +func TestLoadEnvironmentAllowsBootstrapRuntimeFlagButIgnoresUnknownKeys(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) + } + values, err := LoadEnvironment(path, nil) + 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["UNKNOWN_RUNTIME_FLAG"]; exists { + t.Fatal("unknown runtime flag was loaded") + } +} + func TestServiceSynchronizesBillingAccountWithoutRequiringRestart(t *testing.T) { writer := &billingWriterStub{} service := New(filepath.Join(t.TempDir(), ".env.local"), nil, nil).WithBillingAccountWriter(writer) diff --git a/deploy/ack/configmap.yaml b/deploy/ack/configmap.yaml index 43ab959..4914b16 100644 --- a/deploy/ack/configmap.yaml +++ b/deploy/ack/configmap.yaml @@ -14,6 +14,9 @@ data: ZHINIAN_AUTH_COOKIE_SECURE: "true" ZHINIAN_PUBLIC_BASE_URL: https://REPLACE_WITH_PUBLIC_HOST ZHINIAN_GO_EMBEDDED_WORKER: "true" + # Temporary bootstrap switch only. Keep false for steady-state production; + # when true, unconfigured provider requests return 503 until restart. + ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS: "false" ZHINIAN_RUNTIME_DIR: /var/lib/zhinian/runtime ZHINIAN_LOG_DIR: /var/lib/zhinian/logs ZHINIAN_SETTINGS_FILE: /var/lib/zhinian/settings.env diff --git a/deploy/ack/secrets.example.yaml b/deploy/ack/secrets.example.yaml index 669c6f7..48c8a00 100644 --- a/deploy/ack/secrets.example.yaml +++ b/deploy/ack/secrets.example.yaml @@ -29,8 +29,10 @@ metadata: namespace: zhinian type: Opaque stringData: - # The Go API refuses to start in PostgreSQL production mode when any of the - # four selectable real providers is missing its credentials. + # In steady-state production the Go API expects all four selectable real + # providers to be configured. For a temporary bootstrap, set + # ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true in the Go runtime ConfigMap; + # requests for an unconfigured provider will still return 503 until restart. VOLCENGINE_ACCESS_KEY_ID: REPLACE_WITH_VOLCENGINE_ACCESS_KEY_ID VOLCENGINE_SECRET_ACCESS_KEY: REPLACE_WITH_VOLCENGINE_SECRET_ACCESS_KEY EVOLINK_API_KEY: REPLACE_WITH_EVOLINK_API_KEY diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8efb17f..063ecf6 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -126,8 +126,11 @@ Kubernetes Secret: 生产配置的事实来源是 ACK Secret/ConfigMap。不要通过静态页面或修改 Pod 文件来更新 生产密钥;修改 Secret/ConfigMap 后滚动 Go Deployment。 -Go API 在 PostgreSQL 生产模式启动时会校验即梦、EvoLink、百炼和 Seedance 的真实凭据; -任一服务商缺少凭据都会拒绝启动,不会自动切换到占位或模拟生成。 +Go API 在 PostgreSQL 生产模式默认会校验即梦、EvoLink、百炼和 Seedance 的真实凭据; +任一服务商缺少凭据都会拒绝启动,不会自动切换到占位或模拟生成。若需要先上线配置页, +可在 Go API 的 ConfigMap 临时设置 `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true`:服务可以 +启动,但选中未配置服务商的报价和生成请求会返回 503。通过设置页填写凭据后,必须滚动重启 +Go Deployment,确认健康后再将该开关恢复为 `false`。 ## 探针与验收 @@ -162,7 +165,8 @@ curl https://你的域名/api/v1/openapi.json ```bash cp .env.example .env.local -# 将 ZHINIAN_DATA_BACKEND 保持为 postgres,并填写 DATABASE_URL、登录密钥和全部真实服务商凭据 +# 正常发布:保持 ZHINIAN_DATA_BACKEND=postgres,并填写 DATABASE_URL、登录密钥和真实服务商凭据 +# 临时引导:可将 ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS=true,先启动配置页;保存凭据后重启并恢复为 false ./scripts/deploy.sh ``` diff --git a/findings.md b/findings.md index 41d836b..de8b0d9 100644 --- a/findings.md +++ b/findings.md @@ -629,3 +629,9 @@ - `ProviderProcessor` now rejects a provider-reported success without an output URL before terminal finalization. The create page and result-asset task view also treat a succeeded job without a resolvable output asset as `结果同步中`, so neither frontend surface presents a false `已完成` state to the user. - `docs/API.md` and deployment/README guidance now describe the Go API + embedded Worker topology. The old `scripts/worker.mjs` remains only as legacy source and is no longer part of the checked-in Compose or package-script path. - Verification is limited by the host environment: `git diff --check` passes; Go, frontend dependency/typecheck/build, and Docker Compose execution still require a deployment/CI environment with those toolchains. + +### Production provider bootstrap requirement — 2026-08-18 +- PostgreSQL production startup currently calls `validateProductionProviderConfiguration` from `application.New` before the HTTP server is created, so missing any of the four provider credential groups terminates the process before it listens. +- The runtime settings endpoint persists provider secrets to `.env.local`/the configured settings file and reports `RestartRequired` for non-billing updates, but the provider registry and `ProviderJobBuilder` are created once during application composition; saved credentials therefore require a process restart. +- The safe bootstrap boundary is an explicit environment flag, `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS`, that bypasses only the startup guard. The real provider adapters remain the only production adapters, and a selected provider with missing credentials must fail before quote/creation with a clear service-unavailable response. +- Compose injects `.env.local` through `env_file`, and ACK injects runtime values through the Go API ConfigMap/Secret, so the bootstrap flag can be supplied by deployment configuration without exposing it as a mutable settings-panel field. diff --git a/progress.md b/progress.md index a3422dc..15b4c64 100644 --- a/progress.md +++ b/progress.md @@ -1593,3 +1593,22 @@ - Frontend TypeScript, all 54 Vitest files / 173 tests, Next production build, ACK manifest checks, contract JSON parsing, script syntax, and `git diff --check` passed. - Docker Compose was not executed because Docker CLI/daemon is unavailable in this host; live PostgreSQL/provider credentials and rollout checks remain for deployment. - **Status:** complete + +## Session: 2026-08-18 - Production Provider Bootstrap + +### Phase 79: Production Bootstrap Without Provider Credentials +- **Status:** in_progress +- User confirmed the temporary bootstrap flow: allow the production Go API to start without provider credentials, expose clear unavailable responses until credentials are saved, and require a restart for the saved credentials to take effect. +- Existing behavior is intentionally fail-closed in PostgreSQL mode at `application.New` before `ListenAndServe`; the change will be guarded by `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS` and will not restore Mock providers. +- Provider adapters are constructed once at startup, so the selected provider's configuration will be checked by the job builder for both quote and generation creation; this prevents billing/queueing work that cannot execute. +- First focused Go test attempt was blocked before compilation because the default `proxy.golang.org` IPv6 connection timed out while downloading `pgx`, `x/crypto`, and `x/text`; retrying with the repository's configured `goproxy.cn` proxy. +- The `goproxy.cn` retry downloaded dependencies and compiled, but Go 1.21.13 test binaries aborted on macOS with `dyld: missing LC_UUID load command`; `internal/settings` passed. Retrying with `CGO_ENABLED=0` before changing toolchains. +- With `CGO_ENABLED=0`, the focused Go packages passed. One follow-up formatting command repeated the `backend/` prefix while already in the backend working directory; it emitted a path warning but did not affect tests. Final formatting is run from the project root. + +### Phase 79 final verification +- Added `ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS`; it bypasses only PostgreSQL production startup credential validation and emits a warning. Mock providers remain disabled. +- Added hidden runtime-file loading for the bootstrap flag without exposing it as an editable settings-panel field. Added provider-specific availability checks to the job builder, so quote and generation creation return HTTP 503 before billing/queueing when the selected provider lacks credentials. +- Preserved the existing settings `RestartRequired` contract and documented the sequence: enable the flag, deploy, configure credentials, restart Go, verify health, then restore the flag to `false`. +- Verification passed: Go `test ./...`, `go vet ./...`, frontend Vitest 54 files/173 tests, TypeScript, Next production build, ACK manifest assertions, and `git diff --check`. +- The Go checks used an isolated official Go 1.21.13 toolchain with `CGO_ENABLED=0` because the host has no system Go and the default macOS test linker emitted `LC_UUID` errors; module downloads used the repository's `goproxy.cn` setting. Live PostgreSQL/provider connectivity and Docker rollout remain deployment-time checks. +- **Status:** complete diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 1f83b44..2d30f78 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -23,7 +23,7 @@ if command -v node >/dev/null 2>&1; then elif [ ! -f .env.local ]; then cp .env.example .env.local echo "[deploy] Created .env.local from .env.example" - echo "[deploy] Configure DATABASE_URL and all real provider credentials in .env.local before production use." + echo "[deploy] Configure DATABASE_URL and real provider credentials in .env.local before production use, or use the temporary ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS bootstrap flag." fi if ! grep -q '^ZHINIAN_INTERNAL_WORKER_TOKEN=' .env.local || grep -q '^ZHINIAN_INTERNAL_WORKER_TOKEN=$\|^ZHINIAN_INTERNAL_WORKER_TOKEN=change-me-worker-token$' .env.local; then diff --git a/scripts/ensure-deploy-env.mjs b/scripts/ensure-deploy-env.mjs index e4732ec..a76f8a5 100644 --- a/scripts/ensure-deploy-env.mjs +++ b/scripts/ensure-deploy-env.mjs @@ -15,7 +15,7 @@ if (!existsSync(envPath)) { } copyFileSync(examplePath, envPath); console.log("[deploy] Created .env.local from .env.example"); - console.log("[deploy] Configure DATABASE_URL and all real provider credentials in .env.local before production use."); + console.log("[deploy] Configure DATABASE_URL and real provider credentials in .env.local before production use, or use the temporary ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS bootstrap flag."); } let envText = readFileSync(envPath, "utf8"); diff --git a/task_plan.md b/task_plan.md index 15a786a..555118d 100644 --- a/task_plan.md +++ b/task_plan.md @@ -620,3 +620,17 @@ Phase 77 - Alibaba Cloud RDS PostgreSQL Adapter complete - [x] Add focused regression coverage for provider configuration and quote availability - [x] Verify Go tests, frontend tests/typecheck/build, Compose/deployment static checks, and diff hygiene - **Status:** complete; Docker/real-provider rollout verification remains deployment-time + +### Phase 79: Production Bootstrap Without Provider Credentials +- [x] Add an explicit temporary production bootstrap flag that bypasses only startup credential validation +- [x] Reject generation and quote requests clearly when the selected provider is still unconfigured +- [x] Preserve restart-required behavior after settings are saved and document the rollout sequence +- [x] Add focused regression coverage and run Go/frontend/documentation checks +- **Status:** complete; live database/provider rollout remains deployment-time + +## Errors Encountered +| Error | Attempt | Resolution | +| --- | --- | --- | +| Go module downloads timed out through `proxy.golang.org` over IPv6 | Phase 79 focused test attempt 1 | Retry with the `goproxy.cn` proxy used by `backend/Dockerfile.alpine`; no source failure reached yet | +| Go 1.21.13 test binaries aborted with macOS `dyld: missing LC_UUID` | Phase 79 focused test attempt 2 | Retry with `CGO_ENABLED=0`; if needed use a newer official Go toolchain | +| Follow-up `gofmt` path repeated `backend/` from the backend directory | Phase 79 focused verification | Run final `gofmt` from the project root; tests were unaffected |