283 lines
8.5 KiB
TypeScript
283 lines
8.5 KiB
TypeScript
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<Date | null>((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<string, BindingRow[]> {
|
|
const groups = new Map<string, BindingRow[]>();
|
|
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<BindingRow[]> {
|
|
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<string, BindingRow>();
|
|
for (const row of rows) {
|
|
map.set(`${row.actorType}:${row.actorId.toString()}`, row);
|
|
}
|
|
return [...map.values()];
|
|
}
|
|
}
|