Files
NianAIGC/backend/internal/application/application.go
2026-08-18 00:36:05 +08:00

405 lines
16 KiB
Go

// Package application composes the Go backend foundation Modules.
package application
import (
"context"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/administration"
"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/identity"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/localstore"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/orchestration"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/prompt"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/templates"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/usage"
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/webhook"
)
type Options struct {
Context context.Context
Getenv postgres.Getenv
ReadFile postgres.ReadFile
// AuthorizationLoader is an optional Identity persistence Adapter used by
// composition tests and alternate runtime backends. Production defaults to
// the PostgreSQL Store opened below.
AuthorizationLoader identity.AuthorizationSnapshotLoader
// CredentialAuthenticator is the narrow Password Login persistence seam.
// Production defaults to the same PostgreSQL Store used for authorization.
CredentialAuthenticator identity.CredentialAuthenticator
// BlobStore and RemoteFetcher support storage-specific integration tests and
// alternate deployments. Production defaults to the hardened local store;
// a fully configured OSS environment is composed below.
BlobStore assets.BlobStore
RemoteFetcher assets.RemoteFetcher
// ProviderRegistry can replace all external adapters in deterministic tests.
ProviderRegistry jobs.ProviderRegistry
// Log adapters remain injectable for deterministic composition tests.
// Runtime settings intentionally have one concrete source so /api/settings
// and billing account endpoints cannot observe different stores.
Logs httpapi.LogService
}
type App struct {
handler http.Handler
db *postgres.Module
worker *jobs.WorkerLoop
}
func New(options Options) (*App, error) {
ctx := options.Context
if ctx == nil {
ctx = context.Background()
}
getenv := options.Getenv
if getenv == nil {
getenv = os.Getenv
}
readFile := options.ReadFile
if readFile == nil {
readFile = os.ReadFile
}
authConfig, err := ParseAuthConfig(getenv)
if err != nil {
return nil, err
}
config, err := postgres.ParseConfig(getenv, readFile)
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
}
closeOnError := true
defer func() {
if closeOnError {
database.Close()
}
}()
readiness := databaseReadiness{config: config, store: database.Store}
// PostgreSQL remains the production source of truth. Local mode swaps every
// business persistence port as one coherent process-local unit so modules do
// not accidentally call the nil-backed PostgreSQL shell used for readiness.
var authorizationStore identity.AuthorizationSnapshotLoader = database.Store
var credentialStore identity.CredentialAuthenticator = database.Store
var passwordChangeStore identity.PasswordChanger = database.Store
var administrationStore administration.Store = database.Store
var assetCatalog assets.Catalog = database.Store
var billingStore billing.Store = database.Store
var walletPoster billing.WalletPoster = postgres.NewBillingWalletPoster(database.Store)
var usageRepository usage.Repository = postgres.NewUsageRepository(database.Store)
var templateCatalog templates.Catalog = database.Store
var jobStore jobs.Store = database.Store
var creationState orchestration.CreationStateWriter = database.Store
var jobState orchestration.JobStateWriter = database.Store
var settlementState orchestration.SettlementStateWriter = database.Store
if config.Backend == postgres.BackendLocal {
store := localstore.New()
authorizationStore = store
credentialStore = store
passwordChangeStore = store
administrationStore = store
assetCatalog = store
billingStore = store
walletPoster = store
usageRepository = store
templateCatalog = store
jobStore = store
creationState = store
jobState = store
settlementState = store
}
var resolver httpapi.SessionResolver
if authConfig.Configured {
loader := options.AuthorizationLoader
if loader == nil {
loader = authorizationStore
}
resolver = identity.NewResolver(loader, authConfig.SessionSecret, "platform", nil)
}
authState := httpapi.AuthState{Required: authConfig.Required, Configured: authConfig.Configured}
platformAuthorizer, err := httpapi.NewPlatformAuthorizer(
authState,
resolver,
httpapi.WithLocalDevelopmentFallback(config.Backend == postgres.BackendLocal && !strings.EqualFold(strings.TrimSpace(getenv("NODE_ENV")), "production")),
)
if err != nil {
return nil, err
}
authMe, err := httpapi.NewAuthMeHandler(httpapi.AuthState{
Required: authConfig.Required, Configured: authConfig.Configured,
}, resolver)
if err != nil {
return nil, err
}
var passwordIssuer httpapi.PasswordSessionIssuer
if authConfig.Configured {
authenticator := options.CredentialAuthenticator
if authenticator == nil {
authenticator = credentialStore
}
passwordIssuer = identity.NewPasswordLogin(authenticator, nil)
}
cookieSecure := getenv("ZHINIAN_AUTH_COOKIE_SECURE")
publicBaseURL := firstAuthEnv(getenv, "NEXT_PUBLIC_APP_URL", "ZHINIAN_PUBLIC_BASE_URL")
authPassword, err := httpapi.NewAuthPasswordHandler(httpapi.PasswordAuthConfig{
Configured: authConfig.Configured,
SessionSecret: authConfig.SessionSecret,
CookieSecure: cookieSecure,
PublicBaseURL: publicBaseURL,
}, passwordIssuer)
if err != nil {
return nil, err
}
authLogout := httpapi.NewAuthLogoutHandler(httpapi.LogoutConfig{
CookieSecure: cookieSecure,
PublicBaseURL: publicBaseURL,
})
authCompatibility := httpapi.NewAuthCompatibilityHandler()
publicAuthenticator := publicapi.NewAuthenticator(publicapi.Config{
APIKeys: getenv("ZHINIAN_API_KEYS"),
InternalWorkerToken: getenv("ZHINIAN_INTERNAL_WORKER_TOKEN"),
Production: strings.EqualFold(strings.TrimSpace(getenv("NODE_ENV")), "production"),
})
administrationService := administration.NewService(administrationStore)
if created, err := BootstrapSuperAdmin(ctx, config.Backend, administrationService, ParseBootstrapAdminConfig(getenv)); err != nil {
return nil, fmt.Errorf("bootstrap super administrator: %w", err)
} else if created {
log.Printf("zhinian-api bootstrapped the first super administrator from ZHINIAN_BOOTSTRAP_ADMIN_* configuration")
}
adminHandler, err := httpapi.NewAdminHandler(platformAuthorizer, administrationService)
if err != nil {
return nil, err
}
var passwordChangeHandler http.Handler = unavailableHandler(http.StatusServiceUnavailable)
if authConfig.Configured {
passwordChanger := identity.NewPasswordChange(passwordChangeStore, nil)
passwordChangeHandler, err = httpapi.NewAuthPasswordChangeHandler(httpapi.PasswordChangeConfig{
SessionSecret: authConfig.SessionSecret, CookieSecure: cookieSecure, PublicBaseURL: publicBaseURL,
}, platformAuthorizer, passwordChanger)
if err != nil {
return nil, err
}
}
blobStore := options.BlobStore
if blobStore == nil {
var configured bool
blobStore, configured, err = configuredOSSBlobStore(getenv)
if err != nil {
return nil, err
}
if !configured {
runtimeDirectory := strings.TrimSpace(getenv("ZHINIAN_RUNTIME_DIR"))
if runtimeDirectory == "" {
runtimeDirectory = filepath.Join(".runtime")
}
blobStore, err = assets.NewLocalFS(runtimeDirectory, firstNonEmpty(publicBaseURL, "http://127.0.0.1:3000"))
if err != nil {
return nil, err
}
}
}
remoteFetcher := options.RemoteFetcher
if remoteFetcher == nil {
remoteFetcher, err = assets.NewPublicHTTPRemoteFetcher(
30*time.Second,
remoteAssetMaxBytes(getenv),
assets.NewPublicDestinationPolicy(nil, nil),
)
if err != nil {
return nil, err
}
}
assetService := assets.NewService(assetCatalog, blobStore, remoteFetcher, nil, nil)
assetsHandler, err := httpapi.NewAssetsHandler(assetService, platformAuthorizer, publicAuthenticator, httpapi.AssetsConfig{
MaxJSONBytes: positiveInt64Env(getenv, "ZHINIAN_MAX_JSON_BYTES", 1<<20), MaxUploadBytes: positiveInt64Env(getenv, "ZHINIAN_MAX_UPLOAD_BYTES", 20<<20),
})
if err != nil {
return nil, err
}
billingService := billing.NewService(billingStore, nil).SetEnabled(strings.TrimSpace(getenv("ZHINIAN_BILLING_REQUIRED")) != "0")
runtimeSettings := defaultSettingsService(getenv)
billingAccounts := settingsBillingAccountStore{service: runtimeSettings}
templateService := templates.NewService(templateCatalog, nil, nil)
logService := options.Logs
if logService == nil {
logService = defaultLogService(getenv)
}
var eventLogger EventLogger
if candidate, ok := logService.(EventLogger); ok {
eventLogger = candidate
}
miscHandler, err := httpapi.NewMiscHandler(httpapi.MiscDependencies{
Platform: platformAuthorizer, Templates: templateService, PromptAssembler: prompt.Assemble,
Settings: runtimeSettings, Logs: logService, Public: publicAuthenticator,
Capabilities: capabilitySummary(getenv), 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)
}
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,
}
usageService := usage.Service{
Repository: usageRepository,
OrganizationOptions: usage.OrganizationOptionSourceFunc(func(ctx context.Context, requester usage.Requester) ([]usage.Option, error) {
organizations, listErr := administrationService.ListOrganizations(ctx, administration.Actor{
ID: requester.AccountID, Role: administration.Role(requester.Role), OrganizationID: requester.OrganizationID,
})
if listErr != nil {
return nil, listErr
}
options := make([]usage.Option, len(organizations))
for index, organization := range organizations {
options[index] = usage.Option{Value: organization.ID, Label: organization.Name}
}
return options, nil
}),
}
usageHandler := httpapi.NewUsageHandler(platformAuthorizer, usageService, nil)
billingHandler := httpapi.NewBillingHandlerWithBuilder(platformAuthorizer, billingService, billingAccounts, jobBuilder)
ledger := billing.Ledger{Poster: walletPoster, NewID: func() string { return applicationID("ledger") }}
creation := orchestration.NewCreationCoordinator(jobBuilder, jobService, billingService, ledger, creationState)
refunds := orchestration.NewTerminalRefund(ledger, jobState)
usageRecorder := orchestration.NewUsageRecorder(usageService, func() string { return applicationID("usage") }, nil)
webhookSender, err := defaultWebhookSender(getenv)
if err != nil {
return nil, err
}
webhookBridge := orchestration.NewWebhookBridge(webhook.NewDeliverer(webhookSender, getenv("ZHINIAN_WEBHOOK_SECRET"), nil))
outputs := orchestration.NewAssetOutputRegistrar(assetService, orchestration.ResolveProviderOutputURLs)
providerProcessor := jobs.ProviderProcessor{Providers: providerRegistry, Store: jobStore}
settlementProcessor := orchestration.NewSettlementProcessor(providerProcessor, ledger, settlementState, nil)
processor := orchestration.NewOutputRegisteringProcessor(settlementProcessor, outputs, jobState)
artifacts := orchestration.NewAssetArtifacts(assetService)
worker := jobs.NewWorker(jobStore, processor, refunds, usageRecorder, webhookBridge, jobs.WorkerConfig{
BatchSize: int(positiveInt64Env(getenv, "ZHINIAN_WORKER_BATCH_SIZE", 3)),
LockTimeoutSeconds: int(positiveInt64Env(getenv, "ZHINIAN_WORKER_LOCK_TIMEOUT_SECONDS", 300)),
PollInterval: durationEnv(getenv, "ZHINIAN_WORKER_POLL_INTERVAL_MS", 5*time.Second),
}, nil)
jobsHandler, err := httpapi.NewJobsHandler(httpapi.JobsDependencies{
Service: jobService, Platform: platformAuthorizer, Public: publicAuthenticator,
Builder: httpapi.ProviderBuilderAdapter{Builder: jobBuilder}, Creation: creation, Refunds: refunds, Artifacts: artifacts, Worker: WithTickEventLogging(worker, eventLogger),
}, httpapi.JobsConfig{MaxJSONBytes: positiveInt64Env(getenv, "ZHINIAN_MAX_JSON_BYTES", 1<<20), NewID: applicationJobID})
if err != nil {
return nil, err
}
var workerLoop *jobs.WorkerLoop
if parseBool(getenv("ZHINIAN_GO_EMBEDDED_WORKER")) {
workerLoop = jobs.NewWorkerLoop(WithTickEventLogging(worker, eventLogger), jobs.LoopConfig{
Interval: durationEnv(getenv, "ZHINIAN_WORKER_POLL_INTERVAL_MS", 5*time.Second), WorkerID: firstNonEmpty(getenv("ZHINIAN_WORKER_ID"), "embedded-worker"),
})
workerLoop.Start(ctx)
}
foundation := httpapi.NewHandler(readiness, httpapi.WithHealthDetails(runtimeHealthDetails(getenv)))
mux := http.NewServeMux()
mux.Handle("/api/auth/me", authMe)
mux.Handle("/api/auth/password", authPassword)
mux.Handle("/api/auth/logout", authLogout)
mux.Handle("/api/auth/password/change", passwordChangeHandler)
mux.Handle("/api/auth/login", authCompatibility)
mux.Handle("/api/auth/callback", authCompatibility)
mux.Handle("/api/auth/captcha", authCompatibility)
mux.Handle("/api/admin/accounts", adminHandler)
mux.Handle("/api/admin/accounts/", adminHandler)
mux.Handle("/api/admin/organizations", adminHandler)
mux.Handle("/api/assets", assetsHandler)
mux.Handle("/api/assets/", assetsHandler)
mux.Handle("/api/v1/assets", assetsHandler)
mux.Handle("/api/v1/assets/", assetsHandler)
mux.Handle("/uploads/", assetsHandler)
mux.Handle("/generated-results/", assetsHandler)
mux.Handle("/api/billing", billingHandler)
mux.Handle("/api/billing/", billingHandler)
mux.Handle("/api/admin/billing", billingHandler)
mux.Handle("/api/admin/billing/", billingHandler)
mux.Handle("/api/usage", usageHandler)
mux.Handle("/api/admin/usage", usageHandler)
mux.Handle("/api/generations/", jobsHandler)
mux.Handle("/api/v1/jobs", jobsHandler)
mux.Handle("/api/v1/jobs/", jobsHandler)
mux.Handle("/api/internal/worker/tick", jobsHandler)
mux.Handle("/api/image-templates", miscHandler)
mux.Handle("/api/image-templates/", miscHandler)
mux.Handle("/api/prompt/assemble", miscHandler)
mux.Handle("/api/settings", miscHandler)
mux.Handle("/api/logs", miscHandler)
mux.Handle("/api/v1/capabilities", miscHandler)
mux.Handle("/api/v1/openapi.json", miscHandler)
mux.Handle("/", foundation)
closeOnError = false
return &App{
db: database, handler: WithHTTPEventLogging(httpapi.WithRouteMethodCompatibility(mux), eventLogger), worker: workerLoop,
}, nil
}
func (app *App) Handler() http.Handler {
return app.handler
}
func (app *App) Close() {
if app.worker != nil {
app.worker.Stop()
}
if app.db != nil {
app.db.Close()
}
}
type databaseReadiness struct {
config postgres.Config
store *postgres.Store
}
func (adapter databaseReadiness) Status() httpapi.DatabaseStatus {
configured := adapter.config.Backend == postgres.BackendLocal || adapter.config.DatabaseURL != ""
return httpapi.DatabaseStatus{
Backend: string(adapter.config.Backend),
Configured: configured,
}
}
func (adapter databaseReadiness) Ready(ctx context.Context) (httpapi.DatabaseStatus, error) {
status := adapter.Status()
return status, adapter.store.Readiness(ctx)
}