修改配置编辑不了的问题

This commit is contained in:
andy committed 2026-08-18 12:41:39 +08:00
1 parent 22b504e53e
commit 2a2f78c81e
42 files changed
+1082 -178

No files matched your search

+12 -23
View File
@@ -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)
@@ -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()
}
+105 -28
View File
@@ -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) {
+109 -27
View File
@@ -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
+3 -4
View File
@@ -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)
+17 -1
View File
@@ -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,
})
}
+7 -1
View File
@@ -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)
+40 -5
View File
@@ -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 {
+1
View File
@@ -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')
@@ -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 {
@@ -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
}
@@ -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
}
+177 -35
View File
@@ -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
}
+74 -5
View File
@@ -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)