groups.service.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744
  1. import Parse from '../../../../shared/db/parse-client.js';
  2. import { deriveHealthMetrics } from '../utils/group-metrics.util.js';
  3. import { type LifecyclePhase } from '../utils/lifecycle.util.js';
  4. import { getCommunityLifecycleMap, listCommunities } from './community.service.js';
  5. import { getDefaultStore, resolveOwnerName } from './organization.service.js';
  6. import { listGroupMembersByRoom, type GroupMemberDto } from './group-members.service.js';
  7. import { batchGetRoomDetails, getContactDetailsBatch } from './qiwe-api.service.js';
  8. export interface GroupChatDto {
  9. id: string;
  10. roomId: string;
  11. roomName: string;
  12. ownerId: string;
  13. ownerName: string;
  14. memberCount: number;
  15. avatarUrl: string;
  16. status: string;
  17. guid: string;
  18. storeId: string;
  19. storeName: string;
  20. communityId: string;
  21. communityName: string;
  22. activityLevel: string;
  23. healthScore: number;
  24. healthStatus: string;
  25. healthGrade: string;
  26. opsActivityScore: number;
  27. customerActivityScore: number;
  28. lifecyclePhase: string;
  29. hasDocument: boolean;
  30. documentPinned: boolean;
  31. documentInNotice: boolean;
  32. messageCountToday: number;
  33. messageCountTotal: number;
  34. memberChange24h: number;
  35. createdAt: string;
  36. ownerRole: string;
  37. updatedAt: string;
  38. }
  39. export interface GroupListFilters {
  40. storeId?: string;
  41. activityLevel?: string;
  42. healthStatus?: string;
  43. healthGrade?: string;
  44. lifecyclePhase?: string;
  45. hasDocument?: boolean;
  46. q?: string;
  47. }
  48. function startOfToday(): Date {
  49. const shanghaiDate = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Shanghai' });
  50. return new Date(`${shanghaiDate}T00:00:00+08:00`);
  51. }
  52. async function countMessagesToday(roomId: string): Promise<number> {
  53. const query = new Parse.Query('Message');
  54. query.equalTo('roomId', roomId);
  55. query.containedIn('msgType', [0, 2, 6, 13, 14, 15, 16, 20, 22, 23, 29, 41, 78, 123, 141, 146, 213]);
  56. query.greaterThanOrEqualTo('timestamp', startOfToday());
  57. return query.count({ useMasterKey: true });
  58. }
  59. async function countMessagesTotal(roomId: string): Promise<number> {
  60. const query = new Parse.Query('Message');
  61. query.equalTo('roomId', roomId);
  62. query.containedIn('msgType', [0, 2, 6, 13, 14, 15, 16, 20, 22, 23, 29, 41, 78, 123, 141, 146, 213]);
  63. return query.count({ useMasterKey: true });
  64. }
  65. /** 消息入库时发现群不存在则创建占位群,便于列表与详情按 roomId 关联 */
  66. export async function ensureGroupChatForRoom(roomId: string, guid: string): Promise<void> {
  67. if (!roomId || !guid) return;
  68. const query = new Parse.Query('GroupChat');
  69. query.equalTo('roomId', roomId);
  70. query.equalTo('guid', guid);
  71. const existing = await query.first({ useMasterKey: true });
  72. if (existing) return;
  73. const obj = new Parse.Object('GroupChat');
  74. obj.set('roomId', roomId);
  75. obj.set('guid', guid);
  76. obj.set('roomName', `群 ${roomId.slice(-6)}`);
  77. obj.set('status', 'active');
  78. obj.set('memberCount', 0);
  79. initNewGroupDefaults(obj);
  80. await obj.save(null, { useMasterKey: true });
  81. console.log(`[GroupChat] 由消息自动创建群 roomId=${roomId}`);
  82. }
  83. /** 根据已同步的群消息补全 GroupChat(roomId 与 Message 对齐) */
  84. export async function backfillGroupChatsFromMessages(guid: string): Promise<number> {
  85. const query = new Parse.Query('Message');
  86. query.equalTo('guid', guid);
  87. query.equalTo('isGroupChat', 1);
  88. query.exists('roomId');
  89. query.limit(5000);
  90. query.select('roomId');
  91. const rows = await query.find({ useMasterKey: true });
  92. const roomIds = new Set<string>();
  93. for (const row of rows) {
  94. const rid = row.get('roomId');
  95. const key = rid ? String(rid) : '';
  96. if (key.length >= 14 && /^\d+$/.test(key)) roomIds.add(key);
  97. }
  98. let created = 0;
  99. for (const roomId of roomIds) {
  100. const before = new Parse.Query('GroupChat');
  101. before.equalTo('roomId', roomId);
  102. before.equalTo('guid', guid);
  103. const had = await before.first({ useMasterKey: true });
  104. if (had) continue;
  105. await ensureGroupChatForRoom(roomId, guid);
  106. created++;
  107. }
  108. if (created > 0) {
  109. console.log(`[GroupChat] 从消息补全 ${created} 个群记录`);
  110. }
  111. return created;
  112. }
  113. const METRICS_FRESH_MS = 10 * 60 * 1000;
  114. /** 详情页读取:优先用库内缓存指标,避免每次打开都扫 Message 表 */
  115. async function enrichGroupForRead(
  116. obj: Parse.Object,
  117. lifecycleMap: Map<string, LifecyclePhase>,
  118. ): Promise<GroupChatDto> {
  119. const roomId = obj.get('roomId') as string;
  120. const memberCount = obj.get('memberCount') ?? 0;
  121. const status = obj.get('status') || 'active';
  122. const hasDocument = obj.get('hasDocument') === true;
  123. const documentPinned = obj.get('documentPinned') === true;
  124. const documentInNotice = obj.get('documentInNotice') === true;
  125. const memberChange24h = obj.get('memberChange24h') ?? 0;
  126. const updatedAt = obj.get('updatedAt') as Date | undefined;
  127. const cachedTotal = obj.get('messageCountTotal') ?? 0;
  128. const freshMetrics =
  129. cachedTotal > 0
  130. && updatedAt
  131. && Date.now() - updatedAt.getTime() < METRICS_FRESH_MS;
  132. let messageCountToday = obj.get('messageCountToday') ?? 0;
  133. let messageCountTotal = cachedTotal;
  134. if (!freshMetrics) {
  135. [messageCountToday, messageCountTotal] = await Promise.all([
  136. countMessagesToday(roomId),
  137. messageCountTotal > 0 ? Promise.resolve(messageCountTotal) : countMessagesTotal(roomId),
  138. ]);
  139. }
  140. const metrics = deriveHealthMetrics({
  141. memberCount,
  142. messageCountToday,
  143. memberChange24h,
  144. hasDocument,
  145. documentPinned,
  146. documentInNotice,
  147. status,
  148. });
  149. const communityId = obj.get('communityId') as string;
  150. const lifecyclePhase = communityId && lifecycleMap.has(communityId)
  151. ? lifecycleMap.get(communityId)!
  152. : (obj.get('lifecyclePhase') as string) || 'pre_handover';
  153. obj.set('messageCountToday', messageCountToday);
  154. obj.set('messageCountTotal', messageCountTotal);
  155. obj.set('activityLevel', metrics.activityLevel);
  156. obj.set('opsActivityScore', metrics.opsActivityScore);
  157. obj.set('customerActivityScore', metrics.customerActivityScore);
  158. obj.set('healthGrade', metrics.healthGrade);
  159. obj.set('healthScore', metrics.healthScore);
  160. obj.set('healthStatus', metrics.healthStatus);
  161. obj.set('lifecyclePhase', lifecyclePhase);
  162. if (!freshMetrics) {
  163. void obj.save(null, { useMasterKey: true }).catch(() => {});
  164. }
  165. return toDto(obj);
  166. }
  167. async function enrichGroup(
  168. obj: Parse.Object,
  169. lifecycleMap: Map<string, LifecyclePhase>,
  170. persist = true,
  171. ): Promise<GroupChatDto> {
  172. const roomId = obj.get('roomId') as string;
  173. const guid = (obj.get('guid') as string) || '';
  174. const memberCount = obj.get('memberCount') ?? 0;
  175. const status = obj.get('status') || 'active';
  176. const hasDocument = obj.get('hasDocument') === true;
  177. const documentPinned = obj.get('documentPinned') === true;
  178. const documentInNotice = obj.get('documentInNotice') === true;
  179. const memberChange24h = obj.get('memberChange24h') ?? 0;
  180. const [messageCountToday, messageCountTotal] = await Promise.all([
  181. countMessagesToday(roomId),
  182. countMessagesTotal(roomId),
  183. ]);
  184. const metrics = deriveHealthMetrics({
  185. memberCount,
  186. messageCountToday,
  187. memberChange24h,
  188. hasDocument,
  189. documentPinned,
  190. documentInNotice,
  191. status,
  192. });
  193. const communityId = obj.get('communityId') as string;
  194. const lifecyclePhase = communityId && lifecycleMap.has(communityId)
  195. ? lifecycleMap.get(communityId)!
  196. : (obj.get('lifecyclePhase') as string) || 'pre_handover';
  197. let ownerName = (obj.get('ownerName') as string) || '';
  198. if (!ownerName && obj.get('ownerId')) {
  199. ownerName = await resolveOwnerName(obj.get('ownerId') || '');
  200. if (ownerName) {
  201. obj.set('ownerName', ownerName);
  202. }
  203. }
  204. obj.set('messageCountToday', messageCountToday);
  205. obj.set('messageCountTotal', messageCountTotal);
  206. obj.set('activityLevel', metrics.activityLevel);
  207. obj.set('opsActivityScore', metrics.opsActivityScore);
  208. obj.set('customerActivityScore', metrics.customerActivityScore);
  209. obj.set('healthGrade', metrics.healthGrade);
  210. obj.set('healthScore', metrics.healthScore);
  211. obj.set('healthStatus', metrics.healthStatus);
  212. obj.set('lifecyclePhase', lifecyclePhase);
  213. if (persist) {
  214. await obj.save(null, { useMasterKey: true });
  215. }
  216. return toDto(obj);
  217. }
  218. function toListDto(obj: Parse.Object, lifecycleMap: Map<string, LifecyclePhase>): GroupChatDto {
  219. const dto = toDto(obj);
  220. const communityId = obj.get('communityId') as string;
  221. if (communityId && lifecycleMap.has(communityId)) {
  222. dto.lifecyclePhase = lifecycleMap.get(communityId)!;
  223. }
  224. return dto;
  225. }
  226. function toDto(obj: Parse.Object): GroupChatDto {
  227. return {
  228. id: obj.id!,
  229. roomId: obj.get('roomId') || '',
  230. roomName: obj.get('roomName') || '未命名群',
  231. ownerId: obj.get('ownerId') || '',
  232. ownerName: obj.get('ownerName') || '',
  233. memberCount: obj.get('memberCount') ?? 0,
  234. avatarUrl: obj.get('avatarUrl') || '',
  235. status: obj.get('status') || 'active',
  236. guid: obj.get('guid') || '',
  237. storeId: obj.get('storeId') || '',
  238. storeName: obj.get('storeName') || '',
  239. communityId: obj.get('communityId') || '',
  240. communityName: obj.get('communityName') || '',
  241. activityLevel: obj.get('activityLevel') || 'inactive',
  242. healthScore: obj.get('healthScore') ?? 0,
  243. healthStatus: obj.get('healthStatus') || 'warning',
  244. healthGrade: obj.get('healthGrade') || 'C',
  245. opsActivityScore: obj.get('opsActivityScore') ?? 0,
  246. customerActivityScore: obj.get('customerActivityScore') ?? 0,
  247. lifecyclePhase: obj.get('lifecyclePhase') || 'pre_handover',
  248. hasDocument: obj.get('hasDocument') === true,
  249. documentPinned: obj.get('documentPinned') === true,
  250. documentInNotice: obj.get('documentInNotice') === true,
  251. messageCountToday: obj.get('messageCountToday') ?? 0,
  252. messageCountTotal: obj.get('messageCountTotal') ?? 0,
  253. memberChange24h: obj.get('memberChange24h') ?? 0,
  254. createdAt: (obj.get('createdAt') as Date)?.toISOString?.() || new Date().toISOString(),
  255. ownerRole: (obj.get('ownerRole') as string) || '',
  256. updatedAt: (obj.get('updatedAt') as Date)?.toISOString?.() || new Date().toISOString(),
  257. };
  258. }
  259. async function countActiveMembers(roomId: string): Promise<number> {
  260. const query = new Parse.Query('GroupMember');
  261. query.equalTo('roomId', roomId);
  262. query.notEqualTo('status', 'left');
  263. return query.count({ useMasterKey: true });
  264. }
  265. async function resolveOwnerProfile(ownerId: string): Promise<{ name: string; role: string }> {
  266. if (!ownerId) return { name: '', role: '' };
  267. const orgQuery = new Parse.Query('OrgMember');
  268. orgQuery.equalTo('userId', ownerId);
  269. const orgMember = await orgQuery.first({ useMasterKey: true });
  270. if (orgMember) {
  271. return {
  272. name: String(orgMember.get('name') || ownerId),
  273. role: String(orgMember.get('position') || '群主'),
  274. };
  275. }
  276. if (ACTIVE_QIWE_GUID) {
  277. try {
  278. const contacts = await getContactDetailsBatch([ownerId]);
  279. const contact = contacts[0];
  280. if (contact) {
  281. return {
  282. name: contact.name || ownerId,
  283. role: contact.position || '群主',
  284. };
  285. }
  286. } catch {
  287. /* 联系人接口失败时回退 userId */
  288. }
  289. }
  290. return { name: ownerId, role: '群主' };
  291. }
  292. async function ensureOrganizationFields(obj: Parse.Object): Promise<void> {
  293. if (!obj.get('storeId')) {
  294. const def = await getDefaultStore();
  295. if (def.storeId) {
  296. obj.set('storeId', def.storeId);
  297. obj.set('storeName', def.storeName);
  298. }
  299. }
  300. if (!obj.get('communityId')) {
  301. const roomName = String(obj.get('roomName') || '');
  302. const communities = await listCommunities();
  303. const match = communities
  304. .filter((c) => c.name && roomName.includes(c.name))
  305. .sort((a, b) => b.name.length - a.name.length)[0];
  306. if (match) {
  307. obj.set('communityId', match.id);
  308. obj.set('communityName', match.name);
  309. if (!obj.get('storeId') && match.storeId) {
  310. obj.set('storeId', match.storeId);
  311. obj.set('storeName', match.storeName);
  312. }
  313. }
  314. }
  315. }
  316. async function borrowOwnerFromSiblingRows(obj: Parse.Object, roomId: string): Promise<void> {
  317. if (obj.get('ownerId')) return;
  318. const query = new Parse.Query('GroupChat');
  319. query.equalTo('roomId', roomId);
  320. query.exists('ownerId');
  321. query.limit(20);
  322. const rows = await query.find({ useMasterKey: true });
  323. for (const row of rows) {
  324. const ownerId = String(row.get('ownerId') || '');
  325. if (!ownerId) continue;
  326. obj.set('ownerId', ownerId);
  327. if (row.get('ownerName')) obj.set('ownerName', row.get('ownerName'));
  328. return;
  329. }
  330. }
  331. async function inferOwnerFromMembers(
  332. roomId: string,
  333. members: GroupMemberDto[],
  334. ): Promise<{ ownerId: string; name: string; role: string } | null> {
  335. if (members.length === 0) return null;
  336. const userIds = members.map((m) => m.userId).filter(Boolean);
  337. const orgQuery = new Parse.Query('OrgMember');
  338. orgQuery.containedIn('userId', userIds.slice(0, 100));
  339. orgQuery.limit(100);
  340. const orgRows = await orgQuery.find({ useMasterKey: true });
  341. const orgMap = new Map(orgRows.map((row) => [String(row.get('userId')), row]));
  342. for (const member of members) {
  343. const org = orgMap.get(member.userId);
  344. if (org) {
  345. return {
  346. ownerId: member.userId,
  347. name: String(org.get('name') || member.nickname || member.userId),
  348. role: String(org.get('position') || '负责人'),
  349. };
  350. }
  351. }
  352. const msgQuery = new Parse.Query('Message');
  353. msgQuery.equalTo('roomId', roomId);
  354. msgQuery.limit(200);
  355. msgQuery.select('senderId', 'senderName');
  356. const messages = await msgQuery.find({ useMasterKey: true });
  357. const counts = new Map<string, { count: number; name: string }>();
  358. for (const message of messages) {
  359. const userId = String(message.get('senderId') || '');
  360. if (!userId) continue;
  361. const senderName = String(message.get('senderName') || userId);
  362. const current = counts.get(userId) || { count: 0, name: senderName };
  363. current.count += 1;
  364. if (senderName !== userId) current.name = senderName;
  365. counts.set(userId, current);
  366. }
  367. let best: { ownerId: string; name: string; count: number } | null = null;
  368. for (const [ownerId, value] of counts) {
  369. if (!best || value.count > best.count) {
  370. best = { ownerId, name: value.name, count: value.count };
  371. }
  372. }
  373. if (best) {
  374. return { ownerId: best.ownerId, name: best.name, role: '活跃成员' };
  375. }
  376. const first = members.find((m) => m.nickname && m.nickname !== m.userId);
  377. if (first) {
  378. return { ownerId: first.userId, name: first.nickname, role: '群成员' };
  379. }
  380. return null;
  381. }
  382. async function syncQiWeRoomFields(obj: Parse.Object, roomId: string): Promise<void> {
  383. const guid = String(obj.get('guid') || ACTIVE_QIWE_GUID);
  384. if (!guid) return;
  385. const needsSync =
  386. !obj.get('ownerId')
  387. || (obj.get('memberCount') ?? 0) === 0
  388. || isPlaceholderRoomName(String(obj.get('roomName') || ''));
  389. if (!needsSync) return;
  390. try {
  391. const details = await batchGetRoomDetails(guid, [roomId]);
  392. const detail = details[0];
  393. if (!detail) return;
  394. if (detail.roomName) obj.set('roomName', detail.roomName);
  395. if (detail.ownerId) obj.set('ownerId', detail.ownerId);
  396. if (detail.memberCount > 0) obj.set('memberCount', detail.memberCount);
  397. } catch (err: unknown) {
  398. const message = err instanceof Error ? err.message : String(err);
  399. console.warn(`[GroupChat] 详情补全企微字段失败 roomId=${roomId}: ${message}`);
  400. }
  401. }
  402. function pickBestGroupObject(rows: Parse.Object[], lifecycleMap: Map<string, LifecyclePhase>): Parse.Object {
  403. const dtos = rows.map((obj) => toListDto(obj, lifecycleMap));
  404. const [bestDto] = dedupeGroupsByRoomId(dtos);
  405. return rows.find((row) => row.id === bestDto?.id) || rows[0];
  406. }
  407. const ACTIVE_QIWE_GUID = process.env.QIWE_GUID || '';
  408. function scoreGroupForDedupe(g: GroupChatDto): number {
  409. let score = 0;
  410. if (g.guid === ACTIVE_QIWE_GUID) score += 100;
  411. if (g.messageCountTotal > 0) score += 50 + Math.min(g.messageCountTotal, 1000);
  412. if (g.roomName && !g.roomName.startsWith('群 ')) score += 20;
  413. if (g.memberCount > 0) score += 10;
  414. return score;
  415. }
  416. /** 同一 roomId 可能有多条记录(不同 guid / 来源),保留信息最完整的一条 */
  417. function dedupeGroupsByRoomId(groups: GroupChatDto[]): GroupChatDto[] {
  418. const map = new Map<string, GroupChatDto>();
  419. for (const g of groups) {
  420. if (!g.roomId) continue;
  421. const existing = map.get(g.roomId);
  422. if (!existing || scoreGroupForDedupe(g) > scoreGroupForDedupe(existing)) {
  423. map.set(g.roomId, g);
  424. }
  425. }
  426. return [...map.values()];
  427. }
  428. function isPlaceholderRoomName(name: string): boolean {
  429. return !name || name === '未命名群' || /^群 \d{6}$/.test(name);
  430. }
  431. /** 向企微拉取真实群名,替换「群 500656」类占位名 */
  432. export async function enrichPlaceholderGroupNames(guid: string, batchSize = 30): Promise<number> {
  433. if (!guid) return 0;
  434. const query = new Parse.Query('GroupChat');
  435. query.equalTo('guid', guid);
  436. query.limit(200);
  437. const groups = await query.find({ useMasterKey: true });
  438. const targets = groups.filter((g) => isPlaceholderRoomName(String(g.get('roomName') || '')));
  439. if (targets.length === 0) return 0;
  440. const roomIds = targets.slice(0, batchSize).map((g) => String(g.get('roomId')));
  441. let details: Awaited<ReturnType<typeof batchGetRoomDetails>>;
  442. try {
  443. details = await batchGetRoomDetails(guid, roomIds);
  444. } catch (err: unknown) {
  445. const message = err instanceof Error ? err.message : String(err);
  446. console.warn(`[GroupChat] 拉取群详情失败: ${message}`);
  447. return 0;
  448. }
  449. const detailMap = new Map(details.map((d) => [d.roomId, d]));
  450. let updated = 0;
  451. for (const obj of targets) {
  452. const roomId = String(obj.get('roomId'));
  453. const detail = detailMap.get(roomId);
  454. if (!detail?.roomName) continue;
  455. obj.set('roomName', detail.roomName);
  456. if (detail.memberCount > 0) obj.set('memberCount', detail.memberCount);
  457. if (detail.ownerId) obj.set('ownerId', detail.ownerId);
  458. await obj.save(null, { useMasterKey: true });
  459. updated++;
  460. }
  461. if (updated > 0) {
  462. console.log(`[GroupChat] 已补全 ${updated} 个群的真实名称`);
  463. }
  464. return updated;
  465. }
  466. let lastPrepareAt = 0;
  467. const PREPARE_TTL_MS = 10 * 60 * 1000;
  468. /**
  469. * 列表展示前对齐群目录:从 Message 表补全有消息但未入列表的群。
  470. * 带 TTL 缓存,避免每次打开列表都扫库/调企微。
  471. */
  472. export async function prepareGroupListForDisplay(force = false): Promise<number> {
  473. if (!ACTIVE_QIWE_GUID) return 0;
  474. if (!force && Date.now() - lastPrepareAt < PREPARE_TTL_MS) return 0;
  475. lastPrepareAt = Date.now();
  476. const created = await backfillGroupChatsFromMessages(ACTIVE_QIWE_GUID);
  477. await enrichPlaceholderGroupNames(ACTIVE_QIWE_GUID);
  478. return created;
  479. }
  480. /** 同步后批量刷新群指标(写库),不在列表读取路径调用 */
  481. export async function refreshAllGroupMetrics(limit = 200): Promise<number> {
  482. const query = new Parse.Query('GroupChat');
  483. query.limit(limit);
  484. const groups = await query.find({ useMasterKey: true });
  485. const lifecycleMap = await getCommunityLifecycleMap();
  486. await Promise.all(groups.map((obj) => enrichGroup(obj, lifecycleMap)));
  487. return groups.length;
  488. }
  489. export async function listGroupChats(
  490. filters: GroupListFilters = {},
  491. limit = 500,
  492. ): Promise<GroupChatDto[]> {
  493. const query = new Parse.Query('GroupChat');
  494. query.descending('messageCountTotal');
  495. query.addDescending('updatedAt');
  496. if (filters.storeId) query.equalTo('storeId', filters.storeId);
  497. if (filters.activityLevel) query.equalTo('activityLevel', filters.activityLevel);
  498. if (filters.healthStatus) query.equalTo('healthStatus', filters.healthStatus);
  499. if (filters.healthGrade) query.equalTo('healthGrade', filters.healthGrade);
  500. if (filters.lifecyclePhase) query.equalTo('lifecyclePhase', filters.lifecyclePhase);
  501. if (filters.hasDocument !== undefined) query.equalTo('hasDocument', filters.hasDocument);
  502. query.limit(limit);
  503. const [results, lifecycleMap] = await Promise.all([
  504. query.find({ useMasterKey: true }),
  505. getCommunityLifecycleMap(),
  506. ]);
  507. const enriched = results.map((obj) => toListDto(obj, lifecycleMap));
  508. let filtered = enriched;
  509. if (filters.lifecyclePhase) {
  510. filtered = filtered.filter((g) => g.lifecyclePhase === filters.lifecyclePhase);
  511. }
  512. if (filters.healthGrade) {
  513. filtered = filtered.filter((g) => g.healthGrade === filters.healthGrade);
  514. }
  515. if (!filters.q) {
  516. return dedupeGroupsByRoomId(filtered).sort((a, b) => b.messageCountTotal - a.messageCountTotal);
  517. }
  518. const q = filters.q.toLowerCase();
  519. return dedupeGroupsByRoomId(filtered.filter((g) =>
  520. g.roomName.toLowerCase().includes(q)
  521. || g.ownerName.toLowerCase().includes(q)
  522. || g.communityName.toLowerCase().includes(q)
  523. || g.storeName.toLowerCase().includes(q)
  524. || g.roomId.includes(q),
  525. ));
  526. }
  527. export async function getGroupChatByRoomId(roomId: string): Promise<GroupChatDto | null> {
  528. const query = new Parse.Query('GroupChat');
  529. query.equalTo('roomId', roomId);
  530. query.limit(20);
  531. const [rows, lifecycleMap] = await Promise.all([
  532. query.find({ useMasterKey: true }),
  533. getCommunityLifecycleMap(),
  534. ]);
  535. if (rows.length === 0) return null;
  536. const obj = pickBestGroupObject(rows, lifecycleMap);
  537. const needsQiWe =
  538. !obj.get('ownerId')
  539. || (obj.get('memberCount') ?? 0) === 0
  540. || isPlaceholderRoomName(String(obj.get('roomName') || ''));
  541. let membersLoaded: GroupMemberDto[] | null = null;
  542. await Promise.all([
  543. needsQiWe ? syncQiWeRoomFields(obj, roomId) : Promise.resolve(),
  544. !obj.get('ownerId') ? borrowOwnerFromSiblingRows(obj, roomId) : Promise.resolve(),
  545. !obj.get('storeId') ? ensureOrganizationFields(obj) : Promise.resolve(),
  546. ]);
  547. const needsMembers = (obj.get('memberCount') ?? 0) === 0 || !obj.get('ownerId');
  548. if (needsMembers) {
  549. if ((obj.get('memberCount') ?? 0) === 0) {
  550. const memberCount = await countActiveMembers(roomId);
  551. if (memberCount > 0) obj.set('memberCount', memberCount);
  552. }
  553. if ((obj.get('memberCount') ?? 0) === 0 || !obj.get('ownerId')) {
  554. const membersResult = await listGroupMembersByRoom(roomId, false, false);
  555. membersLoaded = membersResult.members;
  556. if (membersResult.total > 0 && (obj.get('memberCount') ?? 0) === 0) {
  557. obj.set('memberCount', membersResult.total);
  558. }
  559. }
  560. }
  561. if (!obj.get('ownerId')) {
  562. if (!membersLoaded) {
  563. const membersResult = await listGroupMembersByRoom(roomId, false, false);
  564. membersLoaded = membersResult.members;
  565. if (membersResult.total > 0 && (obj.get('memberCount') ?? 0) === 0) {
  566. obj.set('memberCount', membersResult.total);
  567. }
  568. }
  569. const inferred = membersLoaded ? await inferOwnerFromMembers(roomId, membersLoaded) : null;
  570. if (inferred) {
  571. obj.set('ownerId', inferred.ownerId);
  572. obj.set('ownerName', inferred.name);
  573. obj.set('ownerRole', inferred.role);
  574. }
  575. }
  576. const ownerId = String(obj.get('ownerId') || '');
  577. let owner = obj.get('ownerName')
  578. ? { name: String(obj.get('ownerName')), role: String(obj.get('ownerRole') || '群主') }
  579. : await resolveOwnerProfile(ownerId);
  580. if (ownerId && (!owner.name || owner.name === ownerId)) {
  581. if (!membersLoaded) {
  582. const membersResult = await listGroupMembersByRoom(roomId, false, false);
  583. membersLoaded = membersResult.members;
  584. }
  585. const member = membersLoaded?.find((m) => m.userId === ownerId);
  586. if (member?.nickname && member.nickname !== ownerId) {
  587. owner = { ...owner, name: member.nickname };
  588. }
  589. }
  590. if (owner.name) obj.set('ownerName', owner.name);
  591. if (owner.role) obj.set('ownerRole', owner.role);
  592. const dto = await enrichGroupForRead(obj, lifecycleMap);
  593. return {
  594. ...dto,
  595. createdAt: (obj.get('createdAt') as Date)?.toISOString?.() || dto.updatedAt,
  596. ownerRole: owner.role || dto.ownerRole || '群主',
  597. };
  598. }
  599. export async function migrateGroupBusinessFields(): Promise<void> {
  600. const query = new Parse.Query('GroupChat');
  601. query.limit(1000);
  602. const groups = await query.find({ useMasterKey: true });
  603. let migrated = 0;
  604. for (const group of groups) {
  605. let changed = false;
  606. if (group.get('hasDocument') === undefined) {
  607. group.set('hasDocument', false);
  608. changed = true;
  609. }
  610. if (group.get('documentPinned') === undefined) {
  611. group.set('documentPinned', false);
  612. changed = true;
  613. }
  614. if (group.get('documentInNotice') === undefined) {
  615. group.set('documentInNotice', false);
  616. changed = true;
  617. }
  618. if (group.get('memberChange24h') === undefined) {
  619. group.set('memberChange24h', 0);
  620. changed = true;
  621. }
  622. if (changed) {
  623. await group.save(null, { useMasterKey: true });
  624. migrated++;
  625. }
  626. }
  627. if (migrated > 0) {
  628. console.log(`[Organization] 已补全 ${migrated} 个群的基础字段`);
  629. }
  630. }
  631. /** 清除历史上自动填入的占位门店/小区数据(无真实业务归属时应为空) */
  632. export async function clearPlaceholderOrganizationFields(): Promise<void> {
  633. const query = new Parse.Query('GroupChat');
  634. query.limit(1000);
  635. const groups = await query.find({ useMasterKey: true });
  636. let cleared = 0;
  637. for (const group of groups) {
  638. const hasStore = group.get('storeId') || group.get('storeName');
  639. const hasCommunity = group.get('communityId') || group.get('communityName');
  640. if (!hasStore && !hasCommunity) continue;
  641. group.unset('storeId');
  642. group.unset('storeName');
  643. group.unset('communityId');
  644. group.unset('communityName');
  645. await group.save(null, { useMasterKey: true });
  646. cleared++;
  647. }
  648. if (cleared > 0) {
  649. console.log(`[Organization] 已清除 ${cleared} 个群的占位门店/小区数据`);
  650. }
  651. }
  652. export function initNewGroupDefaults(obj: Parse.Object): void {
  653. if (obj.get('hasDocument') === undefined) obj.set('hasDocument', false);
  654. if (obj.get('documentPinned') === undefined) obj.set('documentPinned', false);
  655. if (obj.get('documentInNotice') === undefined) obj.set('documentInNotice', false);
  656. if (obj.get('memberChange24h') === undefined) obj.set('memberChange24h', 0);
  657. }