diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 3bc334e..4748a1c 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -29,6 +29,7 @@ import HqLogsPage from './pages/HqLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; import StoreLogsPage from './pages/StoreLogsPage'; import PartnerLogsPage from './pages/PartnerLogsPage'; +import WechatBindingsPage from './pages/WechatBindingsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -48,6 +49,7 @@ export default function App() { > } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 139d7b1..a9e632f 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -23,6 +23,7 @@ const { Header, Sider, Content } = Layout; const MENU_ITEMS: MenuProps['items'] = [ { key: '/', icon: , label: '概览' }, { key: '/users', icon: , label: '用户' }, + { key: '/wechat-bindings', icon: , label: '微信绑定' }, { key: 'products-group', icon: , diff --git a/apps/admin-web/src/pages/WechatBindingsPage.tsx b/apps/admin-web/src/pages/WechatBindingsPage.tsx new file mode 100644 index 0000000..b823e94 --- /dev/null +++ b/apps/admin-web/src/pages/WechatBindingsPage.tsx @@ -0,0 +1,317 @@ +import { useEffect, useState } from 'react'; +import { + Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography, +} from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { request } from '../lib/api'; +import { AdminCellLine } from '../components/AdminCellLine'; +import { fmtTime } from '../lib/constants'; +import { useAdminList } from '../lib/useAdminList'; + +type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ'; + +type Identity = { + actorType: ActorType; + actorId: string; + phone: string | null; + name: string | null; + wxOpenId: string; + wxUnionId: string | null; + phoneVerified?: boolean; + refLabel: string | null; + refId: string | null; + lastLoginAt: string | null; + status: string | number; +}; + +type GroupRow = { + groupKey: string; + unionId: string | null; + identityCount: number; + actorTypes: ActorType[]; + multiRole: boolean; + primaryPhone: string | null; + latestLoginAt: string | null; + identities: Identity[]; +}; + +const ACTOR_TYPE_LABELS: Record = { + USER: 'C 端用户', + STORE: '门店账号', + PARTNER: '合伙人账号', + HQ: 'HQ 账号', +}; + +const ACTOR_TYPE_COLORS: Record = { + USER: 'blue', + STORE: 'green', + PARTNER: 'orange', + HQ: 'purple', +}; + +function renderActorTags(types: ActorType[]) { + return types.map((t) => ( + + {ACTOR_TYPE_LABELS[t]} + + )); +} + +export default function WechatBindingsPage() { + const [form] = Form.useForm(); + const [filters, setFilters] = useState>({ + actorType: '', + phone: '', + unionId: '', + openId: '', + }); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/admin/wechat-bindings', + () => { + const qs = new URLSearchParams(); + if (filters.actorType) qs.set('actorType', filters.actorType); + if (filters.phone) qs.set('phone', filters.phone); + if (filters.unionId) qs.set('unionId', filters.unionId); + if (filters.openId) qs.set('openId', filters.openId); + return qs; + }, + [filters], + ); + const [detail, setDetail] = useState(null); + const [drawerOpen, setDrawerOpen] = useState(false); + + useEffect(() => { + form.setFieldsValue(filters); + }, [form, filters]); + + async function openDetail(row: GroupRow) { + const res = await request(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`); + setDetail(res); + setDrawerOpen(true); + } + + const columns: ColumnsType = [ + { + title: 'unionId', + dataIndex: 'unionId', + width: 180, + ellipsis: true, + render: (v) => v || 无 unionId, + }, + { + title: '身份数', + dataIndex: 'identityCount', + width: 90, + render: (v, r) => ( + + {v} + {r.multiRole ? 一人多角色 : null} + + ), + }, + { + title: '端类型', + dataIndex: 'actorTypes', + width: 220, + render: (types: ActorType[]) => renderActorTags(types), + }, + { + title: '手机号', + dataIndex: 'primaryPhone', + width: 140, + render: (v) => v || '—', + }, + { + title: '身份摘要', + ellipsis: true, + render: (_, r) => ( + ACTOR_TYPE_LABELS[i.actorType]).join(' / ')} + secondary={r.identities + .map((i) => i.refLabel || i.name || i.phone) + .filter(Boolean) + .join(' · ')} + /> + ), + }, + { + title: '最近登录', + dataIndex: 'latestLoginAt', + width: 160, + render: fmtTime, + }, + { + title: '操作', + width: 80, + render: (_, row) => ( + + ), + }, + ]; + + const identityColumns: ColumnsType = [ + { + title: '端类型', + dataIndex: 'actorType', + width: 120, + render: (t: ActorType) => {ACTOR_TYPE_LABELS[t]}, + }, + { + title: '账号', + ellipsis: true, + render: (_, r) => ( + + ), + }, + { + title: '归属', + dataIndex: 'refLabel', + width: 160, + ellipsis: true, + render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'), + }, + { + title: 'wxOpenId', + dataIndex: 'wxOpenId', + width: 160, + ellipsis: true, + }, + { + title: '手机验证', + width: 90, + render: (_, r) => + r.actorType === 'USER' ? ( + r.phoneVerified ? 已验证 : 未验证 + ) : ( + '—' + ), + }, + { + title: '最近登录', + dataIndex: 'lastLoginAt', + width: 160, + render: fmtTime, + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (v) => String(v), + }, + ]; + + return ( +
+ 微信绑定总览 + + 按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。 + + +
{ + setPage(1); + setFilters({ + actorType: values.actorType ?? '', + phone: values.phone?.trim() ?? '', + unionId: values.unionId?.trim() ?? '', + openId: values.openId?.trim() ?? '', + }); + }} + > + + + + + + + + + + + + + + + + +
+ + + rowKey="groupKey" + loading={loading} + columns={columns} + dataSource={data?.items ?? []} + pagination={{ + current: page, + pageSize, + total: data?.total ?? 0, + showSizeChanger: true, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + + setDrawerOpen(false)} + > + {detail ? ( + <> + + {detail.groupKey} + {detail.unionId || '—'} + {detail.identityCount} + {renderActorTags(detail.actorTypes)} + + {detail.multiRole ? : } + + {fmtTime(detail.latestLoginAt)} + + + rowKey={(r) => `${r.actorType}-${r.actorId}`} + size="small" + columns={identityColumns} + dataSource={detail.identities} + pagination={false} + /> + + ) : null} + +
+ ); +} diff --git a/server/dukang-api/src/modules/ops/admin-wechat-bindings.controller.ts b/server/dukang-api/src/modules/ops/admin-wechat-bindings.controller.ts new file mode 100644 index 0000000..440e59a --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-wechat-bindings.controller.ts @@ -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)); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts b/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts new file mode 100644 index 0000000..8b4ec7f --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts @@ -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((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()]; + } +} diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index 4ea6255..b55667b 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -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; +} diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index cf3b6d1..1dcccdd 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -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, ], })