Explorar o código

feat: 群详情展示成员列表并解析昵称

新增群成员 API,从企微联系人/消息/组织通讯录补全昵称,并在群详情页直接展示成员。

Co-authored-by: Cursor <cursoragent@cursor.com>
0235699曾露 hai 3 meses
pai
achega
85d9a2c0a9

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

@@ -9,6 +9,7 @@ import {
 import { buildCommunityDistrictIndex } from '../../../../shared/auth/dashboard-scope.util.js';
 import { listCommunities } from '../services/community.service.js';
 import { getGroupChatByRoomId, listGroupChats } from '../services/groups.service.js';
+import { listGroupMembersByRoom } from '../services/group-members.service.js';
 import { listStores } from '../services/organization.service.js';
 
 function parseBoolean(value: unknown): boolean | undefined {
@@ -73,3 +74,29 @@ export async function handleGetGroup(req: Request, res: Response): Promise<void>
 
   sendSuccess(res, { group: scoped });
 }
+
+export async function handleListGroupMembers(req: Request, res: Response): Promise<void> {
+  const user = await requireUser(req);
+  const rawRoomId = req.params.roomId;
+  const roomId = Array.isArray(rawRoomId) ? rawRoomId[0] : rawRoomId;
+  if (!roomId) {
+    throw new AppError(400, 'INVALID_PARAMS', '缺少 roomId');
+  }
+
+  const group = await getGroupChatByRoomId(roomId);
+  if (!group) {
+    throw new AppError(404, 'GROUP_NOT_FOUND', '群不存在');
+  }
+
+  const communities = await listCommunities();
+  const communityIndex = buildCommunityDistrictIndex(communities);
+  const scope = await resolveDataScope(user, {});
+  const [scoped] = filterGroupsByDataScope([group], scope, communityIndex);
+  if (!scoped) {
+    throw new AppError(403, 'SCOPE_DENIED', '无权查看该群');
+  }
+
+  const includeLeft = parseBoolean(req.query.includeLeft) === true;
+  const result = await listGroupMembersByRoom(roomId, includeLeft);
+  sendSuccess(res, { ...result, ownerId: scoped.ownerId });
+}

+ 2 - 1
backend/backend/src/apps/pc/qiwe/routes/webhook.routes.ts

@@ -3,7 +3,7 @@ import { asyncHandler } from '../../../../shared/http/async-handler.js';
 import { handleWebhook } from '../controllers/webhook.controller.js';
 import { handleSyncGroups, handleBackfillGroupsFromMessages } from '../controllers/sync.controller.js';
 import { handleSyncMessages, handleListMessages } from '../controllers/messages.controller.js';
-import { handleListGroups, handleGetGroup } from '../controllers/groups.controller.js';
+import { handleListGroups, handleGetGroup, handleListGroupMembers } from '../controllers/groups.controller.js';
 import { handleListStores } from '../controllers/stores.controller.js';
 import { handleListCommunities } from '../controllers/community.controller.js';
 import {
@@ -22,6 +22,7 @@ router.get('/org/departments', asyncHandler(handleListOrgDepartments));
 router.get('/org/members', asyncHandler(handleListOrgMembers));
 router.post('/org/sync', asyncHandler(handleSyncOrg));
 router.get('/groups', asyncHandler(handleListGroups));
+router.get('/groups/:roomId/members', asyncHandler(handleListGroupMembers));
 router.get('/groups/:roomId', asyncHandler(handleGetGroup));
 router.post('/webhook', asyncHandler(handleWebhook));
 router.post('/sync-groups', asyncHandler(handleSyncGroups));

+ 245 - 0
backend/backend/src/apps/pc/qiwe/services/group-members.service.ts

@@ -0,0 +1,245 @@
+import Parse from '../../../../shared/db/parse-client.js';
+import { batchGetRoomDetails, getContactDetailsBatch } from './qiwe-api.service.js';
+import { getOrgMemberName } from './org-sync.service.js';
+
+export interface GroupMemberDto {
+  id: string;
+  userId: string;
+  nickname: string;
+  status: string;
+  joinedAt: string | null;
+  leftAt: string | null;
+}
+
+const ACTIVE_QIWE_GUID = process.env.QIWE_GUID || '';
+
+function needsNickname(userId: string, nickname: string): boolean {
+  return !nickname || nickname === userId;
+}
+
+function toMemberDto(obj: Parse.Object): GroupMemberDto {
+  const userId = obj.get('userId') || '';
+  const nickname = obj.get('nickname') || '';
+  return {
+    id: obj.id!,
+    userId,
+    nickname: needsNickname(userId, nickname) ? userId : nickname,
+    status: obj.get('status') || 'active',
+    joinedAt: (obj.get('joinedAt') as Date)?.toISOString?.() ?? null,
+    leftAt: (obj.get('leftAt') as Date)?.toISOString?.() ?? null,
+  };
+}
+
+async function loadSenderNameMap(roomId: string): Promise<Map<string, string>> {
+  const map = new Map<string, string>();
+  const query = new Parse.Query('Message');
+  query.equalTo('roomId', roomId);
+  query.limit(1000);
+  query.select('senderId', 'senderName');
+  const rows = await query.find({ useMasterKey: true });
+  for (const row of rows) {
+    const userId = String(row.get('senderId') || '');
+    const name = String(row.get('senderName') || '');
+    if (!userId || !name || name === userId) continue;
+    map.set(userId, name);
+  }
+  return map;
+}
+
+async function persistMemberNickname(roomId: string, userId: string, nickname: string): Promise<void> {
+  if (!nickname || nickname === userId) return;
+  const query = new Parse.Query('GroupMember');
+  query.equalTo('roomId', roomId);
+  query.equalTo('userId', userId);
+  const existing = await query.first({ useMasterKey: true });
+  if (!existing) return;
+  const current = String(existing.get('nickname') || '');
+  if (current && current !== userId) return;
+  existing.set('nickname', nickname);
+  await existing.save(null, { useMasterKey: true });
+}
+
+async function enrichMemberNicknames(
+  members: GroupMemberDto[],
+  roomId: string,
+): Promise<GroupMemberDto[]> {
+  if (members.length === 0) return [];
+
+  const msgNames = await loadSenderNameMap(roomId);
+  const nameMap = new Map<string, string>();
+
+  for (const m of members) {
+    if (!needsNickname(m.userId, m.nickname)) {
+      nameMap.set(m.userId, m.nickname);
+      continue;
+    }
+    const fromMsg = msgNames.get(m.userId);
+    if (fromMsg) nameMap.set(m.userId, fromMsg);
+  }
+
+  const needIds = members
+    .map((m) => m.userId)
+    .filter((id) => id && needsNickname(id, nameMap.get(id) || id));
+
+  for (let i = 0; i < needIds.length; i += 50) {
+    const batch = needIds.slice(i, i + 50);
+    const contacts = await getContactDetailsBatch(batch);
+    for (const c of contacts) {
+      if (c.name && c.name !== c.userId) nameMap.set(c.userId, c.name);
+    }
+  }
+
+  const stillNeedOrg = needIds.filter((id) => needsNickname(id, nameMap.get(id) || id));
+  if (stillNeedOrg.length > 0) {
+    const orgQuery = new Parse.Query('OrgMember');
+    orgQuery.containedIn('userId', stillNeedOrg.slice(0, 100));
+    orgQuery.limit(100);
+    const orgRows = await orgQuery.find({ useMasterKey: true });
+    for (const row of orgRows) {
+      const uid = String(row.get('userId') || '');
+      const name = String(row.get('name') || '');
+      if (uid && name && name !== uid) nameMap.set(uid, name);
+    }
+    for (const id of stillNeedOrg.slice(0, 10)) {
+      if (nameMap.has(id) && nameMap.get(id) !== id) continue;
+      const orgName = await getOrgMemberName(id);
+      if (orgName && orgName !== id) nameMap.set(id, orgName);
+    }
+  }
+
+  const enriched = members.map((m) => {
+    const resolved =
+      (!needsNickname(m.userId, m.nickname) ? m.nickname : '')
+      || nameMap.get(m.userId)
+      || m.userId;
+    return { ...m, nickname: resolved };
+  });
+
+  await Promise.all(
+    enriched
+      .filter((m) => !needsNickname(m.userId, m.nickname))
+      .map((m) => persistMemberNickname(roomId, m.userId, m.nickname)),
+  );
+
+  return enriched;
+}
+
+async function upsertMemberFromQiWe(
+  roomId: string,
+  guid: string,
+  userId: string,
+  nickname: string,
+): Promise<void> {
+  const query = new Parse.Query('GroupMember');
+  query.equalTo('roomId', roomId);
+  query.equalTo('userId', userId);
+  query.equalTo('guid', guid);
+  const existing = await query.first({ useMasterKey: true });
+
+  if (existing) {
+    if (nickname && needsNickname(userId, String(existing.get('nickname') || ''))) {
+      existing.set('nickname', nickname);
+    }
+    if (existing.get('status') !== 'active') {
+      existing.set('status', 'active');
+      existing.unset('leftAt');
+    }
+    await existing.save(null, { useMasterKey: true });
+    return;
+  }
+
+  const obj = new Parse.Object('GroupMember');
+  obj.set('roomId', roomId);
+  obj.set('userId', userId);
+  obj.set('guid', guid);
+  obj.set('status', 'active');
+  if (nickname && nickname !== userId) obj.set('nickname', nickname);
+  await obj.save(null, { useMasterKey: true });
+}
+
+async function loadMembersFromMessages(roomId: string): Promise<GroupMemberDto[]> {
+  const query = new Parse.Query('Message');
+  query.equalTo('roomId', roomId);
+  query.limit(1000);
+  query.select('senderId', 'senderName');
+  const rows = await query.find({ useMasterKey: true });
+
+  const map = new Map<string, string>();
+  for (const row of rows) {
+    const userId = String(row.get('senderId') || '');
+    if (!userId) continue;
+    const name = String(row.get('senderName') || '');
+    if (!map.has(userId)) map.set(userId, userId);
+    if (name && name !== userId) map.set(userId, name);
+  }
+
+  return [...map.entries()].map(([userId, nickname]) => ({
+    id: `${roomId}:${userId}`,
+    userId,
+    nickname,
+    status: 'active',
+    joinedAt: null,
+    leftAt: null,
+  }));
+}
+
+async function loadMembersFromQiWe(roomId: string, guid: string): Promise<GroupMemberDto[]> {
+  const rooms = await batchGetRoomDetails(guid, [roomId]);
+  const room = rooms[0];
+  if (!room?.members?.length) return [];
+
+  const members: GroupMemberDto[] = [];
+  for (const mem of room.members) {
+    members.push({
+      id: `${roomId}:${mem.userId}`,
+      userId: mem.userId,
+      nickname: mem.nickname || mem.userId,
+      status: 'active',
+      joinedAt: null,
+      leftAt: null,
+    });
+  }
+
+  const enriched = await enrichMemberNicknames(members, roomId);
+  for (const m of enriched) {
+    await upsertMemberFromQiWe(roomId, guid, m.userId, m.nickname);
+  }
+  return enriched;
+}
+
+export async function listGroupMembersByRoom(
+  roomId: string,
+  includeLeft = false,
+): Promise<{ members: GroupMemberDto[]; total: number; source: 'db' | 'qiwe' }> {
+  const query = new Parse.Query('GroupMember');
+  query.equalTo('roomId', roomId);
+  if (!includeLeft) query.notEqualTo('status', 'left');
+  query.ascending('joinedAt');
+  query.limit(500);
+  const rows = await query.find({ useMasterKey: true });
+
+  let members: GroupMemberDto[] = [];
+  let source: 'db' | 'qiwe' = 'db';
+
+  if (rows.length > 0) {
+    members = rows.map(toMemberDto);
+  } else if (ACTIVE_QIWE_GUID) {
+    try {
+      const fromQiWe = await loadMembersFromQiWe(roomId, ACTIVE_QIWE_GUID);
+      if (fromQiWe.length > 0) {
+        members = fromQiWe;
+        source = 'qiwe';
+      }
+    } catch (err: unknown) {
+      const message = err instanceof Error ? err.message : String(err);
+      console.warn(`[GroupMember] 拉取群成员失败 roomId=${roomId}: ${message}`);
+    }
+  }
+
+  if (members.length === 0) {
+    members = await loadMembersFromMessages(roomId);
+  }
+
+  members = await enrichMemberNicknames(members, roomId);
+  return { members, total: members.length, source };
+}

+ 19 - 0
src/app/core/services/api/qiwe-api.service.ts

@@ -109,6 +109,15 @@ export interface MessageDto {
   seq?: number;
 }
 
+export interface GroupMemberDto {
+  id: string;
+  userId: string;
+  nickname: string;
+  status: string;
+  joinedAt: string | null;
+  leftAt: string | null;
+}
+
 export interface SyncMessagesResult {
   sync: MessageSyncResult;
   database: SyncGroupsResult['database'];
@@ -163,6 +172,16 @@ export class QiweApiService {
     return this.api.getResult<{ group: GroupChatDto }>(`/qiwe/groups/${encodeURIComponent(roomId)}`);
   }
 
+  listGroupMembers(roomId: string, includeLeft = false) {
+    const params = includeLeft ? '?includeLeft=true' : '';
+    return this.api.getResult<{
+      members: GroupMemberDto[];
+      total: number;
+      source: 'db' | 'qiwe';
+      ownerId: string;
+    }>(`/qiwe/groups/${encodeURIComponent(roomId)}/members${params}`);
+  }
+
   syncGroups() {
     return this.api.postResult<SyncGroupsResult>('/qiwe/sync-groups', {});
   }

+ 32 - 1
src/app/features/group-management/group-detail/group-detail.component.html

@@ -193,7 +193,38 @@
     }
 
     @if (activeTab === 'members') {
-      <app-empty-state title="群成员" description="成员数据将在群成员模块中展示" [icon]="['fas','users']" />
+      @if (membersLoading) {
+        <p class="text-sm text-surface-500 py-8 text-center">加载成员中…</p>
+      } @else if (membersError) {
+        <app-message-strip type="error" [message]="membersError" />
+      } @else if (members.length > 0) {
+        <p class="text-xs text-surface-500 mb-3">共 {{ membersTotal }} 名成员</p>
+        <div class="fiori-card max-h-[640px] overflow-y-auto divide-y divide-surface-100">
+          @for (m of memberRows; track m.id) {
+            <div class="px-4 py-3 flex items-center justify-between gap-3 hover:bg-surface-50 transition-colors">
+              <div class="min-w-0">
+                <div class="flex items-center gap-2 flex-wrap">
+                  <span class="text-sm font-semibold text-surface-900">{{ m.displayName }}</span>
+                  @if (m.isOwner) {
+                    <span class="text-xs px-1.5 py-0.5 rounded bg-primary-50 text-primary-700">群主</span>
+                  }
+                  @if (m.status === 'left') {
+                    <span class="text-xs px-1.5 py-0.5 rounded bg-surface-100 text-surface-500">已退群</span>
+                  }
+                </div>
+                <p class="text-xs text-surface-400 mt-0.5 truncate">ID: {{ m.userId }}</p>
+              </div>
+              <span class="text-xs text-surface-400 shrink-0 whitespace-nowrap">入群 {{ m.joinLabel }}</span>
+            </div>
+          }
+        </div>
+      } @else {
+        <app-empty-state
+          title="暂无成员数据"
+          [description]="dataSource() === 'api' ? '数据库中尚无成员记录。请点「从企微同步」,或等待 Webhook 推送进群/退群事件。打开本页时会尝试从企微拉取一次。' : '本地演示模式无成员数据'"
+          [icon]="['fas','users']"
+        />
+      }
     }
 
     @if (activeTab === 'documents') {

+ 38 - 2
src/app/features/group-management/group-detail/group-detail.component.ts

@@ -6,7 +6,7 @@ import { MockDataService } from '../../../core/services/mock-data.service';
 import { QiweApiService } from '../../../core/services/api/qiwe-api.service';
 import { environment } from '../../../../environments/environment';
 import type { Group, Document, RiskEvent } from '../../../core/models';
-import type { MessageDto } from '../../../core/services/api/qiwe-api.service';
+import type { MessageDto, GroupMemberDto } from '../../../core/services/api/qiwe-api.service';
 import { StatCardComponent } from '../../../shared/components/stat-card/stat-card.component';
 import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
 import { MessageStripComponent } from '../../../shared/components/message-strip/message-strip.component';
@@ -48,6 +48,12 @@ export class GroupDetailComponent implements OnInit {
   messagesError = '';
   showSystemMessages = false;
 
+  members: GroupMemberDto[] = [];
+  membersTotal = 0;
+  membersLoading = false;
+  membersError = '';
+  memberOwnerId = '';
+
   private static readonly MSG_TYPE_LABELS: Record<number, string> = {
     0: '文本',
     2: '文本',
@@ -72,6 +78,15 @@ export class GroupDetailComponent implements OnInit {
     { key: 'complianceStatus', label: '状态', template: 'status' as const },
   ];
 
+  get memberRows(): Array<GroupMemberDto & { displayName: string; joinLabel: string; isOwner: boolean }> {
+    return this.members.map((m) => ({
+      ...m,
+      displayName: m.nickname && m.nickname !== m.userId ? m.nickname : m.userId || '未知',
+      joinLabel: m.joinedAt ? new Date(m.joinedAt).toLocaleString('zh-CN') : '—',
+      isOwner: !!this.memberOwnerId && m.userId === this.memberOwnerId,
+    }));
+  }
+
   get messageRows(): Array<MessageDto & { senderLabel: string; contentLabel: string; msgTypeLabel: string; timeLabel: string }> {
     return this.messages.map((m) => ({
       ...m,
@@ -169,9 +184,30 @@ export class GroupDetailComponent implements OnInit {
 
   setActiveTab(key: string): void {
     this.activeTab = key;
-    if (key === 'messages' && this.group && environment.useBackendApi && this.messages.length === 0) {
+    if (!this.group) return;
+    if (key === 'messages' && environment.useBackendApi && this.messages.length === 0 && !this.messagesError) {
       void this.loadMessages(this.group.id);
     }
+    if (key === 'members' && environment.useBackendApi && this.members.length === 0 && !this.membersError) {
+      void this.loadMembers(this.group.id);
+    }
+  }
+
+  private async loadMembers(roomId: string): Promise<void> {
+    if (!environment.useBackendApi) return;
+    this.membersLoading = true;
+    this.membersError = '';
+    const result = await this.qiweApi.listGroupMembers(roomId);
+    if (result.ok && result.data) {
+      this.members = result.data.members;
+      this.membersTotal = result.data.total;
+      this.memberOwnerId = result.data.ownerId || this.group?.ownerId || '';
+    } else {
+      this.members = [];
+      this.membersTotal = 0;
+      this.membersError = result.error ?? '加载成员失败';
+    }
+    this.membersLoading = false;
   }
 
   private async loadMessages(roomId: string): Promise<void> {