Просмотр исходного кода

perf: 加速群列表与群消息加载

列表直接读库缓存、补群逻辑加 TTL、消息查询并行且默认不调企微 API,群详情并行拉取数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
0235699曾露 3 месяцев назад
Родитель
Сommit
2bc1e3daf6

+ 2 - 2
backend/backend/src/apps/pc/qiwe/controllers/groups.controller.ts

@@ -32,7 +32,7 @@ export async function handleListGroups(req: Request, res: Response): Promise<voi
     roomId: roomIdQ,
     roomId: roomIdQ,
   });
   });
 
 
-  const [communities, groups] = await Promise.all([
+  const [communities, groups, stores] = await Promise.all([
     listCommunities(),
     listCommunities(),
     listGroupChats({
     listGroupChats({
       activityLevel: typeof req.query.activityLevel === 'string' ? req.query.activityLevel : undefined,
       activityLevel: typeof req.query.activityLevel === 'string' ? req.query.activityLevel : undefined,
@@ -42,11 +42,11 @@ export async function handleListGroups(req: Request, res: Response): Promise<voi
       hasDocument: parseBoolean(req.query.hasDocument),
       hasDocument: parseBoolean(req.query.hasDocument),
       q: typeof req.query.q === 'string' ? req.query.q.trim() : undefined,
       q: typeof req.query.q === 'string' ? req.query.q.trim() : undefined,
     }),
     }),
+    listStores(),
   ]);
   ]);
 
 
   const communityIndex = buildCommunityDistrictIndex(communities);
   const communityIndex = buildCommunityDistrictIndex(communities);
   const scopedGroups = filterGroupsByDataScope(groups, scope, communityIndex);
   const scopedGroups = filterGroupsByDataScope(groups, scope, communityIndex);
-  const stores = await listStores();
   sendSuccess(res, { groups: scopedGroups, total: scopedGroups.length, stores, scope });
   sendSuccess(res, { groups: scopedGroups, total: scopedGroups.length, stores, scope });
 }
 }
 
 

+ 36 - 15
backend/backend/src/apps/pc/qiwe/services/groups.service.ts

@@ -176,6 +176,15 @@ async function enrichGroup(
   return toDto(obj);
   return toDto(obj);
 }
 }
 
 
+function toListDto(obj: Parse.Object, lifecycleMap: Map<string, LifecyclePhase>): 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 {
 function toDto(obj: Parse.Object): GroupChatDto {
   return {
   return {
     id: obj.id!,
     id: obj.id!,
@@ -275,24 +284,39 @@ export async function enrichPlaceholderGroupNames(guid: string, batchSize = 30):
   return updated;
   return updated;
 }
 }
 
 
+let lastPrepareAt = 0;
+const PREPARE_TTL_MS = 10 * 60 * 1000;
+
 /**
 /**
- * 列表展示前对齐群目录:从 Message 表补全有消息但未入列表的群(不调企微,速度快)
+ * 列表展示前对齐群目录:从 Message 表补全有消息但未入列表的群。
+ * 带 TTL 缓存,避免每次打开列表都扫库/调企微。
  */
  */
-export async function prepareGroupListForDisplay(): Promise<number> {
+export async function prepareGroupListForDisplay(force = false): Promise<number> {
   if (!ACTIVE_QIWE_GUID) return 0;
   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);
   const created = await backfillGroupChatsFromMessages(ACTIVE_QIWE_GUID);
   await enrichPlaceholderGroupNames(ACTIVE_QIWE_GUID);
   await enrichPlaceholderGroupNames(ACTIVE_QIWE_GUID);
   return created;
   return created;
 }
 }
 
 
+/** 同步后批量刷新群指标(写库),不在列表读取路径调用 */
+export async function refreshAllGroupMetrics(limit = 200): Promise<number> {
+  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(
 export async function listGroupChats(
   filters: GroupListFilters = {},
   filters: GroupListFilters = {},
   limit = 500,
   limit = 500,
 ): Promise<GroupChatDto[]> {
 ): Promise<GroupChatDto[]> {
-  await prepareGroupListForDisplay();
-
   const query = new Parse.Query('GroupChat');
   const query = new Parse.Query('GroupChat');
-  query.descending('updatedAt');
+  query.descending('messageCountTotal');
+  query.addDescending('updatedAt');
 
 
   if (filters.storeId) query.equalTo('storeId', filters.storeId);
   if (filters.storeId) query.equalTo('storeId', filters.storeId);
   if (filters.activityLevel) query.equalTo('activityLevel', filters.activityLevel);
   if (filters.activityLevel) query.equalTo('activityLevel', filters.activityLevel);
@@ -302,13 +326,12 @@ export async function listGroupChats(
   if (filters.hasDocument !== undefined) query.equalTo('hasDocument', filters.hasDocument);
   if (filters.hasDocument !== undefined) query.equalTo('hasDocument', filters.hasDocument);
 
 
   query.limit(limit);
   query.limit(limit);
-  const results = await query.find({ useMasterKey: true });
-  const lifecycleMap = await getCommunityLifecycleMap();
+  const [results, lifecycleMap] = await Promise.all([
+    query.find({ useMasterKey: true }),
+    getCommunityLifecycleMap(),
+  ]);
 
 
-  const enriched: GroupChatDto[] = [];
-  for (const obj of results) {
-    enriched.push(await enrichGroup(obj, lifecycleMap));
-  }
+  const enriched = results.map((obj) => toListDto(obj, lifecycleMap));
 
 
   let filtered = enriched;
   let filtered = enriched;
   if (filters.lifecyclePhase) {
   if (filters.lifecyclePhase) {
@@ -333,8 +356,6 @@ export async function listGroupChats(
 }
 }
 
 
 export async function getGroupChatByRoomId(roomId: string): Promise<GroupChatDto | null> {
 export async function getGroupChatByRoomId(roomId: string): Promise<GroupChatDto | null> {
-  await prepareGroupListForDisplay();
-
   const query = new Parse.Query('GroupChat');
   const query = new Parse.Query('GroupChat');
   query.equalTo('roomId', roomId);
   query.equalTo('roomId', roomId);
   query.limit(20);
   query.limit(20);
@@ -342,8 +363,8 @@ export async function getGroupChatByRoomId(roomId: string): Promise<GroupChatDto
   if (rows.length === 0) return null;
   if (rows.length === 0) return null;
 
 
   const lifecycleMap = await getCommunityLifecycleMap();
   const lifecycleMap = await getCommunityLifecycleMap();
-  const enriched = await Promise.all(rows.map((obj) => enrichGroup(obj, lifecycleMap)));
-  const [best] = dedupeGroupsByRoomId(enriched);
+  const dtos = rows.map((obj) => toListDto(obj, lifecycleMap));
+  const [best] = dedupeGroupsByRoomId(dtos);
   return best ?? null;
   return best ?? null;
 }
 }
 
 

+ 44 - 55
backend/backend/src/apps/pc/qiwe/services/message-persist.service.ts

@@ -1,7 +1,6 @@
 import Parse from '../../../../shared/db/parse-client.js';
 import Parse from '../../../../shared/db/parse-client.js';
 import { ensureGroupChatForRoom } from './groups.service.js';
 import { ensureGroupChatForRoom } from './groups.service.js';
-import { getContactDetailsBatch, batchGetRoomDetails, getQiWeConfig } from './qiwe-api.service.js';
-import { resolveOwnerName } from './organization.service.js';
+import { getContactDetailsBatch } from './qiwe-api.service.js';
 
 
 const MSG_TYPE_LABELS: Record<number, string> = {
 const MSG_TYPE_LABELS: Record<number, string> = {
   0: '文本消息',
   0: '文本消息',
@@ -103,9 +102,8 @@ function formatSenderDisplay(senderId: string, senderName: string): string {
   return senderId;
   return senderId;
 }
 }
 
 
-async function loadRoomMemberNameMap(roomId: string, guid: string): Promise<Map<string, string>> {
+async function loadRoomMemberNameMapFromDb(roomId: string): Promise<Map<string, string>> {
   const map = new Map<string, string>();
   const map = new Map<string, string>();
-
   const dbQuery = new Parse.Query('GroupMember');
   const dbQuery = new Parse.Query('GroupMember');
   dbQuery.equalTo('roomId', roomId);
   dbQuery.equalTo('roomId', roomId);
   dbQuery.limit(500);
   dbQuery.limit(500);
@@ -115,49 +113,40 @@ async function loadRoomMemberNameMap(roomId: string, guid: string): Promise<Map<
     const nick = m.get('nickname');
     const nick = m.get('nickname');
     if (uid && nick) map.set(String(uid), String(nick));
     if (uid && nick) map.set(String(uid), String(nick));
   }
   }
-
-  try {
-    const rooms = await batchGetRoomDetails(guid, [roomId]);
-    for (const room of rooms) {
-      for (const mem of room.members) {
-        if (mem.nickname) map.set(mem.userId, mem.nickname);
-      }
-    }
-  } catch {
-    /* 群详情不可用时跳过 */
-  }
-
   return map;
   return map;
 }
 }
 
 
+function needsSenderLookup(m: MessageDto): boolean {
+  return !m.senderName || m.senderName === m.senderId;
+}
+
 async function enrichMessagesForDisplay(
 async function enrichMessagesForDisplay(
   messages: MessageDto[],
   messages: MessageDto[],
   roomId: string,
   roomId: string,
+  resolveNames = false,
 ): Promise<MessageDto[]> {
 ): Promise<MessageDto[]> {
-  const guid = getQiWeConfig().guid;
-  const memberMap = guid ? await loadRoomMemberNameMap(roomId, guid) : new Map<string, string>();
+  if (messages.length === 0) return [];
 
 
-  const needIds = [...new Set(
-    messages
-      .filter((m) => !memberMap.has(m.senderId) && (!m.senderName || m.senderName === m.senderId))
-      .map((m) => m.senderId)
-      .filter(Boolean),
-  )];
+  const needsLookup = messages.some(needsSenderLookup);
+  const memberMap = needsLookup ? await loadRoomMemberNameMapFromDb(roomId) : new Map<string, string>();
 
 
   const contactMap = new Map<string, string>();
   const contactMap = new Map<string, string>();
-  if (needIds.length > 0) {
-    try {
-      const contacts = await getContactDetailsBatch(needIds.slice(0, 50));
-      for (const c of contacts) {
-        if (c.name && c.name !== c.userId) contactMap.set(c.userId, c.name);
+  if (resolveNames && needsLookup) {
+    const needIds = [...new Set(
+      messages
+        .filter((m) => needsSenderLookup(m) && !memberMap.has(m.senderId))
+        .map((m) => m.senderId)
+        .filter(Boolean),
+    )];
+    if (needIds.length > 0) {
+      try {
+        const contacts = await getContactDetailsBatch(needIds.slice(0, 50));
+        for (const c of contacts) {
+          if (c.name && c.name !== c.userId) contactMap.set(c.userId, c.name);
+        }
+      } catch {
+        /* 联系人接口不可用时跳过 */
       }
       }
-    } catch {
-      /* 联系人接口不可用时跳过 */
-    }
-    for (const id of needIds.slice(0, 20)) {
-      if (contactMap.has(id) || memberMap.has(id)) continue;
-      const orgName = await resolveOwnerName(id);
-      if (orgName && orgName !== id) contactMap.set(id, orgName);
     }
     }
   }
   }
 
 
@@ -310,35 +299,35 @@ export function toMessageDto(obj: Parse.Object): MessageDto {
   };
   };
 }
 }
 
 
+function messageQueryForRoom(roomId: string): Parse.Query {
+  const query = new Parse.Query('Message');
+  query.equalTo('roomId', roomId);
+  return query;
+}
+
 export async function listMessagesByRoom(
 export async function listMessagesByRoom(
   roomId: string,
   roomId: string,
   limit = 50,
   limit = 50,
   skip = 0,
   skip = 0,
   chatOnly = true,
   chatOnly = true,
+  resolveNames = false,
 ): Promise<{ messages: MessageDto[]; total: number; chatTotal: number }> {
 ): Promise<{ messages: MessageDto[]; total: number; chatTotal: number }> {
-  const query = new Parse.Query('Message');
-  query.equalTo('roomId', roomId);
-  query.descending('timestamp');
+  const listQuery = messageQueryForRoom(roomId);
+  listQuery.descending('timestamp');
+  if (chatOnly) listQuery.containedIn('msgType', [...CHAT_MSG_TYPES]);
+  listQuery.skip(skip).limit(limit);
 
 
-  const countQuery = new Parse.Query('Message');
-  countQuery.equalTo('roomId', roomId);
+  const countQuery = messageQueryForRoom(roomId);
+  const chatTotalQuery = messageQueryForRoom(roomId);
+  chatTotalQuery.containedIn('msgType', [...CHAT_MSG_TYPES]);
 
 
-  const [rows, total] = await Promise.all([
-    query.skip(skip).limit(Math.min(limit * 3, 300)).find({ useMasterKey: true }),
+  const [rows, total, chatTotal] = await Promise.all([
+    listQuery.find({ useMasterKey: true }),
     countQuery.count({ useMasterKey: true }),
     countQuery.count({ useMasterKey: true }),
+    chatTotalQuery.count({ useMasterKey: true }),
   ]);
   ]);
 
 
-  let dtos = rows.map(toMessageDto);
-  if (chatOnly) {
-    dtos = dtos.filter((m) => isChatMessageType(m.msgType));
-  }
-  dtos = dtos.slice(0, limit);
-
-  const chatTotalQuery = new Parse.Query('Message');
-  chatTotalQuery.equalTo('roomId', roomId);
-  chatTotalQuery.containedIn('msgType', [...CHAT_MSG_TYPES]);
-  const chatTotal = await chatTotalQuery.count({ useMasterKey: true });
-
-  const messages = await enrichMessagesForDisplay(dtos, roomId);
+  const dtos = rows.map(toMessageDto);
+  const messages = await enrichMessagesForDisplay(dtos, roomId, resolveNames);
   return { messages, total, chatTotal };
   return { messages, total, chatTotal };
 }
 }

+ 4 - 5
backend/backend/src/apps/pc/qiwe/services/sync.service.ts

@@ -1,6 +1,6 @@
 import Parse from '../../../../shared/db/parse-client.js';
 import Parse from '../../../../shared/db/parse-client.js';
 import { getAllRooms, getSessionList } from './qiwe-api.service.js';
 import { getAllRooms, getSessionList } from './qiwe-api.service.js';
-import { backfillGroupChatsFromMessages, enrichPlaceholderGroupNames, initNewGroupDefaults } from './groups.service.js';
+import { initNewGroupDefaults, prepareGroupListForDisplay } from './groups.service.js';
 
 
 const GUID = process.env.QIWE_GUID || '';
 const GUID = process.env.QIWE_GUID || '';
 
 
@@ -60,12 +60,11 @@ export async function reconcileGroupCatalog(): Promise<{
 }> {
 }> {
   const rooms = await syncGroupsFromQiWe();
   const rooms = await syncGroupsFromQiWe();
   const sessions = await syncGroupSessionsFromQiWe();
   const sessions = await syncGroupSessionsFromQiWe();
-  const fromMessages = await backfillGroupChatsFromMessages(GUID);
-  const namesUpdated = await enrichPlaceholderGroupNames(GUID);
+  const fromMessages = await prepareGroupListForDisplay(true);
   console.log(
   console.log(
-    `[Sync] 群目录对齐完成 rooms+${rooms.created}/${rooms.updated} sessions+${sessions.created} messages+${fromMessages} names+${namesUpdated}`,
+    `[Sync] 群目录对齐完成 rooms+${rooms.created}/${rooms.updated} sessions+${sessions.created} messages+${fromMessages}`,
   );
   );
-  return { rooms, sessions, fromMessages, namesUpdated };
+  return { rooms, sessions, fromMessages, namesUpdated: 0 };
 }
 }
 
 
 export async function syncGroupsFromQiWe(): Promise<{
 export async function syncGroupsFromQiWe(): Promise<{

+ 14 - 4
src/app/features/group-management/group-detail/group-detail.component.ts

@@ -129,17 +129,27 @@ export class GroupDetailComponent implements OnInit {
     this.loading = true;
     this.loading = true;
 
 
     if (environment.useBackendApi) {
     if (environment.useBackendApi) {
-      const result = await this.qiweApi.getGroup(id);
-      if (result.ok && result.data?.group) {
-        this.group = this.qiweApi.mapToGroup(result.data.group);
+      const groupPromise = this.qiweApi.getGroup(id);
+      const messagesPromise = this.qiweApi.listMessages(id, 100, 0, true);
+      const [groupResult, messagesResult] = await Promise.all([groupPromise, messagesPromise]);
+
+      if (groupResult.ok && groupResult.data?.group) {
+        this.group = this.qiweApi.mapToGroup(groupResult.data.group);
         this.dataSource.set('api');
         this.dataSource.set('api');
         this.documents = [];
         this.documents = [];
         this.riskEvents = [];
         this.riskEvents = [];
         if (this.group.messageCountTotal > 0) {
         if (this.group.messageCountTotal > 0) {
           this.activeTab = 'messages';
           this.activeTab = 'messages';
         }
         }
-        await this.loadMessages(id);
         this.loading = false;
         this.loading = false;
+
+        if (messagesResult.ok && messagesResult.data) {
+          this.messages = messagesResult.data.messages;
+          this.messagesTotal = messagesResult.data.total;
+          this.chatMessagesTotal = messagesResult.data.chatTotal ?? messagesResult.data.messages.length;
+        } else {
+          this.messagesError = messagesResult.error ?? '加载消息失败';
+        }
         return;
         return;
       }
       }
     }
     }