package app import ( "encoding/json" "errors" "fmt" "strings" "time" "gorm.io/gorm" "infinite-canvas/backend/internal/model" ) // 资产来源:媒体导入合成/画布产物自动同步。画布产物用 canvas 以便素材面板分组。 const ( AssetSourceUploaded = "uploaded" AssetSourceCanvas = "canvas" ) type LinkProjectAssetRequest struct { AssetID string `json:"assetId"` Category string `json:"category"` FolderID *string `json:"folderId"` // Title 媒体导入场景下由前端携带原始文件名;已有资产时忽略。 Title string `json:"title"` // Source 仅媒体导入合成时生效:uploaded(默认)或 canvas(画布产物自动同步)。 Source string `json:"source"` } type UpdateProjectAssetRequest struct { Category *string `json:"category"` FolderID *string `json:"folderId"` } type CreateAssetVersionRequest struct { Prompt string `json:"prompt"` DefinitionJSON string `json:"definitionJson"` Note string `json:"note"` } type ProjectAssetSummary struct { ID string `json:"id"` Title string `json:"title"` MediaType string `json:"mediaType"` Category model.AssetCategory `json:"category"` Status model.AssetVersionStatus `json:"status"` PrimaryVersionID string `json:"primaryVersionId,omitempty"` VersionCount int `json:"versionCount"` Usages []string `json:"usages"` FolderID string `json:"folderId,omitempty"` Position int `json:"position"` StorageKey string `json:"storageKey,omitempty"` DurationMs int64 `json:"durationMs,omitempty"` PreviewText string `json:"previewText,omitempty"` UpdatedAt time.Time `json:"updatedAt"` Source string `json:"source,omitempty"` Character *CharacterCardSummary `json:"character,omitempty"` } type ProjectAssetFilter struct { Category string MediaType string Status string Usage string } type ConfirmProjectAssetCandidateRequest struct { AssetID string `json:"assetId"` } func (s *Service) FilterProjectAssets(userID string, projectID string, filter ProjectAssetFilter) ([]ProjectAssetSummary, error) { assets, err := s.ProjectAssets(userID, projectID) if err != nil { return nil, err } result := make([]ProjectAssetSummary, 0, len(assets)) for _, asset := range assets { if filter.Category != "" && string(asset.Category) != filter.Category { continue } if filter.MediaType != "" && asset.MediaType != filter.MediaType { continue } if filter.Status != "" && string(asset.Status) != filter.Status { continue } if filter.Usage != "" && !containsString(asset.Usages, filter.Usage) { continue } result = append(result, asset) } return result, nil } func (s *Service) ProjectAssets(userID string, projectID string) ([]ProjectAssetSummary, error) { assets, err := s.repo.ProjectAssets(userID, projectID) if err != nil { return nil, err } result := make([]ProjectAssetSummary, 0, len(assets)) for _, asset := range assets { summary, summaryErr := s.projectAssetSummary(userID, projectID, &asset) if summaryErr != nil { return nil, summaryErr } result = append(result, summary) } return result, nil } func (s *Service) LinkProjectAsset(userID string, projectID string, req LinkProjectAssetRequest) (ProjectAssetSummary, error) { if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { return ProjectAssetSummary{}, err } assetID := strings.TrimSpace(req.AssetID) source := strings.TrimSpace(req.Source) if source != AssetSourceCanvas { source = AssetSourceUploaded } asset, err := s.repo.AssetForUser(userID, assetID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return ProjectAssetSummary{}, err } if asset == nil { // 媒体导入只落 resources 表;首次链接时按资源元数据合成资产记录, // 避免“资源存在但无资产记录”导致导入永远失败。 asset, err = s.assetFromUploadedResource(userID, assetID, req.Title, source) if err != nil { return ProjectAssetSummary{}, err } } category := model.AssetCategory(strings.TrimSpace(req.Category)) if category == "" { category = asset.Category } if !validAssetCategory(category) { return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类") } folderID, err := s.resolveProjectAssetFolderID(projectID, req.FolderID) if err != nil { return ProjectAssetSummary{}, err } linked, err := s.repo.ProjectAssetLinked(projectID, asset.ID) if err != nil { return ProjectAssetSummary{}, err } if linked { return s.projectAssetSummary(userID, projectID, asset) } now := time.Now() asset.Category = category if asset.Status == "" { asset.Status = model.AssetVersionStatusConfirmed } versions, err := s.repo.AssetVersions(asset.ID) if err != nil { return ProjectAssetSummary{}, err } var initialVersion *model.AssetVersion if len(versions) == 0 { version := model.AssetVersion{ID: newID(), AssetID: asset.ID, Version: 1, Status: asset.Status, DefinitionJSON: "{}", CreatedAt: now, UpdatedAt: now} initialVersion = &version asset.PrimaryVersionID = version.ID versions = append(versions, version) } asset.UpdatedAt = now position, err := s.repo.NextProjectAssetPosition(projectID, folderID) if err != nil { return ProjectAssetSummary{}, err } link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: asset.ID, FolderID: folderID, Position: position, CreatedAt: now} created, err := s.repo.LinkProjectAsset(asset, initialVersion, &link) if err != nil { return ProjectAssetSummary{}, err } if !created { current, currentErr := s.repo.AssetForUser(userID, asset.ID) if currentErr != nil { return ProjectAssetSummary{}, currentErr } return s.projectAssetSummary(userID, projectID, current) } return s.projectAssetSummary(userID, projectID, asset) } // assetFromUploadedResource 把媒体导入上传的 Resource 合成为资产记录(内存构造,不落库)。 // 资产创建与项目链接由 LinkProjectAsset 事务原子提交,避免失败留下孤立资产。 func (s *Service) assetFromUploadedResource(userID string, resourceID string, title string, source string) (*model.Asset, error) { resource, err := s.repo.ResourceForUser(userID, resourceID) if err != nil { return nil, err } if resource.Kind == "" { return nil, BadAuthRequest("资源缺少媒体类型,无法导入") } payload, err := json.Marshal(map[string]any{ "data": map[string]any{ "storageKey": "resource:" + resource.ID, "mimeType": resource.MimeType, "kind": resource.Kind, "size": resource.Size, "width": resource.Width, "height": resource.Height, "durationMs": resource.DurationMs, "status": resource.Status, "mediaType": resource.Kind, "source": source, }, }) if err != nil { return nil, err } title = strings.TrimSpace(title) if title == "" { title = mediaTitleFallback(resource) } asset := &model.Asset{ ID: resource.ID, UserID: userID, Kind: resource.Kind, Category: model.NormalizeAssetCategory("", resource.Kind), Status: model.AssetVersionStatusConfirmed, Title: title, PayloadJSON: string(payload), CreatedAt: resource.CreatedAt, UpdatedAt: resource.UpdatedAt, } // 不在此预落库:资产创建交给 LinkProjectAsset 的事务统一提交, // 事务失败时与链接一起回滚,避免留下“有资产无链接”的孤儿记录。 return asset, nil } // mediaTitleFallback 资源无文件名时的展示标题:未命名 + 媒体类型 + 分辨率。 func mediaTitleFallback(resource *model.Resource) string { base := "未命名" + resource.Kind if resource.Width > 0 && resource.Height > 0 { base += fmt.Sprintf(" %dx%d", resource.Width, resource.Height) } return base } func (s *Service) UnlinkProjectAsset(userID string, projectID string, assetID string) error { if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { return err } asset, err := s.repo.AssetForUser(userID, assetID) if err != nil { return err } if asset.Category == model.AssetCategoryCharacter { canvases, canvasErr := s.repo.ProjectCanvasDocuments(userID, projectID) if canvasErr != nil { return canvasErr } for _, canvas := range canvases { referenced, parseErr := canvasReferencesCharacterAsset(canvas.PayloadJSON, assetID) if parseErr != nil { return BadAuthRequest("画布数据格式错误,无法确认角色引用") } if referenced { return BadAuthRequest("角色仍被项目画布引用,请先删除对应角色卡节点") } } } references, err := s.repo.ProjectAssetShotReferenceCount(projectID, assetID) if err != nil { return err } if references > 0 { return BadAuthRequest("素材仍被项目镜头引用,请先解除镜头用途") } if err := s.repo.DeleteProjectAssetLink(projectID, assetID); err != nil { return err } return s.repo.BumpProjectRevision(projectID) } func canvasReferencesCharacterAsset(payloadJSON string, assetID string) (bool, error) { var payload struct { Nodes []struct { Metadata struct { WorkflowKind string `json:"workflowKind"` CharacterAssetID string `json:"characterAssetId"` } `json:"metadata"` } `json:"nodes"` } if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { return false, err } for _, node := range payload.Nodes { if node.Metadata.WorkflowKind == "character" && node.Metadata.CharacterAssetID == assetID { return true, nil } } return false, nil } func (s *Service) UpdateProjectAsset(userID string, projectID string, assetID string, req UpdateProjectAssetRequest) (ProjectAssetSummary, error) { if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { return ProjectAssetSummary{}, err } asset, err := s.repo.AssetForUser(userID, strings.TrimSpace(assetID)) if err != nil { return ProjectAssetSummary{}, err } linked, err := s.repo.ProjectAssetLinked(projectID, asset.ID) if err != nil { return ProjectAssetSummary{}, err } if !linked { return ProjectAssetSummary{}, BadAuthRequest("素材尚未加入当前项目") } if req.Category != nil && req.FolderID != nil { return ProjectAssetSummary{}, BadAuthRequest("一次只能修改素材分类或所在文件夹") } if req.Category != nil { category := model.AssetCategory(strings.TrimSpace(*req.Category)) if !validAssetCategory(category) { return ProjectAssetSummary{}, BadAuthRequest("不支持的资产业务分类") } asset.Category = category asset.UpdatedAt = time.Now() if err := s.repo.UpdateAssetDomain(asset); err != nil { return ProjectAssetSummary{}, err } if err := s.repo.BumpProjectRevision(projectID); err != nil { return ProjectAssetSummary{}, err } } else if req.FolderID != nil { folderID, folderErr := s.resolveProjectAssetFolderID(projectID, req.FolderID) if folderErr != nil { return ProjectAssetSummary{}, folderErr } position, positionErr := s.repo.NextProjectAssetPosition(projectID, folderID) if positionErr != nil { return ProjectAssetSummary{}, positionErr } if err := s.repo.MoveProjectAsset(projectID, asset.ID, folderID, position); err != nil { return ProjectAssetSummary{}, err } } else { return ProjectAssetSummary{}, BadAuthRequest("没有可更新的素材字段") } return s.projectAssetSummary(userID, projectID, asset) } func (s *Service) CreateProjectAssetVersion(userID string, projectID string, assetID string, req CreateAssetVersionRequest) (model.AssetVersion, error) { if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { return model.AssetVersion{}, err } asset, err := s.repo.AssetForUser(userID, assetID) if err != nil { return model.AssetVersion{}, err } linked, err := s.repo.ProjectAssetLinked(projectID, assetID) if err != nil { return model.AssetVersion{}, err } if !linked { return model.AssetVersion{}, BadAuthRequest("素材尚未加入当前项目") } versions, err := s.repo.AssetVersions(assetID) if err != nil { return model.AssetVersion{}, err } nextVersion := 1 if len(versions) > 0 { nextVersion = versions[0].Version + 1 } definition := strings.TrimSpace(req.DefinitionJSON) if definition == "" { definition = "{}" } if !json.Valid([]byte(definition)) { return model.AssetVersion{}, BadAuthRequest("资产版本设定必须是有效 JSON") } now := time.Now() version := model.AssetVersion{ID: newID(), AssetID: assetID, Version: nextVersion, Status: model.AssetVersionStatusDraft, DefinitionJSON: definition, Prompt: strings.TrimSpace(req.Prompt), Note: strings.TrimSpace(req.Note), CreatedAt: now, UpdatedAt: now} if err := s.repo.CreateAssetVersion(&version); err != nil { return model.AssetVersion{}, err } asset.PrimaryVersionID = version.ID asset.Status = model.AssetVersionStatusDraft asset.UpdatedAt = now if err := s.repo.UpdateAssetDomain(asset); err != nil { return model.AssetVersion{}, err } if err := s.repo.BumpProjectRevision(projectID); err != nil { return model.AssetVersion{}, err } return version, nil } func (s *Service) ConfirmProjectAssetCandidate(userID string, projectID string, candidateID string, req ConfirmProjectAssetCandidateRequest) (ProjectAssetSummary, error) { if _, err := s.repo.ProjectForUser(userID, projectID); err != nil { return ProjectAssetSummary{}, err } candidate, err := s.repo.ProjectAssetCandidate(projectID, candidateID) if err != nil { return ProjectAssetSummary{}, err } if candidate.Status != "pending_confirmation" { return ProjectAssetSummary{}, BadAuthRequest("资产候选已处理") } now := time.Now() assetID := strings.TrimSpace(req.AssetID) if assetID != "" && candidate.Category == model.AssetCategoryCharacter { asset, assetErr := s.repo.ProjectCharacterAsset(userID, projectID, assetID) if assetErr != nil { return ProjectAssetSummary{}, assetErr } current, versionErr := s.repo.AssetVersion(asset.PrimaryVersionID) if versionErr != nil { return ProjectAssetSummary{}, versionErr } definition, mergeErr := mergeCharacterCandidateDefinition(current.DefinitionJSON, candidate.DetailsJSON, asset.Title, candidate.Name) if mergeErr != nil { return ProjectAssetSummary{}, mergeErr } nextAsset, nextVersion, representations, voice, prepareErr := s.prepareNextCharacterVersion(asset, asset.Title, definition, nil, nil, false) if prepareErr != nil { return ProjectAssetSummary{}, prepareErr } candidate.Status = "confirmed" candidate.ResolvedAssetID = asset.ID candidate.UpdatedAt = now if saveErr := s.repo.ConfirmProjectCharacterCandidate(candidate, &nextAsset, &nextVersion, representations, voice); saveErr != nil { return ProjectAssetSummary{}, saveErr } return s.projectAssetSummary(userID, projectID, &nextAsset) } createAsset := assetID == "" var asset model.Asset var version model.AssetVersion if createAsset { assetID = newID() versionID := newID() kind := "text" var payload []byte var marshalErr error if candidate.Category == model.AssetCategoryCharacter { kind = "entity" var characterPayload string characterPayload, marshalErr = characterAssetPayload(assetID, versionID, candidate.Name, json.RawMessage(candidate.DetailsJSON), now, now) payload = []byte(characterPayload) } else { payload, marshalErr = json.Marshal(map[string]any{ "id": assetID, "kind": kind, "category": candidate.Category, "status": model.AssetVersionStatusConfirmed, "primaryVersionId": versionID, "title": candidate.Name, "tags": []string{}, "data": map[string]string{"content": ""}, "createdAt": now.Format(time.RFC3339Nano), "updatedAt": now.Format(time.RFC3339Nano), }) } if marshalErr != nil { return ProjectAssetSummary{}, marshalErr } asset = model.Asset{ID: assetID, UserID: userID, Kind: kind, Category: candidate.Category, Status: model.AssetVersionStatusConfirmed, PrimaryVersionID: versionID, Title: candidate.Name, PayloadJSON: string(payload), CreatedAt: now, UpdatedAt: now} version = model.AssetVersion{ID: versionID, AssetID: assetID, Version: 1, Status: model.AssetVersionStatusConfirmed, DefinitionJSON: candidate.DetailsJSON, CreatedAt: now, UpdatedAt: now} } else { existing, assetErr := s.repo.AssetForUser(userID, assetID) if assetErr != nil { return ProjectAssetSummary{}, assetErr } asset = *existing } candidate.Status = "confirmed" candidate.ResolvedAssetID = assetID candidate.UpdatedAt = now folderID, folderErr := s.resolveProjectAssetFolderID(projectID, nil) if folderErr != nil { return ProjectAssetSummary{}, folderErr } position, positionErr := s.repo.NextProjectAssetPosition(projectID, folderID) if positionErr != nil { return ProjectAssetSummary{}, positionErr } link := model.ProjectAssetLink{ID: newID(), ProjectID: projectID, AssetID: assetID, FolderID: folderID, Position: position, CreatedAt: now} if err := s.repo.ConfirmProjectAssetCandidate(candidate, &asset, &version, &link, createAsset); err != nil { return ProjectAssetSummary{}, err } return s.projectAssetSummary(userID, projectID, &asset) } func mergeCharacterCandidateDefinition(currentJSON string, candidateJSON string, currentName string, candidateName string) (string, error) { current := map[string]any{} candidate := map[string]any{} if err := json.Unmarshal([]byte(currentJSON), ¤t); err != nil { return "", err } if err := json.Unmarshal([]byte(candidateJSON), &candidate); err != nil { return "", BadAuthRequest("角色候选设定格式无效") } for key, value := range candidate { if key == "aliases" || !emptyCharacterDefinitionValue(current[key]) { continue } current[key] = value } aliases := make([]string, 0) seen := map[string]bool{} appendAlias := func(value string) { value = strings.TrimSpace(value) key := strings.ToLower(value) if value == "" || seen[key] || strings.EqualFold(value, strings.TrimSpace(currentName)) { return } seen[key] = true aliases = append(aliases, value) } for _, value := range []any{current["aliases"], candidate["aliases"]} { if list, ok := value.([]any); ok { for _, item := range list { if text, valid := item.(string); valid { appendAlias(text) } } } } appendAlias(candidateName) current["aliases"] = aliases encoded, err := json.Marshal(current) return string(encoded), err } func emptyCharacterDefinitionValue(value any) bool { switch typed := value.(type) { case nil: return true case string: return strings.TrimSpace(typed) == "" case []any: return len(typed) == 0 default: return false } } func containsString(values []string, target string) bool { for _, value := range values { if value == target { return true } } return false } func validAssetCategory(category model.AssetCategory) bool { switch category { case model.AssetCategoryCharacter, model.AssetCategoryEnvironment, model.AssetCategoryProp, model.AssetCategoryMaterial, model.AssetCategoryOther: return true default: return false } } func (s *Service) projectAssetSummary(userID string, projectID string, asset *model.Asset) (ProjectAssetSummary, error) { versions, err := s.repo.AssetVersions(asset.ID) if err != nil { return ProjectAssetSummary{}, err } usages, err := s.repo.ProjectAssetUsageRoles(projectID, asset.ID) if err != nil { return ProjectAssetSummary{}, err } link, err := s.repo.ProjectAssetLink(projectID, asset.ID) if err != nil { return ProjectAssetSummary{}, err } storageKey, previewText, source, durationMs := projectAssetPreview(asset.PayloadJSON) summary := ProjectAssetSummary{ID: asset.ID, Title: asset.Title, MediaType: asset.Kind, Category: asset.Category, Status: asset.Status, PrimaryVersionID: asset.PrimaryVersionID, VersionCount: len(versions), Usages: usages, FolderID: link.FolderID, Position: link.Position, StorageKey: storageKey, PreviewText: previewText, DurationMs: durationMs, UpdatedAt: asset.UpdatedAt, Source: source} if asset.Category == model.AssetCategoryCharacter && asset.PrimaryVersionID != "" { card, cardErr := s.characterCard(userID, asset) if cardErr != nil { return ProjectAssetSummary{}, cardErr } summary.Character = &card } return summary, nil } func projectAssetPreview(payloadJSON string) (string, string, string, int64) { var payload struct { Data struct { StorageKey string `json:"storageKey"` Content string `json:"content"` Source string `json:"source"` DurationMs int64 `json:"durationMs"` } `json:"data"` } if json.Unmarshal([]byte(payloadJSON), &payload) != nil { return "", "", "", 0 } previewRunes := []rune(strings.TrimSpace(payload.Data.Content)) if len(previewRunes) > 240 { previewRunes = append(previewRunes[:240], '…') } return strings.TrimSpace(payload.Data.StorageKey), string(previewRunes), strings.TrimSpace(payload.Data.Source), payload.Data.DurationMs }