修改配置编辑不了的问题

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