diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java index 1511096c..297e3722 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java @@ -13,6 +13,7 @@ import vip.mate.workspace.conversation.ConversationService; import vip.mate.workspace.conversation.vo.ConversationVO; import vip.mate.workspace.conversation.vo.MessageVO; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -27,6 +28,8 @@ import java.util.Map; @RequiredArgsConstructor public class ConversationController { + private static final int MAX_BATCH_DELETE_SIZE = 200; + private final ConversationService conversationService; private final ChatStreamTracker streamTracker; @@ -219,13 +222,22 @@ public class ConversationController { String username = auth != null ? auth.getName() : "anonymous"; List ids = body.get("conversationIds"); if (ids == null || ids.isEmpty()) { - return R.fail("未指定要删除的会话"); + return R.fail(400, "未指定要删除的会话"); + } + LinkedHashSet uniqueIds = new LinkedHashSet<>(); + for (String id : ids) { + if (id != null && !id.isBlank()) { + uniqueIds.add(id.trim()); + } + } + if (uniqueIds.isEmpty()) { + return R.fail(400, "未指定要删除的会话"); + } + if (uniqueIds.size() > MAX_BATCH_DELETE_SIZE) { + return R.fail(400, "单次最多删除 " + MAX_BATCH_DELETE_SIZE + " 个会话"); } int deleted = 0; - for (String conversationId : ids) { - if (conversationId == null || conversationId.isBlank()) { - continue; - } + for (String conversationId : uniqueIds) { if (!conversationService.isConversationOwner(conversationId, username)) { continue; } diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerBatchDeleteTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerBatchDeleteTest.java new file mode 100644 index 00000000..ad7b65be --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/conversation/controller/ConversationControllerBatchDeleteTest.java @@ -0,0 +1,76 @@ +package vip.mate.workspace.conversation.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.common.result.R; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ConversationControllerBatchDeleteTest { + + @Mock private ConversationService conversationService; + @Mock private ChatStreamTracker streamTracker; + @Mock private Authentication authentication; + + private ConversationController controller; + + @BeforeEach + void setUp() { + controller = new ConversationController(conversationService, streamTracker); + when(authentication.getName()).thenReturn("alice"); + } + + @Test + void batchDelete_deduplicatesAndTrimsIds_beforeOwnershipCheck() { + when(conversationService.isConversationOwner("conv-1", "alice")).thenReturn(true); + when(conversationService.isConversationOwner("conv-2", "alice")).thenReturn(false); + + R result = controller.batchDelete(Map.of( + "conversationIds", List.of(" conv-1 ", "conv-1", "", "conv-2")), authentication); + + assertEquals(1, result.getData()); + verify(conversationService).isConversationOwner("conv-1", "alice"); + verify(conversationService).isConversationOwner("conv-2", "alice"); + verify(conversationService).deleteConversation("conv-1"); + verify(conversationService, never()).deleteConversation("conv-2"); + } + + @Test + void batchDelete_rejectsBlankOnlyRequest() { + R result = controller.batchDelete(Map.of( + "conversationIds", List.of("", " ")), authentication); + + assertNull(result.getData()); + assertEquals(400, result.getCode()); + verify(conversationService, never()).isConversationOwner(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void batchDelete_rejectsMoreThanMaximumUniqueIds() { + List ids = new ArrayList<>(); + for (int i = 0; i < 201; i++) ids.add("conv-" + i); + + R result = controller.batchDelete(Map.of("conversationIds", ids), authentication); + + assertNull(result.getData()); + assertEquals(400, result.getCode()); + verify(conversationService, never()).isConversationOwner(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString()); + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 11142c85..7934c7a7 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -2256,6 +2256,18 @@ export default { deleteConfirm: 'Are you sure you want to delete this session?', deleteTitle: 'Confirm Delete', deleteFailed: 'Failed to delete session', + selectAll: 'Select all sessions on this page', + deselectAll: 'Deselect all', + selectSession: 'Select session "{title}"', + selectedCount: '{count} sessions selected', + clearSelection: 'Clear selection', + batchDelete: 'Delete selected', + deleting: 'Deleting...', + batchDeleteTitle: 'Confirm Bulk Delete', + batchDeleteConfirm: 'Delete the {count} selected sessions? This action cannot be undone.', + batchDeleteSuccess: 'Deleted {count} sessions', + batchDeletePartial: 'Deleted {deleted} of {total} sessions. The others may no longer exist or may not be accessible.', + batchDeleteFailed: 'Failed to delete selected sessions', switchModel: 'Switch the model used for this conversation', modelSwitched: 'Model switched', modelSwitchFailed: 'Failed to switch model', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 1b88251a..5c43c6b2 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -2130,6 +2130,18 @@ export default { deleteConfirm: '确定要删除这个会话吗?', deleteTitle: '确认删除', deleteFailed: '删除会话失败', + selectAll: '选中当前页全部会话', + deselectAll: '取消全选', + selectSession: '选中会话“{title}”', + selectedCount: '已选 {count} 个会话', + clearSelection: '取消选择', + batchDelete: '批量删除', + deleting: '删除中...', + batchDeleteTitle: '确认批量删除', + batchDeleteConfirm: '确定要删除选中的 {count} 个会话吗?此操作不可撤销。', + batchDeleteSuccess: '已删除 {count} 个会话', + batchDeletePartial: '已删除 {deleted}/{total} 个会话,其余会话可能已不存在或无权操作', + batchDeleteFailed: '批量删除会话失败', switchModel: '切换该会话使用的模型', modelSwitched: '已切换会话模型', modelSwitchFailed: '切换模型失败', diff --git a/mateclaw-ui/src/views/Sessions.vue b/mateclaw-ui/src/views/Sessions.vue index 1dea7d16..60f053f8 100644 --- a/mateclaw-ui/src/views/Sessions.vue +++ b/mateclaw-ui/src/views/Sessions.vue @@ -24,10 +24,34 @@
+
+ {{ t('sessions.selectedCount', { count: selectedSessionIds.length }) }} +
+ + +
+
+ @@ -39,7 +63,22 @@ - + + -
+ + {{ t('sessions.columns.session') }} {{ t('sessions.columns.source') }} {{ t('sessions.columns.agent') }}
+ +
{{ session.title }}
@@ -107,7 +146,7 @@
+
@@ -144,7 +183,7 @@