|
|
@@ -3,6 +3,7 @@ import { deriveHealthMetrics } from '../utils/group-metrics.util.js';
|
|
|
import { type LifecyclePhase } from '../utils/lifecycle.util.js';
|
|
|
import { getCommunityLifecycleMap } from './community.service.js';
|
|
|
import { resolveOwnerName } from './organization.service.js';
|
|
|
+import { batchGetRoomDetails } from './qiwe-api.service.js';
|
|
|
|
|
|
export interface GroupChatDto {
|
|
|
id: string;
|
|
|
@@ -29,6 +30,7 @@ export interface GroupChatDto {
|
|
|
documentPinned: boolean;
|
|
|
documentInNotice: boolean;
|
|
|
messageCountToday: number;
|
|
|
+ messageCountTotal: number;
|
|
|
memberChange24h: number;
|
|
|
updatedAt: string;
|
|
|
}
|
|
|
@@ -55,19 +57,82 @@ async function countMessagesToday(roomId: string): Promise<number> {
|
|
|
return query.count({ useMasterKey: true });
|
|
|
}
|
|
|
|
|
|
+async function countMessagesTotal(roomId: string): Promise<number> {
|
|
|
+ const query = new Parse.Query('Message');
|
|
|
+ query.equalTo('roomId', roomId);
|
|
|
+ return query.count({ useMasterKey: true });
|
|
|
+}
|
|
|
+
|
|
|
+/** 消息入库时发现群不存在则创建占位群,便于列表与详情按 roomId 关联 */
|
|
|
+export async function ensureGroupChatForRoom(roomId: string, guid: string): Promise<void> {
|
|
|
+ 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<number> {
|
|
|
+ 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<string>();
|
|
|
+ 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;
|
|
|
+}
|
|
|
+
|
|
|
async function enrichGroup(
|
|
|
obj: Parse.Object,
|
|
|
lifecycleMap: Map<string, LifecyclePhase>,
|
|
|
persist = true,
|
|
|
): Promise<GroupChatDto> {
|
|
|
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 = await countMessagesToday(roomId);
|
|
|
+ const [messageCountToday, messageCountTotal] = await Promise.all([
|
|
|
+ countMessagesToday(roomId),
|
|
|
+ countMessagesTotal(roomId),
|
|
|
+ ]);
|
|
|
|
|
|
const metrics = deriveHealthMetrics({
|
|
|
memberCount,
|
|
|
@@ -93,6 +158,7 @@ async function enrichGroup(
|
|
|
}
|
|
|
|
|
|
obj.set('messageCountToday', messageCountToday);
|
|
|
+ obj.set('messageCountTotal', messageCountTotal);
|
|
|
obj.set('activityLevel', metrics.activityLevel);
|
|
|
obj.set('opsActivityScore', metrics.opsActivityScore);
|
|
|
obj.set('customerActivityScore', metrics.customerActivityScore);
|
|
|
@@ -134,15 +200,95 @@ function toDto(obj: Parse.Object): GroupChatDto {
|
|
|
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,
|
|
|
updatedAt: (obj.get('updatedAt') as Date)?.toISOString?.() || new Date().toISOString(),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+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<string, GroupChatDto>();
|
|
|
+ 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<number> {
|
|
|
+ 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<ReturnType<typeof batchGetRoomDetails>>;
|
|
|
+ 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;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 列表展示前对齐群目录:从 Message 表补全有消息但未入列表的群(不调企微,速度快)
|
|
|
+ */
|
|
|
+export async function prepareGroupListForDisplay(): Promise<number> {
|
|
|
+ if (!ACTIVE_QIWE_GUID) return 0;
|
|
|
+ const created = await backfillGroupChatsFromMessages(ACTIVE_QIWE_GUID);
|
|
|
+ await enrichPlaceholderGroupNames(ACTIVE_QIWE_GUID);
|
|
|
+ return created;
|
|
|
+}
|
|
|
+
|
|
|
export async function listGroupChats(
|
|
|
filters: GroupListFilters = {},
|
|
|
limit = 500,
|
|
|
): Promise<GroupChatDto[]> {
|
|
|
+ await prepareGroupListForDisplay();
|
|
|
+
|
|
|
const query = new Parse.Query('GroupChat');
|
|
|
query.descending('updatedAt');
|
|
|
|
|
|
@@ -171,25 +317,32 @@ export async function listGroupChats(
|
|
|
}
|
|
|
|
|
|
if (!filters.q) {
|
|
|
- return filtered;
|
|
|
+ return dedupeGroupsByRoomId(filtered).sort((a, b) => b.messageCountTotal - a.messageCountTotal);
|
|
|
}
|
|
|
|
|
|
const q = filters.q.toLowerCase();
|
|
|
- return filtered.filter((g) =>
|
|
|
+ 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.storeName.toLowerCase().includes(q)
|
|
|
+ || g.roomId.includes(q),
|
|
|
+ ));
|
|
|
}
|
|
|
|
|
|
export async function getGroupChatByRoomId(roomId: string): Promise<GroupChatDto | null> {
|
|
|
+ await prepareGroupListForDisplay();
|
|
|
+
|
|
|
const query = new Parse.Query('GroupChat');
|
|
|
query.equalTo('roomId', roomId);
|
|
|
- const obj = await query.first({ useMasterKey: true });
|
|
|
- if (!obj) return null;
|
|
|
+ query.limit(20);
|
|
|
+ const rows = await query.find({ useMasterKey: true });
|
|
|
+ if (rows.length === 0) return null;
|
|
|
+
|
|
|
const lifecycleMap = await getCommunityLifecycleMap();
|
|
|
- return enrichGroup(obj, lifecycleMap);
|
|
|
+ const enriched = await Promise.all(rows.map((obj) => enrichGroup(obj, lifecycleMap)));
|
|
|
+ const [best] = dedupeGroupsByRoomId(enriched);
|
|
|
+ return best ?? null;
|
|
|
}
|
|
|
|
|
|
export async function migrateGroupBusinessFields(): Promise<void> {
|