import Parse from '../../../../shared/db/parse-client.js'; import { deriveHealthMetrics } from '../utils/group-metrics.util.js'; import { type LifecyclePhase } from '../utils/lifecycle.util.js'; import { getCommunityLifecycleMap, listCommunities } from './community.service.js'; import { getDefaultStore, resolveOwnerName } from './organization.service.js'; import { listGroupMembersByRoom, type GroupMemberDto } from './group-members.service.js'; import { batchGetRoomDetails, getContactDetailsBatch } from './qiwe-api.service.js'; export interface GroupChatDto { id: string; roomId: string; roomName: string; ownerId: string; ownerName: string; memberCount: number; avatarUrl: string; status: string; guid: string; storeId: string; storeName: string; communityId: string; communityName: string; activityLevel: string; healthScore: number; healthStatus: string; healthGrade: string; opsActivityScore: number; customerActivityScore: number; lifecyclePhase: string; hasDocument: boolean; documentPinned: boolean; documentInNotice: boolean; messageCountToday: number; messageCountTotal: number; memberChange24h: number; createdAt: string; ownerRole: string; updatedAt: string; } export interface GroupListFilters { storeId?: string; activityLevel?: string; healthStatus?: string; healthGrade?: string; lifecyclePhase?: string; hasDocument?: boolean; q?: string; } function startOfToday(): Date { const shanghaiDate = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' }); return new Date(`${shanghaiDate}T00:00:00+08:00`); } async function countMessagesToday(roomId: string): Promise { const query = new Parse.Query('Message'); query.equalTo('roomId', roomId); query.containedIn('msgType', [0, 2, 6, 13, 14, 15, 16, 20, 22, 23, 29, 41, 78, 123, 141, 146, 213]); query.greaterThanOrEqualTo('timestamp', startOfToday()); return query.count({ useMasterKey: true }); } async function countMessagesTotal(roomId: string): Promise { const query = new Parse.Query('Message'); query.equalTo('roomId', roomId); query.containedIn('msgType', [0, 2, 6, 13, 14, 15, 16, 20, 22, 23, 29, 41, 78, 123, 141, 146, 213]); return query.count({ useMasterKey: true }); } /** 消息入库时发现群不存在则创建占位群,便于列表与详情按 roomId 关联 */ export async function ensureGroupChatForRoom(roomId: string, guid: string): Promise { if (!roomId || !guid) return; const query = new Parse.Query('GroupChat'); query.equalTo('roomId', roomId); query.equalTo('guid', guid); const existing = await query.first({ useMasterKey: true }); if (existing) return; const obj = new Parse.Object('GroupChat'); obj.set('roomId', roomId); obj.set('guid', guid); obj.set('roomName', `群 ${roomId.slice(-6)}`); obj.set('status', 'active'); obj.set('memberCount', 0); initNewGroupDefaults(obj); await obj.save(null, { useMasterKey: true }); console.log(`[GroupChat] 由消息自动创建群 roomId=${roomId}`); } /** 根据已同步的群消息补全 GroupChat(roomId 与 Message 对齐) */ export async function backfillGroupChatsFromMessages(guid: string): Promise { const query = new Parse.Query('Message'); query.equalTo('guid', guid); query.equalTo('isGroupChat', 1); query.exists('roomId'); query.limit(5000); query.select('roomId'); const rows = await query.find({ useMasterKey: true }); const roomIds = new Set(); for (const row of rows) { const rid = row.get('roomId'); const key = rid ? String(rid) : ''; if (key.length >= 14 && /^\d+$/.test(key)) roomIds.add(key); } let created = 0; for (const roomId of roomIds) { const before = new Parse.Query('GroupChat'); before.equalTo('roomId', roomId); before.equalTo('guid', guid); const had = await before.first({ useMasterKey: true }); if (had) continue; await ensureGroupChatForRoom(roomId, guid); created++; } if (created > 0) { console.log(`[GroupChat] 从消息补全 ${created} 个群记录`); } return created; } const METRICS_FRESH_MS = 10 * 60 * 1000; /** 详情页读取:优先用库内缓存指标,避免每次打开都扫 Message 表 */ async function enrichGroupForRead( obj: Parse.Object, lifecycleMap: Map, ): Promise { const roomId = obj.get('roomId') as string; const memberCount = obj.get('memberCount') ?? 0; const status = obj.get('status') || 'active'; const hasDocument = obj.get('hasDocument') === true; const documentPinned = obj.get('documentPinned') === true; const documentInNotice = obj.get('documentInNotice') === true; const memberChange24h = obj.get('memberChange24h') ?? 0; const updatedAt = obj.get('updatedAt') as Date | undefined; const cachedTotal = obj.get('messageCountTotal') ?? 0; const freshMetrics = cachedTotal > 0 && updatedAt && Date.now() - updatedAt.getTime() < METRICS_FRESH_MS; let messageCountToday = obj.get('messageCountToday') ?? 0; let messageCountTotal = cachedTotal; if (!freshMetrics) { [messageCountToday, messageCountTotal] = await Promise.all([ countMessagesToday(roomId), messageCountTotal > 0 ? Promise.resolve(messageCountTotal) : countMessagesTotal(roomId), ]); } const metrics = deriveHealthMetrics({ memberCount, messageCountToday, memberChange24h, hasDocument, documentPinned, documentInNotice, status, }); const communityId = obj.get('communityId') as string; const lifecyclePhase = communityId && lifecycleMap.has(communityId) ? lifecycleMap.get(communityId)! : (obj.get('lifecyclePhase') as string) || 'pre_handover'; obj.set('messageCountToday', messageCountToday); obj.set('messageCountTotal', messageCountTotal); obj.set('activityLevel', metrics.activityLevel); obj.set('opsActivityScore', metrics.opsActivityScore); obj.set('customerActivityScore', metrics.customerActivityScore); obj.set('healthGrade', metrics.healthGrade); obj.set('healthScore', metrics.healthScore); obj.set('healthStatus', metrics.healthStatus); obj.set('lifecyclePhase', lifecyclePhase); if (!freshMetrics) { void obj.save(null, { useMasterKey: true }).catch(() => {}); } return toDto(obj); } async function enrichGroup( obj: Parse.Object, lifecycleMap: Map, persist = true, ): Promise { const roomId = obj.get('roomId') as string; const guid = (obj.get('guid') as string) || ''; const memberCount = obj.get('memberCount') ?? 0; const status = obj.get('status') || 'active'; const hasDocument = obj.get('hasDocument') === true; const documentPinned = obj.get('documentPinned') === true; const documentInNotice = obj.get('documentInNotice') === true; const memberChange24h = obj.get('memberChange24h') ?? 0; const [messageCountToday, messageCountTotal] = await Promise.all([ countMessagesToday(roomId), countMessagesTotal(roomId), ]); const metrics = deriveHealthMetrics({ memberCount, messageCountToday, memberChange24h, hasDocument, documentPinned, documentInNotice, status, }); const communityId = obj.get('communityId') as string; const lifecyclePhase = communityId && lifecycleMap.has(communityId) ? lifecycleMap.get(communityId)! : (obj.get('lifecyclePhase') as string) || 'pre_handover'; let ownerName = (obj.get('ownerName') as string) || ''; if (!ownerName && obj.get('ownerId')) { ownerName = await resolveOwnerName(obj.get('ownerId') || ''); if (ownerName) { obj.set('ownerName', ownerName); } } obj.set('messageCountToday', messageCountToday); obj.set('messageCountTotal', messageCountTotal); obj.set('activityLevel', metrics.activityLevel); obj.set('opsActivityScore', metrics.opsActivityScore); obj.set('customerActivityScore', metrics.customerActivityScore); obj.set('healthGrade', metrics.healthGrade); obj.set('healthScore', metrics.healthScore); obj.set('healthStatus', metrics.healthStatus); obj.set('lifecyclePhase', lifecyclePhase); if (persist) { await obj.save(null, { useMasterKey: true }); } return toDto(obj); } function toListDto(obj: Parse.Object, lifecycleMap: Map): GroupChatDto { const dto = toDto(obj); const communityId = obj.get('communityId') as string; if (communityId && lifecycleMap.has(communityId)) { dto.lifecyclePhase = lifecycleMap.get(communityId)!; } return dto; } function toDto(obj: Parse.Object): GroupChatDto { return { id: obj.id!, roomId: obj.get('roomId') || '', roomName: obj.get('roomName') || '未命名群', ownerId: obj.get('ownerId') || '', ownerName: obj.get('ownerName') || '', memberCount: obj.get('memberCount') ?? 0, avatarUrl: obj.get('avatarUrl') || '', status: obj.get('status') || 'active', guid: obj.get('guid') || '', storeId: obj.get('storeId') || '', storeName: obj.get('storeName') || '', communityId: obj.get('communityId') || '', communityName: obj.get('communityName') || '', activityLevel: obj.get('activityLevel') || 'inactive', healthScore: obj.get('healthScore') ?? 0, healthStatus: obj.get('healthStatus') || 'warning', healthGrade: obj.get('healthGrade') || 'C', opsActivityScore: obj.get('opsActivityScore') ?? 0, customerActivityScore: obj.get('customerActivityScore') ?? 0, lifecyclePhase: obj.get('lifecyclePhase') || 'pre_handover', hasDocument: obj.get('hasDocument') === true, documentPinned: obj.get('documentPinned') === true, documentInNotice: obj.get('documentInNotice') === true, messageCountToday: obj.get('messageCountToday') ?? 0, messageCountTotal: obj.get('messageCountTotal') ?? 0, memberChange24h: obj.get('memberChange24h') ?? 0, createdAt: (obj.get('createdAt') as Date)?.toISOString?.() || new Date().toISOString(), ownerRole: (obj.get('ownerRole') as string) || '', updatedAt: (obj.get('updatedAt') as Date)?.toISOString?.() || new Date().toISOString(), }; } async function countActiveMembers(roomId: string): Promise { const query = new Parse.Query('GroupMember'); query.equalTo('roomId', roomId); query.notEqualTo('status', 'left'); return query.count({ useMasterKey: true }); } async function resolveOwnerProfile(ownerId: string): Promise<{ name: string; role: string }> { if (!ownerId) return { name: '', role: '' }; const orgQuery = new Parse.Query('OrgMember'); orgQuery.equalTo('userId', ownerId); const orgMember = await orgQuery.first({ useMasterKey: true }); if (orgMember) { return { name: String(orgMember.get('name') || ownerId), role: String(orgMember.get('position') || '群主'), }; } if (ACTIVE_QIWE_GUID) { try { const contacts = await getContactDetailsBatch([ownerId]); const contact = contacts[0]; if (contact) { return { name: contact.name || ownerId, role: contact.position || '群主', }; } } catch { /* 联系人接口失败时回退 userId */ } } return { name: ownerId, role: '群主' }; } async function ensureOrganizationFields(obj: Parse.Object): Promise { if (!obj.get('storeId')) { const def = await getDefaultStore(); if (def.storeId) { obj.set('storeId', def.storeId); obj.set('storeName', def.storeName); } } if (!obj.get('communityId')) { const roomName = String(obj.get('roomName') || ''); const communities = await listCommunities(); const match = communities .filter((c) => c.name && roomName.includes(c.name)) .sort((a, b) => b.name.length - a.name.length)[0]; if (match) { obj.set('communityId', match.id); obj.set('communityName', match.name); if (!obj.get('storeId') && match.storeId) { obj.set('storeId', match.storeId); obj.set('storeName', match.storeName); } } } } async function borrowOwnerFromSiblingRows(obj: Parse.Object, roomId: string): Promise { if (obj.get('ownerId')) return; const query = new Parse.Query('GroupChat'); query.equalTo('roomId', roomId); query.exists('ownerId'); query.limit(20); const rows = await query.find({ useMasterKey: true }); for (const row of rows) { const ownerId = String(row.get('ownerId') || ''); if (!ownerId) continue; obj.set('ownerId', ownerId); if (row.get('ownerName')) obj.set('ownerName', row.get('ownerName')); return; } } async function inferOwnerFromMembers( roomId: string, members: GroupMemberDto[], ): Promise<{ ownerId: string; name: string; role: string } | null> { if (members.length === 0) return null; const userIds = members.map((m) => m.userId).filter(Boolean); const orgQuery = new Parse.Query('OrgMember'); orgQuery.containedIn('userId', userIds.slice(0, 100)); orgQuery.limit(100); const orgRows = await orgQuery.find({ useMasterKey: true }); const orgMap = new Map(orgRows.map((row) => [String(row.get('userId')), row])); for (const member of members) { const org = orgMap.get(member.userId); if (org) { return { ownerId: member.userId, name: String(org.get('name') || member.nickname || member.userId), role: String(org.get('position') || '负责人'), }; } } const msgQuery = new Parse.Query('Message'); msgQuery.equalTo('roomId', roomId); msgQuery.limit(200); msgQuery.select('senderId', 'senderName'); const messages = await msgQuery.find({ useMasterKey: true }); const counts = new Map(); for (const message of messages) { const userId = String(message.get('senderId') || ''); if (!userId) continue; const senderName = String(message.get('senderName') || userId); const current = counts.get(userId) || { count: 0, name: senderName }; current.count += 1; if (senderName !== userId) current.name = senderName; counts.set(userId, current); } let best: { ownerId: string; name: string; count: number } | null = null; for (const [ownerId, value] of counts) { if (!best || value.count > best.count) { best = { ownerId, name: value.name, count: value.count }; } } if (best) { return { ownerId: best.ownerId, name: best.name, role: '活跃成员' }; } const first = members.find((m) => m.nickname && m.nickname !== m.userId); if (first) { return { ownerId: first.userId, name: first.nickname, role: '群成员' }; } return null; } async function syncQiWeRoomFields(obj: Parse.Object, roomId: string): Promise { const guid = String(obj.get('guid') || ACTIVE_QIWE_GUID); if (!guid) return; const needsSync = !obj.get('ownerId') || (obj.get('memberCount') ?? 0) === 0 || isPlaceholderRoomName(String(obj.get('roomName') || '')); if (!needsSync) return; try { const details = await batchGetRoomDetails(guid, [roomId]); const detail = details[0]; if (!detail) return; if (detail.roomName) obj.set('roomName', detail.roomName); if (detail.ownerId) obj.set('ownerId', detail.ownerId); if (detail.memberCount > 0) obj.set('memberCount', detail.memberCount); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.warn(`[GroupChat] 详情补全企微字段失败 roomId=${roomId}: ${message}`); } } function pickBestGroupObject(rows: Parse.Object[], lifecycleMap: Map): Parse.Object { const dtos = rows.map((obj) => toListDto(obj, lifecycleMap)); const [bestDto] = dedupeGroupsByRoomId(dtos); return rows.find((row) => row.id === bestDto?.id) || rows[0]; } const ACTIVE_QIWE_GUID = process.env.QIWE_GUID || ''; function scoreGroupForDedupe(g: GroupChatDto): number { let score = 0; if (g.guid === ACTIVE_QIWE_GUID) score += 100; if (g.messageCountTotal > 0) score += 50 + Math.min(g.messageCountTotal, 1000); if (g.roomName && !g.roomName.startsWith('群 ')) score += 20; if (g.memberCount > 0) score += 10; return score; } /** 同一 roomId 可能有多条记录(不同 guid / 来源),保留信息最完整的一条 */ function dedupeGroupsByRoomId(groups: GroupChatDto[]): GroupChatDto[] { const map = new Map(); for (const g of groups) { if (!g.roomId) continue; const existing = map.get(g.roomId); if (!existing || scoreGroupForDedupe(g) > scoreGroupForDedupe(existing)) { map.set(g.roomId, g); } } return [...map.values()]; } function isPlaceholderRoomName(name: string): boolean { return !name || name === '未命名群' || /^群 \d{6}$/.test(name); } /** 向企微拉取真实群名,替换「群 500656」类占位名 */ export async function enrichPlaceholderGroupNames(guid: string, batchSize = 30): Promise { if (!guid) return 0; const query = new Parse.Query('GroupChat'); query.equalTo('guid', guid); query.limit(200); const groups = await query.find({ useMasterKey: true }); const targets = groups.filter((g) => isPlaceholderRoomName(String(g.get('roomName') || ''))); if (targets.length === 0) return 0; const roomIds = targets.slice(0, batchSize).map((g) => String(g.get('roomId'))); let details: Awaited>; try { details = await batchGetRoomDetails(guid, roomIds); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.warn(`[GroupChat] 拉取群详情失败: ${message}`); return 0; } const detailMap = new Map(details.map((d) => [d.roomId, d])); let updated = 0; for (const obj of targets) { const roomId = String(obj.get('roomId')); const detail = detailMap.get(roomId); if (!detail?.roomName) continue; obj.set('roomName', detail.roomName); if (detail.memberCount > 0) obj.set('memberCount', detail.memberCount); if (detail.ownerId) obj.set('ownerId', detail.ownerId); await obj.save(null, { useMasterKey: true }); updated++; } if (updated > 0) { console.log(`[GroupChat] 已补全 ${updated} 个群的真实名称`); } return updated; } let lastPrepareAt = 0; const PREPARE_TTL_MS = 10 * 60 * 1000; /** * 列表展示前对齐群目录:从 Message 表补全有消息但未入列表的群。 * 带 TTL 缓存,避免每次打开列表都扫库/调企微。 */ export async function prepareGroupListForDisplay(force = false): Promise { if (!ACTIVE_QIWE_GUID) return 0; if (!force && Date.now() - lastPrepareAt < PREPARE_TTL_MS) return 0; lastPrepareAt = Date.now(); const created = await backfillGroupChatsFromMessages(ACTIVE_QIWE_GUID); await enrichPlaceholderGroupNames(ACTIVE_QIWE_GUID); return created; } /** 同步后批量刷新群指标(写库),不在列表读取路径调用 */ export async function refreshAllGroupMetrics(limit = 200): Promise { const query = new Parse.Query('GroupChat'); query.limit(limit); const groups = await query.find({ useMasterKey: true }); const lifecycleMap = await getCommunityLifecycleMap(); await Promise.all(groups.map((obj) => enrichGroup(obj, lifecycleMap))); return groups.length; } export async function listGroupChats( filters: GroupListFilters = {}, limit = 500, ): Promise { const query = new Parse.Query('GroupChat'); query.descending('messageCountTotal'); query.addDescending('updatedAt'); if (filters.storeId) query.equalTo('storeId', filters.storeId); if (filters.activityLevel) query.equalTo('activityLevel', filters.activityLevel); if (filters.healthStatus) query.equalTo('healthStatus', filters.healthStatus); if (filters.healthGrade) query.equalTo('healthGrade', filters.healthGrade); if (filters.lifecyclePhase) query.equalTo('lifecyclePhase', filters.lifecyclePhase); if (filters.hasDocument !== undefined) query.equalTo('hasDocument', filters.hasDocument); query.limit(limit); const [results, lifecycleMap] = await Promise.all([ query.find({ useMasterKey: true }), getCommunityLifecycleMap(), ]); const enriched = results.map((obj) => toListDto(obj, lifecycleMap)); let filtered = enriched; if (filters.lifecyclePhase) { filtered = filtered.filter((g) => g.lifecyclePhase === filters.lifecyclePhase); } if (filters.healthGrade) { filtered = filtered.filter((g) => g.healthGrade === filters.healthGrade); } if (!filters.q) { return dedupeGroupsByRoomId(filtered).sort((a, b) => b.messageCountTotal - a.messageCountTotal); } const q = filters.q.toLowerCase(); return dedupeGroupsByRoomId(filtered.filter((g) => g.roomName.toLowerCase().includes(q) || g.ownerName.toLowerCase().includes(q) || g.communityName.toLowerCase().includes(q) || g.storeName.toLowerCase().includes(q) || g.roomId.includes(q), )); } export async function getGroupChatByRoomId(roomId: string): Promise { const query = new Parse.Query('GroupChat'); query.equalTo('roomId', roomId); query.limit(20); const [rows, lifecycleMap] = await Promise.all([ query.find({ useMasterKey: true }), getCommunityLifecycleMap(), ]); if (rows.length === 0) return null; const obj = pickBestGroupObject(rows, lifecycleMap); const needsQiWe = !obj.get('ownerId') || (obj.get('memberCount') ?? 0) === 0 || isPlaceholderRoomName(String(obj.get('roomName') || '')); let membersLoaded: GroupMemberDto[] | null = null; await Promise.all([ needsQiWe ? syncQiWeRoomFields(obj, roomId) : Promise.resolve(), !obj.get('ownerId') ? borrowOwnerFromSiblingRows(obj, roomId) : Promise.resolve(), !obj.get('storeId') ? ensureOrganizationFields(obj) : Promise.resolve(), ]); const needsMembers = (obj.get('memberCount') ?? 0) === 0 || !obj.get('ownerId'); if (needsMembers) { if ((obj.get('memberCount') ?? 0) === 0) { const memberCount = await countActiveMembers(roomId); if (memberCount > 0) obj.set('memberCount', memberCount); } if ((obj.get('memberCount') ?? 0) === 0 || !obj.get('ownerId')) { const membersResult = await listGroupMembersByRoom(roomId, false, false); membersLoaded = membersResult.members; if (membersResult.total > 0 && (obj.get('memberCount') ?? 0) === 0) { obj.set('memberCount', membersResult.total); } } } if (!obj.get('ownerId')) { if (!membersLoaded) { const membersResult = await listGroupMembersByRoom(roomId, false, false); membersLoaded = membersResult.members; if (membersResult.total > 0 && (obj.get('memberCount') ?? 0) === 0) { obj.set('memberCount', membersResult.total); } } const inferred = membersLoaded ? await inferOwnerFromMembers(roomId, membersLoaded) : null; if (inferred) { obj.set('ownerId', inferred.ownerId); obj.set('ownerName', inferred.name); obj.set('ownerRole', inferred.role); } } const ownerId = String(obj.get('ownerId') || ''); let owner = obj.get('ownerName') ? { name: String(obj.get('ownerName')), role: String(obj.get('ownerRole') || '群主') } : await resolveOwnerProfile(ownerId); if (ownerId && (!owner.name || owner.name === ownerId)) { if (!membersLoaded) { const membersResult = await listGroupMembersByRoom(roomId, false, false); membersLoaded = membersResult.members; } const member = membersLoaded?.find((m) => m.userId === ownerId); if (member?.nickname && member.nickname !== ownerId) { owner = { ...owner, name: member.nickname }; } } if (owner.name) obj.set('ownerName', owner.name); if (owner.role) obj.set('ownerRole', owner.role); const dto = await enrichGroupForRead(obj, lifecycleMap); return { ...dto, createdAt: (obj.get('createdAt') as Date)?.toISOString?.() || dto.updatedAt, ownerRole: owner.role || dto.ownerRole || '群主', }; } export async function migrateGroupBusinessFields(): Promise { const query = new Parse.Query('GroupChat'); query.limit(1000); const groups = await query.find({ useMasterKey: true }); let migrated = 0; for (const group of groups) { let changed = false; if (group.get('hasDocument') === undefined) { group.set('hasDocument', false); changed = true; } if (group.get('documentPinned') === undefined) { group.set('documentPinned', false); changed = true; } if (group.get('documentInNotice') === undefined) { group.set('documentInNotice', false); changed = true; } if (group.get('memberChange24h') === undefined) { group.set('memberChange24h', 0); changed = true; } if (changed) { await group.save(null, { useMasterKey: true }); migrated++; } } if (migrated > 0) { console.log(`[Organization] 已补全 ${migrated} 个群的基础字段`); } } /** 清除历史上自动填入的占位门店/小区数据(无真实业务归属时应为空) */ export async function clearPlaceholderOrganizationFields(): Promise { const query = new Parse.Query('GroupChat'); query.limit(1000); const groups = await query.find({ useMasterKey: true }); let cleared = 0; for (const group of groups) { const hasStore = group.get('storeId') || group.get('storeName'); const hasCommunity = group.get('communityId') || group.get('communityName'); if (!hasStore && !hasCommunity) continue; group.unset('storeId'); group.unset('storeName'); group.unset('communityId'); group.unset('communityName'); await group.save(null, { useMasterKey: true }); cleared++; } if (cleared > 0) { console.log(`[Organization] 已清除 ${cleared} 个群的占位门店/小区数据`); } } export function initNewGroupDefaults(obj: Parse.Object): void { if (obj.get('hasDocument') === undefined) obj.set('hasDocument', false); if (obj.get('documentPinned') === undefined) obj.set('documentPinned', false); if (obj.get('documentInNotice') === undefined) obj.set('documentInNotice', false); if (obj.get('memberChange24h') === undefined) obj.set('memberChange24h', 0); }