feat: 新增群员统计与退群事件,并把本地索引集中到设置页

- 群员统计:档案中按群统计发言排行与未发言成员,可复制纯文本、按平台能力发送
- 退群事件永久保存,并以本地推断条目并入档案消息流
- 设置新增本地索引页,集中管理聊天记录索引与图片文字索引
This commit is contained in:
Wxw-Gu
2026-09-20 21:46:37 +08:00
parent b6d287a299
commit 766cd94788
44 changed files with 3949 additions and 24 deletions
+120
View File
@@ -0,0 +1,120 @@
import { describe, expect, it } from 'vitest'
import {
GROUP_EXIT_EVENT_MESSAGE_TYPE,
isGroupExitEventMessage,
mergeGroupExitEvents,
toGroupExitEventMessage
} from '../../src/shared/group-exit-event-message'
import type { GroupExitMonitorEvent } from '../../src/shared/group-exit-monitor'
import type { Message } from '../../src/shared/types'
const at = (year: number, month: number, day: number, hour = 0, minute = 0): number =>
new Date(year, month - 1, day, hour, minute).getTime()
const makeEvent = (overrides: Partial<GroupExitMonitorEvent> = {}): GroupExitMonitorEvent => ({
id: 'evt-1',
contactId: 'g'.repeat(32),
roomId: 'room@chatroom',
groupName: '研发群',
memberWxid: 'wxid_a',
memberName: '老张',
previousCount: 10,
currentCount: 9,
delta: -1,
message: '老张退出了研发群',
detectedAt: at(2026, 9, 20, 14, 30),
...overrides
})
const makeMessage = (id: string, createTimeSec: number): Message => ({
id,
from: 'user',
type: '文本',
datetime: '2026-09-20 14:00',
content: 'hello',
isSender: false,
createTime: createTimeSec
})
describe('toGroupExitEventMessage', () => {
it('把事件映射成系统分支的消息,并带专用 type 标记', () => {
const message = toGroupExitEventMessage(makeEvent())
expect(message.id).toBe('group-exit:evt-1')
expect(message.from).toBe('system')
expect(message.type).toBe(GROUP_EXIT_EVENT_MESSAGE_TYPE)
expect(message.content).toBe('老张退出了研发群')
expect(message.isSender).toBe(false)
expect(message.senderId).toBe('wxid_a')
expect(message.sessionId).toBe('room@chatroom')
expect(isGroupExitEventMessage(message)).toBe(true)
})
it('⚠️ createTime 必须从毫秒换算成秒(全项目 Message.createTime 都是秒)', () => {
const detectedAt = at(2026, 9, 20, 14, 30)
const message = toGroupExitEventMessage(makeEvent({ detectedAt }))
// 直接塞毫秒会让事件被排到几万年之后,或者从时间轴上消失。
expect(message.createTime).toBe(Math.floor(detectedAt / 1000))
expect(message.createTime).toBeLessThan(1e11)
})
it('真实微信系统消息不会被误判为退群事件', () => {
const systemMessage: Message = {
id: 'm1',
from: 'system',
type: '系统消息',
datetime: '2026-09-20 14:00',
content: '某某加入了群聊',
isSender: false
}
expect(isGroupExitEventMessage(systemMessage)).toBe(false)
})
})
describe('mergeGroupExitEvents', () => {
it('按时间升序并入,事件落在正确位置', () => {
const messages = [
makeMessage('m1', Math.floor(at(2026, 9, 20, 10) / 1000)),
makeMessage('m2', Math.floor(at(2026, 9, 20, 16) / 1000))
]
const events = [makeEvent({ detectedAt: at(2026, 9, 20, 14, 30) })]
const merged = mergeGroupExitEvents(messages, events)
expect(merged.map((message) => message.id)).toEqual(['m1', 'group-exit:evt-1', 'm2'])
})
it('重复合并是幂等的(广播会反复刷新,不能越插越多)', () => {
const messages = [makeMessage('m1', Math.floor(at(2026, 9, 20, 10) / 1000))]
const events = [makeEvent()]
const once = mergeGroupExitEvents(messages, events)
const twice = mergeGroupExitEvents(once, events)
expect(twice).toHaveLength(2)
expect(twice.map((message) => message.id)).toEqual(once.map((message) => message.id))
})
it('没有事件时原样返回同一个引用(避免无谓的重渲染)', () => {
const messages = [makeMessage('m1', 1_700_000_000)]
expect(mergeGroupExitEvents(messages, [])).toBe(messages)
})
it('多个事件各自成组、保持时间顺序', () => {
const messages = [makeMessage('m1', Math.floor(at(2026, 9, 20, 8) / 1000))]
const events = [
makeEvent({ id: 'e1', detectedAt: at(2026, 9, 20, 12) }),
makeEvent({ id: 'e2', detectedAt: at(2026, 9, 20, 15) })
]
const merged = mergeGroupExitEvents(messages, events)
expect(merged.map((message) => message.id)).toEqual([
'm1',
'group-exit:e1',
'group-exit:e2'
])
})
})
@@ -1,4 +1,4 @@
import { mkdtempSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra'
import { existsSync, mkdtempSync, readFileSync, readJsonSync, rmSync, writeJsonSync } from 'fs-extra'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -394,6 +394,7 @@ describe('GroupExitMonitorService', () => {
await service.start(true)
const statePath = join(mocks.userData, 'group-exit-monitor.json')
const eventsPath = join(mocks.userData, 'group-exit-monitor-events.jsonl')
await vi.waitFor(() => {
expect(readJsonSync(statePath).snapshots[0].members[1]).toMatchObject({
wxid: departed.wxid,
@@ -401,7 +402,10 @@ describe('GroupExitMonitorService', () => {
})
})
const baseline = readJsonSync(statePath)
expect(baseline.events).toEqual([])
// 事件**不再写在状态文件里**(它们走 append-only 的 JSONL),
// 所以这里断言的是「两条存储都没有事件」,而不是只看状态文件。
expect(baseline.events).toBeUndefined()
expect(existsSync(eventsPath) ? readFileSync(eventsPath, 'utf8').trim() : '').toBe('')
expect(baseline.snapshots).toEqual([
expect.objectContaining({
roomId: 'room@chatroom',
+172
View File
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import {
formatActiveMemberStats,
formatSilentMemberStats,
formatStatsDate,
formatStatsDateTime,
resolveGroupStatsRangeStart,
type GroupMemberStatsResult
} from '../../src/shared/group-stats'
/** 用本地时区构造时间,避免测试受运行机器时区影响。 */
const at = (year: number, month: number, day: number, hour = 0): number =>
new Date(year, month - 1, day, hour).getTime()
const base = (overrides: Partial<GroupMemberStatsResult> = {}): GroupMemberStatsResult => ({
conversationId: 'c'.repeat(32),
startTime: at(2026, 6, 15),
endTime: at(2026, 9, 4),
freshness: 'fresh',
complete: true,
memberCount: 48,
activeMemberCount: 45,
silentMemberCount: 3,
activeMembers: [],
silentMembers: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
firstMessageTime: at(2026, 6, 15),
limitations: [],
...overrides
})
describe('GroupStats formatter', () => {
it('未发言模板按编号列出,并带区间与人数前缀', () => {
const text = formatSilentMemberStats(
base({
silentMembers: [
{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' },
{ senderId: 'wxid_2', displayName: '十二', groupNickname: '' },
{ senderId: 'wxid_3', displayName: '湘江中路', groupNickname: '' }
]
})
)
expect(text).toContain('未发言统计')
expect(text).toContain('2026-06-15 至 2026-09-04')
expect(text).toContain('1. 轩轩')
expect(text).toContain('2. 十二')
expect(text).toContain('3. 湘江中路')
// 纯文本:不能出现 Markdown 标记
expect(text).not.toContain('**')
expect(text).not.toContain('##')
})
it('未发言模板保留 limitation 文案', () => {
const text = formatSilentMemberStats(
base({
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }],
limitations: ['统计基于当前群成员名单与本地已归档消息。']
})
)
expect(text).toContain('注:统计基于当前群成员名单与本地已归档消息。')
})
it('未发言为空时给出明确文案而不是空白', () => {
const text = formatSilentMemberStats(base())
expect(text).toContain('(无)')
})
it('未发言模板支持 limit 并说明省略人数', () => {
const silentMembers = Array.from({ length: 5 }, (_, index) => ({
senderId: `wxid_${index}`,
displayName: `成员${index}`,
groupNickname: ''
}))
const text = formatSilentMemberStats(base({ silentMembers }), { limit: 2 })
expect(text).toContain('1. 成员0')
expect(text).toContain('2. 成员1')
expect(text).not.toContain('3. 成员2')
expect(text).toContain('其余 3 人未列出')
})
it('活跃模板带条数', () => {
const text = formatActiveMemberStats(
base({
activeMembers: [
{
senderId: 'wxid_1',
displayName: '张三',
groupNickname: '',
messageCount: 328,
lastMessageTime: at(2026, 9, 20, 12)
},
{
senderId: 'wxid_2',
displayName: '李四',
groupNickname: '',
messageCount: 217,
lastMessageTime: at(2026, 9, 19, 12)
}
]
})
)
expect(text).toContain('群聊活跃统计')
expect(text).toContain('统计时间:2026-06-15 至 2026-09-04')
expect(text).toContain('1. 张三 328 条')
expect(text).toContain('2. 李四 217 条')
})
it('群昵称与主名不同时显示成「微信名(群昵称)」', () => {
const text = formatSilentMemberStats(
base({
silentMembers: [
{ senderId: 'wxid_1', displayName: '张三', groupNickname: '老张' },
// 两者相同时不重复展示,避免噪声
{ senderId: 'wxid_2', displayName: '李四', groupNickname: '李四' },
// 没有群昵称时只显示主名
{ senderId: 'wxid_3', displayName: '王五', groupNickname: '' }
]
})
)
expect(text).toContain('1. 张三(老张)')
expect(text).toContain('2. 李四')
expect(text).not.toContain('李四(李四)')
expect(text).toContain('3. 王五')
})
it('日期格式化使用本地时区且补零', () => {
expect(formatStatsDate(at(2026, 1, 5))).toBe('2026-01-05')
expect(formatStatsDateTime(at(2026, 1, 5, 9))).toBe('2026-01-05 09:00')
})
it('快捷范围按滑动窗口折算起点', () => {
const end = at(2026, 9, 20)
expect(resolveGroupStatsRangeStart('7d', end)).toBe(end - 7 * 24 * 60 * 60 * 1000)
expect(resolveGroupStatsRangeStart('30d', end)).toBe(end - 30 * 24 * 60 * 60 * 1000)
expect(resolveGroupStatsRangeStart('90d', end)).toBe(end - 90 * 24 * 60 * 60 * 1000)
})
it('「全部」不放下界(startTime = 0)', () => {
expect(resolveGroupStatsRangeStart('all', at(2026, 9, 20))).toBe(0)
})
it('选「全部」时区间文案用本机第一条消息的真实日期,而不是 1970-01-01', () => {
const text = formatSilentMemberStats(
base({
startTime: 0,
firstMessageTime: at(2020, 2, 2),
endTime: at(2026, 9, 20),
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }]
})
)
expect(text).toContain('2020-02-02 至 2026-09-20')
expect(text).not.toContain('1970')
expect(text).not.toContain('全部历史')
})
it('选「全部」但取不到第一条消息时,退化成「全部历史」且不假装从 1970 开始', () => {
const text = formatSilentMemberStats(
base({
startTime: 0,
firstMessageTime: null,
endTime: at(2026, 9, 20),
silentMembers: [{ senderId: 'wxid_1', displayName: '轩轩', groupNickname: '' }]
})
)
expect(text).toContain('全部历史')
expect(text).not.toContain('1970')
})
})
+297
View File
@@ -0,0 +1,297 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* 群员统计服务的差集 / freshness 行为。
*
* 依赖全部 mock:这一层要证明的是**编排规则**(谁进活跃榜、谁算未发言、
* 什么情况下结论不完整),不是 SQL 正确性 —— 后者由
* `knowledge-store-member-stats.test.ts` 用真实 SQLite 覆盖。
*/
const chatMock = vi.hoisted(() => ({
resolveGroupRoomId: vi.fn(),
getGroupMemberIdsAsync: vi.fn(),
getGroupMemberNamesAsync: vi.fn()
}))
vi.mock('../../src/main/services/chat-service', () => chatMock)
import { GroupStatsService } from '../../src/main/services/group-stats-service'
import {
GROUP_STATS_LIMITATION,
GROUP_STATS_STALE_LIMITATION
} from '../../src/shared/group-stats'
import type { KnowledgeSearchService } from '../../src/main/knowledge/knowledge-search-service'
import type { KnowledgeMemberStatsResult } from '../../src/shared/knowledge'
const USER_MD5 = 'a'.repeat(32)
const ROOM_ID = 'room@chatroom'
const NOW = 1_700_000_000_000
const DAY = 24 * 60 * 60 * 1000
type Member = {
wxid: string
nickname: string
groupNickname: string
wechatNickname: string
remark: string
avatar: string
}
const member = (wxid: string, nickname: string): Member => ({
wxid,
nickname,
groupNickname: '',
wechatNickname: '',
remark: '',
avatar: ''
})
const statsResult = (
overrides: Partial<KnowledgeMemberStatsResult> = {}
): KnowledgeMemberStatsResult => ({
conversationId: USER_MD5,
totalMessages: 0,
senders: [],
unattributedMessages: 0,
excludedSystemMessages: 0,
earliestMessageTime: NOW - 30 * DAY,
indexLatestAt: NOW,
...overrides
})
function makeKnowledge(options: {
result?: KnowledgeMemberStatsResult | null
sourceLatestAt?: number | null
retryResult?: KnowledgeMemberStatsResult | null
} = {}): KnowledgeSearchService {
const first = options.result === undefined ? statsResult() : options.result
const retry = options.retryResult === undefined ? first : options.retryResult
let call = 0
return {
memberStats: vi.fn(async () => {
call += 1
return {
result: call === 1 ? first : retry,
sourceLatestAt: options.sourceLatestAt === undefined ? NOW : options.sourceLatestAt
}
}),
requestCatchUp: vi.fn(() => ({ triggered: true, inProgress: true })),
waitForIndexingComplete: vi.fn(async () => true)
} as unknown as KnowledgeSearchService
}
const run = (
knowledge: KnowledgeSearchService,
startTime = NOW - 30 * DAY,
endTime = NOW
) => new GroupStatsService(knowledge).getMemberStats({ userMd5: USER_MD5, startTime, endTime })
beforeEach(() => {
chatMock.resolveGroupRoomId.mockReset().mockReturnValue(ROOM_ID)
chatMock.getGroupMemberIdsAsync.mockReset().mockResolvedValue({
roomId: ROOM_ID,
memberIds: []
})
chatMock.getGroupMemberNamesAsync.mockReset().mockResolvedValue([])
})
describe('GroupStatsService · 差集', () => {
it('5 名成员里 3 人有发言、2 人未发言', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
member('wxid_a', '阿一'),
member('wxid_b', '阿二'),
member('wxid_c', '阿三'),
member('wxid_d', '阿四'),
member('wxid_e', '阿五')
])
const knowledge = makeKnowledge({
result: statsResult({
totalMessages: 16,
senders: [
{ senderId: 'wxid_a', messageCount: 10, lastMessageTime: NOW - 1000 },
{ senderId: 'wxid_b', messageCount: 5, lastMessageTime: NOW - 2000 },
{ senderId: 'wxid_c', messageCount: 1, lastMessageTime: NOW - 3000 }
]
})
})
const result = await run(knowledge)
expect(result.memberCount).toBe(5)
expect(result.activeMemberCount).toBe(3)
expect(result.silentMemberCount).toBe(2)
expect(result.activeMembers.map((item) => item.senderId)).toEqual([
'wxid_a',
'wxid_b',
'wxid_c'
])
expect(result.silentMembers.map((item) => item.senderId).sort()).toEqual([
'wxid_d',
'wxid_e'
])
})
it('历史消息的 sender 已退群时,不进当前成员活跃榜,并留下 limitation', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '阿一')])
const knowledge = makeKnowledge({
result: statsResult({
senders: [
{ senderId: 'wxid_a', messageCount: 5, lastMessageTime: NOW - 1000 },
{ senderId: 'wxid_left', messageCount: 3, lastMessageTime: NOW - 2000 }
]
})
})
const result = await run(knowledge)
expect(result.activeMembers.map((item) => item.senderId)).toEqual(['wxid_a'])
expect(result.activeMemberCount).toBe(1)
expect(result.limitations.some((text) => text.includes('已不在当前群成员名单中'))).toBe(true)
})
it('成员改昵称不影响统计:join 用 wxid,展示用当前昵称', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '改过的名字')])
const knowledge = makeKnowledge({
result: statsResult({
senders: [{ senderId: 'wxid_a', messageCount: 7, lastMessageTime: NOW - 500 }]
})
})
const result = await run(knowledge)
expect(result.activeMembers).toHaveLength(1)
expect(result.activeMembers[0].displayName).toBe('改过的名字')
expect(result.activeMembers[0].senderId).toBe('wxid_a')
expect(result.silentMemberCount).toBe(0)
})
it('sender 缺失的消息只计入诊断,不硬映射给任何成员', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
member('wxid_a', '阿一'),
member('wxid_b', '阿二')
])
const knowledge = makeKnowledge({
result: statsResult({
senders: [{ senderId: 'wxid_a', messageCount: 4, lastMessageTime: NOW - 1000 }],
unattributedMessages: 9
})
})
const result = await run(knowledge)
expect(result.unattributedMessages).toBe(9)
// 未归属消息既不能把人抬进活跃榜,也不能把人踢进未发言名单之外。
expect(result.activeMemberCount).toBe(1)
expect(result.silentMemberCount).toBe(1)
expect(result.activeMembers[0].senderId).toBe('wxid_a')
expect(result.silentMembers[0].senderId).toBe('wxid_b')
})
it('活跃榜按条数降序,同数按最后发言时间降序', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([
member('wxid_a', 'A'),
member('wxid_b', 'B'),
member('wxid_c', 'C')
])
const knowledge = makeKnowledge({
result: statsResult({
senders: [
{ senderId: 'wxid_b', messageCount: 5, lastMessageTime: NOW - 9000 },
{ senderId: 'wxid_c', messageCount: 5, lastMessageTime: NOW - 1000 },
{ senderId: 'wxid_a', messageCount: 9, lastMessageTime: NOW - 5000 }
]
})
})
const result = await run(knowledge)
expect(result.activeMembers.map((item) => item.senderId)).toEqual([
'wxid_a',
'wxid_c',
'wxid_b'
])
})
})
describe('GroupStatsService · freshness', () => {
it('索引追平时 complete = true', async () => {
const result = await run(makeKnowledge({ sourceLatestAt: NOW }))
expect(result.freshness).toBe('fresh')
expect(result.complete).toBe(true)
expect(result.limitations).not.toContain(GROUP_STATS_STALE_LIMITATION)
})
it('索引落后时 complete = false,绝不输出完整结论', async () => {
const knowledge = makeKnowledge({
result: statsResult({ indexLatestAt: NOW - 3 * DAY }),
sourceLatestAt: NOW,
// 追赶后仍然落后 —— 必须保持不完整。
retryResult: statsResult({ indexLatestAt: NOW - 3 * DAY })
})
const result = await run(knowledge)
expect(result.freshness).toBe('stale')
expect(result.complete).toBe(false)
expect(result.limitations).toContain(GROUP_STATS_STALE_LIMITATION)
})
it('落后时先触发有界追赶,追平后回到完整结论', async () => {
const knowledge = makeKnowledge({
result: statsResult({ indexLatestAt: NOW - 3 * DAY }),
sourceLatestAt: NOW,
retryResult: statsResult({ indexLatestAt: NOW })
})
const result = await run(knowledge)
expect(knowledge.requestCatchUp).toHaveBeenCalled()
expect(knowledge.waitForIndexingComplete).toHaveBeenCalled()
expect(result.freshness).toBe('fresh')
expect(result.complete).toBe(true)
})
it('口径缺失(indexLatestAt 为 null)按 unknown 处理,不得当作完整', async () => {
const knowledge = makeKnowledge({
result: statsResult({ indexLatestAt: null }),
sourceLatestAt: null
})
const result = await run(knowledge)
expect(result.freshness).toBe('unknown')
expect(result.complete).toBe(false)
expect(result.limitations).toContain(GROUP_STATS_STALE_LIMITATION)
})
it('结果里始终带边界说明', async () => {
const result = await run(makeKnowledge())
expect(result.limitations).toContain(GROUP_STATS_LIMITATION)
})
})
describe('GroupStatsService · 异常输入', () => {
it('不是群会话时返回空结果,不编造统计', async () => {
chatMock.resolveGroupRoomId.mockReturnValue(null)
const result = await run(makeKnowledge())
expect(result.memberCount).toBe(0)
expect(result.activeMemberCount).toBe(0)
expect(result.silentMemberCount).toBe(0)
expect(result.complete).toBe(false)
expect(result.freshness).toBe('unknown')
expect(chatMock.getGroupMemberIdsAsync).not.toHaveBeenCalled()
})
it('Knowledge 返回 null(派生库不可用)时不崩溃,且不完整', async () => {
chatMock.getGroupMemberNamesAsync.mockResolvedValue([member('wxid_a', '阿一')])
const result = await run(makeKnowledge({ result: null, sourceLatestAt: null }))
expect(result.memberCount).toBe(1)
expect(result.silentMemberCount).toBe(1)
expect(result.complete).toBe(false)
})
})
@@ -0,0 +1,185 @@
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { KnowledgeStore } from '../../src/main/knowledge/knowledge-store'
import {
DEFAULT_KNOWLEDGE_CHUNKER,
DEFAULT_KNOWLEDGE_FTS_CONFIG,
type KnowledgeIndexRequest,
type KnowledgeMessageKind,
type KnowledgeSourceMessage
} from '../../src/shared/knowledge'
/**
* 「按发送者聚合」SQL 的正确性。
*
* 这一层必须用**真实 SQLite**:系统消息排除、`sender_id IS NULL` 的归属、
* 时间窗闭区间,三条规则都写在 SQL 里 —— mock 掉数据库就等于什么都没验证。
*/
const ACCOUNT = 'acct-stats-test'
const CONV = 'c'.repeat(32)
const OTHER_CONV = 'd'.repeat(32)
const DAY = 24 * 60 * 60 * 1000
const BASE = new Date(2026, 5, 15, 12).getTime()
let root = ''
let store: KnowledgeStore | null = null
const message = (
id: string,
senderId: string | undefined,
kind: KnowledgeMessageKind,
createTime: number,
conversationId = CONV
): KnowledgeSourceMessage => ({
accountId: ACCOUNT,
conversationId,
messageId: id,
createTime,
senderId,
kind,
// 必须带可索引文本,否则会被 normalizer 的可索引性判定挡在索引之外,
// 测试就会因为「消息压根没入库」而假通过。
text: `payload-${id}`
})
async function seed(messages: KnowledgeSourceMessage[]): Promise<void> {
store = new KnowledgeStore(root, ACCOUNT, DEFAULT_KNOWLEDGE_FTS_CONFIG)
const conversations = Array.from(new Set(messages.map((item) => item.conversationId))).map(
(conversationId) => ({
conversationId,
completeSnapshot: true,
messages: messages.filter((item) => item.conversationId === conversationId),
sourceHighWaterTime: BASE + 10 * DAY
})
)
const request: KnowledgeIndexRequest = {
accountId: ACCOUNT,
databaseRoot: root,
conversations,
chunker: DEFAULT_KNOWLEDGE_CHUNKER,
fts: DEFAULT_KNOWLEDGE_FTS_CONFIG
}
await store.index(request, undefined, () => undefined)
}
const query = (startTime: number, endTime: number, conversationId = CONV) =>
store!.memberStats({ conversationId, startTime, endTime })
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'tm-knowledge-stats-'))
})
afterEach(() => {
try {
store?.close()
} finally {
store = null
if (root) rmSync(root, { recursive: true, force: true })
}
})
describe('KnowledgeStore.memberStats', () => {
it('按 sender 聚合出条数与最后发言时间,并按条数降序', async () => {
await seed([
message('m1', 'wxid_a', 'text', BASE + 1000),
message('m2', 'wxid_a', 'text', BASE + 5000),
message('m3', 'wxid_a', 'text', BASE + 3000),
message('m4', 'wxid_b', 'text', BASE + 2000)
])
const result = query(BASE, BASE + DAY)
expect(result.totalMessages).toBe(4)
expect(result.senders).toEqual([
{ senderId: 'wxid_a', messageCount: 3, lastMessageTime: BASE + 5000 },
{ senderId: 'wxid_b', messageCount: 1, lastMessageTime: BASE + 2000 }
])
})
it('系统消息不参与任何成员的发言统计', async () => {
await seed([
message('m1', 'wxid_a', 'text', BASE + 1000),
message('m2', 'wxid_a', 'system', BASE + 2000),
message('m3', undefined, 'system', BASE + 3000)
])
const result = query(BASE, BASE + DAY)
// 只有那条 text 算发言;系统消息既不计入总数,也不进 senders。
expect(result.totalMessages).toBe(1)
expect(result.senders).toEqual([
{ senderId: 'wxid_a', messageCount: 1, lastMessageTime: BASE + 1000 }
])
expect(result.excludedSystemMessages).toBe(2)
expect(result.unattributedMessages).toBe(0)
})
it('sender_id 缺失的消息只计入诊断,不归属任何成员', async () => {
await seed([
message('m1', 'wxid_a', 'text', BASE + 1000),
message('m2', undefined, 'text', BASE + 2000),
message('m3', undefined, 'other', BASE + 3000)
])
const result = query(BASE, BASE + DAY)
expect(result.unattributedMessages).toBe(2)
expect(result.totalMessages).toBe(3)
// 未归属消息**不得**变成一个空 sender 的分组。
expect(result.senders).toEqual([
{ senderId: 'wxid_a', messageCount: 1, lastMessageTime: BASE + 1000 }
])
})
it('时间窗口是闭区间,窗口外的消息不参与', async () => {
await seed([
message('before', 'wxid_a', 'text', BASE - 1),
message('start', 'wxid_a', 'text', BASE),
message('inside', 'wxid_a', 'text', BASE + 5000),
message('end', 'wxid_a', 'text', BASE + DAY),
message('after', 'wxid_a', 'text', BASE + DAY + 1)
])
const result = query(BASE, BASE + DAY)
// 闭区间:两头都算在内。
expect(result.totalMessages).toBe(3)
expect(result.senders[0].messageCount).toBe(3)
expect(result.senders[0].lastMessageTime).toBe(BASE + DAY)
})
it('只统计指定会话,不串到其它群', async () => {
await seed([
message('mine', 'wxid_a', 'text', BASE + 1000),
message('theirs', 'wxid_z', 'text', BASE + 1000, OTHER_CONV)
])
const result = query(BASE, BASE + DAY, CONV)
expect(result.totalMessages).toBe(1)
expect(result.senders.map((item) => item.senderId)).toEqual(['wxid_a'])
})
it('窗口内没有消息时返回空聚合而不是报错', async () => {
await seed([message('m1', 'wxid_a', 'text', BASE + 1000)])
const result = query(BASE + 100 * DAY, BASE + 101 * DAY)
expect(result.totalMessages).toBe(0)
expect(result.senders).toEqual([])
expect(result.unattributedMessages).toBe(0)
expect(result.indexLatestAt).not.toBeNull()
})
it('conversationId 为空时安全返回空结果', async () => {
await seed([message('m1', 'wxid_a', 'text', BASE + 1000)])
const result = store!.memberStats({ conversationId: '', startTime: BASE, endTime: BASE + DAY })
expect(result.totalMessages).toBe(0)
expect(result.senders).toEqual([])
})
})