perf: 重构退群监控,持久化快照并降低 CPU 占用

- 持久化群成员
- 支持 TraceMemo 重启后恢复离线期间的退群检测
- 使用 Batch Membership 替代逐群成员查询
- 降低大量群聊监控时的 CPU 和数据库查询开销
This commit is contained in:
电摇小子
2026-09-03 01:50:34 +08:00
parent 4d758b2aca
commit 3462d49516
6 changed files with 1284 additions and 202 deletions
+580 -51
View File
@@ -9,6 +9,10 @@ const mocks = vi.hoisted(() => ({
getChatDb: vi.fn(),
getCurrentAccountRoot: vi.fn(() => 'fixture-account'),
isReady: vi.fn(() => true),
getGroupNamesAsync: vi.fn(async () => ({})),
isGroupMemberIdsBatchAvailable: vi.fn(() => true),
getGroupMemberIdsBatchAsync: vi.fn(),
getGroupMemberIdsAsync: vi.fn(),
getGroupSnapshotAsync: vi.fn()
},
capability: {
@@ -35,17 +39,39 @@ import { GroupExitMonitorService } from '../../src/main/services/group-exit-moni
import { GROUP_EXIT_NOTIFICATION_TEMPLATE } from '../../src/shared/group-exit-monitor'
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
const otherMember = { wxid: 'wxid_other', wechatNickname: '另一个人' }
function installGroupDb(): void {
const client = {
getSessionsAsync: vi
.fn()
.mockResolvedValue([{ username: 'room@chatroom', nickname: '测试群' }]),
md5: vi.fn(() => 'room-md5')
md5: vi.fn((roomId: string) => `${roomId}-md5`),
invalidateGroupNicknameCache: vi.fn()
}
mocks.chat.getChatDb.mockReturnValue({ getWcdb4Client: () => client })
}
function writeBaseline(
snapshots: Array<{
roomId: string
groupName: string
members: Array<{ wxid: string; [key: string]: string }>
}>,
notificationRoomIds: string[] = []
): void {
writeJsonSync(join(mocks.userData, 'group-exit-monitor.json'), {
accountRoot: 'fixture-account',
events: [],
monitoredRoomIds: snapshots.map((snapshot) => snapshot.roomId),
notificationRoomIds,
snapshots: snapshots.map((snapshot) => ({
contactId: `${snapshot.roomId}-md5`,
roomId: snapshot.roomId,
groupName: snapshot.groupName,
capturedAt: Date.now() - 1_000,
members: snapshot.members
}))
})
}
describe('GroupExitMonitorService', () => {
const temporaryDirectories: string[] = []
@@ -55,7 +81,11 @@ describe('GroupExitMonitorService', () => {
mocks.chat.getChatDb.mockReset()
mocks.chat.getCurrentAccountRoot.mockReset().mockReturnValue('fixture-account')
mocks.chat.isReady.mockReset().mockReturnValue(true)
mocks.chat.getGroupSnapshotAsync.mockReset()
mocks.chat.getGroupNamesAsync.mockReset().mockResolvedValue({})
mocks.chat.isGroupMemberIdsBatchAvailable.mockReset().mockReturnValue(true)
mocks.chat.getGroupMemberIdsBatchAsync.mockReset()
mocks.chat.getGroupMemberIdsAsync.mockReset()
mocks.chat.getGroupSnapshotAsync.mockReset().mockResolvedValue(null)
mocks.capability.getPersonalWechatSendCapability.mockReset()
mocks.sender.send.mockReset()
})
@@ -89,38 +119,418 @@ describe('GroupExitMonitorService', () => {
expect(service.getState().notificationTemplate).toBe(GROUP_EXIT_NOTIFICATION_TEMPLATE)
})
it('keeps the previous baseline when a native snapshot contains null members', async () => {
it('uses the Session nickname when establishing a new group baseline', async () => {
installGroupDb()
const callOrder: string[] = []
mocks.chat.getGroupNamesAsync.mockImplementation(async () => {
callOrder.push('names')
return { 'A@chatroom': '测试群A' }
})
mocks.chat.getGroupMemberIdsBatchAsync.mockImplementation(async () => {
callOrder.push('batch')
return [{ roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] }]
})
mocks.chat.getGroupSnapshotAsync.mockImplementation(async () => {
callOrder.push('hydration')
return null
})
const service = new GroupExitMonitorService()
await service.setMonitoredRoomIds(['A@chatroom'])
await service.start(true)
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
expect(stored.snapshots[0]).toMatchObject({ roomId: 'A@chatroom', groupName: '测试群A' })
expect(callOrder.indexOf('batch')).toBeGreaterThan(callOrder.indexOf('names'))
expect(callOrder.indexOf('hydration')).toBeGreaterThan(callOrder.indexOf('batch'))
})
it('preserves existing member metadata during a Batch membership refresh', async () => {
installGroupDb()
const richMember = {
wxid: 'wxid_rich',
nickname: 'Shinven',
groupNickname: '群内 Shinven',
wechatNickname: 'Shinven',
remark: '小号',
avatar: 'data:image/png;base64,known'
}
writeBaseline([{ roomId: 'A@chatroom', groupName: '测试群A', members: [richMember] }])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'A@chatroom', status: 'ok', memberIds: [richMember.wxid] }
])
const service = new GroupExitMonitorService()
await service.start(true)
expect(
readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members
).toEqual([richMember])
})
it('keeps hydrated member metadata across the next Batch refresh', async () => {
installGroupDb()
writeBaseline([
{ roomId: 'A@chatroom', groupName: '测试群A', members: [{ wxid: 'wxid_rich' }] }
])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'A@chatroom', status: 'ok', memberIds: ['wxid_rich'] }
])
mocks.chat.getGroupSnapshotAsync.mockResolvedValue({
roomId: 'A@chatroom',
groupName: '测试群A',
members: [
{
wxid: 'wxid_rich',
nickname: 'Shinven',
groupNickname: '群内 Shinven',
wechatNickname: 'Shinven',
remark: '小号',
avatar: ''
}
]
})
const service = new GroupExitMonitorService()
await service.start(true)
await vi.waitFor(() => {
expect(
readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members[0]
).toMatchObject({ wxid: 'wxid_rich', nickname: 'Shinven', remark: '小号' })
})
await service.checkNow()
expect(
readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members[0]
).toMatchObject({ wxid: 'wxid_rich', nickname: 'Shinven', remark: '小号' })
})
it('preserves old member metadata while allowing a new member to start wxid-only', async () => {
installGroupDb()
const existing = { wxid: 'wxid_existing', nickname: 'Shinven', remark: '小号' }
writeBaseline([{ roomId: 'A@chatroom', groupName: '测试群A', members: [existing] }])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'A@chatroom', status: 'ok', memberIds: [existing.wxid, 'wxid_new'] }
])
mocks.chat.getGroupSnapshotAsync.mockResolvedValue({
roomId: 'A@chatroom',
groupName: '测试群A',
members: [{ wxid: existing.wxid }, { wxid: 'wxid_new', nickname: '新成员' }]
})
const service = new GroupExitMonitorService()
await service.start(true)
await vi.waitFor(() => {
expect(
readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members
).toEqual([existing, { wxid: 'wxid_new', nickname: '新成员' }])
})
})
it('repairs a persisted room id fallback when Session metadata becomes available', async () => {
installGroupDb()
writeBaseline([{ roomId: 'A@chatroom', groupName: 'A@chatroom', members: [member] }])
mocks.chat.getGroupNamesAsync.mockResolvedValue({ 'A@chatroom': '测试群A' })
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const service = new GroupExitMonitorService()
await service.start(true)
expect(
readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0]
).toMatchObject({ roomId: 'A@chatroom', groupName: '测试群A' })
})
it('keeps the previous resolved group name when Session metadata is temporarily unavailable', async () => {
installGroupDb()
writeBaseline([{ roomId: 'A@chatroom', groupName: '测试群A', members: [member] }])
mocks.chat.getGroupNamesAsync.mockResolvedValue({})
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const service = new GroupExitMonitorService()
await service.start(true)
expect(
readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0]
).toMatchObject({ roomId: 'A@chatroom', groupName: '测试群A' })
})
it('uses one resolved group name for both 3-to-1 events and Gateway notification recipients', async () => {
installGroupDb()
const departed = { wxid: 'wxid_departed', groupNickname: '离群一' }
const secondDeparted = { wxid: 'wxid_second_departed', groupNickname: '离群二' }
writeBaseline(
[
{
roomId: 'A@chatroom',
groupName: 'A@chatroom',
members: [member, departed, secondDeparted]
}
],
['A@chatroom']
)
mocks.chat.getGroupNamesAsync.mockResolvedValue({ 'A@chatroom': '测试群A' })
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'A@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const requests: Array<{ recipient?: { name?: string } }> = []
const actionGateway = {
execute: vi.fn(async (request: { recipient?: { name?: string } }) => {
requests.push(request)
return { status: 'sent', actionId: 'action-1', decision: 'allow' }
}),
registerMemberEvent: vi.fn()
}
const service = new GroupExitMonitorService({ actionGateway })
await service.start(true)
expect(service.getState().events).toHaveLength(2)
expect(service.getState().events).toEqual(
expect.arrayContaining([
expect.objectContaining({
memberWxid: departed.wxid,
groupName: '测试群A',
message: '离群一退出了测试群A',
previousCount: 3,
currentCount: 1
}),
expect.objectContaining({
memberWxid: secondDeparted.wxid,
groupName: '测试群A',
message: '离群二退出了测试群A',
previousCount: 3,
currentCount: 1
})
])
)
expect(requests).toHaveLength(2)
expect(requests.every((request) => request.recipient?.name === '测试群A')).toBe(true)
expect(mocks.chat.getGroupSnapshotAsync).not.toHaveBeenCalled()
})
it('persists a compact last good snapshot and recovers offline exits after restart', async () => {
installGroupDb()
const departed = {
wxid: 'wxid_departed',
nickname: '旧昵称',
wechatNickname: '离群成员',
groupNickname: '群内昵称',
remark: '通讯录备注',
avatar: 'data:image/png;base64,not-persisted'
}
const secondDeparted = {
wxid: 'wxid_second_departed',
nickname: '另一个旧昵称',
wechatNickname: '第二位离群成员',
groupNickname: '第二位群内昵称',
remark: '第二位通讯录备注'
}
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member]
members: [member, departed, secondDeparted]
})
mocks.chat.getGroupMemberIdsBatchAsync
.mockResolvedValueOnce([
{
roomId: 'room@chatroom',
status: 'ok',
memberIds: [member.wxid, departed.wxid, secondDeparted.wxid]
}
])
.mockResolvedValue([{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }])
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'])
await service.start(true)
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: null
const statePath = join(mocks.userData, 'group-exit-monitor.json')
await vi.waitFor(() => {
expect(readJsonSync(statePath).snapshots[0].members[1]).toMatchObject({
wxid: departed.wxid,
wechatNickname: departed.wechatNickname
})
})
const state = await service.checkNow()
const baseline = readJsonSync(statePath)
expect(baseline.events).toEqual([])
expect(baseline.snapshots).toEqual([
expect.objectContaining({
roomId: 'room@chatroom',
groupName: 'room@chatroom',
capturedAt: expect.any(Number),
members: [
member,
expect.objectContaining({ wxid: 'wxid_departed' }),
expect.objectContaining({ wxid: 'wxid_second_departed' })
]
})
])
expect(baseline.snapshots[0].members[1]).toHaveProperty('avatar', departed.avatar)
service.stop()
const restarted = new GroupExitMonitorService()
await restarted.start(true)
expect(restarted.getState().events).toHaveLength(2)
expect(restarted.getState().events).toEqual(
expect.arrayContaining([
expect.objectContaining({
memberWxid: 'wxid_departed',
memberName: departed.groupNickname,
previousCount: 3,
currentCount: 1
}),
expect.objectContaining({
memberWxid: 'wxid_second_departed',
memberName: secondDeparted.groupNickname,
previousCount: 3,
currentCount: 1
})
])
)
expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledTimes(2)
expect(mocks.chat.getGroupMemberIdsAsync).not.toHaveBeenCalled()
expect(readJsonSync(statePath).snapshots[0].members).toEqual([member])
await restarted.start(true)
expect(restarted.getState().events).toHaveLength(2)
restarted.clearEvents()
expect(readJsonSync(statePath).snapshots[0].members).toEqual([member])
})
it('keeps every previous baseline when a batch result is invalid or incomplete', async () => {
installGroupDb()
writeBaseline([
{ roomId: 'room@chatroom', groupName: '测试群', members: [member] },
{ roomId: 'other@chatroom', groupName: '其他群', members: [otherMember] }
])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue(null)
const service = new GroupExitMonitorService()
await service.start(true)
const state = service.getState()
expect(state.events).toEqual([])
expect(state.monitoredGroupCount).toBe(1)
expect(state.monitoredGroupCount).toBe(2)
expect(readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots).toEqual([
expect.objectContaining({ roomId: 'room@chatroom', members: [member] }),
expect.objectContaining({ roomId: 'other@chatroom', members: [otherMember] })
])
})
it('treats not_found as unavailable and preserves the last good snapshot', async () => {
installGroupDb()
writeBaseline([
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }
])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'not_found', memberIds: [] }
])
const service = new GroupExitMonitorService()
await service.start(true)
expect(service.getState().events).toEqual([])
expect(
readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).snapshots[0].members
).toEqual([member, otherMember])
})
it('uses the legacy member reader only when the batch symbol is unavailable', async () => {
installGroupDb()
writeBaseline([
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] },
{
roomId: 'other@chatroom',
groupName: '其他群',
members: [{ wxid: 'wxid_stays', nickname: '保留成员' }]
}
])
mocks.chat.isGroupMemberIdsBatchAvailable.mockReturnValue(false)
mocks.chat.getGroupMemberIdsAsync.mockImplementation(async (roomId: string) => ({
roomId,
memberIds: roomId === 'room@chatroom' ? [member.wxid] : ['wxid_stays']
}))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const service = new GroupExitMonitorService()
await service.start(true)
await service.checkNow()
expect(service.getState().events).toHaveLength(1)
expect(mocks.chat.getGroupMemberIdsBatchAsync).not.toHaveBeenCalled()
expect(mocks.chat.getGroupMemberIdsAsync).toHaveBeenCalledTimes(4)
expect(warn).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(
'[GroupMonitor] batch membership unavailable; using legacy fallback'
)
warn.mockRestore()
})
it('keeps snapshots when only notification settings change', async () => {
writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member] }])
const service = new GroupExitMonitorService()
service.getState()
await service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
expect(stored.notificationRoomIds).toEqual(['room@chatroom'])
expect(stored.snapshots).toHaveLength(1)
expect(stored.snapshots[0].members).toEqual([member])
})
it('retains existing baselines and only establishes a baseline for a newly monitored group', async () => {
installGroupDb()
writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member] }])
const service = new GroupExitMonitorService()
await service.setMonitoredRoomIds(['room@chatroom', 'new-room@chatroom'])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] },
{ roomId: 'new-room@chatroom', status: 'ok', memberIds: ['wxid_new_group_member'] }
])
await service.start(true)
expect(service.getState().events).toEqual([])
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
expect(stored.snapshots.map((snapshot: { roomId: string }) => snapshot.roomId).sort()).toEqual([
'new-room@chatroom',
'room@chatroom'
])
})
it('does not diff persisted snapshots across account roots', async () => {
installGroupDb()
writeBaseline([
{
roomId: 'room@chatroom',
groupName: '测试群',
members: [member, { wxid: 'wxid_account_a_member' }]
}
])
mocks.chat.getCurrentAccountRoot.mockReturnValue('different-account')
const switched = new GroupExitMonitorService()
await switched.start(true)
expect(switched.getState().events).toEqual([])
const stored = readJsonSync(join(mocks.userData, 'group-exit-monitor.json'))
expect(stored.accountRoot).toBe('different-account')
expect(stored.snapshots).toEqual([])
})
it('returns from scope saving before the background baseline read completes', async () => {
installGroupDb()
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member]
})
writeBaseline([{ roomId: 'room@chatroom', groupName: '测试群', members: [member] }])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValueOnce([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'])
await service.start(true)
let release!: (snapshot: unknown) => void
mocks.chat.getGroupSnapshotAsync.mockReturnValueOnce(
mocks.chat.getGroupMemberIdsBatchAsync.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve
})
@@ -130,27 +540,148 @@ describe('GroupExitMonitorService', () => {
expect(Date.now() - startedAt).toBeLessThan(250)
expect(state.monitoredRoomIds).toEqual(['room@chatroom'])
release({ roomId: 'room@chatroom', members: [member] })
release([{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }])
})
it('detects a removed member across multiple groups without full snapshot hydration', async () => {
installGroupDb()
const departed = {
wxid: 'wxid_departed',
wechatNickname: 'Previous Snapshot 姓名',
groupNickname: 'Previous Snapshot 群昵称',
remark: 'Previous Snapshot 备注'
}
writeBaseline([
{ roomId: 'room@chatroom', groupName: '目标群', members: [member, departed] },
{
roomId: 'other-1@chatroom',
groupName: '其他群 1',
members: [{ wxid: 'wxid_other_1', nickname: '成员 1' }]
},
{
roomId: 'other-2@chatroom',
groupName: '其他群 2',
members: [{ wxid: 'wxid_other_2', nickname: '成员 2' }]
}
])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] },
{ roomId: 'other-1@chatroom', status: 'ok', memberIds: ['wxid_other_1'] },
{ roomId: 'other-2@chatroom', status: 'ok', memberIds: ['wxid_other_2'] }
])
const service = new GroupExitMonitorService()
await service.start(true)
expect(service.getState().events[0]).toMatchObject({
memberWxid: departed.wxid,
memberName: departed.groupNickname,
wechatName: departed.wechatNickname,
groupRemark: departed.groupNickname,
contactRemark: departed.remark
})
expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledOnce()
expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledWith([
'room@chatroom',
'other-1@chatroom',
'other-2@chatroom'
])
expect(mocks.chat.getGroupMemberIdsAsync).not.toHaveBeenCalled()
expect(mocks.chat.getGroupSnapshotAsync).not.toHaveBeenCalled()
})
it('logs one summary for a membership check and one for a hydration batch', async () => {
installGroupDb()
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
mocks.chat.getGroupSnapshotAsync.mockResolvedValue({
roomId: 'room@chatroom',
members: [member]
})
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'])
await service.start(true)
await vi.waitFor(() => {
expect(log).toHaveBeenCalledWith(
expect.stringMatching(/^\[GroupMonitor\] hydration groups=1 costMs=\d+$/)
)
})
expect(log).toHaveBeenCalledWith(
expect.stringMatching(
/^\[GroupMonitor\] check mode=batch groups=1 membershipCostMs=\d+ changedGroups=1 totalCostMs=\d+$/
)
)
log.mockRestore()
})
it('pauses queued hydration as soon as a contact change is pending', async () => {
vi.useFakeTimers()
installGroupDb()
let membershipCalls = 0
let finishPriorityCheck!: () => void
const priorityCheckFinished = new Promise<void>((resolve) => {
finishPriorityCheck = resolve
})
mocks.chat.getGroupMemberIdsBatchAsync.mockImplementation(async (roomIds: string[]) => {
membershipCalls += 1
if (membershipCalls === 2) finishPriorityCheck()
return roomIds.map((roomId) => ({
roomId,
status: 'ok',
memberIds: [roomId === 'room@chatroom' ? 'wxid_room' : 'wxid_other']
}))
})
let finishFirstHydration!: (snapshot: unknown) => void
mocks.chat.getGroupSnapshotAsync
.mockReturnValueOnce(
new Promise((resolve) => {
finishFirstHydration = resolve
})
)
.mockReturnValue(new Promise(() => undefined))
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom', 'other@chatroom'])
await service.start(true)
expect(mocks.chat.getGroupSnapshotAsync).toHaveBeenCalledOnce()
service.notifyDatabaseChanged('{"table":"contact","action":"update"}')
finishFirstHydration({
roomId: 'room@chatroom',
members: [{ wxid: 'wxid_room', nickname: '成员 1' }]
})
await Promise.resolve()
await Promise.resolve()
expect(mocks.chat.getGroupSnapshotAsync).toHaveBeenCalledOnce()
expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(350)
await priorityCheckFinished
expect(mocks.chat.getGroupMemberIdsBatchAsync).toHaveBeenCalledTimes(2)
service.stop()
vi.useRealTimers()
})
it('renders the saved template before sending a group notification', async () => {
installGroupDb()
writeBaseline(
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
['room@chatroom']
)
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
ready: true,
capabilities: { text: true }
})
mocks.sender.send.mockResolvedValue({ success: true })
mocks.chat.getGroupSnapshotAsync
.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
})
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const service = new GroupExitMonitorService()
service.setNotificationTemplate('退群: {user}/{groupRemark}/{wxid}')
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
await service.start(true)
await service.checkNow()
expect(mocks.sender.send).toHaveBeenCalledWith({
type: 'text',
@@ -162,16 +693,15 @@ describe('GroupExitMonitorService', () => {
it('records an exit without creating a send action when notification is disabled', async () => {
installGroupDb()
mocks.chat.getGroupSnapshotAsync
.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
})
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
writeBaseline([
{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }
])
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'])
await service.start(true)
const state = await service.checkNow()
const state = service.getState()
expect(state.events[0]).toMatchObject({
memberWxid: 'wxid_other',
@@ -183,6 +713,10 @@ describe('GroupExitMonitorService', () => {
it('微信发送能力不可用时仍保留退群事件', async () => {
installGroupDb()
writeBaseline(
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
['room@chatroom']
)
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
ready: false,
supported: true,
@@ -191,16 +725,12 @@ describe('GroupExitMonitorService', () => {
senderStatus: {},
message: '当前微信发送能力不可用'
})
mocks.chat.getGroupSnapshotAsync
.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
})
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
await service.start(true)
const state = await service.checkNow()
const state = service.getState()
expect(state.events[0]).toMatchObject({
notificationStatus: 'failed',
@@ -214,6 +744,10 @@ describe('GroupExitMonitorService', () => {
it('does not send twice when the same snapshot diff is checked again', async () => {
installGroupDb()
writeBaseline(
[{ roomId: 'room@chatroom', groupName: '测试群', members: [member, otherMember] }],
['room@chatroom']
)
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
ready: true,
capabilities: { text: true, image: false, voice: false },
@@ -223,15 +757,10 @@ describe('GroupExitMonitorService', () => {
message: 'ready'
})
mocks.sender.send.mockResolvedValue({ success: true })
mocks.chat.getGroupSnapshotAsync
.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
})
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
mocks.chat.getGroupMemberIdsBatchAsync.mockResolvedValue([
{ roomId: 'room@chatroom', status: 'ok', memberIds: [member.wxid] }
])
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
await service.start(true)
await service.checkNow()
await service.checkNow()
+144 -6
View File
@@ -87,6 +87,35 @@ describe('WCDB group member names', () => {
)
})
it('reads only member ids without hydrating names or avatars', async () => {
const getGroupMembers = vi.fn()
const callJsonAsync = vi.fn(async () => [
...groupMemberRows,
{ username: 'wxid-other' },
{ username: 'wxid-member' },
{ username: '' }
])
const getGroupNicknamesAsync = vi.fn()
const readContactMemberNamesAsync = vi.fn()
const hydrateAvatarUrlsAsync = vi.fn()
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
wcdbGetGroupMembers: getGroupMembers,
callJsonAsync,
getGroupNicknamesAsync,
readContactMemberNamesAsync,
hydrateAvatarUrlsAsync
}) as Wcdb4Client
await expect(client.getGroupMemberIdsAsync('fixture@chatroom')).resolves.toEqual([
'wxid-member',
'wxid-other'
])
expect(callJsonAsync).toHaveBeenCalledOnce()
expect(getGroupNicknamesAsync).not.toHaveBeenCalled()
expect(readContactMemberNamesAsync).not.toHaveBeenCalled()
expect(hydrateAvatarUrlsAsync).not.toHaveBeenCalled()
})
it('falls back to the group nickname without leaking an ambiguous member nickname', () => {
const getGroupMembers = vi.fn(() => 0)
const executeQuery = vi.fn(() => 0)
@@ -121,12 +150,14 @@ describe('WCDB group member names', () => {
wxid: ''
}) as Wcdb4Client
const finalizeMessages = (client as unknown as {
finalizeMessages: (
username: string,
rows: Record<string, unknown>[]
) => Array<{ senderNickname?: string }>
}).finalizeMessages
const finalizeMessages = (
client as unknown as {
finalizeMessages: (
username: string,
rows: Record<string, unknown>[]
) => Array<{ senderNickname?: string }>
}
).finalizeMessages
expect(
finalizeMessages.call(client, 'fixture@chatroom', [
@@ -142,3 +173,110 @@ describe('WCDB group member names', () => {
expect(client.getGroupNicknames).toHaveBeenCalledWith('fixture@chatroom')
})
})
describe('WCDB group member batch', () => {
it('reads 200 rooms with one batch call and no legacy member call', async () => {
const roomIds = Array.from({ length: 200 }, (_, index) => `room-${index}@chatroom`)
const batchResult = roomIds.map((roomId, index) => ({
roomId,
status: 'ok',
memberWxids: [`wxid_${index}`]
}))
const batchAsync = vi.fn(
(
_handle: number,
_roomIdsJson: string,
outJson: [unknown],
callback: (error: unknown, result: number) => void
) => {
outJson[0] = 'batch-json-pointer'
callback(null, 0)
}
)
const legacyAsync = vi.fn()
const batchBinding = Object.assign(vi.fn(), { async: batchAsync })
const legacyBinding = Object.assign(vi.fn(), { async: legacyAsync })
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
wcdbGetGroupMembersBatch: batchBinding,
wcdbGetGroupMembers: legacyBinding,
handle: 42,
closing: false,
nativeCallsInFlight: new Set<Promise<unknown>>(),
koffi: { decode: vi.fn(() => JSON.stringify(batchResult)) },
wcdbFreeString: vi.fn()
}) as Wcdb4Client
await expect(client.getGroupMemberIdsBatchAsync(roomIds)).resolves.toEqual(batchResult)
expect(batchAsync).toHaveBeenCalledOnce()
expect(batchAsync).toHaveBeenCalledWith(
42,
JSON.stringify(roomIds),
expect.any(Array),
expect.any(Function)
)
expect(legacyAsync).not.toHaveBeenCalled()
})
it('trims and deduplicates room ids while preserving request order', async () => {
const batchBinding = vi.fn()
const requestedRoomIds = ['b@chatroom', 'a@chatroom']
const callJsonAsync = vi.fn(async () =>
requestedRoomIds.map((roomId) => ({ roomId, status: 'ok', memberWxids: [] }))
)
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
wcdbGetGroupMembersBatch: batchBinding,
callJsonAsync
}) as Wcdb4Client
await expect(
client.getGroupMemberIdsBatchAsync([
' b@chatroom ',
'a@chatroom',
'b@chatroom',
'',
' a@chatroom '
])
).resolves.toEqual([
{ roomId: 'b@chatroom', status: 'ok', memberWxids: [] },
{ roomId: 'a@chatroom', status: 'ok', memberWxids: [] }
])
expect(callJsonAsync).toHaveBeenCalledWith(batchBinding, JSON.stringify(requestedRoomIds))
})
it.each([
['non-array result', { roomId: 'a@chatroom' }],
['missing room result', [{ roomId: 'a@chatroom', status: 'ok', memberWxids: ['wxid_a'] }]],
[
'duplicate room result',
[
{ roomId: 'a@chatroom', status: 'ok', memberWxids: ['wxid_a'] },
{ roomId: 'a@chatroom', status: 'ok', memberWxids: ['wxid_b'] }
]
],
[
'unknown status',
[
{ roomId: 'a@chatroom', status: 'stale', memberWxids: ['wxid_a'] },
{ roomId: 'b@chatroom', status: 'ok', memberWxids: ['wxid_b'] }
]
],
[
'malformed member list',
[
{ roomId: 'a@chatroom', status: 'ok', memberWxids: ['wxid_a'] },
{ roomId: 'b@chatroom', status: 'ok', memberWxids: [null] }
]
]
])('rejects an invalid batch: %s', async (_name, rawResult) => {
const batchBinding = vi.fn()
const callJsonAsync = vi.fn(async () => rawResult)
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
wcdbGetGroupMembersBatch: batchBinding,
callJsonAsync
}) as Wcdb4Client
await expect(
client.getGroupMemberIdsBatchAsync(['a@chatroom', 'b@chatroom'])
).resolves.toBeNull()
})
})