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 时按单账号分组。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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,
],
})