diff --git a/AGENTS.md b/AGENTS.md index 79710404b..48621a5c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,7 +109,7 @@ - TanStack Query 使用稳定的业务 `queryKey`,写操作成功后主动 invalidate/refetch 相关 query;不要用页面级 `useEffect` 手工复制整个缓存。 - Zustand store 只保存跨页面状态和持久化配置;页面临时状态留在页面,媒体大对象不要塞进 localStorage。 - 生成任务、资源、画布、素材的本地缓存是后端不可用时的降级,不代表后端已保存;写路径必须区分“本地缓存成功”和“服务端持久化成功”。 -- 媒体删除不会自动清理 OSS 远端对象;涉及删除提示和文档时必须说明这个边界。 +- 素材删除会先校验项目、画布、任务、其他素材和相关业务记录的资源引用;无占用时同步清理服务器本地文件或 OSS/COS 对象,物理删除失败必须保留素材记录并返回明确错误。画布节点等其他业务记录的单独删除仍不自动回收资源。 ## 6. 前端 UI 与设计系统 @@ -208,6 +208,6 @@ ## 13. 当前边界 - 画布和素材支持登录后端同步;localForage 仍是缓存及后端/OSS 不可用时的降级存储。 -- 媒体资源支持私有 OSS 或后端文件存储;删除节点或素材不会自动清理 OSS 远端对象。 +- 媒体资源支持私有阿里云 OSS、腾讯云 COS 或后端文件存储;删除素材会在无其他业务引用时同步清理对应物理对象,删除画布节点等其他记录本身不主动回收资源。 - 用户 AI API Key 保存在浏览器本地,任务创建时可能提交到自部署后端;安全说明必须强调 HTTPS 和可信部署。 - Docker 静态资源和生产部署仍需按待测试清单验证,不得写成已全面生产验证。 diff --git a/README.md b/README.md index c3a02594f..b05c4a877 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ docker compose -f docker-compose.local.yml up -d --build - 用户自定义 AI API Key 保存在浏览器本地;登录态拉取模型目录时会临时提交给自部署后端但不会保存,创建异步任务时会加密入队;仅应使用可信部署,生产环境必须启用 HTTPS。 - 画布和素材登录后同步到后端,本地 `localForage` 继续承担缓存和降级存储。 -- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名。上传继续使用 Endpoint,配置 CDN 后登录资源、公开画布分享及管理后台媒体的下载与预览会在权限校验后统一重定向到 CDN;需要同源 Blob 的内部读取仍由后端代理,但后端同样通过 CDN 取源。CDN 地址不携带 OSS/COS 签名,敏感资源必须在 CDN 侧配置 URL 鉴权,并为需要浏览器跨域读取的场景配置 CORS;删除业务记录不会自动清理远端对象。 +- 媒体资源在启用对象存储时可保存到私有阿里云 OSS 或腾讯云 COS,否则保存到后端数据目录;两种对象存储均可选填 CDN 加速域名。上传继续使用 Endpoint,配置 CDN 后登录资源、公开画布分享及管理后台媒体的下载与预览会在权限校验后统一重定向到 CDN;需要同源 Blob 的内部读取仍由后端代理,但后端同样通过 CDN 取源。CDN 地址不携带 OSS/COS 签名,敏感资源必须在 CDN 侧配置 URL 鉴权,并为需要浏览器跨域读取的场景配置 CORS。用户删除素材时,后端会先检查项目、画布、任务、其他素材、工作流、风格和声音等引用;存在占用时保留素材并返回具体来源,不存在占用时同步删除对应的本地文件或 OSS/COS 对象及资源记录,物理对象删除失败也会阻止素材记录删除;删除画布、节点等其他业务记录仍不会自动清理远端对象。 - 用户主动上传、Agent 会话附件和 AI 生成资源的单文件上限、账号容量及 UTC 日上传总量由后台“资源与策略”统一维护,默认分别为 50MB、32MB、64MB、2GB 和 200MB;管理员可按可信部署需要调整,单文件业务上限最高 999MB,Nginx 请求体硬上限为 1024MB。 ## 公网部署安全 diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 77760ed47..981730b81 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -852,19 +852,7 @@ func (r *Repository) UpsertAsset(asset *model.Asset) error { } func (r *Repository) DeleteAsset(userID string, id string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", id) - if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.CharacterVoiceBinding{}).Error; err != nil { - return err - } - if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.AssetRepresentation{}).Error; err != nil { - return err - } - if err := tx.Where("asset_id = ?", id).Delete(&model.AssetVersion{}).Error; err != nil { - return err - } - return tx.Delete(&model.Asset{}, "id = ? AND user_id = ?", id, userID).Error - }) + return r.DeleteAssetAndResources(userID, id, nil) } func (r *Repository) ReplaceAssets(userID string, assets []model.Asset) error { diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go new file mode 100644 index 000000000..36767dcb6 --- /dev/null +++ b/backend/internal/repository/resource_reference.go @@ -0,0 +1,273 @@ +package repository + +import ( + "strings" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +// ResourceReferenceDocument 是资源删除校验使用的只读业务文档快照。 +// JSON 中的精确资源 ID 识别由 service 完成,repository 只负责缩小候选记录。 +type ResourceReferenceDocument struct { + Kind string + ID string + Title string + PrimaryJSON string + SecondaryJSON string +} + +type ResourceDirectReference struct { + Kind string + ID string + Title string + ResourceID string +} + +type ResourceReferenceSnapshot struct { + Documents []ResourceReferenceDocument + Direct []ResourceDirectReference +} + +func (r *Repository) AssetResourceRecords(assetID string) ([]model.AssetVersion, []model.AssetRepresentation, error) { + var versions []model.AssetVersion + if err := r.db.Where("asset_id = ?", assetID).Find(&versions).Error; err != nil { + return nil, nil, err + } + if len(versions) == 0 { + return versions, nil, nil + } + versionIDs := make([]string, 0, len(versions)) + for _, version := range versions { + versionIDs = append(versionIDs, version.ID) + } + var representations []model.AssetRepresentation + if err := r.db.Where("asset_version_id IN ?", versionIDs).Find(&representations).Error; err != nil { + return nil, nil, err + } + return versions, representations, nil +} + +func (r *Repository) ResourcesForUserIDs(userID string, resourceIDs []string) ([]model.Resource, error) { + if len(resourceIDs) == 0 { + return []model.Resource{}, nil + } + var resources []model.Resource + err := r.db.Where("user_id = ? AND id IN ?", userID, resourceIDs).Find(&resources).Error + return resources, err +} + +// ResourceStorageReferenceCount 返回未包含在 excludedResourceIDs 中、但指向同一物理对象的资源记录数。 +// 这用于兼容历史数据中多个 Resource 行复用一个对象路径的情况。 +func (r *Repository) ResourceStorageReferenceCount(resource *model.Resource, excludedResourceIDs []string) (int64, error) { + if resource == nil { + return 0, nil + } + query := r.db.Model(&model.Resource{}). + Where("endpoint = ? AND bucket = ? AND object_key = ?", resource.Endpoint, resource.Bucket, resource.ObjectKey) + if strings.TrimSpace(resource.Provider) == "" || strings.EqualFold(strings.TrimSpace(resource.Provider), "local") { + query = query.Where("provider IN ?", []string{"", "local"}) + } else { + query = query.Where("provider = ?", resource.Provider) + } + if len(excludedResourceIDs) > 0 { + query = query.Where("id NOT IN ?", excludedResourceIDs) + } + var count int64 + err := query.Count(&count).Error + return count, err +} + +func (r *Repository) ResourceReferenceSnapshot(userID string, excludingAssetID string, resourceIDs []string) (ResourceReferenceSnapshot, error) { + snapshot := ResourceReferenceSnapshot{Documents: []ResourceReferenceDocument{}, Direct: []ResourceDirectReference{}} + if len(resourceIDs) == 0 { + return snapshot, nil + } + + var assets []model.Asset + assetQuery := r.db.Where("user_id = ? AND id <> ?", userID, excludingAssetID) + if err := resourceTextQuery(assetQuery, []string{"payload_json"}, resourceIDs).Find(&assets).Error; err != nil { + return snapshot, err + } + for _, asset := range assets { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: asset.ID, Title: asset.Title, PrimaryJSON: asset.PayloadJSON}) + } + + var canvases []model.CanvasProject + if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"payload_json"}, resourceIDs).Find(&canvases).Error; err != nil { + return snapshot, err + } + for _, canvas := range canvases { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "画布", ID: canvas.ID, Title: canvas.Title, PrimaryJSON: canvas.PayloadJSON}) + } + + var tasks []model.Task + if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"input_json", "result_json"}, resourceIDs).Find(&tasks).Error; err != nil { + return snapshot, err + } + for _, task := range tasks { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "任务", ID: task.ID, Title: task.Prompt, PrimaryJSON: task.InputJSON, SecondaryJSON: task.ResultJSON}) + } + + var projects []model.Project + if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"style_profile_json"}, resourceIDs).Find(&projects).Error; err != nil { + return snapshot, err + } + for _, project := range projects { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "项目", ID: project.ID, Title: project.Name, PrimaryJSON: project.StyleProfileJSON}) + } + + var styles []model.StyleProfile + if err := resourceTextQuery(r.db.Where("user_id = ?", userID), []string{"cover_url", "profile_json"}, resourceIDs).Find(&styles).Error; err != nil { + return snapshot, err + } + for _, style := range styles { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "风格", ID: style.ID, Title: style.Name, PrimaryJSON: style.CoverURL, SecondaryJSON: style.ProfileJSON}) + } + + type joinedDocument struct { + ID string + Title string + PrimaryJSON string + SecondaryJSON string + } + var versions []joinedDocument + versionQuery := r.db.Table("asset_versions"). + Select("asset_versions.id, assets.title, asset_versions.definition_json AS primary_json"). + Joins("JOIN assets ON assets.id = asset_versions.asset_id"). + Where("assets.user_id = ? AND assets.id <> ?", userID, excludingAssetID) + if err := resourceTextQuery(versionQuery, []string{"asset_versions.definition_json"}, resourceIDs).Scan(&versions).Error; err != nil { + return snapshot, err + } + for _, version := range versions { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "素材", ID: version.ID, Title: version.Title, PrimaryJSON: version.PrimaryJSON}) + } + + var candidates []joinedDocument + candidateQuery := r.db.Table("project_asset_candidates"). + Select("project_asset_candidates.id, projects.name AS title, project_asset_candidates.details_json AS primary_json"). + Joins("JOIN projects ON projects.id = project_asset_candidates.project_id"). + Where("projects.user_id = ?", userID) + if err := resourceTextQuery(candidateQuery, []string{"project_asset_candidates.details_json"}, resourceIDs).Scan(&candidates).Error; err != nil { + return snapshot, err + } + for _, candidate := range candidates { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "项目", ID: candidate.ID, Title: candidate.Title, PrimaryJSON: candidate.PrimaryJSON}) + } + + var steps []joinedDocument + stepQuery := r.db.Table("workflow_step_instances"). + Select("workflow_step_instances.id, workflow_step_instances.name AS title, workflow_step_instances.input_json AS primary_json, workflow_step_instances.output_json AS secondary_json"). + Joins("JOIN workflow_instances ON workflow_instances.id = workflow_step_instances.workflow_instance_id"). + Joins("JOIN projects ON projects.id = workflow_instances.project_id"). + Where("projects.user_id = ?", userID) + if err := resourceTextQuery(stepQuery, []string{"workflow_step_instances.input_json", "workflow_step_instances.output_json"}, resourceIDs).Scan(&steps).Error; err != nil { + return snapshot, err + } + for _, step := range steps { + snapshot.Documents = append(snapshot.Documents, ResourceReferenceDocument{Kind: "工作流", ID: step.ID, Title: step.Title, PrimaryJSON: step.PrimaryJSON, SecondaryJSON: step.SecondaryJSON}) + } + + type joinedRepresentation struct { + ID string + Title string + ResourceID string + } + var representations []joinedRepresentation + if err := r.db.Table("asset_representations"). + Select("asset_representations.id, assets.title, asset_representations.resource_id"). + Joins("JOIN asset_versions ON asset_versions.id = asset_representations.asset_version_id"). + Joins("JOIN assets ON assets.id = asset_versions.asset_id"). + Where("assets.user_id = ? AND assets.id <> ? AND asset_representations.resource_id IN ?", userID, excludingAssetID, resourceIDs). + Scan(&representations).Error; err != nil { + return snapshot, err + } + for _, representation := range representations { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "素材", ID: representation.ID, Title: representation.Title, ResourceID: representation.ResourceID}) + } + + var voices []model.VoiceProfile + if err := r.db.Where("user_id = ? AND sample_resource_id IN ?", userID, resourceIDs).Find(&voices).Error; err != nil { + return snapshot, err + } + for _, voice := range voices { + snapshot.Direct = append(snapshot.Direct, ResourceDirectReference{Kind: "声音", ID: voice.ID, Title: voice.Name, ResourceID: voice.SampleResourceID}) + } + return snapshot, nil +} + +func (r *Repository) AssetBusinessReferences(userID string, assetID string) ([]ResourceDirectReference, error) { + type projectReference struct { + ID string + Title string + } + var projects []projectReference + if err := r.db.Table("project_asset_links"). + Distinct("projects.id, projects.name AS title"). + Joins("JOIN projects ON projects.id = project_asset_links.project_id"). + Where("projects.user_id = ? AND project_asset_links.asset_id = ?", userID, assetID). + Scan(&projects).Error; err != nil { + return nil, err + } + var shotProjects []projectReference + if err := r.db.Table("shot_asset_references"). + Distinct("projects.id, projects.name AS title"). + Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id"). + Joins("JOIN shots ON shots.id = shot_asset_references.shot_id"). + Joins("JOIN projects ON projects.id = shots.project_id"). + Where("projects.user_id = ? AND asset_versions.asset_id = ?", userID, assetID). + Scan(&shotProjects).Error; err != nil { + return nil, err + } + var candidateProjects []projectReference + if err := r.db.Table("project_asset_candidates"). + Distinct("projects.id, projects.name AS title"). + Joins("JOIN projects ON projects.id = project_asset_candidates.project_id"). + Where("projects.user_id = ? AND project_asset_candidates.resolved_asset_id = ?", userID, assetID). + Scan(&candidateProjects).Error; err != nil { + return nil, err + } + result := make([]ResourceDirectReference, 0, len(projects)+len(shotProjects)+len(candidateProjects)) + for _, project := range append(append(projects, shotProjects...), candidateProjects...) { + result = append(result, ResourceDirectReference{Kind: "项目", ID: project.ID, Title: project.Title}) + } + return result, nil +} + +func (r *Repository) DeleteAssetAndResources(userID string, assetID string, resourceIDs []string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id = ?", assetID) + if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.CharacterVoiceBinding{}).Error; err != nil { + return err + } + if err := tx.Where("asset_version_id IN (?)", versionIDs).Delete(&model.AssetRepresentation{}).Error; err != nil { + return err + } + if err := tx.Where("asset_id = ?", assetID).Delete(&model.AssetVersion{}).Error; err != nil { + return err + } + if err := tx.Delete(&model.Asset{}, "id = ? AND user_id = ?", assetID, userID).Error; err != nil { + return err + } + if len(resourceIDs) == 0 { + return nil + } + return tx.Where("user_id = ? AND id IN ?", userID, resourceIDs).Delete(&model.Resource{}).Error + }) +} + +func resourceTextQuery(query *gorm.DB, columns []string, resourceIDs []string) *gorm.DB { + conditions := make([]string, 0, len(columns)*len(resourceIDs)) + args := make([]any, 0, len(columns)*len(resourceIDs)) + for _, column := range columns { + for _, resourceID := range resourceIDs { + conditions = append(conditions, column+" LIKE ?") + args = append(args, "%"+resourceID+"%") + } + } + if len(conditions) == 0 { + return query + } + return query.Where("("+strings.Join(conditions, " OR ")+")", args...) +} diff --git a/backend/internal/service/resource_delete.go b/backend/internal/service/resource_delete.go new file mode 100644 index 000000000..4703e7e94 --- /dev/null +++ b/backend/internal/service/resource_delete.go @@ -0,0 +1,369 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +func (s *Service) deleteUserAssetWithResources(userID string, assetID string) error { + asset, err := s.repo.AssetForUser(userID, assetID) + if err != nil { + return err + } + assetReferences, err := s.repo.AssetBusinessReferences(userID, assetID) + if err != nil { + return err + } + versions, representations, err := s.repo.AssetResourceRecords(assetID) + if err != nil { + return err + } + + resourceIDs := map[string]struct{}{} + originTaskIDs := map[string]struct{}{} + if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs, originTaskIDs); err != nil { + return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件") + } + for _, version := range versions { + if err := collectOwnedAssetDocumentReferences(version.DefinitionJSON, resourceIDs, originTaskIDs); err != nil { + return BadAuthRequest("素材版本数据无法解析,已停止删除以避免误删文件") + } + } + for _, representation := range representations { + if resourceID := validCanvasResourceID(representation.ResourceID); resourceID != "" { + resourceIDs[resourceID] = struct{}{} + } + if taskID := validCanvasResourceID(representation.TaskID); taskID != "" { + originTaskIDs[taskID] = struct{}{} + } + if err := collectOwnedAssetDocumentReferences(representation.MetadataJSON, resourceIDs, originTaskIDs); err != nil { + return BadAuthRequest("素材表现数据无法解析,已停止删除以避免误删文件") + } + } + + candidateIDs := sortedReferenceIDs(resourceIDs) + resources, err := s.repo.ResourcesForUserIDs(userID, candidateIDs) + if err != nil { + return err + } + ownedIDs := make([]string, 0, len(resources)) + ownedIDSet := make(map[string]struct{}, len(resources)) + for _, resource := range resources { + ownedIDs = append(ownedIDs, resource.ID) + ownedIDSet[resource.ID] = struct{}{} + } + + usages := make([]resourceUsage, 0, len(assetReferences)) + for _, reference := range assetReferences { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } + if len(ownedIDs) > 0 { + snapshot, snapshotErr := s.repo.ResourceReferenceSnapshot(userID, assetID, ownedIDs) + if snapshotErr != nil { + return snapshotErr + } + for _, reference := range snapshot.Direct { + if _, exists := ownedIDSet[reference.ResourceID]; exists { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } + } + for _, document := range snapshot.Documents { + primaryReferenced := documentReferencesResources(document.PrimaryJSON, ownedIDSet) + secondaryReferenced := documentReferencesResources(document.SecondaryJSON, ownedIDSet) + if document.Kind == "任务" { + _, originTask := originTaskIDs[document.ID] + secondaryReferenced = secondaryReferenced && !originTask + } + if primaryReferenced || secondaryReferenced { + usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) + } + } + } + if message := resourceOccupiedMessage(usages); message != "" { + return BadAuthRequest(message) + } + + // 所有引用校验必须先完成;任何资源仍被占用时,一个物理文件都不会开始删除。 + physicalObjects := map[string]*model.Resource{} + for index := range resources { + resource := &resources[index] + sharedCount, countErr := s.repo.ResourceStorageReferenceCount(resource, ownedIDs) + if countErr != nil { + return countErr + } + if sharedCount > 0 { + continue + } + physicalObjects[resourceStorageIdentity(resource)] = resource + } + physicalKeys := make([]string, 0, len(physicalObjects)) + for key := range physicalObjects { + physicalKeys = append(physicalKeys, key) + } + sort.Strings(physicalKeys) + for _, key := range physicalKeys { + resource := physicalObjects[key] + if err := s.deleteStoredResourceObject(userID, resource); err != nil { + return fmt.Errorf("素材文件删除失败,素材记录已保留:%w", err) + } + } + if err := s.repo.DeleteAssetAndResources(userID, assetID, ownedIDs); err != nil { + return fmt.Errorf("素材记录删除失败,请重试:%w", err) + } + return nil +} + +type resourceUsage struct { + Kind string + ID string + Title string +} + +func resourceOccupiedMessage(usages []resourceUsage) string { + seen := map[string]struct{}{} + labels := make([]string, 0, len(usages)) + for _, usage := range usages { + key := usage.Kind + "\x00" + usage.ID + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + title := strings.TrimSpace(usage.Title) + if title == "" { + title = usage.ID + } + title = truncateRunes(title, 32) + labels = append(labels, usage.Kind+"「"+title+"」") + } + if len(labels) == 0 { + return "" + } + sort.Strings(labels) + visible := labels + if len(visible) > 3 { + visible = append(append([]string{}, visible[:3]...), fmt.Sprintf("等 %d 处", len(labels))) + } + return "素材仍被" + strings.Join(visible, "、") + "引用,请先在对应画布、任务或业务记录中解除引用后再删除" +} + +func collectOwnedAssetDocumentReferences(raw string, resourceIDs map[string]struct{}, taskIDs map[string]struct{}) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return err + } + walkReferenceDocument(value, "", resourceIDs, taskIDs) + return nil +} + +func walkReferenceDocument(value any, parentKey string, resourceIDs map[string]struct{}, taskIDs map[string]struct{}) { + switch item := value.(type) { + case map[string]any: + for key, child := range item { + walkReferenceDocument(child, key, resourceIDs, taskIDs) + } + case []any: + for _, child := range item { + walkReferenceDocument(child, parentKey, resourceIDs, taskIDs) + } + case string: + key := normalizeReferenceKey(parentKey) + if isResourceLocatorKey(key) { + if resourceID := canvasResourceID(item); resourceID != "" { + resourceIDs[resourceID] = struct{}{} + } + } + if isBareResourceIDKey(key) { + if resourceID := validCanvasResourceID(item); resourceID != "" { + resourceIDs[resourceID] = struct{}{} + } + } + if key == "taskid" || key == "taskids" { + if taskID := validCanvasResourceID(item); taskID != "" { + taskIDs[taskID] = struct{}{} + } + } + } +} + +func normalizeReferenceKey(key string) string { + key = strings.ToLower(strings.TrimSpace(key)) + return strings.NewReplacer("_", "", "-", "").Replace(key) +} + +func isBareResourceIDKey(key string) bool { + return key == "resourceid" || key == "resourceids" || key == "sampleresourceid" || key == "referenceresourceid" || key == "referenceresourceids" +} + +func isResourceLocatorKey(key string) bool { + if key == "" || strings.HasSuffix(key, "url") || strings.HasSuffix(key, "urls") || strings.HasSuffix(key, "storagekey") { + return true + } + switch key { + case "content", "data", "image", "images", "video", "videos", "audio", "audios", "media", "references", "result", "output", "artifactref", "providerartifactref": + return true + default: + return false + } +} + +func documentReferencesResources(raw string, resourceIDs map[string]struct{}) bool { + raw = strings.TrimSpace(raw) + if raw == "" || len(resourceIDs) == 0 { + return false + } + var value any + if err := json.Unmarshal([]byte(raw), &value); err == nil { + found := map[string]struct{}{} + walkReferenceDocument(value, "", found, map[string]struct{}{}) + for resourceID := range found { + if _, exists := resourceIDs[resourceID]; exists { + return true + } + } + return false + } + // cover_url 等字段可以直接保存资源 URL,而不是 JSON。候选记录已经包含完整 ID,保守阻止删除。 + for resourceID := range resourceIDs { + if strings.Contains(raw, resourceID) { + return true + } + } + return false +} + +func sortedReferenceIDs(values map[string]struct{}) []string { + result := make([]string, 0, len(values)) + for value := range values { + result = append(result, value) + } + sort.Strings(result) + return result +} + +func resourceStorageIdentity(resource *model.Resource) string { + if resource == nil { + return "" + } + provider := strings.ToLower(strings.TrimSpace(resource.Provider)) + if provider == "" { + provider = "local" + } + return strings.Join([]string{provider, resource.Endpoint, resource.Bucket, resource.ObjectKey}, "\x00") +} + +func (s *Service) deleteStoredResourceObject(userID string, resource *model.Resource) error { + if resource == nil { + return errors.New("资源记录为空") + } + if strings.TrimSpace(resource.ObjectKey) == "" { + return fmt.Errorf("资源 %s 的存储路径为空", resource.ID) + } + switch strings.ToLower(strings.TrimSpace(resource.Provider)) { + case "", "local": + return s.deleteLocalResourceObject(resource.ObjectKey) + case aliyunOSSProvider: + setting, err := s.ossSettingForResource(userID, resource) + if err != nil { + return fmt.Errorf("无法读取阿里云 OSS 配置:%w", err) + } + return deleteAliyunOSSObject(setting, resource.ObjectKey) + case tencentCOSProvider: + setting, err := s.ossSettingForResource(userID, resource) + if err != nil { + return fmt.Errorf("无法读取腾讯云 COS 配置:%w", err) + } + return deleteTencentCOSObject(setting, resource.ObjectKey) + default: + return fmt.Errorf("资源 %s 使用了不支持的存储类型 %q", resource.ID, resource.Provider) + } +} + +func (s *Service) deleteLocalResourceObject(objectKey string) error { + root, err := filepath.Abs(filepath.Join(s.dataDir, "resources")) + if err != nil { + return fmt.Errorf("解析本地资源目录失败:%w", err) + } + target, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(strings.TrimLeft(objectKey, "/\\")))) + if err != nil { + return fmt.Errorf("解析本地资源路径失败:%w", err) + } + relative, err := filepath.Rel(root, target) + if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errors.New("本地资源路径超出允许目录") + } + fileInfo, err := os.Lstat(target) + if errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return fmt.Errorf("检查服务器本地文件失败:%w", err) + } + if fileInfo.IsDir() { + return errors.New("本地资源路径指向目录,已停止删除") + } + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return fmt.Errorf("检查本地资源目录失败:%w", err) + } + resolvedTarget, err := filepath.EvalSymlinks(target) + if err != nil { + return fmt.Errorf("检查本地资源路径失败:%w", err) + } + resolvedRelative, err := filepath.Rel(resolvedRoot, resolvedTarget) + if err != nil || resolvedRelative == "." || resolvedRelative == ".." || strings.HasPrefix(resolvedRelative, ".."+string(filepath.Separator)) { + return errors.New("本地资源真实路径超出允许目录") + } + if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("删除服务器本地文件失败:%w", err) + } + return nil +} + +func deleteAliyunOSSObject(setting ossSettingValue, objectKey string) error { + req, err := newOSSRequest(http.MethodDelete, setting, objectKey, "", nil) + if err != nil { + return err + } + resp, err := OutboundHTTPClient(2 * time.Minute).Do(req) + if err != nil { + return fmt.Errorf("删除阿里云 OSS 对象失败:%w", err) + } + defer resp.Body.Close() + if (resp.StatusCode < 200 || resp.StatusCode >= 300) && resp.StatusCode != http.StatusNotFound { + detail, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("删除阿里云 OSS 对象失败:%s %s", resp.Status, strings.TrimSpace(string(detail))) + } + return nil +} + +func deleteTencentCOSObject(setting ossSettingValue, objectKey string) error { + client, err := newCOSClient(setting, 2*time.Minute) + if err != nil { + return err + } + resp, err := client.Object.Delete(context.Background(), objectKey) + if resp != nil && resp.Body != nil { + defer resp.Body.Close() + } + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return nil + } + return fmt.Errorf("删除腾讯云 COS 对象失败:%w", err) + } + return nil +} diff --git a/backend/internal/service/resource_delete_test.go b/backend/internal/service/resource_delete_test.go new file mode 100644 index 000000000..7f223384f --- /dev/null +++ b/backend/internal/service/resource_delete_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestCollectOwnedAssetDocumentReferences(t *testing.T) { + resources := map[string]struct{}{} + tasks := map[string]struct{}{} + raw := `{ + "data":{"storageKey":"resource:resource-1","url":"/api/resources/resource-2/file"}, + "metadata":{"taskId":"task-1","referenceResourceIds":["resource-3"],"errorDetails":"failed near /api/resources/not-a-reference/file"} + }` + if err := collectOwnedAssetDocumentReferences(raw, resources, tasks); err != nil { + t.Fatal(err) + } + for _, resourceID := range []string{"resource-1", "resource-2", "resource-3"} { + if _, exists := resources[resourceID]; !exists { + t.Fatalf("resource %q was not collected: %#v", resourceID, resources) + } + } + if _, exists := tasks["task-1"]; !exists { + t.Fatalf("origin task was not collected: %#v", tasks) + } + if _, exists := resources["not-a-reference"]; exists { + t.Fatalf("diagnostic text must not be treated as an actual resource reference: %#v", resources) + } +} + +func TestDocumentReferencesResourcesUsesExactResourceID(t *testing.T) { + candidates := map[string]struct{}{"resource-1": {}} + if documentReferencesResources(`{"storageKey":"resource:resource-10"}`, candidates) { + t.Fatal("resource-1 must not match resource-10") + } + if !documentReferencesResources(`{"storageKey":"resource:resource-1"}`, candidates) { + t.Fatal("exact resource ID should match") + } +} + +func TestResourceOccupiedMessageNamesBusinessRecord(t *testing.T) { + message := resourceOccupiedMessage([]resourceUsage{ + {Kind: "画布", ID: "canvas-1", Title: "广告分镜"}, + {Kind: "画布", ID: "canvas-1", Title: "广告分镜"}, + }) + if !strings.Contains(message, "画布「广告分镜」") || !strings.Contains(message, "解除引用") { + t.Fatalf("message = %q", message) + } +} + +func TestDeleteLocalResourceObjectRemovesOnlyResourceDirectoryFile(t *testing.T) { + dataDir := t.TempDir() + resourcePath := filepath.Join(dataDir, "resources", "users", "user-1", "image", "asset.png") + if err := os.MkdirAll(filepath.Dir(resourcePath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(resourcePath, []byte("image"), 0o640); err != nil { + t.Fatal(err) + } + service := &Service{dataDir: dataDir} + if err := service.deleteLocalResourceObject("users/user-1/image/asset.png"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(resourcePath); !os.IsNotExist(err) { + t.Fatalf("resource file still exists or stat failed unexpectedly: %v", err) + } + + outsidePath := filepath.Join(dataDir, "outside.txt") + if err := os.WriteFile(outsidePath, []byte("keep"), 0o640); err != nil { + t.Fatal(err) + } + if err := service.deleteLocalResourceObject("../outside.txt"); err == nil { + t.Fatal("path traversal should be rejected") + } + if _, err := os.Stat(outsidePath); err != nil { + t.Fatalf("outside file was changed: %v", err) + } +} diff --git a/backend/internal/service/user_data.go b/backend/internal/service/user_data.go index 3b886be48..8c2ca7008 100644 --- a/backend/internal/service/user_data.go +++ b/backend/internal/service/user_data.go @@ -103,17 +103,9 @@ func (s *Service) UpsertUserAsset(userID string, raw json.RawMessage) (UserDataS } func (s *Service) DeleteUserAsset(userID string, id string) error { - if _, err := s.repo.AssetForUser(userID, id); err != nil { - return err - } - references, err := s.repo.AssetReferenceCount(id) - if err != nil { - return err - } - if references > 0 { - return BadAuthRequest("素材仍被项目或镜头引用,请先解除引用") - } - return s.repo.DeleteAsset(userID, id) + s.storageMu.Lock() + defer s.storageMu.Unlock() + return s.deleteUserAssetWithResources(userID, id) } func (s *Service) UserAssets(userID string) ([]json.RawMessage, error) { diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index 9615e07ff..96130751a 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -450,10 +450,10 @@ export default function AssetsPage() { { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} /> setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> - 确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。 + 确定删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除;若仍被画布、任务或其他素材占用,本次删除将被阻止。 setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消"> - 确定删除已选择的 {selectedAssets.length} 个素材吗?此操作会同步移除远端记录。 + 确定删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除;仍被画布、任务或其他素材占用的素材会保留并提示具体来源。 );