feat(media): 素材删除 - 安全清理未引用的存储资源
删除素材前检查项目、画布、任务、其他素材、工作流、风格和声音引用;仅清理未复用的本地或 OSS/COS 对象。
This commit is contained in:
1 parent
9f182a2902
commit
c7965e7934
8 files changed
+731
-29
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user