package application import ( "context" "crypto/rand" "encoding/hex" "fmt" "io" "net/http" "path" "path/filepath" "strconv" "strings" "time" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/httpapi" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/logging" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/settings" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/webhook" ) func unavailableHandler(status int) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(status) }) } func firstNonEmpty(values ...string) string { for _, value := range values { if value = strings.TrimSpace(value); value != "" { return value } } return "" } func positiveInt64Env(getenv postgres.Getenv, name string, fallback int64) int64 { value, err := strconv.ParseInt(strings.TrimSpace(getenv(name)), 10, 64) if err != nil || value <= 0 { return fallback } return value } func durationEnv(getenv postgres.Getenv, name string, fallback time.Duration) time.Duration { milliseconds := positiveInt64Env(getenv, name, fallback.Milliseconds()) return time.Duration(milliseconds) * time.Millisecond } func parseBool(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "true", "yes", "on": return true default: return false } } func allowUnconfiguredProviders(getenv postgres.Getenv) bool { return parseBool(getenv("ZHINIAN_ALLOW_UNCONFIGURED_PROVIDERS")) } func applicationJobID() string { return applicationID("job") } func applicationID(prefix string) string { raw := make([]byte, 12) if _, err := rand.Read(raw); err == nil { return prefix + "-" + hex.EncodeToString(raw) } return prefix + "-" + strconv.FormatInt(time.Now().UnixNano(), 36) } func imageEngine(getenv postgres.Getenv) string { engine := strings.ToLower(firstNonEmpty(getenv("IMAGE_GENERATE_ENGINE"), getenv("IMAGE_CREATION_ENGINE"), getenv("IMAGE_PROVIDER"), "jimeng")) if engine != "evolink" && engine != "bailian" { return "jimeng" } return engine } func videoEngine(getenv postgres.Getenv) string { if strings.EqualFold(strings.TrimSpace(getenv("VIDEO_GENERATE_ENGINE")), "seedance") { return "seedance" } return "bailian" } func imageProvider(getenv postgres.Getenv) string { switch imageEngine(getenv) { case "evolink": return "evolink" case "bailian": return "bailian" default: return "volcengine-visual" } } func videoProvider(getenv postgres.Getenv) string { if videoEngine(getenv) == "seedance" { return "seedance" } return "bailian" } func imageModel(getenv postgres.Getenv) string { switch imageEngine(getenv) { case "evolink": return firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2") case "bailian": return firstNonEmpty(getenv("BAILIAN_IMAGE_MODEL"), "wan2.7-image-pro") default: return firstNonEmpty(getenv("JIMENG_IMAGE_GENERATE_46_REQ_KEY"), "jimeng_seedream46_cvtob") } } func videoModel(getenv postgres.Getenv) string { if videoEngine(getenv) == "seedance" { return firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128") } return firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25") } func bailianAPIKey(getenv postgres.Getenv) string { return firstNonEmpty(getenv("BAILIAN_API_KEY"), getenv("DASHSCOPE_API_KEY")) } func bailianNativeBaseURL(getenv postgres.Getenv) string { base := strings.TrimRight(firstNonEmpty(getenv("BAILIAN_BASE_URL"), "https://llm-126wneubbdo6dbr5.cn-beijing.maas.aliyuncs.com/compatible-mode/v1"), "/") lower := strings.ToLower(base) const suffix = "/compatible-mode/v1" if strings.HasSuffix(lower, suffix) { return base[:len(base)-len(suffix)] } return base } 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 jobs.ProviderRegistry{ "volcengine-visual": providers.NewVolcengine(providers.Config{ BaseURL: firstNonEmpty(getenv("VOLCENGINE_VISUAL_ENDPOINT"), "https://visual.volcengineapi.com"), Model: imageModel(getenv), AccessKeyID: getenv("VOLCENGINE_ACCESS_KEY_ID"), SecretAccessKey: getenv("VOLCENGINE_SECRET_ACCESS_KEY"), Region: firstNonEmpty(getenv("VOLCENGINE_REGION"), "cn-north-1"), Service: firstNonEmpty(getenv("VOLCENGINE_SERVICE"), "cv"), MaxResponseBytes: maximum, }, client, nil), "evolink": providers.NewEvoLink(providers.Config{BaseURL: firstNonEmpty(getenv("EVOLINK_BASE_URL"), "https://api.evolink.ai"), APIKey: getenv("EVOLINK_API_KEY"), Model: firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2"), MaxResponseBytes: maximum}, client), "bailian": providers.NewBailian(providers.Config{BaseURL: bailianNativeBaseURL(getenv), APIKey: bailianAPIKey(getenv), Model: firstNonEmpty(getenv("BAILIAN_IMAGE_MODEL"), "wan2.7-image-pro"), MaxResponseBytes: maximum}, client), "seedance": providers.NewSeedance(providers.Config{BaseURL: firstNonEmpty(getenv("SEEDANCE_BASE_URL"), "https://ark.cn-beijing.volces.com/api/v3"), APIKey: getenv("SEEDANCE_API_KEY"), Model: firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"), MaxResponseBytes: maximum}, client), } } func providerImageTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget { return map[string]jobs.ProviderTarget{ "jimeng": { Provider: "volcengine-visual", Model: firstNonEmpty(getenv("JIMENG_IMAGE_GENERATE_46_REQ_KEY"), "jimeng_seedream46_cvtob"), }, "evolink": { Provider: "evolink", Model: firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2"), }, "bailian": { Provider: "bailian", Model: firstNonEmpty(getenv("BAILIAN_IMAGE_MODEL"), "wan2.7-image-pro"), }, } } func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget { return map[string]jobs.ProviderTarget{ "seedance": { Provider: "seedance", Model: firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"), Settings: map[string]any{ "ratio": firstNonEmpty(getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"), "duration": float64(positiveInt64Env(getenv, "SEEDANCE_DEFAULT_DURATION", 5)), "resolution": firstNonEmpty(getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"), }, }, "bailian": { Provider: "bailian", Model: firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"), }, } } 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") } 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), webhook.NewPublicDestinationPolicy(nil, nil), ) } func capabilitySummary(getenv postgres.Getenv) func(context.Context) (any, error) { return func(context.Context) (any, error) { engine := imageEngine(getenv) provider := map[string]string{"jimeng": "volcengine-visual", "evolink": "evolink", "bailian": "bailian"}[engine] primaryVideoEngine := videoEngine(getenv) primaryVideo := map[string]any{ "id": "video.generate", "kind": "video", "engine": primaryVideoEngine, "provider": primaryVideoEngine, "reqKey": videoModel(getenv), } if primaryVideoEngine == "seedance" { primaryVideo["label"] = "Seedance 视频生成" primaryVideo["limits"] = seedanceCapabilityLimits() } else { primaryVideo["label"] = "百炼图生视频" primaryVideo["limits"] = bailianVideoCapabilityLimits() } return []any{ map[string]any{"id": "image.generate", "label": "图片生成 4.6", "kind": "image", "engine": engine, "provider": provider, "reqKey": imageModel(getenv)}, primaryVideo, map[string]any{"id": "video.generate.bailian", "label": "百炼图生视频", "kind": "video", "engine": "bailian", "provider": "bailian", "reqKey": firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"), "limits": bailianVideoCapabilityLimits()}, }, nil } } func seedanceCapabilityLimits() map[string]any { return map[string]any{"durationSeconds": map[string]int{"min": 4, "max": 15}, "ratios": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}, "resolutions": []string{"480p", "720p", "1080p"}} } func bailianVideoCapabilityLimits() map[string]any { return map[string]any{"inputImages": map[string]int{"min": 1, "max": 2}, "durationSeconds": map[string]int{"min": 2, "max": 15}, "resolutions": []string{"720P", "1080P"}} } func runtimeHealthDetails(getenv postgres.Getenv) httpapi.HealthDetails { image := imageEngine(getenv) imageLabel := map[string]string{"jimeng": "即梦", "evolink": "EvoLink", "bailian": "阿里云百炼"}[image] visualConfigured := strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) != "" && strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) != "" auth, _ := ParseAuthConfig(getenv) bailianKey := bailianAPIKey(getenv) authMode := "disabled" if auth.Required { authMode = "missing" if auth.Configured { authMode = "configured" } } return httpapi.HealthDetails{ VisualAPIMode: providerMode(visualConfigured, "volcengine"), EvolinkMode: providerMode(strings.TrimSpace(getenv("EVOLINK_API_KEY")) != "", "evolink"), SeedanceMode: providerMode(strings.TrimSpace(getenv("SEEDANCE_API_KEY")) != "", "seedance"), BailianMode: providerMode(bailianKey != "", "bailian"), 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")}, }, } } func providerMode(configured bool, live string) string { if !configured { return "missing" } return live } func remoteAssetMaxBytes(getenv postgres.Getenv) int64 { return positiveInt64Env(getenv, "ZHINIAN_REMOTE_ASSET_MAX_BYTES", 20<<20) } var runtimeSettingKeys = []string{ "ALI_OSS_ACCESS_KEY_ID", "ALI_OSS_ACCESS_KEY_SECRET", "ALI_OSS_BUCKET", "ALI_OSS_ENDPOINT", "ALI_OSS_PUBLIC_BASE_URL", "BAILIAN_API_KEY", "BAILIAN_BASE_URL", "BAILIAN_IMAGE_MODEL", "BAILIAN_VIDEO_MODEL", "DATABASE_URL", "DASHSCOPE_API_KEY", "EVOLINK_API_KEY", "EVOLINK_BASE_URL", "EVOLINK_IMAGE_MODEL", "EVOLINK_IMAGE_QUALITY", "IMAGE_GENERATE_ENGINE", "SEEDANCE_API_KEY", "SEEDANCE_MODEL", "VIDEO_GENERATE_ENGINE", "VOLCENGINE_ACCESS_KEY_ID", "VOLCENGINE_SECRET_ACCESS_KEY", "ZHINIAN_AUTH_REQUIRED", "ZHINIAN_AUTH_SESSION_SECRET", "ZHINIAN_BILLING_ACCOUNT_BANK", "ZHINIAN_BILLING_ACCOUNT_NAME", "ZHINIAN_BILLING_ACCOUNT_NUMBER", "ZHINIAN_BILLING_CONTACT", "ZHINIAN_BILLING_REQUIRED", } func runtimeEnvironment(getenv postgres.Getenv) map[string]string { values := make(map[string]string, len(runtimeSettingKeys)) for _, key := range runtimeSettingKeys { if value := getenv(key); value != "" { values[key] = value } } return values } func defaultSettingsService(getenv postgres.Getenv) *settings.Service { path := firstNonEmpty(getenv("ZHINIAN_SETTINGS_FILE"), ".env.local") return settings.New(path, runtimeEnvironment(getenv), nil) } type settingsBillingAccountStore struct{ service *settings.Service } func (store settingsBillingAccountStore) Load(ctx context.Context) (billing.AccountConfig, error) { value, err := store.service.Get(ctx) if err != nil { return billing.AccountConfig{}, err } payload, ok := value.(settings.Payload) if !ok { return billing.AccountConfig{}, fmt.Errorf("load billing account settings: unexpected settings payload") } fields := map[string]string{} for _, group := range payload.Groups { for _, field := range group.Fields { fields[field.Key] = field.Value } } return billing.AccountConfig{ AccountName: fields["ZHINIAN_BILLING_ACCOUNT_NAME"], BankName: fields["ZHINIAN_BILLING_ACCOUNT_BANK"], AccountNumber: fields["ZHINIAN_BILLING_ACCOUNT_NUMBER"], Contact: fields["ZHINIAN_BILLING_CONTACT"], }, nil } func (store settingsBillingAccountStore) Save(ctx context.Context, value billing.AccountConfig) error { _, err := store.service.Save(ctx, map[string]any{ "ZHINIAN_BILLING_ACCOUNT_NAME": value.AccountName, "ZHINIAN_BILLING_ACCOUNT_BANK": value.BankName, "ZHINIAN_BILLING_ACCOUNT_NUMBER": value.AccountNumber, "ZHINIAN_BILLING_CONTACT": value.Contact, }) return err } type logServiceAdapter struct{ service *logging.Service } func (adapter logServiceAdapter) List(ctx context.Context, filters httpapi.LogFilters) (any, error) { return adapter.service.List(ctx, logging.Filters{Level: filters.Level, Q: filters.Q, Source: filters.Source, Limit: filters.Limit}) } func (adapter logServiceAdapter) Clear(ctx context.Context) error { return adapter.service.Clear(ctx) } func (adapter logServiceAdapter) Append(ctx context.Context, input logging.Input) (logging.Entry, error) { return adapter.service.Append(ctx, input) } func defaultLogService(getenv postgres.Getenv) httpapi.LogService { runtimeDirectory := firstNonEmpty(getenv("ZHINIAN_RUNTIME_DIR"), ".runtime") logDirectory := firstNonEmpty(getenv("ZHINIAN_LOG_DIR"), filepath.Join(runtimeDirectory, "logs")) return logServiceAdapter{service: logging.New(filepath.Join(logDirectory, "server-events.jsonl"), positiveInt64Env(getenv, "ZHINIAN_LOG_MAX_BYTES", 5<<20), nil, nil)} } type prefixedBlobStore struct { prefix string store assets.BlobStore } func (store prefixedBlobStore) Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) (assets.StoredObject, error) { stored, err := store.store.Put(ctx, path.Join(store.prefix, key), body, size, contentType) if err != nil { return assets.StoredObject{}, err } // StoragePath is an application key used by /uploads and // /generated-results. The OSS namespace prefix is private to this adapter. stored.Key = key return stored, nil } func (store prefixedBlobStore) Read(ctx context.Context, key string) (assets.Blob, error) { return store.store.Read(ctx, path.Join(store.prefix, key)) } func (store prefixedBlobStore) Delete(ctx context.Context, key string) error { return store.store.Delete(ctx, path.Join(store.prefix, key)) } func configuredOSSBlobStore(getenv postgres.Getenv) (assets.BlobStore, bool, error) { endpoint, bucket := strings.TrimSpace(getenv("ALI_OSS_ENDPOINT")), strings.TrimSpace(getenv("ALI_OSS_BUCKET")) accessKeyID, secret := strings.TrimSpace(getenv("ALI_OSS_ACCESS_KEY_ID")), strings.TrimSpace(getenv("ALI_OSS_ACCESS_KEY_SECRET")) publicURL := strings.TrimSpace(getenv("ALI_OSS_PUBLIC_BASE_URL")) if endpoint == "" || bucket == "" || accessKeyID == "" || secret == "" || publicURL == "" { return nil, false, nil } client, err := assets.NewOSSHTTPClient(accessKeyID, secret, &http.Client{Timeout: durationEnv(getenv, "ZHINIAN_OSS_TIMEOUT_MS", 30*time.Second)}, nil) if err != nil { return nil, false, err } store, err := assets.NewOSS(assets.OSSConfig{Endpoint: endpoint, Bucket: bucket, PublicBaseURL: publicURL, PublicRead: true}, client) if err != nil { return nil, false, err } prefix := strings.Trim(strings.TrimSpace(getenv("ALI_OSS_PREFIX")), "/") if prefix == "" { prefix = "zhinian" } return prefixedBlobStore{prefix: prefix, store: store}, true, nil }