Skip to content

Commit efda47d

Browse files
authored
Merge pull request #35 from DEEIX-AI/clean_conversation_files
feat: support optional file cleanup when deleting conversations
2 parents 61c0020 + 0def583 commit efda47d

33 files changed

Lines changed: 893 additions & 83 deletions

backend/docs/docs.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4160,7 +4160,7 @@ const docTemplate = `{
41604160
"BearerAuth": []
41614161
}
41624162
],
4163-
"description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。",
4163+
"description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true 时同步删除不再被其他会话引用的文件。",
41644164
"consumes": [
41654165
"application/json"
41664166
],
@@ -4184,6 +4184,12 @@ const docTemplate = `{
41844184
"description": "是否同时删除项目内会话",
41854185
"name": "delete_conversations",
41864186
"in": "query"
4187+
},
4188+
{
4189+
"type": "boolean",
4190+
"description": "是否同步删除不再被其他会话引用的会话文件",
4191+
"name": "delete_files",
4192+
"in": "query"
41874193
}
41884194
],
41894195
"responses": {
@@ -4675,6 +4681,12 @@ const docTemplate = `{
46754681
"name": "id",
46764682
"in": "path",
46774683
"required": true
4684+
},
4685+
{
4686+
"type": "boolean",
4687+
"description": "是否同步删除不再被其他会话引用的会话文件",
4688+
"name": "delete_files",
4689+
"in": "query"
46784690
}
46794691
],
46804692
"responses": {
@@ -10168,6 +10180,12 @@ const docTemplate = `{
1016810180
"properties": {
1016910181
"deleted": {
1017010182
"type": "boolean"
10183+
},
10184+
"deletedFileCount": {
10185+
"type": "integer"
10186+
},
10187+
"quota": {
10188+
"$ref": "#/definitions/internal_transport_http_conversation.StorageQuotaResponse"
1017110189
}
1017210190
}
1017310191
},

backend/docs/swagger.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4153,7 +4153,7 @@
41534153
"BearerAuth": []
41544154
}
41554155
],
4156-
"description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。",
4156+
"description": "删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true 时同步删除不再被其他会话引用的文件",
41574157
"consumes": [
41584158
"application/json"
41594159
],
@@ -4177,6 +4177,12 @@
41774177
"description": "是否同时删除项目内会话",
41784178
"name": "delete_conversations",
41794179
"in": "query"
4180+
},
4181+
{
4182+
"type": "boolean",
4183+
"description": "是否同步删除不再被其他会话引用的会话文件",
4184+
"name": "delete_files",
4185+
"in": "query"
41804186
}
41814187
],
41824188
"responses": {
@@ -4668,6 +4674,12 @@
46684674
"name": "id",
46694675
"in": "path",
46704676
"required": true
4677+
},
4678+
{
4679+
"type": "boolean",
4680+
"description": "是否同步删除不再被其他会话引用的会话文件",
4681+
"name": "delete_files",
4682+
"in": "query"
46714683
}
46724684
],
46734685
"responses": {
@@ -10161,6 +10173,12 @@
1016110173
"properties": {
1016210174
"deleted": {
1016310175
"type": "boolean"
10176+
},
10177+
"deletedFileCount": {
10178+
"type": "integer"
10179+
},
10180+
"quota": {
10181+
"$ref": "#/definitions/internal_transport_http_conversation.StorageQuotaResponse"
1016410182
}
1016510183
}
1016610184
},

backend/docs/swagger.yaml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2397,6 +2397,10 @@ definitions:
23972397
properties:
23982398
deleted:
23992399
type: boolean
2400+
deletedFileCount:
2401+
type: integer
2402+
quota:
2403+
$ref: '#/definitions/internal_transport_http_conversation.StorageQuotaResponse'
24002404
type: object
24012405
internal_transport_http_conversation.ConversationDeleteResponseDoc:
24022406
properties:
@@ -5974,7 +5978,8 @@ paths:
59745978
delete:
59755979
consumes:
59765980
- application/json
5977-
description: 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话。
5981+
description: 删除当前用户项目分组。默认仅解除其下会话归属;delete_conversations=true 时同时软删除项目内会话;delete_files=true
5982+
时同步删除不再被其他会话引用的文件。
59785983
parameters:
59795984
- description: 项目 public_id
59805985
in: path
@@ -5985,6 +5990,10 @@ paths:
59855990
in: query
59865991
name: delete_conversations
59875992
type: boolean
5993+
- description: 是否同步删除不再被其他会话引用的会话文件
5994+
in: query
5995+
name: delete_files
5996+
type: boolean
59885997
produces:
59895998
- application/json
59905999
responses:
@@ -6227,6 +6236,10 @@ paths:
62276236
name: id
62286237
required: true
62296238
type: string
6239+
- description: 是否同步删除不再被其他会话引用的会话文件
6240+
in: query
6241+
name: delete_files
6242+
type: boolean
62306243
produces:
62316244
- application/json
62326245
responses:

backend/internal/application/conversation/service_conversation.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@ const (
1515
maxPageSize = 100
1616
)
1717

18+
// DeleteConversationOptions 定义会话删除选项。
19+
type DeleteConversationOptions struct {
20+
DeleteFiles bool
21+
}
22+
23+
// DeleteConversationResult 返回会话删除结果。
24+
type DeleteConversationResult struct {
25+
Deleted bool
26+
DeletedFileCount int
27+
Quota *model.StorageQuota
28+
}
29+
1830
// CreateConversation 创建用户新会话。
1931
func (s *Service) CreateConversation(ctx context.Context, userID uint, title string, modelName string, projectPublicID string) (*model.Conversation, error) {
2032
normalizedTitle := strings.TrimSpace(title)
@@ -218,15 +230,20 @@ func (s *Service) SetConversationArchived(ctx context.Context, userID uint, publ
218230
return item, nil
219231
}
220232

221-
// DeleteConversation 删除会话(软删除)。
222-
func (s *Service) DeleteConversation(ctx context.Context, userID uint, publicID string) error {
223-
if err := s.repo.DeleteConversationByPublicID(ctx, userID, publicID); err != nil {
233+
// DeleteConversation 删除会话(软删除),并按需清理不再被其他会话引用的文件。
234+
func (s *Service) DeleteConversation(ctx context.Context, userID uint, publicID string, options DeleteConversationOptions) (*DeleteConversationResult, error) {
235+
cleanupFileIDs, err := s.repo.DeleteConversationByPublicID(ctx, userID, publicID, options.DeleteFiles)
236+
if err != nil {
224237
if errors.Is(err, repository.ErrNotFound) {
225-
return ErrConversationNotFound
238+
return nil, ErrConversationNotFound
226239
}
227-
return err
240+
return nil, err
241+
}
242+
result := &DeleteConversationResult{Deleted: true}
243+
if options.DeleteFiles {
244+
result.DeletedFileCount, result.Quota = s.deleteConversationFiles(ctx, userID, cleanupFileIDs)
228245
}
229-
return nil
246+
return result, nil
230247
}
231248

232249
// GetConversation 查询用户会话元信息。
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package conversation
2+
3+
import (
4+
"context"
5+
"strings"
6+
7+
model "github.com/DEEIX-AI/DEEIX-Chat/backend/internal/domain/conversation"
8+
"go.uber.org/zap"
9+
)
10+
11+
// deleteConversationFiles 清理会话删除后不再被其他活跃会话引用的文件。
12+
func (s *Service) deleteConversationFiles(ctx context.Context, userID uint, fileIDs []string) (int, *model.StorageQuota) {
13+
if len(fileIDs) == 0 || s.uploadSvc == nil {
14+
return 0, nil
15+
}
16+
deletedCount := 0
17+
var latestQuota *model.StorageQuota
18+
for _, rawFileID := range fileIDs {
19+
fileID := strings.TrimSpace(rawFileID)
20+
if fileID == "" {
21+
continue
22+
}
23+
result, deleted, err := s.uploadSvc.DeleteFileIfUnreferenced(ctx, userID, fileID)
24+
if err != nil {
25+
if s.logger != nil {
26+
s.logger.Warn("delete_conversation_file_failed",
27+
zap.Uint("user_id", userID),
28+
zap.String("file_id", fileID),
29+
zap.Error(err),
30+
)
31+
}
32+
continue
33+
}
34+
if !deleted || result == nil {
35+
continue
36+
}
37+
deletedCount++
38+
quota := result.Quota
39+
latestQuota = &quota
40+
}
41+
return deletedCount, latestQuota
42+
}

backend/internal/application/conversation/service_project.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,31 @@ func (s *Service) UpdateConversationProject(
8282
}
8383

8484
// DeleteConversationProject 删除当前用户项目分组。
85-
func (s *Service) DeleteConversationProject(ctx context.Context, userID uint, publicID string, deleteConversations bool) error {
86-
if err := s.repo.DeleteConversationProjectByPublicID(ctx, userID, strings.TrimSpace(publicID), deleteConversations); err != nil {
85+
func (s *Service) DeleteConversationProject(
86+
ctx context.Context,
87+
userID uint,
88+
publicID string,
89+
deleteConversations bool,
90+
options DeleteConversationOptions,
91+
) (*DeleteConversationResult, error) {
92+
cleanupFileIDs, err := s.repo.DeleteConversationProjectByPublicID(
93+
ctx,
94+
userID,
95+
strings.TrimSpace(publicID),
96+
deleteConversations,
97+
deleteConversations && options.DeleteFiles,
98+
)
99+
if err != nil {
87100
if errors.Is(err, repository.ErrNotFound) {
88-
return ErrConversationProjectNotFound
101+
return nil, ErrConversationProjectNotFound
89102
}
90-
return err
103+
return nil, err
91104
}
92-
return nil
105+
result := &DeleteConversationResult{Deleted: true}
106+
if deleteConversations && options.DeleteFiles {
107+
result.DeletedFileCount, result.Quota = s.deleteConversationFiles(ctx, userID, cleanupFileIDs)
108+
}
109+
return result, nil
93110
}
94111

95112
// ReorderConversationProjects 更新当前用户项目展示顺序。

backend/internal/application/upload/service.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ func (s *Service) tryReuseExistingFile(
348348
zap.String("path", existingFile.StoragePath),
349349
)
350350
}
351-
deletedFile, _, shouldRemovePhysical, deleteErr := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, existingFile.FileID, quotaBytes)
351+
deletedFile, _, shouldRemovePhysical, deleteErr := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, existingFile.FileID, quotaBytes, repository.DeleteFileObjectOptions{})
352352
if deleteErr != nil {
353353
return nil, false, deleteErr
354354
}
@@ -413,15 +413,28 @@ func objectMatchesContent(ctx context.Context, store objectstore.Store, path str
413413

414414
// DeleteFile 删除文件并回收配额。
415415
func (s *Service) DeleteFile(ctx context.Context, userID uint, fileID string) (*DeleteFileResult, error) {
416+
result, _, err := s.deleteFile(ctx, userID, fileID, repository.DeleteFileObjectOptions{})
417+
return result, err
418+
}
419+
420+
// DeleteFileIfUnreferenced 仅在文件未被活跃会话引用时删除文件并回收配额。
421+
func (s *Service) DeleteFileIfUnreferenced(ctx context.Context, userID uint, fileID string) (*DeleteFileResult, bool, error) {
422+
return s.deleteFile(ctx, userID, fileID, repository.DeleteFileObjectOptions{RequireUnreferenced: true})
423+
}
424+
425+
func (s *Service) deleteFile(ctx context.Context, userID uint, fileID string, options repository.DeleteFileObjectOptions) (*DeleteFileResult, bool, error) {
416426
normalizedFileID := strings.TrimSpace(fileID)
417427
if normalizedFileID == "" {
418-
return nil, s.errInvalidFileReference()
428+
return nil, false, s.errInvalidFileReference()
419429
}
420430

421431
cfg := s.snapshot()
422-
deletedFile, quota, shouldRemovePhysical, err := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, normalizedFileID, cfg.UserStorageQuotaBytes)
432+
deletedFile, quota, shouldRemovePhysical, err := s.repo.DeleteFileObjectAndReleaseQuota(ctx, userID, normalizedFileID, cfg.UserStorageQuotaBytes, options)
423433
if err != nil {
424-
return nil, err
434+
if options.RequireUnreferenced && errors.Is(err, repository.ErrConflict) {
435+
return nil, false, nil
436+
}
437+
return nil, false, err
425438
}
426439
if shouldRemovePhysical {
427440
store, storeErr := s.openObjectStore(ctx)
@@ -442,7 +455,7 @@ func (s *Service) DeleteFile(ctx context.Context, userID uint, fileID string) (*
442455
Deleted: true,
443456
FileID: normalizedFileID,
444457
Quota: *quota,
445-
}, nil
458+
}, true, nil
446459
}
447460

448461
// RenameFile 重命名当前用户文件。

backend/internal/application/upload/service_test.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,33 @@ func TestUploadFileAllowsReuploadAfterDelete(t *testing.T) {
8585
}
8686
}
8787

88+
func TestDeleteFileIfUnreferencedSkipsReferencedFile(t *testing.T) {
89+
ctx := context.Background()
90+
repo := newUploadTestRepo()
91+
store := newUploadTestStore()
92+
service := newUploadTestService(repo, store)
93+
94+
uploaded, err := service.UploadFile(ctx, uploadTestInput("notes.md", "same content"))
95+
if err != nil {
96+
t.Fatalf("upload failed: %v", err)
97+
}
98+
repo.referencedFileIDs[uploaded.File.FileID] = true
99+
100+
result, deleted, err := service.DeleteFileIfUnreferenced(ctx, 1, uploaded.File.FileID)
101+
if err != nil {
102+
t.Fatalf("delete if unreferenced failed: %v", err)
103+
}
104+
if deleted || result != nil {
105+
t.Fatal("referenced file should be skipped without returning a delete result")
106+
}
107+
if status := repo.fileStatus(uploaded.File.FileID); status != "active" {
108+
t.Fatalf("referenced file should remain active, got %q", status)
109+
}
110+
if got := store.objectCount(); got != 1 {
111+
t.Fatalf("referenced file should keep physical object, got %d objects", got)
112+
}
113+
}
114+
88115
func TestUploadFileReplacesStaleDuplicatePointer(t *testing.T) {
89116
ctx := context.Background()
90117
repo := newUploadTestRepo()
@@ -301,6 +328,7 @@ type uploadTestRepo struct {
301328
quota domainconversation.StorageQuota
302329
missNextDuplicateLookup bool
303330
failNextCreateDuplicate bool
331+
referencedFileIDs map[string]bool
304332
}
305333

306334
func newUploadTestRepo() *uploadTestRepo {
@@ -310,6 +338,7 @@ func newUploadTestRepo() *uploadTestRepo {
310338
UserID: 1,
311339
QuotaBytes: 10 * 1024 * 1024,
312340
},
341+
referencedFileIDs: make(map[string]bool),
313342
}
314343
}
315344

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

395-
func (r *uploadTestRepo) DeleteFileObjectAndReleaseQuota(_ context.Context, userID uint, fileID string, quotaLimit int64) (*domainconversation.FileObject, *domainconversation.StorageQuota, bool, error) {
424+
func (r *uploadTestRepo) DeleteFileObjectAndReleaseQuota(_ context.Context, userID uint, fileID string, quotaLimit int64, options repository.DeleteFileObjectOptions) (*domainconversation.FileObject, *domainconversation.StorageQuota, bool, error) {
396425
if quotaLimit > 0 {
397426
r.quota.QuotaBytes = quotaLimit
398427
}
399428
for i := range r.files {
400429
if r.files[i].UserID != userID || r.files[i].FileID != fileID || r.files[i].Status != "active" {
401430
continue
402431
}
432+
if options.RequireUnreferenced && r.referencedFileIDs[fileID] {
433+
return nil, nil, false, repository.ErrConflict
434+
}
403435
deleted := r.files[i]
404436
r.files[i].Status = "deleted"
405437
remainingRefs := 0

0 commit comments

Comments
 (0)