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

427 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}