mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
refactor: unify member query helpers in core
Extract duplicated member list/pagination logic from electron/main/worker/query/basic.ts and packages/server/src/http/routes/web.ts into shared core functions. - Add getMembersPaginated with search, sort, pagination to core - Extend MemberWithAliases type with avatar field - Extend getMembersWithAliases with avatar column detection - Electron basic.ts: getMemberNameHistory, getMembers, getMembersPaginated delegate to core - Server web.ts: /_web/members and /_web/members/paginated both delegate to core - Retain Electron-only schema migrations and member DML operations - Add 14 unit tests covering aliases/avatar presence, pagination, search, sort, system filter Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
f4d5010e77
commit
7c11aefacd
6 files changed
+435
-236
No files matched your search
@@ -5,7 +5,7 @@
|
||||
|
||||
import Database from 'better-sqlite3'
|
||||
import * as fs from 'fs'
|
||||
import { openDatabase, closeDatabase, getDbPath, getCacheDir, openDatabaseAdapter, type TimeFilter } from '../core'
|
||||
import { closeDatabase, getDbPath, getCacheDir, openDatabaseAdapter, type TimeFilter } from '../core'
|
||||
import { getCache, CACHE_KEY_OVERVIEW, type OverviewCache } from '../../database/sessionCache'
|
||||
import {
|
||||
getAvailableYears as coreGetAvailableYears,
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
getMessageTypeStats as coreGetMessageTypeStats,
|
||||
getMessageLengthDistribution as coreGetMessageLengthDistribution,
|
||||
getTimeRange as coreGetTimeRange,
|
||||
getMemberNameHistory as coreGetMemberNameHistory,
|
||||
getMembersWithAliases as coreGetMembersWithAliases,
|
||||
getMembersPaginated as coreGetMembersPaginated,
|
||||
} from '@openchatlab/core'
|
||||
import type { MembersPaginationParams, MembersPaginatedResult, MemberWithAliases } from '@openchatlab/core'
|
||||
|
||||
// ==================== 基础查询(委托给 core) ====================
|
||||
|
||||
@@ -94,41 +98,16 @@ export function getTimeRange(sessionId: string): { start: number; end: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取成员的历史昵称记录
|
||||
* 获取成员的历史昵称记录 (delegates to core)
|
||||
*/
|
||||
export function getMemberNameHistory(sessionId: string, memberId: number): any[] {
|
||||
const db = openDatabase(sessionId)
|
||||
const db = openDatabaseAdapter(sessionId)
|
||||
if (!db) return []
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT name_type as nameType, name, start_ts as startTs, end_ts as endTs
|
||||
FROM member_name_history
|
||||
WHERE member_id = ?
|
||||
ORDER BY start_ts DESC
|
||||
`
|
||||
)
|
||||
.all(memberId) as Array<{ nameType: string; name: string; startTs: number; endTs: number | null }>
|
||||
|
||||
return rows
|
||||
return coreGetMemberNameHistory(db, memberId)
|
||||
}
|
||||
|
||||
// ==================== 成员管理 ====================
|
||||
|
||||
/**
|
||||
* 成员信息(含统计数据)
|
||||
*/
|
||||
interface MemberWithStats {
|
||||
id: number
|
||||
platformId: string
|
||||
accountName: string | null
|
||||
groupNickname: string | null
|
||||
aliases: string[]
|
||||
messageCount: number
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
// 用于标记已检查过 aliases 字段的会话
|
||||
const aliasesCheckedSessions = new Set<string>()
|
||||
// 用于标记已检查过 avatar 字段的会话
|
||||
@@ -208,165 +187,34 @@ export function ensureAvatarColumn(sessionId: string): void {
|
||||
|
||||
/**
|
||||
* 获取所有成员列表(含消息数、别名和头像)
|
||||
* Delegates to core getMembersWithAliases after ensuring schema columns exist.
|
||||
*/
|
||||
export function getMembers(sessionId: string): MemberWithStats[] {
|
||||
// 先确保数据库有 aliases 和 avatar 字段(兼容旧数据库)
|
||||
export function getMembers(sessionId: string): MemberWithAliases[] {
|
||||
ensureAliasesColumn(sessionId)
|
||||
ensureAvatarColumn(sessionId)
|
||||
|
||||
const db = openDatabase(sessionId)
|
||||
const db = openDatabaseAdapter(sessionId)
|
||||
if (!db) return []
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
m.id,
|
||||
m.platform_id as platformId,
|
||||
m.account_name as accountName,
|
||||
m.group_nickname as groupNickname,
|
||||
m.aliases,
|
||||
m.avatar,
|
||||
COUNT(msg.id) as messageCount
|
||||
FROM member m
|
||||
LEFT JOIN message msg ON m.id = msg.sender_id
|
||||
WHERE COALESCE(m.group_nickname, m.account_name, m.platform_id) != '系统消息'
|
||||
GROUP BY m.id
|
||||
ORDER BY messageCount DESC
|
||||
`
|
||||
)
|
||||
.all() as Array<{
|
||||
id: number
|
||||
platformId: string
|
||||
accountName: string | null
|
||||
groupNickname: string | null
|
||||
aliases: string | null
|
||||
avatar: string | null
|
||||
messageCount: number
|
||||
}>
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
platformId: row.platformId,
|
||||
accountName: row.accountName,
|
||||
groupNickname: row.groupNickname,
|
||||
aliases: row.aliases ? JSON.parse(row.aliases) : [],
|
||||
messageCount: row.messageCount,
|
||||
avatar: row.avatar,
|
||||
}))
|
||||
return coreGetMembersWithAliases(db)
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页参数类型
|
||||
*/
|
||||
export interface MembersPaginationParams {
|
||||
page: number
|
||||
pageSize: number
|
||||
search?: string
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页结果类型
|
||||
*/
|
||||
export interface MembersPaginatedResult {
|
||||
members: MemberWithStats[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
export type { MembersPaginationParams, MembersPaginatedResult }
|
||||
|
||||
/**
|
||||
* 获取成员列表(分页版本,支持搜索和排序)
|
||||
* Delegates to core getMembersPaginated after ensuring schema columns exist.
|
||||
*/
|
||||
export function getMembersPaginated(sessionId: string, params: MembersPaginationParams): MembersPaginatedResult {
|
||||
const { page = 1, pageSize = 20, search = '', sortOrder = 'desc' } = params
|
||||
|
||||
// 先确保数据库有 aliases 和 avatar 字段(兼容旧数据库)
|
||||
ensureAliasesColumn(sessionId)
|
||||
ensureAvatarColumn(sessionId)
|
||||
|
||||
const db = openDatabase(sessionId)
|
||||
const db = openDatabaseAdapter(sessionId)
|
||||
if (!db) {
|
||||
const page = params.page ?? 1
|
||||
const pageSize = params.pageSize ?? 20
|
||||
return { members: [], total: 0, page, pageSize, totalPages: 0 }
|
||||
}
|
||||
|
||||
// 构建搜索条件
|
||||
const searchCondition = search
|
||||
? `AND (
|
||||
m.group_nickname LIKE '%' || @search || '%' COLLATE NOCASE
|
||||
OR m.account_name LIKE '%' || @search || '%' COLLATE NOCASE
|
||||
OR m.platform_id LIKE '%' || @search || '%' COLLATE NOCASE
|
||||
OR m.aliases LIKE '%' || @search || '%' COLLATE NOCASE
|
||||
)`
|
||||
: ''
|
||||
|
||||
// 排序方向
|
||||
const orderDirection = sortOrder === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
// 计算总数
|
||||
const countResult = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as total FROM (
|
||||
SELECT m.id
|
||||
FROM member m
|
||||
LEFT JOIN message msg ON m.id = msg.sender_id
|
||||
WHERE COALESCE(m.group_nickname, m.account_name, m.platform_id) != '系统消息'
|
||||
${searchCondition}
|
||||
GROUP BY m.id
|
||||
)
|
||||
`
|
||||
)
|
||||
.get({ search }) as { total: number }
|
||||
|
||||
const total = countResult?.total || 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
// 查询分页数据
|
||||
const rows = db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
m.id,
|
||||
m.platform_id as platformId,
|
||||
m.account_name as accountName,
|
||||
m.group_nickname as groupNickname,
|
||||
m.aliases,
|
||||
m.avatar,
|
||||
COUNT(msg.id) as messageCount
|
||||
FROM member m
|
||||
LEFT JOIN message msg ON m.id = msg.sender_id
|
||||
WHERE COALESCE(m.group_nickname, m.account_name, m.platform_id) != '系统消息'
|
||||
${searchCondition}
|
||||
GROUP BY m.id
|
||||
ORDER BY messageCount ${orderDirection}
|
||||
LIMIT @pageSize OFFSET @offset
|
||||
`
|
||||
)
|
||||
.all({ search, pageSize, offset }) as Array<{
|
||||
id: number
|
||||
platformId: string
|
||||
accountName: string | null
|
||||
groupNickname: string | null
|
||||
aliases: string | null
|
||||
avatar: string | null
|
||||
messageCount: number
|
||||
}>
|
||||
|
||||
const members = rows.map((row) => ({
|
||||
id: row.id,
|
||||
platformId: row.platformId,
|
||||
accountName: row.accountName,
|
||||
groupNickname: row.groupNickname,
|
||||
aliases: row.aliases ? JSON.parse(row.aliases) : [],
|
||||
messageCount: row.messageCount,
|
||||
avatar: row.avatar,
|
||||
}))
|
||||
|
||||
return { members, total, page, pageSize, totalPages }
|
||||
return coreGetMembersPaginated(db, params)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,7 @@ export {
|
||||
getConversationBetween,
|
||||
getMemberNameHistory,
|
||||
getMembersWithAliases,
|
||||
getMembersPaginated,
|
||||
executeParameterizedSql,
|
||||
getCatchphraseAnalysis,
|
||||
getMentionAnalysis,
|
||||
@@ -107,6 +108,8 @@ export type {
|
||||
ConversationData,
|
||||
MemberNameHistoryEntry,
|
||||
MemberWithAliases,
|
||||
MembersPaginationParams,
|
||||
MembersPaginatedResult,
|
||||
CatchphraseAnalysis,
|
||||
MemberCatchphrase,
|
||||
CatchphraseItem,
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Tests for member query functions: getMembersWithAliases, getMembersPaginated.
|
||||
*
|
||||
* Covers:
|
||||
* - aliases/avatar column existence vs. absence
|
||||
* - pagination, search, asc/desc sort
|
||||
* - system message member filtering
|
||||
*
|
||||
* Run: npx tsx --test packages/core/src/query/__tests__/member-queries.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { getMembersWithAliases, getMembersPaginated } from '../message-queries'
|
||||
import type { DatabaseAdapter, PreparedStatement } from '../../interfaces'
|
||||
|
||||
// ==================== Mock helpers ====================
|
||||
|
||||
interface MockMember {
|
||||
id: number
|
||||
platformId: string
|
||||
accountName: string | null
|
||||
groupNickname: string | null
|
||||
aliases: string | null
|
||||
avatar: string | null
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
const SAMPLE_MEMBERS: MockMember[] = [
|
||||
{
|
||||
id: 1,
|
||||
platformId: 'u1',
|
||||
accountName: 'Alice',
|
||||
groupNickname: 'A',
|
||||
aliases: '["小A"]',
|
||||
avatar: 'data:img1',
|
||||
messageCount: 100,
|
||||
},
|
||||
{ id: 2, platformId: 'u2', accountName: 'Bob', groupNickname: null, aliases: '[]', avatar: null, messageCount: 50 },
|
||||
{
|
||||
id: 3,
|
||||
platformId: 'u3',
|
||||
accountName: 'Carol',
|
||||
groupNickname: 'C',
|
||||
aliases: null,
|
||||
avatar: 'data:img3',
|
||||
messageCount: 30,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
platformId: 'sys',
|
||||
accountName: '系统消息',
|
||||
groupNickname: '系统消息',
|
||||
aliases: null,
|
||||
avatar: null,
|
||||
messageCount: 999,
|
||||
},
|
||||
]
|
||||
|
||||
function createMockDb(opts: { hasAliases?: boolean; hasAvatar?: boolean } = {}): DatabaseAdapter {
|
||||
const { hasAliases = true, hasAvatar = true } = opts
|
||||
|
||||
const nonSystemMembers = SAMPLE_MEMBERS.filter((m) => {
|
||||
const displayName = m.groupNickname || m.accountName || m.platformId
|
||||
return displayName !== '系统消息'
|
||||
})
|
||||
|
||||
return {
|
||||
prepare(sql: string): PreparedStatement {
|
||||
return {
|
||||
get(...params: unknown[]) {
|
||||
if (sql.includes('PRAGMA table_info')) {
|
||||
return undefined
|
||||
}
|
||||
if (sql.includes('COUNT(*)')) {
|
||||
const searchParam = params.find((p) => typeof p === 'string' && (p as string).includes('%'))
|
||||
let filtered = nonSystemMembers
|
||||
if (searchParam) {
|
||||
const term = (searchParam as string).replace(/%/g, '').toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(m) =>
|
||||
m.accountName?.toLowerCase().includes(term) ||
|
||||
m.groupNickname?.toLowerCase().includes(term) ||
|
||||
m.platformId.toLowerCase().includes(term) ||
|
||||
(hasAliases && m.aliases?.toLowerCase().includes(term))
|
||||
)
|
||||
}
|
||||
return { total: filtered.length }
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
all(...params: unknown[]) {
|
||||
if (sql.includes('PRAGMA table_info')) {
|
||||
const cols = [{ name: 'id' }, { name: 'platform_id' }, { name: 'account_name' }, { name: 'group_nickname' }]
|
||||
if (sql.includes('member')) {
|
||||
if (hasAliases) cols.push({ name: 'aliases' })
|
||||
if (hasAvatar) cols.push({ name: 'avatar' })
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
let filtered = nonSystemMembers
|
||||
|
||||
const searchParam = params.find((p) => typeof p === 'string' && (p as string).includes('%'))
|
||||
if (searchParam) {
|
||||
const term = (searchParam as string).replace(/%/g, '').toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(m) =>
|
||||
m.accountName?.toLowerCase().includes(term) ||
|
||||
m.groupNickname?.toLowerCase().includes(term) ||
|
||||
m.platformId.toLowerCase().includes(term) ||
|
||||
(hasAliases && m.aliases?.toLowerCase().includes(term))
|
||||
)
|
||||
}
|
||||
|
||||
if (sql.includes('ORDER BY messageCount ASC')) {
|
||||
filtered = [...filtered].sort((a, b) => a.messageCount - b.messageCount)
|
||||
} else {
|
||||
filtered = [...filtered].sort((a, b) => b.messageCount - a.messageCount)
|
||||
}
|
||||
|
||||
const limitIdx = params.findIndex((_, i) => {
|
||||
const remaining = params.slice(i)
|
||||
return remaining.length >= 2 && typeof remaining[0] === 'number' && typeof remaining[1] === 'number'
|
||||
})
|
||||
if (limitIdx >= 0) {
|
||||
const limit = params[limitIdx] as number
|
||||
const offset = params[limitIdx + 1] as number
|
||||
filtered = filtered.slice(offset, offset + limit)
|
||||
}
|
||||
|
||||
return filtered.map((m) => ({
|
||||
id: m.id,
|
||||
platformId: m.platformId,
|
||||
accountName: m.accountName,
|
||||
groupNickname: m.groupNickname,
|
||||
aliases: hasAliases ? m.aliases : null,
|
||||
avatar: hasAvatar ? m.avatar : null,
|
||||
messageCount: m.messageCount,
|
||||
}))
|
||||
},
|
||||
run() {
|
||||
return { changes: 0, lastInsertRowid: 0 }
|
||||
},
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
exec() {},
|
||||
transaction<T>(fn: () => T) {
|
||||
return fn()
|
||||
},
|
||||
pragma() {
|
||||
return undefined
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
close() {},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== getMembersWithAliases ====================
|
||||
|
||||
describe('getMembersWithAliases', () => {
|
||||
it('returns members with parsed aliases and avatar', () => {
|
||||
const db = createMockDb({ hasAliases: true, hasAvatar: true })
|
||||
const result = getMembersWithAliases(db)
|
||||
|
||||
assert.equal(result.length, 3, 'should exclude system message member')
|
||||
assert.deepEqual(result[0].aliases, ['小A'])
|
||||
assert.equal(result[0].avatar, 'data:img1')
|
||||
assert.equal(result[0].accountName, 'Alice')
|
||||
assert.equal(result[0].messageCount, 100)
|
||||
})
|
||||
|
||||
it('returns empty aliases and null avatar when columns do not exist', () => {
|
||||
const db = createMockDb({ hasAliases: false, hasAvatar: false })
|
||||
const result = getMembersWithAliases(db)
|
||||
|
||||
assert.equal(result.length, 3)
|
||||
for (const m of result) {
|
||||
assert.deepEqual(m.aliases, [], 'aliases should default to empty array')
|
||||
assert.equal(m.avatar, null, 'avatar should default to null')
|
||||
}
|
||||
})
|
||||
|
||||
it('excludes system message members', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersWithAliases(db)
|
||||
const hasSys = result.some((m) => m.accountName === '系统消息')
|
||||
assert.equal(hasSys, false)
|
||||
})
|
||||
|
||||
it('sorts by messageCount descending', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersWithAliases(db)
|
||||
for (let i = 1; i < result.length; i++) {
|
||||
assert.ok(result[i - 1].messageCount >= result[i].messageCount)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ==================== getMembersPaginated ====================
|
||||
|
||||
describe('getMembersPaginated', () => {
|
||||
it('returns first page with correct pagination meta', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersPaginated(db, { page: 1, pageSize: 2 })
|
||||
|
||||
assert.equal(result.page, 1)
|
||||
assert.equal(result.pageSize, 2)
|
||||
assert.equal(result.total, 3)
|
||||
assert.equal(result.totalPages, 2)
|
||||
assert.equal(result.members.length, 2)
|
||||
})
|
||||
|
||||
it('returns second page', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersPaginated(db, { page: 2, pageSize: 2 })
|
||||
|
||||
assert.equal(result.page, 2)
|
||||
assert.equal(result.members.length, 1)
|
||||
})
|
||||
|
||||
it('filters by search term', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersPaginated(db, { search: 'alice' })
|
||||
|
||||
assert.equal(result.total, 1)
|
||||
assert.equal(result.members[0].accountName, 'Alice')
|
||||
})
|
||||
|
||||
it('sorts ascending', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersPaginated(db, { sortOrder: 'asc' })
|
||||
|
||||
for (let i = 1; i < result.members.length; i++) {
|
||||
assert.ok(result.members[i - 1].messageCount <= result.members[i].messageCount)
|
||||
}
|
||||
})
|
||||
|
||||
it('sorts descending by default', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersPaginated(db, {})
|
||||
|
||||
for (let i = 1; i < result.members.length; i++) {
|
||||
assert.ok(result.members[i - 1].messageCount >= result.members[i].messageCount)
|
||||
}
|
||||
})
|
||||
|
||||
it('works when aliases/avatar columns are missing', () => {
|
||||
const db = createMockDb({ hasAliases: false, hasAvatar: false })
|
||||
const result = getMembersPaginated(db, {})
|
||||
|
||||
assert.equal(result.total, 3)
|
||||
for (const m of result.members) {
|
||||
assert.deepEqual(m.aliases, [])
|
||||
assert.equal(m.avatar, null)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses default page and pageSize when omitted', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersPaginated(db, {})
|
||||
|
||||
assert.equal(result.page, 1)
|
||||
assert.equal(result.pageSize, 20)
|
||||
})
|
||||
|
||||
it('clamps page to minimum 1', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersPaginated(db, { page: -5 })
|
||||
|
||||
assert.equal(result.page, 1)
|
||||
})
|
||||
|
||||
it('clamps pageSize to range [1, 100]', () => {
|
||||
const db = createMockDb()
|
||||
const r1 = getMembersPaginated(db, { pageSize: 0 })
|
||||
assert.equal(r1.pageSize, 1)
|
||||
|
||||
const r2 = getMembersPaginated(db, { pageSize: 999 })
|
||||
assert.equal(r2.pageSize, 100)
|
||||
})
|
||||
|
||||
it('excludes system message members', () => {
|
||||
const db = createMockDb()
|
||||
const result = getMembersPaginated(db, { pageSize: 100 })
|
||||
const hasSys = result.members.some((m) => m.accountName === '系统消息')
|
||||
assert.equal(hasSys, false)
|
||||
})
|
||||
})
|
||||
@@ -60,6 +60,7 @@ export {
|
||||
getConversationBetween,
|
||||
getMemberNameHistory,
|
||||
getMembersWithAliases,
|
||||
getMembersPaginated,
|
||||
executeParameterizedSql,
|
||||
} from './message-queries'
|
||||
export type {
|
||||
@@ -72,6 +73,8 @@ export type {
|
||||
ConversationData,
|
||||
MemberNameHistoryEntry,
|
||||
MemberWithAliases,
|
||||
MembersPaginationParams,
|
||||
MembersPaginatedResult,
|
||||
} from './message-queries'
|
||||
|
||||
// Shared full-message SQL, types, and mapper
|
||||
|
||||
@@ -351,9 +351,25 @@ export interface MemberWithAliases {
|
||||
accountName: string | null
|
||||
groupNickname: string | null
|
||||
aliases: string[]
|
||||
avatar: string | null
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
export interface MembersPaginationParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
search?: string
|
||||
sortOrder?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
export interface MembersPaginatedResult {
|
||||
members: MemberWithAliases[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-load messages by IDs with full sender info (avatar, aliases, reply, etc.)
|
||||
*/
|
||||
@@ -533,18 +549,20 @@ export function getMemberNameHistory(db: DatabaseAdapter, memberId: number): Mem
|
||||
}
|
||||
|
||||
/**
|
||||
* Get member list with aliases and detailed info
|
||||
* Get member list with aliases, avatar and detailed info
|
||||
*/
|
||||
export function getMembersWithAliases(db: DatabaseAdapter): MemberWithAliases[] {
|
||||
const aliasesAvailable = hasColumn(db, 'member', 'aliases')
|
||||
const avatarAvailable = hasColumn(db, 'member', 'avatar')
|
||||
|
||||
const aliasesSelect = aliasesAvailable ? 'm.aliases' : 'NULL as aliases'
|
||||
const avatarSelect = avatarAvailable ? 'm.avatar' : 'NULL as avatar'
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
m.id, m.platform_id as platformId,
|
||||
m.account_name as accountName, m.group_nickname as groupNickname,
|
||||
${aliasesSelect},
|
||||
${aliasesSelect}, ${avatarSelect},
|
||||
COUNT(msg.id) as messageCount
|
||||
FROM member m
|
||||
LEFT JOIN message msg ON m.id = msg.sender_id
|
||||
@@ -557,17 +575,102 @@ export function getMembersWithAliases(db: DatabaseAdapter): MemberWithAliases[]
|
||||
accountName: string | null
|
||||
groupNickname: string | null
|
||||
aliases: string | null
|
||||
avatar: string | null
|
||||
messageCount: number
|
||||
}>
|
||||
|
||||
return rows.map((row) => ({
|
||||
return rows.map(mapMemberRow)
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated member list with search and sort.
|
||||
*/
|
||||
export function getMembersPaginated(db: DatabaseAdapter, params: MembersPaginationParams): MembersPaginatedResult {
|
||||
const page = Math.max(1, params.page ?? 1)
|
||||
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20))
|
||||
const search = params.search?.trim() || ''
|
||||
const sortDirection = params.sortOrder === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
const aliasesAvailable = hasColumn(db, 'member', 'aliases')
|
||||
const avatarAvailable = hasColumn(db, 'member', 'avatar')
|
||||
const aliasesSelect = aliasesAvailable ? 'm.aliases' : 'NULL as aliases'
|
||||
const avatarSelect = avatarAvailable ? 'm.avatar' : 'NULL as avatar'
|
||||
|
||||
let searchClause = ''
|
||||
const searchParams: unknown[] = []
|
||||
if (search) {
|
||||
const clauses = ['m.account_name LIKE ?', 'm.group_nickname LIKE ?', 'm.platform_id LIKE ?']
|
||||
const like = `%${search}%`
|
||||
searchParams.push(like, like, like)
|
||||
if (aliasesAvailable) {
|
||||
clauses.push('m.aliases LIKE ?')
|
||||
searchParams.push(like)
|
||||
}
|
||||
searchClause = `AND (${clauses.join(' OR ')})`
|
||||
}
|
||||
|
||||
const systemFilter = "COALESCE(m.group_nickname, m.account_name, m.platform_id) != '系统消息'"
|
||||
|
||||
const countRow = db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as total FROM (
|
||||
SELECT m.id FROM member m
|
||||
LEFT JOIN message msg ON m.id = msg.sender_id
|
||||
WHERE ${systemFilter} ${searchClause}
|
||||
GROUP BY m.id
|
||||
)`
|
||||
)
|
||||
.get(...searchParams) as { total: number } | undefined
|
||||
|
||||
const total = countRow?.total ?? 0
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
m.id, m.platform_id as platformId,
|
||||
m.account_name as accountName, m.group_nickname as groupNickname,
|
||||
${aliasesSelect}, ${avatarSelect},
|
||||
COUNT(msg.id) as messageCount
|
||||
FROM member m
|
||||
LEFT JOIN message msg ON m.id = msg.sender_id
|
||||
WHERE ${systemFilter} ${searchClause}
|
||||
GROUP BY m.id
|
||||
ORDER BY messageCount ${sortDirection}
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...searchParams, pageSize, offset) as Array<{
|
||||
id: number
|
||||
platformId: string
|
||||
accountName: string | null
|
||||
groupNickname: string | null
|
||||
aliases: string | null
|
||||
avatar: string | null
|
||||
messageCount: number
|
||||
}>
|
||||
|
||||
return { members: rows.map(mapMemberRow), total, page, pageSize, totalPages }
|
||||
}
|
||||
|
||||
function mapMemberRow(row: {
|
||||
id: number
|
||||
platformId: string
|
||||
accountName: string | null
|
||||
groupNickname: string | null
|
||||
aliases: string | null
|
||||
avatar: string | null
|
||||
messageCount: number
|
||||
}): MemberWithAliases {
|
||||
return {
|
||||
id: row.id,
|
||||
platformId: row.platformId,
|
||||
accountName: row.accountName,
|
||||
groupNickname: row.groupNickname,
|
||||
aliases: row.aliases ? JSON.parse(row.aliases) : [],
|
||||
avatar: row.avatar ?? null,
|
||||
messageCount: row.messageCount,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { DatabaseManager } from '@openchatlab/node-runtime'
|
||||
import type { TimeFilter } from '@openchatlab/shared-types'
|
||||
import {
|
||||
getSessionInfo,
|
||||
getMembersWithAliases,
|
||||
getMembersPaginated,
|
||||
getTimeRange,
|
||||
getAvailableYears,
|
||||
getMemberActivity,
|
||||
@@ -201,23 +203,7 @@ export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseMa
|
||||
|
||||
server.get<{ Params: { id: string } }>('/_web/sessions/:id/members', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
m.id, m.platform_id as platformId,
|
||||
m.account_name as accountName,
|
||||
m.group_nickname as groupNickname,
|
||||
m.aliases, m.avatar,
|
||||
(SELECT COUNT(*) FROM message WHERE sender_id = m.id) as messageCount
|
||||
FROM member m
|
||||
WHERE COALESCE(m.account_name, '') != '系统消息'
|
||||
ORDER BY messageCount DESC`
|
||||
)
|
||||
.all() as any[]
|
||||
return rows.map((r: any) => ({
|
||||
...r,
|
||||
aliases: r.aliases ? (typeof r.aliases === 'string' ? JSON.parse(r.aliases) : r.aliases) : [],
|
||||
}))
|
||||
return getMembersWithAliases(db)
|
||||
})
|
||||
|
||||
server.get<{
|
||||
@@ -225,52 +211,18 @@ export function registerWebRoutes(server: FastifyInstance, dbManager: DatabaseMa
|
||||
Querystring: { page?: string; pageSize?: string; search?: string; sortOrder?: string }
|
||||
}>('/_web/sessions/:id/members/paginated', async (request) => {
|
||||
const db = ensureDb(dbManager, request.params.id)
|
||||
const page = Math.max(1, parseInt(request.query.page || '1', 10))
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(request.query.pageSize || '20', 10)))
|
||||
const search = request.query.search?.trim() || ''
|
||||
const sortOrder = request.query.sortOrder === 'asc' ? 'ASC' : 'DESC'
|
||||
|
||||
let whereClause = ''
|
||||
const params: unknown[] = []
|
||||
if (search) {
|
||||
whereClause = `WHERE (m.account_name LIKE ? OR m.group_nickname LIKE ? OR m.platform_id LIKE ? OR m.aliases LIKE ?)`
|
||||
const searchParam = `%${search}%`
|
||||
params.push(searchParam, searchParam, searchParam, searchParam)
|
||||
}
|
||||
|
||||
const countRow = db.prepare(`SELECT COUNT(*) as total FROM member m ${whereClause}`).get(...params) as {
|
||||
total: number
|
||||
}
|
||||
const total = countRow.total
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT
|
||||
m.id, m.platform_id as platformId,
|
||||
m.account_name as accountName,
|
||||
m.group_nickname as groupNickname,
|
||||
m.aliases, m.avatar,
|
||||
COUNT(msg.id) as messageCount
|
||||
FROM member m
|
||||
LEFT JOIN message msg ON m.id = msg.sender_id
|
||||
${whereClause}
|
||||
GROUP BY m.id
|
||||
ORDER BY messageCount ${sortOrder}
|
||||
LIMIT ? OFFSET ?`
|
||||
)
|
||||
.all(...params, pageSize, offset) as any[]
|
||||
|
||||
const result = getMembersPaginated(db, {
|
||||
page: parseInt(request.query.page || '1', 10),
|
||||
pageSize: parseInt(request.query.pageSize || '20', 10),
|
||||
search: request.query.search,
|
||||
sortOrder: request.query.sortOrder === 'asc' ? 'asc' : 'desc',
|
||||
})
|
||||
return {
|
||||
items: rows.map((r: any) => ({
|
||||
...r,
|
||||
aliases: r.aliases ? (typeof r.aliases === 'string' ? JSON.parse(r.aliases) : r.aliases) : [],
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
items: result.members,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
pageSize: result.pageSize,
|
||||
totalPages: result.totalPages,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user