package app import ( "errors" "fmt" "os" "path/filepath" "sort" "strings" "time" "yingce/backend/internal/assets" "yingce/backend/internal/model" "yingce/backend/internal/repository" "gorm.io/gorm" ) func (s *Service) deleteUserAssetWithResources(userID string, assetID string, purge bool) error { return s.deleteUserAssetsWithResources(userID, []string{assetID}, purge) } func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, purge bool) error { if strings.TrimSpace(userID) == "" || len(ids) == 0 || len(ids) > 1000 { return BadAuthRequest("每次删除须提供 1–1000 个素材 ID 和有效用户") } assetIDs := make([]string, 0, len(ids)) seen := make(map[string]struct{}, len(ids)) for _, id := range ids { id = strings.TrimSpace(id) if id == "" { return BadAuthRequest("素材 ID 不能为空") } if _, exists := seen[id]; !exists { seen[id] = struct{}{} assetIDs = append(assetIDs, id) } } assetRecords, err := s.repo.AssetsForUserIDs(userID, assetIDs) if err != nil { return err } if len(assetRecords) != len(assetIDs) { return NotFound("素材不存在或无权删除") } // 删除语义(产品约定,勿收紧): // - purge=true 是用户在素材库确认后的「彻底删除」(单个 DELETE、批量删除、清空回收站), // 不因画布、任务、项目或画布历史快照仍引用而拒绝;旧引用随之失效,画布保存时由 // repairMissingResources 修复断链。 // - purge=false 是内部普通删除:回收站(archived)素材同样直接删除,其余素材保留引用保护。 // - 无论哪种,被其他素材复用的资源与物理对象都保留,物理删除一律经 Outbox 异步执行。 deleteReferencedResources := purge if !purge { if len(assetRecords) != 1 { return BadAuthRequest("普通删除仅支持单个素材") } deleteReferencedResources = assetRecords[0].Status == model.AssetVersionStatusArchived } versions, representations, err := s.repo.AssetsResourceRecords(assetIDs) if err != nil { return err } resourceIDs := map[string]struct{}{} for _, asset := range assetRecords { if err := collectOwnedAssetDocumentReferences(asset.PayloadJSON, resourceIDs); err != nil { return BadAuthRequest("素材数据无法解析,已停止删除以避免误删文件") } } for _, version := range versions { if err := collectOwnedAssetDocumentReferences(version.DefinitionJSON, resourceIDs); err != nil { return BadAuthRequest("素材版本数据无法解析,已停止删除以避免误删文件") } } for _, representation := range representations { if resourceID := validCanvasResourceID(representation.ResourceID); resourceID != "" { resourceIDs[resourceID] = struct{}{} } if err := collectOwnedAssetDocumentReferences(representation.MetadataJSON, resourceIDs); 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) if !deleteReferencedResources { for _, assetID := range assetIDs { assetReferences, referenceErr := s.repo.AssetBusinessReferences(userID, assetID) if referenceErr != nil { return referenceErr } for _, reference := range assetReferences { usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) } } } if len(ownedIDs) > 0 { var snapshot repository.ResourceReferenceSnapshot var snapshotErr error if deleteReferencedResources { // 彻底删除只需要知道哪些资源仍被「其他素材」复用(这些资源要保留),不扫描任务和画布。 snapshot, snapshotErr = s.repo.OtherAssetResourceReferences(userID, assetIDs) } else { snapshot, snapshotErr = s.repo.ResourceReferenceSnapshotExcludingAssets(userID, assetIDs, ownedIDs) } if snapshotErr != nil { return snapshotErr } sharedAssetResourceIDs := map[string]struct{}{} for _, reference := range snapshot.Direct { if _, exists := ownedIDSet[reference.ResourceID]; exists { if reference.Kind == "素材" { sharedAssetResourceIDs[reference.ResourceID] = struct{}{} continue } if !deleteReferencedResources { usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) } } } for _, document := range snapshot.Documents { // 已结束任务的输出和日志仅记录生成历史,不构成素材占用。 // 仅在素材删除时放行;孤儿清理仍保留尚未入库的任务产物。 switch document.TaskStatus { case model.TaskStatusSucceeded, model.TaskStatusFailed, model.TaskStatusCancelled: if document.Kind == "任务日志" || document.Kind == "任务结果" { continue } if document.Kind == "任务" { document.SecondaryJSON = "" } } referencedIDs := documentReferencedResourceIDs(document.PrimaryJSON, ownedIDSet) for resourceID := range documentReferencedResourceIDs(document.SecondaryJSON, ownedIDSet) { referencedIDs[resourceID] = struct{}{} } if len(referencedIDs) > 0 { if document.Kind == "素材" { for resourceID := range referencedIDs { sharedAssetResourceIDs[resourceID] = struct{}{} } continue } if !deleteReferencedResources { usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) } } } if len(sharedAssetResourceIDs) > 0 { deletableOwnedIDs := ownedIDs[:0] for _, resourceID := range ownedIDs { if _, shared := sharedAssetResourceIDs[resourceID]; !shared { deletableOwnedIDs = append(deletableOwnedIDs, resourceID) } } ownedIDs = deletableOwnedIDs } } 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 } deletionJobs := resourceDeletionJobs(userID, physicalObjects) // 业务记录和 Outbox 必须在同一事务提交。事务失败时物理文件完全不动; // 提交成功后由幂等 worker 清理,进程退出或对象存储暂时失败都可继续重试。 if err := s.repo.DeleteAssetsAndResources(userID, assetIDs, ownedIDs, deletionJobs, deleteReferencedResources); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return NotFound("素材不存在或无权删除") } if errors.Is(err, repository.ErrCanvasHistoryResourceReferenced) { return BadAuthRequest("素材仍被画布历史版本引用,已保留文件") } return fmt.Errorf("素材记录删除失败,请重试:%w", err) } if len(deletionJobs) > 0 { // 纳入停机等待;排空期间拒绝新 worker 时,已提交的 outbox 留待下次启动处理。 s.runWorkerTask(func() { s.drainResourceDeletionJobs(len(deletionJobs)) }) } return nil } func resourceDeletionJobs(userID string, physicalObjects map[string]*model.Resource) []model.ResourceDeletionJob { keys := make([]string, 0, len(physicalObjects)) for key := range physicalObjects { keys = append(keys, key) } sort.Strings(keys) now := time.Now() jobs := make([]model.ResourceDeletionJob, 0, len(keys)) for _, key := range keys { resource := physicalObjects[key] jobs = append(jobs, model.ResourceDeletionJob{ ID: newID(), UserID: userID, ResourceID: resource.ID, Provider: resource.Provider, Endpoint: resource.Endpoint, Bucket: resource.Bucket, StorageSettingID: resource.StorageSettingID, ObjectKey: resource.ObjectKey, Status: model.ResourceDeletionStatusPending, NextAttemptAt: now, }) } return jobs } 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{}) error { return assets.CollectOwnedDocumentReferences(raw, resourceIDs) } func documentReferencesResources(raw string, resourceIDs map[string]struct{}) bool { return assets.DocumentReferences(raw, resourceIDs) } func documentReferencedResourceIDs(raw string, resourceIDs map[string]struct{}) map[string]struct{} { return assets.DocumentReferencedIDs(raw, resourceIDs) } func sortedReferenceIDs(values map[string]struct{}) []string { return assets.SortedIDs(values) } 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) } protected, err := s.repo.CanvasHistoryReferencesObject(resource) if err != nil { return err } if protected { return errors.New("资源仍被画布历史版本引用") } 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) case qiniuKodoProvider: setting, err := s.ossSettingForResource(userID, resource) if err != nil { return fmt.Errorf("无法读取七牛云 Kodo 配置:%w", err) } return deleteQiniuObject(setting, resource.ObjectKey) case s3Provider: setting, err := s.ossSettingForResource(userID, resource) if err != nil { return fmt.Errorf("无法读取 S3 配置:%w", err) } return deleteS3Object(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 }