Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion backend/docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -4160,7 +4160,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。",
"description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true 时同步删除不再被其他会话引用的文件。",
"consumes": [
"application/json"
],
Expand All @@ -4184,6 +4184,12 @@ const docTemplate = `{
"description": "是否同时删除项目内会话",
"name": "delete_conversations",
"in": "query"
},
{
"type": "boolean",
"description": "是否同步删除不再被其他会话引用的会话文件",
"name": "delete_files",
"in": "query"
}
],
"responses": {
Expand Down Expand Up @@ -4675,6 +4681,12 @@ const docTemplate = `{
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "是否同步删除不再被其他会话引用的会话文件",
"name": "delete_files",
"in": "query"
}
],
"responses": {
Expand Down Expand Up @@ -10168,6 +10180,12 @@ const docTemplate = `{
"properties": {
"deleted": {
"type": "boolean"
},
"deletedFileCount": {
"type": "integer"
},
"quota": {
"$ref": "#/definitions/internal_transport_http_conversation.StorageQuotaResponse"
}
}
},
Expand Down
20 changes: 19 additions & 1 deletion backend/docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -4153,7 +4153,7 @@
"BearerAuth": []
}
],
"description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。",
"description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true 时同步删除不再被其他会话引用的文件。",
"consumes": [
"application/json"
],
Expand All @@ -4177,6 +4177,12 @@
"description": "是否同时删除项目内会话",
"name": "delete_conversations",
"in": "query"
},
{
"type": "boolean",
"description": "是否同步删除不再被其他会话引用的会话文件",
"name": "delete_files",
"in": "query"
}
],
"responses": {
Expand Down Expand Up @@ -4668,6 +4674,12 @@
"name": "id",
"in": "path",
"required": true
},
{
"type": "boolean",
"description": "是否同步删除不再被其他会话引用的会话文件",
"name": "delete_files",
"in": "query"
}
],
"responses": {
Expand Down Expand Up @@ -10161,6 +10173,12 @@
"properties": {
"deleted": {
"type": "boolean"
},
"deletedFileCount": {
"type": "integer"
},
"quota": {
"$ref": "#/definitions/internal_transport_http_conversation.StorageQuotaResponse"
}
}
},
Expand Down
15 changes: 14 additions & 1 deletion backend/docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2397,6 +2397,10 @@ definitions:
properties:
deleted:
type: boolean
deletedFileCount:
type: integer
quota:
$ref: '#/definitions/internal_transport_http_conversation.StorageQuotaResponse'
type: object
internal_transport_http_conversation.ConversationDeleteResponseDoc:
properties:
Expand Down Expand Up @@ -5974,7 +5978,8 @@ paths:
delete:
consumes:
- application/json
description: 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。
description: 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true
时同步删除不再被其他会话引用的文件。
parameters:
- description: 项目 public_id
in: path
Expand All @@ -5985,6 +5990,10 @@ paths:
in: query
name: delete_conversations
type: boolean
- description: 是否同步删除不再被其他会话引用的会话文件
in: query
name: delete_files
type: boolean
produces:
- application/json
responses:
Expand Down Expand Up @@ -6227,6 +6236,10 @@ paths:
name: id
required: true
type: string
- description: 是否同步删除不再被其他会话引用的会话文件
in: query
name: delete_files
type: boolean
produces:
- application/json
responses:
Expand Down
29 changes: 23 additions & 6 deletions backend/internal/application/conversation/service_conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ const (
maxPageSize = 100
)

// DeleteConversationOptions 定义会话删除选项。
type DeleteConversationOptions struct {
DeleteFiles bool
}

// DeleteConversationResult 返回会话删除结果。
type DeleteConversationResult struct {
Deleted bool
DeletedFileCount int
Quota *model.StorageQuota
}

// CreateConversation 创建用户新会话。
func (s *Service) CreateConversation(ctx context.Context, userID uint, title string, modelName string, projectPublicID string) (*model.Conversation, error) {
normalizedTitle := strings.TrimSpace(title)
Expand Down Expand Up @@ -218,15 +230,20 @@ func (s *Service) SetConversationArchived(ctx context.Context, userID uint, publ
return item, nil
}

// DeleteConversation 删除会话(软删除)。
func (s *Service) DeleteConversation(ctx context.Context, userID uint, publicID string) error {
if err := s.repo.DeleteConversationByPublicID(ctx, userID, publicID); err != nil {
// DeleteConversation 删除会话(软删除),并按需清理不再被其他会话引用的文件。
func (s *Service) DeleteConversation(ctx context.Context, userID uint, publicID string, options DeleteConversationOptions) (*DeleteConversationResult, error) {
cleanupFileIDs, err := s.repo.DeleteConversationByPublicID(ctx, userID, publicID, options.DeleteFiles)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return ErrConversationNotFound
return nil, ErrConversationNotFound
}
return err
return nil, err
}
result := &DeleteConversationResult{Deleted: true}
if options.DeleteFiles {
result.DeletedFileCount, result.Quota = s.deleteConversationFiles(ctx, userID, cleanupFileIDs)
}
return nil
return result, nil
}

// GetConversation 查询用户会话元信息。
Expand Down
42 changes: 42 additions & 0 deletions backend/internal/application/conversation/service_file_cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package conversation

import (
"context"
"strings"

model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
"go.uber.org/zap"
)

// deleteConversationFiles 清理会话删除后不再被其他活跃会话引用的文件。
func (s *Service) deleteConversationFiles(ctx context.Context, userID uint, fileIDs []string) (int, *model.StorageQuota) {
if len(fileIDs) == 0 || s.uploadSvc == nil {
return 0, nil
}
deletedCount := 0
var latestQuota *model.StorageQuota
for _, rawFileID := range fileIDs {
fileID := strings.TrimSpace(rawFileID)
if fileID == "" {
continue
}
result, deleted, err := s.uploadSvc.DeleteFileIfUnreferenced(ctx, userID, fileID)
if err != nil {
if s.logger != nil {
s.logger.Warn("delete_conversation_file_failed",
zap.Uint("user_id", userID),
zap.String("file_id", fileID),
zap.Error(err),
)
}
continue
}
if !deleted || result == nil {
continue
}
deletedCount++
quota := result.Quota
latestQuota = &quota
}
return deletedCount, latestQuota
}
27 changes: 22 additions & 5 deletions backend/internal/application/conversation/service_project.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,31 @@ func (s *Service) UpdateConversationProject(
}

// DeleteConversationProject 删除当前用户项目分组。
func (s *Service) DeleteConversationProject(ctx context.Context, userID uint, publicID string, deleteConversations bool) error {
if err := s.repo.DeleteConversationProjectByPublicID(ctx, userID, strings.TrimSpace(publicID), deleteConversations); err != nil {
func (s *Service) DeleteConversationProject(
ctx context.Context,
userID uint,
publicID string,
deleteConversations bool,
options DeleteConversationOptions,
) (*DeleteConversationResult, error) {
cleanupFileIDs, err := s.repo.DeleteConversationProjectByPublicID(
ctx,
userID,
strings.TrimSpace(publicID),
deleteConversations,
deleteConversations && options.DeleteFiles,
)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return ErrConversationProjectNotFound
return nil, ErrConversationProjectNotFound
}
return err
return nil, err
}
return nil
result := &DeleteConversationResult{Deleted: true}
if deleteConversations && options.DeleteFiles {
result.DeletedFileCount, result.Quota = s.deleteConversationFiles(ctx, userID, cleanupFileIDs)
}
return result, nil
}

// ReorderConversationProjects 更新当前用户项目展示顺序。
Expand Down
23 changes: 18 additions & 5 deletions backend/internal/application/upload/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ func (s *Service) tryReuseExistingFile(
zap.String("path", existingFile.StoragePath),
)
}
deletedFile, _, shouldRemovePhysical, deleteErr := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, existingFile.FileID, quotaBytes)
deletedFile, _, shouldRemovePhysical, deleteErr := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, existingFile.FileID, quotaBytes, repository.DeleteFileObjectOptions{})
if deleteErr != nil {
return nil, false, deleteErr
}
Expand Down Expand Up @@ -413,15 +413,28 @@ func objectMatchesContent(ctx context.Context, store objectstore.Store, path str

// DeleteFile 删除文件并回收配额。
func (s *Service) DeleteFile(ctx context.Context, userID uint, fileID string) (*DeleteFileResult, error) {
result, _, err := s.deleteFile(ctx, userID, fileID, repository.DeleteFileObjectOptions{})
return result, err
}

// DeleteFileIfUnreferenced 仅在文件未被活跃会话引用时删除文件并回收配额。
func (s *Service) DeleteFileIfUnreferenced(ctx context.Context, userID uint, fileID string) (*DeleteFileResult, bool, error) {
return s.deleteFile(ctx, userID, fileID, repository.DeleteFileObjectOptions{RequireUnreferenced: true})
}

func (s *Service) deleteFile(ctx context.Context, userID uint, fileID string, options repository.DeleteFileObjectOptions) (*DeleteFileResult, bool, error) {
normalizedFileID := strings.TrimSpace(fileID)
if normalizedFileID == "" {
return nil, s.errInvalidFileReference()
return nil, false, s.errInvalidFileReference()
}

cfg := s.snapshot()
deletedFile, quota, shouldRemovePhysical, err := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, normalizedFileID, cfg.UserStorageQuotaBytes)
deletedFile, quota, shouldRemovePhysical, err := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, normalizedFileID, cfg.UserStorageQuotaBytes, options)
if err != nil {
return nil, err
if options.RequireUnreferenced && errors.Is(err, repository.ErrConflict) {
return nil, false, nil
}
return nil, false, err
}
if shouldRemovePhysical {
store, storeErr := s.openObjectStore(ctx)
Expand All @@ -442,7 +455,7 @@ func (s *Service) DeleteFile(ctx context.Context, userID uint, fileID string) (*
Deleted: true,
FileID: normalizedFileID,
Quota: *quota,
}, nil
}, true, nil
}

// RenameFile 重命名当前用户文件。
Expand Down
34 changes: 33 additions & 1 deletion backend/internal/application/upload/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,33 @@ func TestUploadFileAllowsReuploadAfterDelete(t *testing.T) {
}
}

func TestDeleteFileIfUnreferencedSkipsReferencedFile(t *testing.T) {
ctx := context.Background()
repo := newUploadTestRepo()
store := newUploadTestStore()
service := newUploadTestService(repo, store)

uploaded, err := service.UploadFile(ctx, uploadTestInput("notes.md", "same content"))
if err != nil {
t.Fatalf("upload failed: %v", err)
}
repo.referencedFileIDs[uploaded.File.FileID] = true

result, deleted, err := service.DeleteFileIfUnreferenced(ctx, 1, uploaded.File.FileID)
if err != nil {
t.Fatalf("delete if unreferenced failed: %v", err)
}
if deleted || result != nil {
t.Fatal("referenced file should be skipped without returning a delete result")
}
if status := repo.fileStatus(uploaded.File.FileID); status != "active" {
t.Fatalf("referenced file should remain active, got %q", status)
}
if got := store.objectCount(); got != 1 {
t.Fatalf("referenced file should keep physical object, got %d objects", got)
}
}

func TestUploadFileReplacesStaleDuplicatePointer(t *testing.T) {
ctx := context.Background()
repo := newUploadTestRepo()
Expand Down Expand Up @@ -301,6 +328,7 @@ type uploadTestRepo struct {
quota domainconversation.StorageQuota
missNextDuplicateLookup bool
failNextCreateDuplicate bool
referencedFileIDs map[string]bool
}

func newUploadTestRepo() *uploadTestRepo {
Expand All @@ -310,6 +338,7 @@ func newUploadTestRepo() *uploadTestRepo {
UserID: 1,
QuotaBytes: 10 * 1024 * 1024,
},
referencedFileIDs: make(map[string]bool),
}
}

Expand Down Expand Up @@ -392,14 +421,17 @@ func (r *uploadTestRepo) CreateFileObjectAndConsumeQuota(_ context.Context, item
return cloneQuota(r.quota), nil
}

func (r *uploadTestRepo) DeleteFileObjectAndReleaseQuota(_ context.Context, userID uint, fileID string, quotaLimit int64) (*domainconversation.FileObject, *domainconversation.StorageQuota, bool, error) {
func (r *uploadTestRepo) DeleteFileObjectAndReleaseQuota(_ context.Context, userID uint, fileID string, quotaLimit int64, options repository.DeleteFileObjectOptions) (*domainconversation.FileObject, *domainconversation.StorageQuota, bool, error) {
if quotaLimit > 0 {
r.quota.QuotaBytes = quotaLimit
}
for i := range r.files {
if r.files[i].UserID != userID || r.files[i].FileID != fileID || r.files[i].Status != "active" {
continue
}
if options.RequireUnreferenced && r.referencedFileIDs[fileID] {
return nil, nil, false, repository.ErrConflict
}
deleted := r.files[i]
r.files[i].Status = "deleted"
remainingRefs := 0
Expand Down
Loading
Loading