575 lines
23 KiB
Go
575 lines
23 KiB
Go
package application
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"errors"
|
||
"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 {
|
||
return positiveInt64Value(getenv(name), fallback)
|
||
}
|
||
|
||
func positiveInt64Value(raw string, fallback int64) int64 {
|
||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 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 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" && engine != "seedream" {
|
||
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"
|
||
case "seedream":
|
||
return "seedream"
|
||
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")
|
||
case "seedream":
|
||
return providers.Seedream50ProModel
|
||
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 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"),
|
||
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),
|
||
"seedream": providers.NewSeedream(providers.Config{BaseURL: firstNonEmpty(getenv("SEEDANCE_BASE_URL"), "https://ark.cn-beijing.volces.com/api/v3"), APIKey: getenv("SEEDANCE_API_KEY"), Model: providers.Seedream50ProModel, 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"),
|
||
Settings: map[string]any{"quality": firstNonEmpty(getenv("EVOLINK_IMAGE_QUALITY"), "medium")},
|
||
},
|
||
"bailian": {
|
||
Provider: "bailian",
|
||
Model: firstNonEmpty(getenv("BAILIAN_IMAGE_MODEL"), "wan2.7-image-pro"),
|
||
},
|
||
"seedream": {
|
||
Provider: "seedream",
|
||
Model: providers.Seedream50ProModel,
|
||
Settings: map[string]any{"size": "1.5K", "outputFormat": "png", "optimizeMode": "standard", "imageCount": float64(1)},
|
||
},
|
||
}
|
||
}
|
||
|
||
func providerVideoTargets(getenv postgres.Getenv) map[string]jobs.ProviderTarget {
|
||
seedanceModel := firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128")
|
||
return map[string]jobs.ProviderTarget{
|
||
"seedance": {
|
||
Provider: "seedance",
|
||
Model: seedanceModel,
|
||
Settings: seedanceDefaultSettings(getenv, seedanceModel),
|
||
},
|
||
"bailian": {
|
||
Provider: "bailian",
|
||
Model: firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"),
|
||
},
|
||
}
|
||
}
|
||
|
||
func providerVideoModels(getenv postgres.Getenv) map[string]jobs.ProviderTarget {
|
||
models := []string{
|
||
"doubao-seedance-2-0-260128",
|
||
"doubao-seedance-2-5-260628",
|
||
firstNonEmpty(getenv("SEEDANCE_MODEL"), "doubao-seedance-2-0-260128"),
|
||
}
|
||
targets := make(map[string]jobs.ProviderTarget, len(models))
|
||
for _, model := range models {
|
||
targets[model] = jobs.ProviderTarget{Provider: "seedance", Model: model, Settings: seedanceDefaultSettings(getenv, model)}
|
||
}
|
||
return targets
|
||
}
|
||
|
||
func seedanceDefaultSettings(getenv postgres.Getenv, model string) map[string]any {
|
||
duration := positiveInt64Value(firstNonEmpty(getenv("SEEDANCE_DURATION"), getenv("SEEDANCE_DEFAULT_DURATION")), 5)
|
||
maximum := int64(15)
|
||
if model == "doubao-seedance-2-5-260628" {
|
||
maximum = 30
|
||
}
|
||
if duration < 4 {
|
||
duration = 4
|
||
} else if duration > maximum {
|
||
duration = maximum
|
||
}
|
||
return map[string]any{
|
||
"ratio": firstNonEmpty(getenv("SEEDANCE_RATIO"), getenv("SEEDANCE_DEFAULT_RATIO"), "9:16"),
|
||
"duration": float64(duration),
|
||
"resolution": firstNonEmpty(getenv("SEEDANCE_RESOLUTION"), getenv("SEEDANCE_DEFAULT_RESOLUTION"), "720p"),
|
||
}
|
||
}
|
||
|
||
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。"
|
||
missing["seedream"] = "Seedream 服务商未配置,请先配置火山方舟 API Key(SEEDANCE_API_KEY)。"
|
||
}
|
||
return 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", "seedream": "seedream"}[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(videoModel(getenv))
|
||
primaryVideo["models"] = seedanceModelOptions()
|
||
} else {
|
||
primaryVideo["label"] = "百炼图生视频"
|
||
primaryVideo["limits"] = bailianVideoCapabilityLimits()
|
||
}
|
||
return []any{
|
||
map[string]any{"id": "image.generate", "label": imageCapabilityLabel(engine), "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(model string) map[string]any {
|
||
maximum := 15
|
||
if model == "doubao-seedance-2-5-260628" {
|
||
maximum = 30
|
||
}
|
||
return map[string]any{"durationSeconds": map[string]int{"min": 4, "max": maximum}, "ratios": []string{"16:9", "4:3", "1:1", "3:4", "9:16", "21:9", "adaptive"}, "resolutions": []string{"480p", "720p", "1080p"}}
|
||
}
|
||
|
||
func seedanceModelOptions() []any {
|
||
return []any{
|
||
map[string]any{"id": "doubao-seedance-2-0-260128", "label": "Seedance 2.0", "limits": seedanceCapabilityLimits("doubao-seedance-2-0-260128")},
|
||
map[string]any{"id": "doubao-seedance-2-5-260628", "label": "Seedance 2.5", "limits": seedanceCapabilityLimits("doubao-seedance-2-5-260628")},
|
||
}
|
||
}
|
||
|
||
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": "阿里云百炼", "seedream": "Seedream 5.0 Pro"}[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)
|
||
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": imageCapabilityLabel(image), "engine": image, "engineLabel": imageLabel, "reqKey": imageModel(getenv)},
|
||
map[string]any{"id": "video.generate", "label": "视频生成", "engine": video, "engineLabel": videoLabel, "reqKey": videoModel(getenv), "models": seedanceModelOptions()},
|
||
},
|
||
}
|
||
}
|
||
|
||
func imageCapabilityLabel(engine string) string {
|
||
if engine == "seedream" {
|
||
return "Seedream 5.0 Pro 图片生成"
|
||
}
|
||
return "图片生成 4.6"
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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), VideoModels: providerVideoModels(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) {
|
||
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 (store prefixedBlobStore) SignReadURL(key string, ttl time.Duration) (string, error) {
|
||
signer, ok := store.store.(assets.BlobURLSigner)
|
||
if !ok {
|
||
return "", errors.New("blob store does not support signed URLs")
|
||
}
|
||
return signer.SignReadURL(path.Join(store.prefix, key), ttl)
|
||
}
|
||
|
||
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: false}, 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
|
||
}
|