import { Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AdminWechatBindingsQueryDto } from './dto/admin-query.dto'; type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ'; type BindingRow = { actorType: ActorType; actorId: bigint; phone: string | null; name: string | null; wxOpenId: string; wxUnionId: string | null; phoneVerified?: boolean; refLabel?: string | null; refId?: bigint | null; lastLoginAt: Date | null; status: string | number; }; function buildGroupKey(row: BindingRow): string { if (row.wxUnionId) return `union:${row.wxUnionId}`; return `solo:${row.actorType}:${row.actorId.toString()}`; } function mapBindingRow(row: BindingRow) { return { actorType: row.actorType, actorId: row.actorId.toString(), phone: row.phone, name: row.name, wxOpenId: row.wxOpenId, wxUnionId: row.wxUnionId, phoneVerified: row.phoneVerified, refLabel: row.refLabel ?? null, refId: row.refId?.toString() ?? null, lastLoginAt: row.lastLoginAt, status: row.status, }; } function summarizeGroup(groupKey: string, rows: BindingRow[]) { const unionId = groupKey.startsWith('union:') ? groupKey.slice('union:'.length) : null; const actorTypes = [...new Set(rows.map((r) => r.actorType))]; const phones = rows.map((r) => r.phone).filter((p): p is string => !!p); const latestLoginAt = rows.reduce((max, r) => { if (!r.lastLoginAt) return max; if (!max || r.lastLoginAt > max) return r.lastLoginAt; return max; }, null); return { groupKey, unionId, identityCount: rows.length, actorTypes, multiRole: rows.length > 1, primaryPhone: phones[0] ?? null, latestLoginAt, identities: rows.map(mapBindingRow), }; } @Injectable() export class AdminWechatBindingsService { constructor(private readonly prisma: PrismaService) {} async list(query: AdminWechatBindingsQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; let rows = await this.fetchBindings(query); const shouldExpandUnion = !!(query.phone || query.openId || query.actorType); if (shouldExpandUnion) { const unionIds = [ ...new Set(rows.map((r) => r.wxUnionId).filter((id): id is string => !!id)), ]; if (unionIds.length > 0) { const expanded = ( await Promise.all(unionIds.map((unionId) => this.fetchBindings({ unionId }))) ).flat(); const soloRows = rows.filter((r) => !r.wxUnionId); rows = this.dedupeBindings([...expanded, ...soloRows]); } } const groups = this.groupBindings(rows); const summaries = [...groups.entries()] .map(([groupKey, groupRows]) => summarizeGroup(groupKey, groupRows)) .sort((a, b) => { if (a.multiRole !== b.multiRole) return a.multiRole ? -1 : 1; const ta = a.latestLoginAt ? new Date(a.latestLoginAt).getTime() : 0; const tb = b.latestLoginAt ? new Date(b.latestLoginAt).getTime() : 0; return tb - ta; }); const total = summaries.length; const items = summaries.slice((page - 1) * pageSize, page * pageSize); return serializeBigInt({ items, total, page, pageSize }); } async detail(groupKey: string) { const rows = await this.fetchBindings({}); const groups = this.groupBindings(rows); const groupRows = groups.get(groupKey); if (!groupRows?.length) { throw new NotFoundException('微信绑定分组不存在'); } return serializeBigInt(summarizeGroup(groupKey, groupRows)); } private groupBindings(rows: BindingRow[]): Map { const groups = new Map(); for (const row of rows) { const key = buildGroupKey(row); const list = groups.get(key) ?? []; list.push(row); groups.set(key, list); } for (const [key, list] of groups) { list.sort((a, b) => { const ta = a.lastLoginAt?.getTime() ?? 0; const tb = b.lastLoginAt?.getTime() ?? 0; return tb - ta; }); groups.set(key, list); } return groups; } private async fetchBindings(query: AdminWechatBindingsQueryDto): Promise { const actorType = query.actorType as ActorType | undefined; const phoneFilter = query.phone?.trim(); const unionIdFilter = query.unionId?.trim(); const openIdFilter = query.openId?.trim(); const rows: BindingRow[] = []; if (!actorType || actorType === 'USER') { const where: Prisma.UserWhereInput = { wxOpenId: { not: null }, status: 1, mergedIntoUserId: null, }; if (phoneFilter) where.phone = { contains: phoneFilter }; if (unionIdFilter) where.wxUnionId = unionIdFilter; if (openIdFilter) where.wxOpenId = openIdFilter; const users = await this.prisma.user.findMany({ where, select: { id: true, phone: true, nickname: true, phoneVerifiedAt: true, wxOpenId: true, wxUnionId: true, status: true, updatedAt: true, }, }); for (const u of users) { if (!u.wxOpenId) continue; rows.push({ actorType: 'USER', actorId: u.id, phone: u.phone, name: u.nickname, wxOpenId: u.wxOpenId, wxUnionId: u.wxUnionId, phoneVerified: !!u.phoneVerifiedAt, lastLoginAt: u.updatedAt, status: u.status, }); } } if (!actorType || actorType === 'STORE') { const where: Prisma.StoreAccountWhereInput = { wxOpenId: { not: null } }; if (phoneFilter) where.phone = { contains: phoneFilter }; if (unionIdFilter) where.wxUnionId = unionIdFilter; if (openIdFilter) where.wxOpenId = openIdFilter; const accounts = await this.prisma.storeAccount.findMany({ where, include: { store: { select: { id: true, name: true } } }, }); for (const a of accounts) { if (!a.wxOpenId) continue; rows.push({ actorType: 'STORE', actorId: a.id, phone: a.phone, name: a.name, wxOpenId: a.wxOpenId, wxUnionId: a.wxUnionId, refId: a.storeId, refLabel: a.store.name, lastLoginAt: a.lastLoginAt, status: a.status, }); } } if (!actorType || actorType === 'PARTNER') { const where: Prisma.PartnerAccountWhereInput = { wxOpenId: { not: null } }; if (phoneFilter) where.phone = { contains: phoneFilter }; if (unionIdFilter) where.wxUnionId = unionIdFilter; if (openIdFilter) where.wxOpenId = openIdFilter; const accounts = await this.prisma.partnerAccount.findMany({ where, include: { partner: { select: { id: true, companyName: true } } }, }); for (const a of accounts) { if (!a.wxOpenId) continue; rows.push({ actorType: 'PARTNER', actorId: a.id, phone: a.phone, name: a.name, wxOpenId: a.wxOpenId, wxUnionId: a.wxUnionId, refId: a.partnerId, refLabel: a.partner.companyName, lastLoginAt: a.lastLoginAt, status: a.status, }); } } if (!actorType || actorType === 'HQ') { const where: Prisma.HqAccountWhereInput = { wxOpenId: { not: null } }; if (phoneFilter) where.phone = { contains: phoneFilter }; if (unionIdFilter) where.wxUnionId = unionIdFilter; if (openIdFilter) where.wxOpenId = openIdFilter; const accounts = await this.prisma.hqAccount.findMany({ where, select: { id: true, phone: true, name: true, adminRole: true, wxOpenId: true, wxUnionId: true, lastLoginAt: true, status: true, }, }); for (const a of accounts) { if (!a.wxOpenId) continue; rows.push({ actorType: 'HQ', actorId: a.id, phone: a.phone, name: a.name, wxOpenId: a.wxOpenId, wxUnionId: a.wxUnionId, refLabel: a.adminRole, lastLoginAt: a.lastLoginAt, status: a.status, }); } } return rows; } private dedupeBindings(rows: BindingRow[]): BindingRow[] { const map = new Map(); for (const row of rows) { map.set(`${row.actorType}:${row.actorId.toString()}`, row); } return [...map.values()]; } }