web端增加微信绑定管理

This commit is contained in:
2026-07-07 18:25:15 +08:00
parent 887d728496
commit 98e9652865
7 changed files with 644 additions and 0 deletions
@@ -0,0 +1,20 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
import { AdminWechatBindingsQueryDto } from './dto/admin-query.dto';
@Controller('admin/wechat-bindings')
@UseGuards(HqAuthGuard)
export class AdminWechatBindingsController {
constructor(private readonly wechatBindingsService: AdminWechatBindingsService) {}
@Get()
list(@Query() query: AdminWechatBindingsQueryDto) {
return this.wechatBindingsService.list(query);
}
@Get(':groupKey')
detail(@Param('groupKey') groupKey: string) {
return this.wechatBindingsService.detail(decodeURIComponent(groupKey));
}
}
@@ -0,0 +1,282 @@
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()];
}
}
@@ -388,3 +388,21 @@ export class AdminPromoCodesQueryDto extends PaginationQueryDto {
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
export class AdminWechatBindingsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsIn(['USER', 'STORE', 'PARTNER', 'HQ'])
actorType?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
unionId?: string;
@IsOptional()
@IsString()
openId?: string;
}
@@ -44,6 +44,8 @@ import { AdminProductDetailTemplatesService } from './admin-product-detail-templ
import { RedeemModule } from '../redeem/redeem.module';
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
import { AdminWechatBindingsController } from './admin-wechat-bindings.controller';
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
@Module({
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
@@ -72,6 +74,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
AdminProductDetailTemplatesController,
AdminRedeemDebugController,
AdminPromoCodesController,
AdminWechatBindingsController,
],
providers: [
AdminDashboardService,
@@ -94,6 +97,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
AdminProductDetailTemplatesService,
AdminRedeemDebugService,
AdminPromoCodesService,
AdminWechatBindingsService,
SuperAdminGuard,
],
})