feat(media): 素材删除 - 安全清理未引用的存储资源

删除素材前检查项目、画布、任务、其他素材、工作流、风格和声音引用;仅清理未复用的本地或 OSS/COS 对象。
This commit is contained in:
MasterChiefCN
2026-08-16 13:11:30 +08:00
committed by GitHub
parent 9f182a2902
commit c7965e7934
8 changed files with 731 additions and 29 deletions

View File

@@ -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 静态资源和生产部署仍需按待测试清单验证,不得写成已全面生产验证。

View File

@@ -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。
## 公网部署安全

View File

@@ -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 {

View File

@@ -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...)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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) {

View File

@@ -450,10 +450,10 @@ export default function AssetsPage() {
<input ref={modelInputRef} type="file" accept=".glb,.gltf,model/gltf-binary,model/gltf+json" className="hidden" onChange={(event) => { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} />
<Modal className="library-modal library-confirm-modal" title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
确定删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除;若仍被画布、任务或其他素材占用,本次删除将被阻止。
</Modal>
<Modal className="library-modal library-confirm-modal" title="批量删除素材" open={batchDeleteOpen} onCancel={() => setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
确定删除已选择的 {selectedAssets.length} 个素材吗?此操作会同步移除远端记录。
确定删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除;仍被画布、任务或其他素材占用的素材会保留并提示具体来源。
</Modal>
</>
);