feat: allow production provider bootstrap #2
@@ -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.
|
||||
|
||||
@@ -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 路由提供访问。
|
||||
|
||||
## 数据库
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 项目结构
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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": {},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
19
progress.md
19
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
14
task_plan.md
14
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 |
|
||||
|
||||
Reference in New Issue
Block a user