feat(members): support batch deletion

This commit is contained in:
digua committed 2026-07-16 20:09:18 +08:00
1 parent 5a5fae880a
commit fdd2a943f2
13 files changed
+302 -36

No files matched your search

+1
View File
@@ -126,6 +126,7 @@ export {
updateMemberAliases,
mergeMembers,
deleteMember,
deleteMembers,
ensureAliasesColumn,
ensureAvatarColumn,
updateSessionOwnerId,
+8 -1
View File
@@ -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 {
+17 -6
View File
@@ -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
}
@@ -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')
+37 -1
View File
@@ -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) => {
@@ -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.
+99 -24
View File
@@ -40,15 +40,17 @@ const members = ref<MemberWithStats[]>([])
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<MemberWithStats | null>(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(() => {
<UButton icon="i-heroicons-x-mark" variant="link" color="neutral" size="xs" @click="searchQuery = ''" />
</template>
</UInput>
<div v-if="showMergeControls" class="flex items-center gap-2">
<div v-if="showSelectionControls" class="flex items-center gap-2">
<UIcon
v-if="isLoading && members.length > 0"
name="i-heroicons-arrow-path"
class="h-4 w-4 animate-spin text-primary-500"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('members.list.mergeSelectedCount', { count: selectedMergeIds.size }) }}
{{ t('members.list.selectedCount', { count: selectedMemberIds.size }) }}
</span>
<UButton
icon="i-heroicons-link"
@@ -367,6 +398,16 @@ onUnmounted(() => {
>
{{ t('members.list.mergeSelected') }}
</UButton>
<UButton
icon="i-heroicons-trash"
size="sm"
color="error"
variant="outline"
:disabled="selectedMemberIds.size === 0"
@click="openBatchDeleteModal"
>
{{ t('members.list.deleteSelected') }}
</UButton>
</div>
</div>
@@ -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"
>
<span v-if="showMergeControls" aria-hidden="true" />
<span v-if="showSelectionControls" aria-hidden="true" />
<span>{{ t('members.list.table.accountName') }}</span>
<button
v-if="props.chatType === 'group'"
@@ -488,16 +529,18 @@ onUnmounted(() => {
class="member-table-grid absolute left-0 top-0 w-full min-h-16 border-b border-gray-100/80 px-3 py-2 transition-colors hover:bg-gray-50 dark:border-white/5 dark:hover:bg-gray-800/30"
:class="[
memberTableGridClass,
showMergeControls ? 'cursor-pointer' : '',
showMergeControls && selectedMergeIds.has(member.id) ? 'bg-primary-50/70 dark:bg-primary-950/20' : '',
showSelectionControls ? 'cursor-pointer' : '',
showSelectionControls && selectedMemberIds.has(member.id)
? 'bg-primary-50/70 dark:bg-primary-950/20'
: '',
]"
:style="{ transform: `translateY(${virtualItem.start}px)` }"
@mousedown="handleMemberRowMouseDown"
@click="handleMemberRowClick(virtualItem.index, member.id, $event)"
>
<div v-if="showMergeControls" class="flex justify-center">
<div v-if="showSelectionControls" class="flex justify-center">
<UCheckbox
:model-value="selectedMergeIds.has(member.id)"
:model-value="selectedMemberIds.has(member.id)"
@click.stop="handleMemberRowClick(virtualItem.index, member.id, $event)"
/>
</div>
@@ -608,6 +651,38 @@ onUnmounted(() => {
</template>
</UModal>
<!-- 批量删除确认弹窗 -->
<UModal :open="showBatchDeleteModal" :ui="{ content: 'max-w-sm' }" @update:open="showBatchDeleteModal = $event">
<template #content>
<div class="p-6 text-center">
<div
class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30"
>
<UIcon name="i-heroicons-exclamation-triangle" class="h-7 w-7 text-red-500" />
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ t('members.list.batchDeleteModal.title') }}
</h3>
<p class="mb-6 text-sm text-gray-500 dark:text-gray-400">
{{
t('members.list.batchDeleteModal.content', {
count: selectedMembers.length,
messageCount: selectedMessageCount.toLocaleString(),
})
}}
</p>
<div class="flex justify-center gap-3">
<UButton variant="outline" :disabled="isBatchDeleting" @click="showBatchDeleteModal = false">
{{ t('members.list.modal.cancel') }}
</UButton>
<UButton color="error" :loading="isBatchDeleting" @click="confirmBatchDelete">
{{ t('members.list.modal.confirm') }}
</UButton>
</div>
</div>
</template>
</UModal>
<!-- 合并确认弹窗 -->
<UModal :open="showMergeModal" :ui="{ content: 'max-w-md' }" @update:open="showMergeModal = $event">
<template #content>
+6 -1
View File
@@ -35,8 +35,9 @@
},
"aliasPlaceholder": "Press Enter to add",
"delete": "Delete",
"deleteSelected": "Delete Selected",
"mergeSelected": "Merge Selected",
"mergeSelectedCount": "{count} members selected",
"selectedCount": "{count} members selected",
"pagination": "Showing {start} - {end} of {total} members",
"tip": "Tip: Adding aliases helps identify chat participants and improves search and AI analysis accuracy.",
"mergeModal": {
@@ -46,6 +47,10 @@
"cancel": "Cancel",
"confirm": "Confirm Merge"
},
"batchDeleteModal": {
"title": "Delete Selected Members?",
"content": "This will delete {count} selected members and their {messageCount} messages. This action cannot be undone."
},
"modal": {
"title": "Delete Member?",
"content": "This will delete member {name} and their {count} messages. This action cannot be undone.",
+6 -1
View File
@@ -35,8 +35,9 @@
},
"aliasPlaceholder": "入力して Enter で追加",
"delete": "削除",
"deleteSelected": "選択項目を削除",
"mergeSelected": "選択を結合",
"mergeSelectedCount": "{count} 名を選択中",
"selectedCount": "{count} 名を選択中",
"pagination": "{start} - {end} 件表示、全 {total} 名のメンバー",
"tip": "ヒント:別名を追加すると、チャット履歴の相手をより正確に識別できます。別名は検索や AI 分析で使用されます。",
"mergeModal": {
@@ -46,6 +47,10 @@
"cancel": "キャンセル",
"confirm": "結合を確認"
},
"batchDeleteModal": {
"title": "選択したメンバーを削除しますか?",
"content": "選択した {count} 名のメンバーと、その {messageCount} 件のメッセージを削除します。この操作は元に戻せません。"
},
"modal": {
"title": "メンバーを削除しますか?",
"content": "メンバー {name} とその {count} 件のメッセージを削除します。この操作は元に戻せません。",
+6 -1
View File
@@ -35,8 +35,9 @@
},
"aliasPlaceholder": "输入后回车添加",
"delete": "删除",
"deleteSelected": "删除选中",
"mergeSelected": "合并选中",
"mergeSelectedCount": "已选 {count} 位成员",
"selectedCount": "已选 {count} 位成员",
"pagination": "显示 {start} - {end} 条,共 {total} 位成员",
"tip": "提示:添加别名可以更好地识别聊天记录中的对话对象,别名将用于搜索和 AI 分析中。",
"mergeModal": {
@@ -46,6 +47,10 @@
"cancel": "取消",
"confirm": "确认合并"
},
"batchDeleteModal": {
"title": "确认批量删除成员?",
"content": "即将删除选中的 {count} 位成员及其 {messageCount} 条消息,此操作不可恢复。"
},
"modal": {
"title": "确认删除成员?",
"content": "即将删除成员 {name} 及其 {count} 条消息,此操作不可恢复。",
+6 -1
View File
@@ -35,8 +35,9 @@
},
"aliasPlaceholder": "輸入後按 Enter 新增",
"delete": "刪除",
"deleteSelected": "刪除所選",
"mergeSelected": "合併所選",
"mergeSelectedCount": "已選 {count} 位成員",
"selectedCount": "已選 {count} 位成員",
"pagination": "顯示第 {start} - {end} 筆,共 {total} 位成員",
"tip": "提示:新增別名後,搜尋與 AI 分析時會更容易辨識聊天對象。",
"mergeModal": {
@@ -46,6 +47,10 @@
"cancel": "取消",
"confirm": "確認合併"
},
"batchDeleteModal": {
"title": "確認批次刪除成員?",
"content": "即將刪除所選的 {count} 位成員及其 {messageCount} 則訊息,此操作無法復原。"
},
"modal": {
"title": "確認刪除成員?",
"content": "即將刪除成員 {name} 及其 {count} 則訊息,此操作不可復原。",
+5
View File
@@ -351,6 +351,11 @@ export class FetchDataAdapter implements DataAdapter {
return result.success
}
async deleteMembers(sessionId: string, memberIds: number[]): Promise<boolean> {
const result = await post<{ success: boolean }>(`/sessions/${sessionId}/members/batch-delete`, { memberIds })
return result.success
}
// ==================== 社交分析 ====================
getCatchphraseAnalysis(sessionId: string, filter?: TimeFilter): Promise<CatchphraseAnalysis> {
+1
View File
@@ -220,6 +220,7 @@ export interface DataAdapter {
updateMemberAliases(sessionId: string, memberId: number, aliases: string[]): Promise<boolean>
mergeMembers(sessionId: string, memberId1: number, memberId2: number): Promise<boolean>
deleteMember(sessionId: string, memberId: number): Promise<boolean>
deleteMembers(sessionId: string, memberIds: number[]): Promise<boolean>
// ==================== 社交分析 ====================