Files
NianAIGC/backend/internal/application/runtime.go

434 lines
17 KiB
Go

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 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 mockEnabled(getenv postgres.Getenv, flag string, configured bool) bool {
switch strings.ToLower(strings.TrimSpace(getenv(flag))) {
case "1", "true":
return true
case "0", "false":
return false
default:
return !configured
}
}
func imageProvider(getenv postgres.Getenv) string {
switch imageEngine(getenv) {
case "evolink":
if mockEnabled(getenv, "EVOLINK_MOCK", strings.TrimSpace(getenv("EVOLINK_API_KEY")) != "") {
return "mock"
}
return "evolink"
case "bailian":
if mockEnabled(getenv, "BAILIAN_MOCK", bailianAPIKey(getenv) != "") {
return "mock"
}
return "bailian"
default:
configured := strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) != "" && strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) != ""
if mockEnabled(getenv, "JIMENG_VISUAL_MOCK", configured) {
return "mock"
}
return "volcengine-visual"
}
}
func videoProvider(getenv postgres.Getenv) string {
if videoEngine(getenv) == "seedance" {
if mockEnabled(getenv, "SEEDANCE_MOCK", strings.TrimSpace(getenv("SEEDANCE_API_KEY")) != "") {
return "mock"
}
return "seedance"
}
if mockEnabled(getenv, "BAILIAN_MOCK", bailianAPIKey(getenv) != "") {
return "mock"
}
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{
"mock": providers.NewMock(firstNonEmpty(getenv("ZHINIAN_MOCK_SEED"), "zhinian")),
"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 {
jimengConfigured := strings.TrimSpace(getenv("VOLCENGINE_ACCESS_KEY_ID")) != "" && strings.TrimSpace(getenv("VOLCENGINE_SECRET_ACCESS_KEY")) != ""
return map[string]jobs.ProviderTarget{
"jimeng": {
Provider: providerOrMock("volcengine-visual", mockEnabled(getenv, "JIMENG_VISUAL_MOCK", jimengConfigured)),
Model: firstNonEmpty(getenv("JIMENG_IMAGE_GENERATE_46_REQ_KEY"), "jimeng_seedream46_cvtob"),
},
"evolink": {
Provider: providerOrMock("evolink", mockEnabled(getenv, "EVOLINK_MOCK", strings.TrimSpace(getenv("EVOLINK_API_KEY")) != "")),
Model: firstNonEmpty(getenv("EVOLINK_IMAGE_MODEL"), "gpt-image-2"),
},
"bailian": {
Provider: providerOrMock("bailian", mockEnabled(getenv, "BAILIAN_MOCK", bailianAPIKey(getenv) != "")),
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: providerOrMock("seedance", mockEnabled(getenv, "SEEDANCE_MOCK", strings.TrimSpace(getenv("SEEDANCE_API_KEY")) != "")),
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: providerOrMock("bailian", mockEnabled(getenv, "BAILIAN_MOCK", bailianAPIKey(getenv) != "")),
Model: firstNonEmpty(getenv("BAILIAN_VIDEO_MODEL"), "wan2.7-i2v-2026-04-25"),
},
}
}
func providerOrMock(provider string, mocked bool) string {
if mocked {
return "mock"
}
return provider
}
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)
bailianMode := "missing"
if mockFlagEnabled(getenv("BAILIAN_MOCK")) {
bailianMode = "mock"
} else if bailianKey != "" {
bailianMode = "bailian"
}
authMode := "disabled"
if auth.Required {
authMode = "missing"
if auth.Configured {
authMode = "configured"
}
}
return httpapi.HealthDetails{
VisualAPIMode: providerMode(mockEnabled(getenv, "JIMENG_VISUAL_MOCK", visualConfigured), "volcengine"),
EvolinkMode: providerMode(mockEnabled(getenv, "EVOLINK_MOCK", strings.TrimSpace(getenv("EVOLINK_API_KEY")) != ""), "evolink"),
SeedanceMode: providerMode(mockEnabled(getenv, "SEEDANCE_MOCK", strings.TrimSpace(getenv("SEEDANCE_API_KEY")) != ""), "seedance"),
BailianMode: bailianMode,
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(mock bool, live string) string {
if mock {
return "mock"
}
return live
}
func mockFlagEnabled(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true":
return true
default:
return false
}
}
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
}