web端增加微信绑定管理
This commit is contained in:
@@ -29,6 +29,7 @@ import HqLogsPage from './pages/HqLogsPage';
|
|||||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||||
import StoreLogsPage from './pages/StoreLogsPage';
|
import StoreLogsPage from './pages/StoreLogsPage';
|
||||||
import PartnerLogsPage from './pages/PartnerLogsPage';
|
import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||||
|
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||||
if (!getToken()) return <Navigate to="/login" replace />;
|
if (!getToken()) return <Navigate to="/login" replace />;
|
||||||
@@ -48,6 +49,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/users" element={<UsersPage />} />
|
<Route path="/users" element={<UsersPage />} />
|
||||||
|
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
<Route path="/products" element={<ProductsPage />} />
|
<Route path="/products" element={<ProductsPage />} />
|
||||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const { Header, Sider, Content } = Layout;
|
|||||||
const MENU_ITEMS: MenuProps['items'] = [
|
const MENU_ITEMS: MenuProps['items'] = [
|
||||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||||
|
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
||||||
{
|
{
|
||||||
key: 'products-group',
|
key: 'products-group',
|
||||||
icon: <ShoppingOutlined />,
|
icon: <ShoppingOutlined />,
|
||||||
|
|||||||
@@ -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<ActorType, string> = {
|
||||||
|
USER: 'C 端用户',
|
||||||
|
STORE: '门店账号',
|
||||||
|
PARTNER: '合伙人账号',
|
||||||
|
HQ: 'HQ 账号',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTOR_TYPE_COLORS: Record<ActorType, string> = {
|
||||||
|
USER: 'blue',
|
||||||
|
STORE: 'green',
|
||||||
|
PARTNER: 'orange',
|
||||||
|
HQ: 'purple',
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderActorTags(types: ActorType[]) {
|
||||||
|
return types.map((t) => (
|
||||||
|
<Tag key={t} color={ACTOR_TYPE_COLORS[t]}>
|
||||||
|
{ACTOR_TYPE_LABELS[t]}
|
||||||
|
</Tag>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WechatBindingsPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({
|
||||||
|
actorType: '',
|
||||||
|
phone: '',
|
||||||
|
unionId: '',
|
||||||
|
openId: '',
|
||||||
|
});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<GroupRow>(
|
||||||
|
'/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<GroupRow | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.setFieldsValue(filters);
|
||||||
|
}, [form, filters]);
|
||||||
|
|
||||||
|
async function openDetail(row: GroupRow) {
|
||||||
|
const res = await request<GroupRow>(`/admin/wechat-bindings/${encodeURIComponent(row.groupKey)}`);
|
||||||
|
setDetail(res);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<GroupRow> = [
|
||||||
|
{
|
||||||
|
title: 'unionId',
|
||||||
|
dataIndex: 'unionId',
|
||||||
|
width: 180,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || <Tag>无 unionId</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '身份数',
|
||||||
|
dataIndex: 'identityCount',
|
||||||
|
width: 90,
|
||||||
|
render: (v, r) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{r.multiRole ? <Tag color="red">一人多角色</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '端类型',
|
||||||
|
dataIndex: 'actorTypes',
|
||||||
|
width: 220,
|
||||||
|
render: (types: ActorType[]) => renderActorTags(types),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '手机号',
|
||||||
|
dataIndex: 'primaryPhone',
|
||||||
|
width: 140,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '身份摘要',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => (
|
||||||
|
<AdminCellLine
|
||||||
|
primary={r.identities.map((i) => 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) => (
|
||||||
|
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const identityColumns: ColumnsType<Identity> = [
|
||||||
|
{
|
||||||
|
title: '端类型',
|
||||||
|
dataIndex: 'actorType',
|
||||||
|
width: 120,
|
||||||
|
render: (t: ActorType) => <Tag color={ACTOR_TYPE_COLORS[t]}>{ACTOR_TYPE_LABELS[t]}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账号',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => (
|
||||||
|
<AdminCellLine
|
||||||
|
primary={r.name || '—'}
|
||||||
|
secondary={[r.phone, `#${r.actorId}`].filter(Boolean).join(' ')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 ? <Tag color="blue">已验证</Tag> : <Tag>未验证</Tag>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最近登录',
|
||||||
|
dataIndex: 'lastLoginAt',
|
||||||
|
width: 160,
|
||||||
|
render: fmtTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => String(v),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>微信绑定总览</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(values) => {
|
||||||
|
setPage(1);
|
||||||
|
setFilters({
|
||||||
|
actorType: values.actorType ?? '',
|
||||||
|
phone: values.phone?.trim() ?? '',
|
||||||
|
unionId: values.unionId?.trim() ?? '',
|
||||||
|
openId: values.openId?.trim() ?? '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="actorType" label="端类型">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="全部"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'USER', label: 'C 端用户' },
|
||||||
|
{ value: 'STORE', label: '门店账号' },
|
||||||
|
{ value: 'PARTNER', label: '合伙人账号' },
|
||||||
|
{ value: 'HQ', label: 'HQ 账号' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="phone" label="手机号">
|
||||||
|
<Input allowClear placeholder="模糊匹配" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="unionId" label="unionId">
|
||||||
|
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="openId" label="openId">
|
||||||
|
<Input allowClear placeholder="精确匹配" style={{ width: 180 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
form.resetFields();
|
||||||
|
setPage(1);
|
||||||
|
setFilters({ actorType: '', phone: '', unionId: '', openId: '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => void reload()}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table<GroupRow>
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="微信绑定详情"
|
||||||
|
width={960}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
>
|
||||||
|
{detail ? (
|
||||||
|
<>
|
||||||
|
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="groupKey">{detail.groupKey}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="unionId">{detail.unionId || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="身份数">{detail.identityCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="端类型">{renderActorTags(detail.actorTypes)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="一人多角色">
|
||||||
|
{detail.multiRole ? <Tag color="red">是</Tag> : <Tag>否</Tag>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="最近登录">{fmtTime(detail.latestLoginAt)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Table<Identity>
|
||||||
|
rowKey={(r) => `${r.actorType}-${r.actorId}`}
|
||||||
|
size="small"
|
||||||
|
columns={identityColumns}
|
||||||
|
dataSource={detail.identities}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'])
|
@IsIn(['ACTIVE', 'DISABLED'])
|
||||||
status?: string;
|
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 { RedeemModule } from '../redeem/redeem.module';
|
||||||
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
|
import { AdminRedeemDebugController } from './admin-redeem-debug.controller';
|
||||||
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
||||||
|
import { AdminWechatBindingsController } from './admin-wechat-bindings.controller';
|
||||||
|
import { AdminWechatBindingsService } from './admin-wechat-bindings.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||||
@@ -72,6 +74,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
|||||||
AdminProductDetailTemplatesController,
|
AdminProductDetailTemplatesController,
|
||||||
AdminRedeemDebugController,
|
AdminRedeemDebugController,
|
||||||
AdminPromoCodesController,
|
AdminPromoCodesController,
|
||||||
|
AdminWechatBindingsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AdminDashboardService,
|
AdminDashboardService,
|
||||||
@@ -94,6 +97,7 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
|||||||
AdminProductDetailTemplatesService,
|
AdminProductDetailTemplatesService,
|
||||||
AdminRedeemDebugService,
|
AdminRedeemDebugService,
|
||||||
AdminPromoCodesService,
|
AdminPromoCodesService,
|
||||||
|
AdminWechatBindingsService,
|
||||||
SuperAdminGuard,
|
SuperAdminGuard,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user