package runner import ( "context" "crypto/sha256" "encoding/hex" "fmt" "net/http" "net/url" "regexp" "strings" "github.com/easyai/easyai-ai-gateway/apps/api/internal/auth" "github.com/easyai/easyai-ai-gateway/apps/api/internal/clients" "github.com/easyai/easyai-ai-gateway/apps/api/internal/store" ) var portraitAssetPlaceholderPattern = regexp.MustCompile(`(?i)<<<[[:space:]]*portrait[_-]?asset_([0-9]+)[[:space:]]*>>>|@portrait_asset([0-9]+)|@人像资产([0-9]+)`) type PortraitAssetCapability struct { Enabled bool `json:"enabled"` CanUse bool `json:"canUse"` CanCreate bool `json:"canCreate"` CanUseAsPortraitAsset bool `json:"canUseAsPortraitAsset"` CanUseAsPlainMaterial bool `json:"canUseAsPlainMaterial"` AvailablePlatformIDs []string `json:"availablePlatformIds"` CreationPlatformIDs []string `json:"creationPlatformIds"` CanReferenceTencentAsset bool `json:"canReferenceTencentAssetUri"` Reason string `json:"reason,omitempty"` } type PortraitAssetCreateInput struct { Name string Description string SourceType string URL string Preview string MimeType string ByteSize int64 SourceSHA256 string PrivateAvatarEligible bool Metadata map[string]any } type PortraitAssetSyncResponse struct { Requested int `json:"requested"` Accepted int `json:"accepted"` SyncedIDs []string `json:"syncedIds"` Skipped []PortraitAssetIssue `json:"skipped"` Failed []PortraitAssetIssue `json:"failed"` Assets []store.PortraitAsset `json:"assets"` } type PortraitAssetIssue struct { ID string `json:"id"` Reason string `json:"reason"` } type portraitAssetPlatformSettings struct { ProjectName string AssetGroupID string Credentials clients.VolcesAssetCredentials } func (s *Service) PortraitAssetCapability(ctx context.Context) (PortraitAssetCapability, error) { platforms, err := s.store.ListPortraitAssetPlatforms(ctx) if err != nil { return PortraitAssetCapability{}, err } ids := make([]string, 0, len(platforms)) for _, platform := range platforms { if _, ok := portraitAssetSettings(platform); ok { ids = append(ids, platform.PlatformID) } } capability := PortraitAssetCapability{ Enabled: len(ids) > 0, CanUse: len(ids) > 0, CanCreate: len(ids) > 0, CanUseAsPortraitAsset: len(ids) > 0, CanUseAsPlainMaterial: true, AvailablePlatformIDs: ids, CreationPlatformIDs: ids, } if len(ids) == 0 { capability.Reason = "未配置可用的火山 Seedance 人像资产平台;请在 Volces 平台 config.seedancePrivateAsset 中配置 enabled、accessKey、secretKey、projectName、assetGroupId。" } return capability, nil } func (s *Service) CreatePortraitAsset(ctx context.Context, user *auth.User, input PortraitAssetCreateInput) (store.PortraitAsset, bool, error) { if s.store == nil { return store.PortraitAsset{}, false, fmt.Errorf("portrait asset store is unavailable") } if !validPortraitAssetSourceType(input.SourceType) { return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_unsupported_type", Message: "source type must be image, video, or audio", StatusCode: http.StatusBadRequest, Retryable: false} } if strings.TrimSpace(input.URL) == "" { return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_source_url_required", Message: "portrait asset source URL is required", StatusCode: http.StatusBadRequest, Retryable: false} } if !input.PrivateAvatarEligible { return store.PortraitAsset{}, false, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "private_avatar_eligible must be true after the user confirms authorization", StatusCode: http.StatusBadRequest, Retryable: false} } if existing, found, err := s.store.FindPortraitAssetBySourceHash(ctx, user, input.SourceSHA256); err != nil { return store.PortraitAsset{}, false, err } else if found { return existing, true, nil } gatewayUserID, userID := portraitAssetUserKeys(user) if user == nil || userID == "" { return store.PortraitAsset{}, false, store.ErrLocalUserRequired } asset, err := s.store.CreatePortraitAsset(ctx, store.PortraitAssetInput{ GatewayUserID: gatewayUserID, UserID: userID, GatewayTenantID: strings.TrimSpace(user.GatewayTenantID), TenantID: strings.TrimSpace(user.TenantID), TenantKey: strings.TrimSpace(user.TenantKey), Name: strings.TrimSpace(input.Name), Description: strings.TrimSpace(input.Description), SourceType: strings.ToLower(strings.TrimSpace(input.SourceType)), URL: strings.TrimSpace(input.URL), Preview: firstNonEmptyString(strings.TrimSpace(input.Preview), strings.TrimSpace(input.URL)), MimeType: strings.TrimSpace(input.MimeType), ByteSize: input.ByteSize, SourceSHA256: strings.TrimSpace(input.SourceSHA256), PrivateAvatarEligible: input.PrivateAvatarEligible, Metadata: input.Metadata, }) return asset, false, err } func (s *Service) SyncPortraitAssets(ctx context.Context, user *auth.User, ids []string) (PortraitAssetSyncResponse, error) { response := PortraitAssetSyncResponse{ Requested: len(ids), SyncedIDs: make([]string, 0), Skipped: make([]PortraitAssetIssue, 0), Failed: make([]PortraitAssetIssue, 0), Assets: make([]store.PortraitAsset, 0), } platforms, err := s.store.ListPortraitAssetPlatforms(ctx) if err != nil { return response, err } configured := make([]store.PortraitAssetPlatform, 0, len(platforms)) for _, platform := range platforms { if _, ok := portraitAssetSettings(platform); ok { configured = append(configured, platform) } } seen := map[string]bool{} for _, value := range ids { assetID := strings.TrimSpace(value) if assetID == "" || seen[assetID] { continue } seen[assetID] = true asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID) if err != nil { return response, err } if !found { response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: assetID, Reason: "portrait asset not found"}) continue } if !asset.PrivateAvatarEligible { response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: "portrait asset authorization is required"}) continue } if len(configured) == 0 { _ = s.store.UpdatePortraitAssetStatus(ctx, asset.ID, "not_configured", "no configured Volces portrait asset platform") asset.Status = "not_configured" asset.LastError = "no configured Volces portrait asset platform" response.Skipped = append(response.Skipped, PortraitAssetIssue{ID: asset.ID, Reason: asset.LastError}) response.Assets = append(response.Assets, asset) continue } response.Accepted++ assetFailed := false for _, platform := range configured { if err := s.syncPortraitAssetToPlatform(ctx, asset, platform); err != nil { assetFailed = true response.Failed = append(response.Failed, PortraitAssetIssue{ID: asset.ID, Reason: platform.PlatformID + ": " + err.Error()}) } } updated, _, err := s.refreshPortraitAssetStatus(ctx, user, asset.ID) if err != nil { return response, err } response.Assets = append(response.Assets, updated) if !assetFailed { response.SyncedIDs = append(response.SyncedIDs, updated.ID) } } return response, nil } func (s *Service) syncPortraitAssetToPlatform(ctx context.Context, asset store.PortraitAsset, platform store.PortraitAssetPlatform) error { settings, ok := portraitAssetSettings(platform) if !ok { return &clients.ClientError{Code: "portrait_asset_not_configured", Message: "platform portrait asset configuration is incomplete", Retryable: false} } binding, found, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, platform.PlatformID) if err != nil { return err } if !found { binding = store.PortraitAssetBinding{AssetID: asset.ID, PlatformID: platform.PlatformID, ProjectName: settings.ProjectName, AssetGroupID: settings.AssetGroupID, Status: "pending"} } if !portraitAssetHasPublicURL(asset.URL) { return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{ Code: "portrait_asset_public_url_required", Message: "portrait asset URL must be an absolute http(s) URL reachable by Volces", StatusCode: http.StatusBadRequest, Retryable: false, }) } client := clients.VolcesAssetClient{HTTPClient: s.portraitAssetHTTPClient()} remoteID := strings.TrimSpace(binding.RemoteAssetID) if remoteID == "" { created, _, createErr := client.CreateAsset(ctx, settings.Credentials, map[string]any{ "GroupId": settings.AssetGroupID, "URL": asset.URL, "Name": asset.Name, "AssetType": volcesPortraitAssetType(asset.SourceType), "ProjectName": settings.ProjectName, }) if createErr != nil { return s.recordPortraitAssetBindingFailure(ctx, binding, settings, createErr) } remoteID = strings.TrimSpace(created.ID) if remoteID == "" { return s.recordPortraitAssetBindingFailure(ctx, binding, settings, &clients.ClientError{Code: "invalid_response", Message: "volces CreateAsset returned no asset id", Retryable: false}) } binding.RemoteAssetID = remoteID } remote, _, getErr := client.GetAsset(ctx, settings.Credentials, map[string]any{"Id": remoteID, "ProjectName": settings.ProjectName}) if getErr != nil { return s.recordPortraitAssetBindingFailure(ctx, binding, settings, getErr) } binding.ProjectName = settings.ProjectName binding.AssetGroupID = settings.AssetGroupID binding.RemoteAssetID = firstNonEmptyString(remote.ID, remoteID) binding.RemoteAssetURI = "asset://" + binding.RemoteAssetID binding.Status = portraitAssetBindingStatus(remote.Status) binding.LastErrorCode = strings.TrimSpace(stringFromMap(remote.Error, "Code")) binding.LastErrorMessage = strings.TrimSpace(stringFromMap(remote.Error, "Message")) if binding.Status == "failed" && binding.LastErrorMessage == "" { binding.LastErrorMessage = "volces portrait asset processing failed" } _, err = s.store.UpsertPortraitAssetBinding(ctx, binding) return err } func (s *Service) recordPortraitAssetBindingFailure(ctx context.Context, binding store.PortraitAssetBinding, settings portraitAssetPlatformSettings, cause error) error { binding.ProjectName = settings.ProjectName binding.AssetGroupID = settings.AssetGroupID binding.Status = "failed" binding.LastErrorCode = clients.ErrorCode(cause) binding.LastErrorMessage = cause.Error() _, err := s.store.UpsertPortraitAssetBinding(ctx, binding) if err != nil { return err } return cause } func (s *Service) refreshPortraitAssetStatus(ctx context.Context, user *auth.User, assetID string) (store.PortraitAsset, bool, error) { asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID) if err != nil || !found { return asset, found, err } active, total, lastError, _, err := s.store.PortraitAssetBindingSummary(ctx, asset.ID) if err != nil { return asset, true, err } status := "not_synced" if total == 0 { status = "not_synced" } else if active > 0 { status = "active" if active < total { status = "partial" } } else if lastError != "" { status = "failed" } else { status = "pending" } if err := s.store.UpdatePortraitAssetStatus(ctx, asset.ID, status, lastError); err != nil { return asset, true, err } asset.Status = status asset.LastError = lastError return asset, true, nil } func (s *Service) compilePortraitAssetReferences(ctx context.Context, user *auth.User, kind string, body map[string]any, candidate store.RuntimeModelCandidate) (map[string]any, error) { entries := portraitAssetList(body["portrait_asset_list"]) if len(entries) == 0 { return body, nil } if kind != "videos.generations" || !isVolcesPortraitAssetCandidate(candidate) { return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "portrait assets require a configured Volces Seedance omni video model", StatusCode: http.StatusBadRequest, Retryable: false} } if !candidateSupportsPortraitAssets(candidate) { return nil, &clients.ClientError{Code: "portrait_asset_unsupported_model", Message: "selected model does not enable supports_portrait_asset_reference", StatusCode: http.StatusBadRequest, Retryable: false} } out := cloneMap(body) content := contentItems(out["content"]) labels := make([]string, len(entries)) nonAudioAssets := 0 for index, entry := range entries { assetID := firstNonEmptyString(stringFromMap(entry, "id"), stringFromMap(entry, "easyai_portrait_asset_id")) if assetID == "" { return nil, &clients.ClientError{Code: "portrait_asset_id_required", Message: fmt.Sprintf("portrait_asset_list[%d].id is required", index), StatusCode: http.StatusBadRequest, Retryable: false} } asset, found, err := s.store.FindPortraitAssetForUser(ctx, user, assetID) if err != nil { return nil, err } if !found { return nil, &clients.ClientError{Code: "portrait_asset_not_found", Message: "portrait asset not found", StatusCode: http.StatusNotFound, Retryable: false} } if !asset.PrivateAvatarEligible { return nil, &clients.ClientError{Code: "portrait_asset_authorization_required", Message: "portrait asset authorization is required", StatusCode: http.StatusBadRequest, Retryable: false} } binding, bound, err := s.store.GetPortraitAssetBinding(ctx, asset.ID, candidate.PlatformID) if err != nil { return nil, err } if !bound || binding.Status != "active" || strings.TrimSpace(binding.RemoteAssetURI) == "" { return nil, &clients.ClientError{Code: "portrait_asset_processing", Message: "portrait asset is not active for the selected Volces platform; sync it and retry", StatusCode: http.StatusServiceUnavailable, Retryable: true} } labels[index] = firstNonEmptyString(strings.TrimSpace(stringFromMap(entry, "name")), asset.Name, "portrait asset "+fmt.Sprint(index+1)) if asset.SourceType != "audio" { nonAudioAssets++ } content = append(content, portraitAssetContent(asset.SourceType, binding.RemoteAssetURI)) } if nonAudioAssets == 0 { return nil, &clients.ClientError{Code: "portrait_asset_audio_only", Message: "portrait_asset_list cannot contain audio-only assets", StatusCode: http.StatusBadRequest, Retryable: false} } for index := range content { if strings.ToLower(strings.TrimSpace(stringFromAny(content[index]["type"]))) != "text" { continue } content[index]["text"] = replacePortraitAssetPlaceholders(stringFromAny(content[index]["text"]), labels) } out["content"] = mapsToAnySlice(content) delete(out, "portrait_asset_list") return out, nil } func (s *Service) portraitAssetHTTPClient() *http.Client { if s.httpClients != nil && s.httpClients.none != nil { return s.httpClients.none } return http.DefaultClient } func portraitAssetSettings(platform store.PortraitAssetPlatform) (portraitAssetPlatformSettings, bool) { config := portraitAssetNestedConfig(platform.Config) accessKey := firstNonEmptyString(portraitAssetValue(config, "accessKey", "access_key"), portraitAssetValue(platform.Credentials, "accessKey", "access_key")) secretKey := firstNonEmptyString(portraitAssetValue(config, "secretKey", "secret_key"), portraitAssetValue(platform.Credentials, "secretKey", "secret_key")) projectName := firstNonEmptyString(portraitAssetValue(config, "projectName", "project_name"), "default") assetGroupID := portraitAssetValue(config, "assetGroupId", "asset_group_id") endpoint := firstNonEmptyString(portraitAssetValue(config, "assetEndpoint", "asset_endpoint", "volcesAssetEndpoint", "volces_asset_endpoint"), clientsVolcesAssetDefaultEndpoint()) if accessKey == "" || secretKey == "" || projectName == "" || assetGroupID == "" { return portraitAssetPlatformSettings{}, false } if enabled, present := portraitAssetBool(config, "enabled"); present && !enabled { return portraitAssetPlatformSettings{}, false } return portraitAssetPlatformSettings{ProjectName: projectName, AssetGroupID: assetGroupID, Credentials: clients.VolcesAssetCredentials{AccessKey: accessKey, SecretKey: secretKey, Endpoint: endpoint}}, true } func portraitAssetNestedConfig(config map[string]any) map[string]any { for _, key := range []string{"seedancePrivateAsset", "seedance_private_asset", "portraitAsset", "portrait_asset"} { if nested, ok := config[key].(map[string]any); ok { return nested } } return config } func portraitAssetValue(values map[string]any, keys ...string) string { for _, key := range keys { if value := strings.TrimSpace(stringFromAny(values[key])); value != "" { return value } } return "" } func portraitAssetBool(values map[string]any, key string) (bool, bool) { value, ok := values[key] if !ok { return false, false } switch typed := value.(type) { case bool: return typed, true case string: return strings.EqualFold(strings.TrimSpace(typed), "true"), true default: return false, false } } func validPortraitAssetSourceType(value string) bool { switch strings.ToLower(strings.TrimSpace(value)) { case "image", "video", "audio": return true default: return false } } func portraitAssetHasPublicURL(value string) bool { parsed, err := url.Parse(strings.TrimSpace(value)) if err != nil || parsed.Host == "" { return false } return strings.EqualFold(parsed.Scheme, "http") || strings.EqualFold(parsed.Scheme, "https") } func volcesPortraitAssetType(sourceType string) string { switch strings.ToLower(strings.TrimSpace(sourceType)) { case "video": return "Video" case "audio": return "Audio" default: return "Image" } } func portraitAssetBindingStatus(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "active", "succeeded", "success": return "active" case "failed", "error": return "failed" default: return "processing" } } func portraitAssetList(value any) []map[string]any { switch typed := value.(type) { case []any: out := make([]map[string]any, 0, len(typed)) for _, item := range typed { if object, ok := item.(map[string]any); ok { out = append(out, object) } } return out case []map[string]any: return typed default: return nil } } func portraitAssetContent(sourceType string, assetURI string) map[string]any { switch strings.ToLower(strings.TrimSpace(sourceType)) { case "video": return map[string]any{"type": "video_url", "role": "reference_video", "video_url": map[string]any{"url": assetURI}} case "audio": return map[string]any{"type": "audio_url", "role": "reference_audio", "audio_url": map[string]any{"url": assetURI}} default: return map[string]any{"type": "image_url", "role": "reference_image", "image_url": map[string]any{"url": assetURI}} } } func replacePortraitAssetPlaceholders(value string, labels []string) string { return portraitAssetPlaceholderPattern.ReplaceAllStringFunc(value, func(match string) string { parts := portraitAssetPlaceholderPattern.FindStringSubmatch(match) for index := 1; index < len(parts); index++ { if parts[index] == "" { continue } position := int(parts[index][0] - '0') if len(parts[index]) > 1 { position = 0 for _, r := range parts[index] { position = position*10 + int(r-'0') } } if position > 0 && position <= len(labels) && strings.TrimSpace(labels[position-1]) != "" { return labels[position-1] } } return match }) } func isVolcesPortraitAssetCandidate(candidate store.RuntimeModelCandidate) bool { provider := strings.ToLower(strings.TrimSpace(candidate.Provider)) return provider == "volces" || provider == "volces-openai" } func candidateSupportsPortraitAssets(candidate store.RuntimeModelCandidate) bool { capabilities := effectiveModelCapability(candidate) for _, key := range []string{candidate.ModelType, "omni_video", "omni", "video_generate"} { if capability, ok := capabilities[key].(map[string]any); ok { if enabled, present := portraitAssetBool(capability, "supports_portrait_asset_reference"); present { return enabled } } } return false } func portraitAssetUserKeys(user *auth.User) (string, string) { if user == nil { return "", "" } gatewayUserID := strings.TrimSpace(user.GatewayUserID) if gatewayUserID == "" && user.Source == "gateway" { gatewayUserID = strings.TrimSpace(user.ID) } return gatewayUserID, strings.TrimSpace(user.ID) } func portraitAssetSHA256(payload []byte) string { digest := sha256.Sum256(payload) return hex.EncodeToString(digest[:]) } func clientsVolcesAssetDefaultEndpoint() string { return "https://ark.cn-beijing.volcengineapi.com" }