From fdd2a943f2641571fc5dd616a8e962b5cfe31fd0 Mon Sep 17 00:00:00 2001 From: digua Date: Thu, 16 Jul 2026 12:43:33 +0800 Subject: [PATCH] feat(members): support batch deletion --- packages/core/src/index.ts | 1 + packages/core/src/query/index.ts | 9 +- packages/core/src/query/member-ops.ts | 23 +++- .../src/routes/shared-routes.test.ts | 97 ++++++++++++++ .../http-routes/src/routes/web/members.ts | 38 +++++- .../src/services/member-service.ts | 13 ++ src/components/common/member/MemberList.vue | 123 ++++++++++++++---- src/i18n/locales/en-US/members.json | 7 +- src/i18n/locales/ja-JP/members.json | 7 +- src/i18n/locales/zh-CN/members.json | 7 +- src/i18n/locales/zh-TW/members.json | 7 +- src/services/data/fetch.ts | 5 + src/services/data/types.ts | 1 + 13 files changed, 302 insertions(+), 36 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b40723d5..c91a15dd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,6 +126,7 @@ export { updateMemberAliases, mergeMembers, deleteMember, + deleteMembers, ensureAliasesColumn, ensureAvatarColumn, updateSessionOwnerId, diff --git a/packages/core/src/query/index.ts b/packages/core/src/query/index.ts index 9d160cd3..4c42d22d 100644 --- a/packages/core/src/query/index.ts +++ b/packages/core/src/query/index.ts @@ -201,7 +201,14 @@ export type { } from './message-query-functions' // Member write operations (merge, delete, update aliases, DDL migration) -export { updateMemberAliases, mergeMembers, deleteMember, ensureAliasesColumn, ensureAvatarColumn } from './member-ops' +export { + updateMemberAliases, + mergeMembers, + deleteMember, + deleteMembers, + ensureAliasesColumn, + ensureAvatarColumn, +} from './member-ops' // Advanced analytics export { diff --git a/packages/core/src/query/member-ops.ts b/packages/core/src/query/member-ops.ts index 1c08408e..7edeff6f 100644 --- a/packages/core/src/query/member-ops.ts +++ b/packages/core/src/query/member-ops.ts @@ -107,17 +107,28 @@ export function mergeMembers(db: DatabaseAdapter, memberId1: number, memberId2: } } +/** + * Delete members and all their messages / name history in one transaction. + */ +export function deleteMembers(db: DatabaseAdapter, memberIds: readonly number[]): boolean { + const uniqueMemberIds = Array.from(new Set(memberIds)) + if (uniqueMemberIds.length === 0) return false + + const placeholders = uniqueMemberIds.map(() => '?').join(', ') + db.transaction(() => { + db.prepare(`DELETE FROM message WHERE sender_id IN (${placeholders})`).run(...uniqueMemberIds) + db.prepare(`DELETE FROM member_name_history WHERE member_id IN (${placeholders})`).run(...uniqueMemberIds) + db.prepare(`DELETE FROM member WHERE id IN (${placeholders})`).run(...uniqueMemberIds) + }) + return true +} + /** * Delete a member and all their messages / name history. */ export function deleteMember(db: DatabaseAdapter, memberId: number): boolean { try { - db.transaction(() => { - db.prepare('DELETE FROM message WHERE sender_id = ?').run(memberId) - db.prepare('DELETE FROM member_name_history WHERE member_id = ?').run(memberId) - db.prepare('DELETE FROM member WHERE id = ?').run(memberId) - }) - return true + return deleteMembers(db, [memberId]) } catch { return false } diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index 88e76285..59a771e5 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -95,6 +95,13 @@ function createSessionDb(): TestSqliteDb { content TEXT, platform_message_id TEXT ); + CREATE TABLE member_name_history ( + id INTEGER PRIMARY KEY, + member_id INTEGER, + name TEXT, + start_time INTEGER, + end_time INTEGER + ); CREATE TABLE segment ( id INTEGER PRIMARY KEY, start_ts INTEGER, @@ -114,6 +121,9 @@ function createSessionDb(): TestSqliteDb { (1, 1, 100, 0, 'alpha first', 'm-1'), (2, 2, 200, 0, 'alpha from bob', 'm-2'), (3, 1, 300, 0, 'alpha later', 'm-3'); + INSERT INTO member_name_history (id, member_id, name, start_time, end_time) VALUES + (1, 1, 'Alice', 100, 300), + (2, 2, 'Bob', 200, 200); `) return db } @@ -290,6 +300,93 @@ describe('registerSharedRoutes smoke tests', () => { assert.equal(typeof body.duration, 'number') }) + it('POST /_web/sessions/:id/members/batch-delete deletes selected member data in one request', async () => { + const db = createSessionDb() + db.exec(` + INSERT INTO member (id, platform_id, account_name, group_nickname, avatar) + VALUES (3, 'carol', 'Carol', NULL, NULL); + INSERT INTO message (id, sender_id, ts, type, content, platform_message_id) + VALUES (4, 3, 400, 0, 'carol stays', 'm-4'); + INSERT INTO member_name_history (id, member_id, name, start_time, end_time) + VALUES (3, 3, 'Carol', 400, 400); + `) + const routeApp = Fastify() + registerSharedRoutes(routeApp, createTestContext(new Map([['chat-1', db]]))) + await routeApp.ready() + + const resp = await routeApp.inject({ + method: 'POST', + url: '/_web/sessions/chat-1/members/batch-delete', + payload: { memberIds: [1, 2] }, + }) + const remainingMembers = db.prepare('SELECT id FROM member ORDER BY id').all() + const remainingMessages = db.prepare('SELECT sender_id AS senderId FROM message ORDER BY id').all() + const remainingHistory = db.prepare('SELECT member_id AS memberId FROM member_name_history ORDER BY id').all() + + await routeApp.close() + db.close() + + assert.equal(resp.statusCode, 200) + assert.deepEqual(resp.json(), { success: true, deletedCount: 2 }) + assert.deepEqual(remainingMembers, [{ id: 3 }]) + assert.deepEqual(remainingMessages, [{ senderId: 3 }]) + assert.deepEqual(remainingHistory, [{ memberId: 3 }]) + }) + + it('POST /_web/sessions/:id/members/batch-delete rejects an empty selection', async () => { + const db = createSessionDb() + const routeApp = Fastify() + registerSharedRoutes(routeApp, createTestContext(new Map([['chat-1', db]]))) + await routeApp.ready() + + const resp = await routeApp.inject({ + method: 'POST', + url: '/_web/sessions/chat-1/members/batch-delete', + payload: { memberIds: [] }, + }) + const memberCount = db.prepare('SELECT COUNT(*) AS count FROM member').get() as { count: number } + + await routeApp.close() + db.close() + + assert.equal(resp.statusCode, 400) + assert.deepEqual(resp.json(), { success: false, error: 'memberIds must contain at least one positive integer' }) + assert.equal(memberCount.count, 2) + }) + + it('POST /_web/sessions/:id/members/batch-delete rolls back every deletion when one member fails', async () => { + const db = createSessionDb() + db.exec(` + CREATE TRIGGER prevent_bob_delete + BEFORE DELETE ON member + WHEN OLD.id = 2 + BEGIN + SELECT RAISE(ABORT, 'Bob cannot be deleted'); + END; + `) + const routeApp = Fastify() + registerSharedRoutes(routeApp, createTestContext(new Map([['chat-1', db]]))) + await routeApp.ready() + + const resp = await routeApp.inject({ + method: 'POST', + url: '/_web/sessions/chat-1/members/batch-delete', + payload: { memberIds: [1, 2] }, + }) + const memberCount = db.prepare('SELECT COUNT(*) AS count FROM member').get() as { count: number } + const messageCount = db.prepare('SELECT COUNT(*) AS count FROM message').get() as { count: number } + const historyCount = db.prepare('SELECT COUNT(*) AS count FROM member_name_history').get() as { count: number } + + await routeApp.close() + db.close() + + assert.equal(resp.statusCode, 500) + assert.deepEqual(resp.json(), { success: false, error: 'Failed to delete selected members' }) + assert.equal(memberCount.count, 2) + assert.equal(messageCount.count, 3) + assert.equal(historyCount.count, 2) + }) + it('POST /_web/ai/logs/show reveals the current AI log file through the host shell', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'chatlab-ai-log-route-')) const currentLogPath = path.join(dir, 'logs', 'ai', 'ai_current.log') diff --git a/packages/http-routes/src/routes/web/members.ts b/packages/http-routes/src/routes/web/members.ts index 1e06554d..e09713a0 100644 --- a/packages/http-routes/src/routes/web/members.ts +++ b/packages/http-routes/src/routes/web/members.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify' import type { HttpRouteContext } from '../../context' -import { memberService } from '@openchatlab/node-runtime' +import { appLogger, memberService } from '@openchatlab/node-runtime' +import { apiErrorFromUnknown } from '../../errors' export function registerMemberRoutes(server: FastifyInstance, ctx: HttpRouteContext): void { const { sessionAdapter: adapter } = ctx @@ -39,6 +40,41 @@ export function registerMemberRoutes(server: FastifyInstance, ctx: HttpRouteCont } ) + server.post<{ Params: { id: string }; Body: { memberIds?: unknown } }>( + '/_web/sessions/:id/members/batch-delete', + async (request, reply) => { + const memberIds = request.body?.memberIds + if ( + !Array.isArray(memberIds) || + memberIds.length === 0 || + memberIds.some((memberId) => !Number.isSafeInteger(memberId) || Number(memberId) <= 0) + ) { + return reply.code(400).send({ + success: false, + error: 'memberIds must contain at least one positive integer', + }) + } + + const uniqueMemberIds = Array.from(new Set(memberIds as number[])) + try { + const success = memberService.deleteMembers(adapter, request.params.id, uniqueMemberIds) + if (!success) { + throw new Error('Member deletion transaction returned no result') + } + } catch (error) { + if (apiErrorFromUnknown(error)) throw error + appLogger.error('members', 'Batch member deletion failed', error) + return reply.code(500).send({ success: false, error: 'Failed to delete selected members' }) + } + + appLogger.info('members', 'Batch member deletion completed', { + sessionId: request.params.id, + memberCount: uniqueMemberIds.length, + }) + return { success: true, deletedCount: uniqueMemberIds.length } + } + ) + server.post<{ Params: { id: string }; Body: { memberId1: number; memberId2: number } }>( '/_web/sessions/:id/members/merge', async (request) => { diff --git a/packages/node-runtime/src/services/member-service.ts b/packages/node-runtime/src/services/member-service.ts index 5a76c162..710b9f59 100644 --- a/packages/node-runtime/src/services/member-service.ts +++ b/packages/node-runtime/src/services/member-service.ts @@ -13,6 +13,7 @@ import { updateMemberAliases as coreUpdateMemberAliases, mergeMembers as coreMergeMembers, deleteMember as coreDeleteMember, + deleteMembers as coreDeleteMembers, } from '@openchatlab/core' import type { MemberWithAliases, MembersPaginationParams, MembersPaginatedResult } from '@openchatlab/core' import type { SessionRuntimeAdapter } from './adapters' @@ -95,6 +96,18 @@ export function deleteMember(adapter: SessionRuntimeAdapter, sessionId: string, return coreDeleteMember(db, memberId) } +/** + * Delete multiple members and all associated data atomically. + */ +export function deleteMembers( + adapter: SessionRuntimeAdapter, + sessionId: string, + memberIds: readonly number[] +): boolean { + const db = adapter.ensureWritable(sessionId) + return coreDeleteMembers(db, memberIds) +} + /** * Get member name change history. * Core prefers member_name_history and falls back to message-derived names for legacy sessions. diff --git a/src/components/common/member/MemberList.vue b/src/components/common/member/MemberList.vue index 81742730..79d50ab7 100644 --- a/src/components/common/member/MemberList.vue +++ b/src/components/common/member/MemberList.vue @@ -40,15 +40,17 @@ const members = ref([]) const isLoading = ref(false) const loadFailed = ref(false) const searchQuery = ref('') -const showMergeControls = computed(() => shouldShowMemberMergeControls(props.chatType, members.value.length)) +const showSelectionControls = computed(() => shouldShowMemberMergeControls(props.chatType, members.value.length)) const memberTableGridClass = computed(() => { if (props.chatType === 'group') return '' - return showMergeControls.value ? 'member-table-grid--private' : 'member-table-grid--private-without-selection' + return showSelectionControls.value ? 'member-table-grid--private' : 'member-table-grid--private-without-selection' }) // 删除确认状态 const deletingMember = ref(null) const isDeleting = ref(false) +const showBatchDeleteModal = ref(false) +const isBatchDeleting = ref(false) // 合并确认状态 const showMergeModal = ref(false) @@ -91,31 +93,34 @@ function viewMemberChatRecords(member: MemberWithStats) { const filteredSortedMembers = computed(() => filterAndSortMembers(members.value, searchQuery.value, sortState.value)) const { - selectedIds: selectedMergeIds, - setSelection: setSelectedMergeIds, - clearSelection: clearMergeSelection, + selectedIds: selectedMemberIds, + setSelection: setSelectedMemberIds, + clearSelection: clearMemberSelection, handleRowClick: handleSelectionRowClick, handleRowMouseDown: handleSelectionRowMouseDown, } = useTableRowSelection({ rows: filteredSortedMembers, getRowId: (member) => member.id, }) -const selectedMergeMembers = computed(() => members.value.filter((member) => selectedMergeIds.value.has(member.id))) -const canMergeSelected = computed(() => selectedMergeMembers.value.length === 2) +const selectedMembers = computed(() => members.value.filter((member) => selectedMemberIds.value.has(member.id))) +const canMergeSelected = computed(() => selectedMembers.value.length === 2) +const selectedMessageCount = computed(() => + selectedMembers.value.reduce((total, member) => total + member.messageCount, 0) +) function handleMemberRowClick(index: number, memberId: number, event: MouseEvent) { - if (!showMergeControls.value) return + if (!showSelectionControls.value) return handleSelectionRowClick(index, memberId, event) } function handleMemberRowMouseDown(event: MouseEvent) { - if (!showMergeControls.value) return + if (!showSelectionControls.value) return handleSelectionRowMouseDown(event) } const mergePlan = computed(() => { - if (selectedMergeMembers.value.length !== 2) return null - const [memberA, memberB] = selectedMergeMembers.value + if (selectedMembers.value.length !== 2) return null + const [memberA, memberB] = selectedMembers.value if ( memberB.messageCount > memberA.messageCount || (memberB.messageCount === memberA.messageCount && memberB.id < memberA.id) @@ -243,9 +248,9 @@ async function confirmDelete() { const memberId = deletingMember.value.id const success = await dataService.deleteMember(props.sessionId, memberId) if (success) { - const nextSelection = new Set(selectedMergeIds.value) + const nextSelection = new Set(selectedMemberIds.value) nextSelection.delete(memberId) - setSelectedMergeIds(nextSelection) + setSelectedMemberIds(nextSelection) await loadMembers() emit('data-changed') } @@ -257,6 +262,32 @@ async function confirmDelete() { } } +function openBatchDeleteModal() { + if (selectedMemberIds.value.size === 0) return + showBatchDeleteModal.value = true +} + +async function confirmBatchDelete() { + const sessionId = props.sessionId + const memberIds = Array.from(selectedMemberIds.value) + if (memberIds.length === 0) return + + isBatchDeleting.value = true + try { + const success = await dataService.deleteMembers(sessionId, memberIds) + if (success && sessionId === props.sessionId) { + showBatchDeleteModal.value = false + clearMemberSelection() + await loadMembers() + emit('data-changed') + } + } catch (error) { + reportMemberManagementError('Failed to delete selected members:', error) + } finally { + isBatchDeleting.value = false + } +} + function openMergeModal() { if (!canMergeSelected.value) return showMergeModal.value = true @@ -273,7 +304,7 @@ async function confirmMerge() { ) if (success) { showMergeModal.value = false - clearMergeSelection() + clearMemberSelection() await loadMembers() emit('data-changed') } @@ -285,8 +316,8 @@ async function confirmMerge() { } watch([searchQuery, sortState], resetVirtualScroll) -watch(showMergeControls, (show) => { - if (!show) clearMergeSelection() +watch(showSelectionControls, (show) => { + if (!show) clearMemberSelection() }) // 监听 sessionId 变化 @@ -299,7 +330,7 @@ watch( savingAliasIds.value = new Set() searchQuery.value = '' resetSort() - clearMergeSelection() + clearMemberSelection() void resetVirtualScroll() void loadMembers() }, @@ -349,14 +380,14 @@ onUnmounted(() => { -
+
- {{ t('members.list.mergeSelectedCount', { count: selectedMergeIds.size }) }} + {{ t('members.list.selectedCount', { count: selectedMemberIds.size }) }} { > {{ t('members.list.mergeSelected') }} + + {{ t('members.list.deleteSelected') }} +
@@ -408,7 +449,7 @@ onUnmounted(() => { class="member-table-grid shrink-0 border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs font-medium text-gray-500 dark:border-white/5 dark:bg-page-dark/80 dark:text-gray-400" :class="memberTableGridClass" > -