diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 3bc334e..b99046d 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -29,6 +29,8 @@ 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'; +import HqPermissionsPage from './pages/HqPermissionsPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -48,6 +50,7 @@ export default function App() { > } /> } /> + } /> } /> } /> } /> @@ -73,6 +76,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..7216fbf 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -14,6 +14,7 @@ import { LogoutOutlined, CloudUploadOutlined, FileTextOutlined, + LockOutlined, } from '@ant-design/icons'; import { clearAuth, request, type HqProfile } from '../lib/api'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; @@ -23,6 +24,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: , @@ -33,7 +35,6 @@ const MENU_ITEMS: MenuProps['items'] = [ ], }, { key: '/orders', icon: , label: '订单' }, - { key: '/resources', icon: , label: 'OSS 资源库' }, { key: 'stores-group', icon: , @@ -53,6 +54,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/partners', label: '开城合伙人' }, { key: '/cities', label: '开城城市' }, { key: '/partner-accounts', label: '开城合伙人账户' }, + { key: '/partner-bills', label: '合伙人结算' }, ], }, { @@ -66,8 +68,17 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/redeem/debug', label: '核销调试' }, ], }, - { key: '/partner-bills', icon: , label: '合伙人结算' }, + { + key: 'deliveries-group', + icon: , + label: '配送单', + children: [ + { key: '/deliveries', label: '配送单列表' }, + { key: '/deliveries/xiaofeixia', label: '小飞侠联调' }, + ], + }, { key: '/tickets', icon: , label: '工单中心' }, + { key: '/resources', icon: , label: 'OSS 资源库' }, { key: 'logs-group', icon: , @@ -80,15 +91,7 @@ const MENU_ITEMS: MenuProps['items'] = [ { key: '/logs/third-party', label: '第三方日志' }, ], }, - { - key: 'deliveries-group', - icon: , - label: '配送单', - children: [ - { key: '/deliveries', label: '配送单列表' }, - { key: '/deliveries/xiaofeixia', label: '小飞侠联调' }, - ], - }, + { key: '/hq-permissions', icon: , label: '权限分配' }, { key: '/hq-accounts', icon: , label: 'HQ账户' }, ]; diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index 62bbbe1..9eebfc8 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -6,7 +6,8 @@ export const HQ_OPERATION_ACTION_OPTIONS = [ { value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' }, { value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' }, { value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' }, - { value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员/权限' }, + { value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' }, + { value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' }, { value: 'USER_DELETE', label: '删除用户' }, { value: 'USER_BATCH_DELETE', label: '批量删除用户' }, { value: 'ORDER_SHIP', label: '订单发货' }, diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index 0536a38..9a4aee7 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -8,7 +8,6 @@ export type StoreCreateForm = { districtCode?: string; name: string; phone: string; - smsCode: string; address: string; intro?: string; coverUrl?: string; @@ -23,7 +22,7 @@ const PHONE_RE = /^1\d{10}$/; const BANK_RE = /^\d{16,19}$/; export function validateStoreCreateStep1( - form: Pick, + form: Pick, ): string | null { if (!form.partnerId) return '请选择开城合伙人'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; @@ -31,7 +30,6 @@ export function validateStoreCreateStep1( if (!form.name?.trim()) return '请填写门店名称'; if (!form.phone?.trim()) return '请填写门店手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; - if (!form.smsCode?.trim()) return '请填写短信验证码'; if (!form.address?.trim()) return '请填写详细地址'; if (form.intro?.trim()) { const len = form.intro.trim().length; diff --git a/apps/admin-web/src/pages/HqAccountsPage.tsx b/apps/admin-web/src/pages/HqAccountsPage.tsx index c56cd20..3e53760 100644 --- a/apps/admin-web/src/pages/HqAccountsPage.tsx +++ b/apps/admin-web/src/pages/HqAccountsPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { - Button, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, + Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type HqProfile } from '../lib/api'; @@ -8,7 +8,15 @@ import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; type Row = { - id: string; phone: string; name: string; adminRole: string; status: string; lastLoginAt: string | null; createdAt: string; + id: string; + phone: string; + loginName: string | null; + hasPassword: boolean; + name: string; + adminRole: string; + status: string; + lastLoginAt: string | null; + createdAt: string; }; const ROLE_LABELS: Record = { @@ -38,6 +46,7 @@ export default function HqAccountsPage() { const [detail, setDetail] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone'; const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; useEffect(() => { @@ -46,7 +55,18 @@ export default function HqAccountsPage() { const columns: ColumnsType = [ { title: '姓名', dataIndex: 'name' }, + { title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 130 }, + { + title: '登录方式', + width: 110, + render: (_, r) => ( + + {r.hasPassword ? 密码 : null} + 短信 + + ), + }, { title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => {ACCOUNT_STATUS_LABELS[s] || s} }, { title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime }, @@ -55,7 +75,12 @@ export default function HqAccountsPage() { render: (_, row) => ( ), @@ -66,7 +91,18 @@ export default function HqAccountsPage() {
HQ 账户 - {isSuperAdmin && } + {isSuperAdmin && ( + + )}
{ setFilters(v); setPage(1); }}> @@ -82,7 +118,9 @@ export default function HqAccountsPage() {
diff --git a/apps/admin-web/src/pages/HqPermissionsPage.tsx b/apps/admin-web/src/pages/HqPermissionsPage.tsx new file mode 100644 index 0000000..11ae3ed --- /dev/null +++ b/apps/admin-web/src/pages/HqPermissionsPage.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Button, + Card, + Checkbox, + Col, + Form, + Row, + Select, + Space, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import { + HQ_ADMIN_ROLES, + HQ_PERMISSION_CATALOG, + type HqPermissionKey, +} from '@dukang/shared-types'; +import { request, type HqProfile } from '../lib/api'; + +type RolePermRes = { role: string; permissionKeys: string[] }; +type AccountOption = { id: string; name: string; phone: string; loginName: string | null; adminRole: string }; +type AccountPermRes = { + account: AccountOption; + permissionKeys: string[]; + rolePermissionKeys: string[]; + userPermissionKeys: string[]; + effectivePermissionKeys: string[]; +}; + +const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label])); + +function PermissionChecklist({ + value, + onChange, + disabled, +}: { + value: string[]; + onChange: (keys: string[]) => void; + disabled?: boolean; +}) { + return ( + onChange(checked as string[])} + > + + {HQ_PERMISSION_CATALOG.map((item) => ( + + {item.label} + + ))} + + + ); +} + +export default function HqPermissionsPage() { + const [profile, setProfile] = useState(null); + const [role, setRole] = useState('OPS'); + const [roleKeys, setRoleKeys] = useState([]); + const [roleLoading, setRoleLoading] = useState(false); + const [roleSaving, setRoleSaving] = useState(false); + + const [accounts, setAccounts] = useState([]); + const [accountId, setAccountId] = useState(); + const [accountKeys, setAccountKeys] = useState([]); + const [roleInheritedKeys, setRoleInheritedKeys] = useState([]); + const [accountLoading, setAccountLoading] = useState(false); + const [accountSaving, setAccountSaving] = useState(false); + + const previewEffectiveKeys = useMemo( + () => [...new Set([...roleInheritedKeys, ...accountKeys])], + [roleInheritedKeys, accountKeys], + ); + + const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN'; + + useEffect(() => { + request('/admin/auth/me').then(setProfile).catch(() => {}); + }, []); + + useEffect(() => { + if (!isSuperAdmin) return; + request<{ items: AccountOption[] }>('/admin/hq-accounts?page=1&pageSize=100') + .then((res) => setAccounts(res.items)) + .catch(() => {}); + }, [isSuperAdmin]); + + useEffect(() => { + if (!isSuperAdmin || !role) return; + setRoleLoading(true); + request(`/admin/hq-permissions/roles/${role}`) + .then((res) => setRoleKeys(res.permissionKeys)) + .finally(() => setRoleLoading(false)); + }, [isSuperAdmin, role]); + + useEffect(() => { + if (!isSuperAdmin || !accountId) return; + setAccountLoading(true); + request(`/admin/hq-permissions/accounts/${accountId}`) + .then((res) => { + setAccountKeys(res.userPermissionKeys); + setRoleInheritedKeys(res.rolePermissionKeys); + }) + .finally(() => setAccountLoading(false)); + }, [isSuperAdmin, accountId]); + + async function saveRolePermissions() { + setRoleSaving(true); + try { + const res = await request(`/admin/hq-permissions/roles/${role}`, { + method: 'PUT', + body: JSON.stringify({ permissionKeys: roleKeys }), + }); + setRoleKeys(res.permissionKeys); + message.success('角色权限已保存'); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setRoleSaving(false); + } + } + + async function saveAccountPermissions() { + if (!accountId) return; + setAccountSaving(true); + try { + const res = await request(`/admin/hq-permissions/accounts/${accountId}`, { + method: 'PUT', + body: JSON.stringify({ permissionKeys: accountKeys }), + }); + setAccountKeys(res.userPermissionKeys); + setRoleInheritedKeys(res.rolePermissionKeys); + message.success('用户权限已保存'); + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败'); + } finally { + setAccountSaving(false); + } + } + + if (!isSuperAdmin) { + return ( +
+ 权限分配 + +
+ ); + } + + return ( +
+ 权限分配 + + 按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。 + + + +
+ + a.adminRole !== 'SUPER_ADMIN') + .map((a) => ({ + value: a.id, + label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`, + }))} + /> + +
+ {!accountId ? ( + + ) : ( + <> + + 角色继承: + {roleInheritedKeys.map((key) => { + const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); + return ( + + {item?.label || key} + + ); + })} + + + 下方勾选为用户专属追加权限(保存后与角色权限合并生效)。 + + + + 合并生效: + {previewEffectiveKeys.map((key) => { + const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); + return {item?.label || key}; + })} + +
+ +
+ + )} + + ), + }, + ]} + /> +
+ ); +} diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index caffa5e..7e43603 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -83,7 +83,6 @@ export default function StoresPage() { const [createOpen, setCreateOpen] = useState(false); const [createStep, setCreateStep] = useState(0); const [createError, setCreateError] = useState(''); - const [smsCooldown, setSmsCooldown] = useState(0); const [partners, setPartners] = useState([]); const [cities, setCities] = useState([]); const [optionsLoading, setOptionsLoading] = useState(false); @@ -141,38 +140,9 @@ export default function StoresPage() { setCreateOpen(false); setCreateStep(0); setCreateError(''); - setSmsCooldown(0); createForm.resetFields(); } - async function sendCreateSms() { - const phone = String(createForm.getFieldValue('phone') ?? '').trim(); - if (!/^1\d{10}$/.test(phone)) { - message.error('请先填写正确的11位门店手机号'); - return; - } - if (smsCooldown > 0) return; - try { - await request('/admin/stores/phone/sms/send', { - method: 'POST', - body: JSON.stringify({ phone }), - }); - message.success('验证码已发送'); - setSmsCooldown(60); - const timer = setInterval(() => { - setSmsCooldown((s) => { - if (s <= 1) { - clearInterval(timer); - return 0; - } - return s - 1; - }); - }, 1000); - } catch (e) { - message.error(e instanceof Error ? e.message : '发送失败'); - } - } - function openCreateModal() { void loadOptions(); createForm.setFieldsValue({ @@ -192,7 +162,7 @@ export default function StoresPage() { return; } try { - await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'smsCode', 'address']); + await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']); } catch { return; } @@ -220,7 +190,6 @@ export default function StoresPage() { city: values.city, name: values.name.trim(), phone: values.phone.trim(), - smsCode: values.smsCode.trim(), district: values.district.trim(), address: values.address.trim(), intro: values.intro?.trim() || undefined, @@ -239,7 +208,7 @@ export default function StoresPage() { if (e && typeof e === 'object' && 'errorFields' in e) { const fields = e as { errorFields?: Array<{ name: string[] }> }; const first = fields.errorFields?.[0]?.name?.[0]; - if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone' || first === 'smsCode') { + if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') { setCreateStep(0); } return; @@ -409,16 +378,6 @@ export default function StoresPage() { - - - - - - - - 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/packages/shared-types/src/hq-permissions.ts b/packages/shared-types/src/hq-permissions.ts new file mode 100644 index 0000000..8565a4b --- /dev/null +++ b/packages/shared-types/src/hq-permissions.ts @@ -0,0 +1,45 @@ +export const HQ_PERMISSION_CATALOG = [ + { key: 'dashboard', label: '概览' }, + { key: 'users', label: '用户管理' }, + { key: 'wechat_bindings', label: '微信绑定' }, + { key: 'products', label: '商品管理' }, + { key: 'orders', label: '订单管理' }, + { key: 'stores', label: '门店管理' }, + { key: 'partners', label: '开城管理' }, + { key: 'benefit', label: '好客权益' }, + { key: 'deliveries', label: '配送单' }, + { key: 'tickets', label: '工单中心' }, + { key: 'resources', label: 'OSS 资源库' }, + { key: 'logs', label: '日志' }, + { key: 'hq_permissions', label: '权限分配' }, + { key: 'hq_accounts', label: 'HQ 账户' }, +] as const; + +export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key']; + +export const HQ_ADMIN_ROLES = [ + { value: 'SUPER_ADMIN', label: '超级管理员' }, + { value: 'OPS', label: '运营' }, + { value: 'FINANCE', label: '财务' }, + { value: 'CUSTOMER_SERVICE', label: '客服' }, +] as const; + +export const HQ_ROLE_DEFAULT_PERMISSIONS: Record = { + SUPER_ADMIN: HQ_PERMISSION_CATALOG.map((p) => p.key), + OPS: [ + 'dashboard', + 'users', + 'wechat_bindings', + 'products', + 'orders', + 'stores', + 'partners', + 'benefit', + 'deliveries', + 'tickets', + 'resources', + 'logs', + ], + FINANCE: ['dashboard', 'orders', 'stores', 'partners', 'benefit', 'logs'], + CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'logs'], +}; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 8fddc1c..c04afc5 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -13,3 +13,4 @@ export * from './user-log'; export * from './store-log'; export * from './partner-log'; export * from './promo'; +export * from './hq-permissions'; diff --git a/scripts/test-shop-auth.mjs b/scripts/test-shop-auth.mjs index ce0c69a..f5e02d6 100644 --- a/scripts/test-shop-auth.mjs +++ b/scripts/test-shop-auth.mjs @@ -57,7 +57,7 @@ async function main() { }); if (!refreshed.accessToken) throw new Error('refresh failed'); - console.log('4. STORE_ACCOUNT_OPEN occupied phone'); + console.log('4. Admin create store rejects occupied phone'); await req('HQ_WEB', '/admin/auth/sms/send', { method: 'POST', body: JSON.stringify({ phone: '13600000001', scene: 'HQ_LOGIN' }), @@ -66,10 +66,17 @@ async function main() { method: 'POST', body: JSON.stringify({ phone: '13600000001', code: MOCK_SMS_CODE }), }); - const occupied = await expectFail('HQ_WEB', '/admin/stores/phone/sms/send', { + const occupied = await expectFail('HQ_WEB', '/admin/stores', { method: 'POST', token: admin.accessToken, - body: JSON.stringify({ phone: '13910000001' }), + body: JSON.stringify({ + partnerId: '1', + cityId: '1', + name: '重复手机号测试店', + phone: '13910000001', + district: '测试区', + address: '测试地址1号', + }), }); if (!occupied.includes('已绑定')) throw new Error(`unexpected: ${occupied}`); diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 18e6289..2f456b7 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -506,9 +506,31 @@ model HqAccount { createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) + permissions HqAccountPermission[] + @@map("hq_account") } +model HqRolePermission { + adminRole HqAdminRole @map("admin_role") + permissionKey String @map("permission_key") @db.VarChar(64) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + @@id([adminRole, permissionKey]) + @@map("hq_role_permission") +} + +model HqAccountPermission { + hqAccountId BigInt @map("hq_account_id") @db.UnsignedBigInt + permissionKey String @map("permission_key") @db.VarChar(64) + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + + hqAccount HqAccount @relation(fields: [hqAccountId], references: [id], onDelete: Cascade) + + @@id([hqAccountId, permissionKey]) + @@map("hq_account_permission") +} + // ─── USER ───────────────────────────────────────────── model User { diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 40669e0..92b71f1 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -8,6 +8,7 @@ export const HqOperationAction = { PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE', HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE', HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE', + HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE', USER_DELETE: 'USER_DELETE', USER_BATCH_DELETE: 'USER_BATCH_DELETE', ORDER_SHIP: 'ORDER_SHIP', @@ -50,7 +51,8 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户', [HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户', [HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员', - [HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员/权限', + [HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员', + [HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限', [HqOperationAction.USER_DELETE]: '删除用户', [HqOperationAction.USER_BATCH_DELETE]: '批量删除用户', [HqOperationAction.ORDER_SHIP]: '订单发货', diff --git a/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts b/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts index a289c1e..c582a7a 100644 --- a/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts +++ b/server/dukang-api/src/modules/ops/admin-hq-accounts.service.ts @@ -4,6 +4,31 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AdminHqAccountsQueryDto } from './dto/admin-query.dto'; import type { CreateHqAccountDto, UpdateHqAccountDto } from './dto/admin-mutate.dto'; +import { hashPassword } from '../../common/crypto/password.util'; + +function mapHqAccountRow(account: { + id: bigint; + phone: string; + loginName: string | null; + passwordHash: string | null; + name: string; + adminRole: string; + status: string; + lastLoginAt: Date | null; + createdAt: Date; +}) { + return { + id: account.id, + phone: account.phone, + loginName: account.loginName, + hasPassword: !!account.passwordHash, + name: account.name, + adminRole: account.adminRole, + status: account.status, + lastLoginAt: account.lastLoginAt, + createdAt: account.createdAt, + }; +} @Injectable() export class AdminHqAccountsService { @@ -23,42 +48,130 @@ export class AdminHqAccountsService { orderBy: { createdAt: 'desc' }, skip: (page - 1) * pageSize, take: pageSize, + select: { + id: true, + phone: true, + loginName: true, + passwordHash: true, + name: true, + adminRole: true, + status: true, + lastLoginAt: true, + createdAt: true, + }, }), this.prisma.hqAccount.count({ where }), ]); - return serializeBigInt({ items, total, page, pageSize }); + return serializeBigInt({ + items: items.map(mapHqAccountRow), + total, + page, + pageSize, + }); } async detail(id: bigint) { - const account = await this.prisma.hqAccount.findUnique({ where: { id } }); + const account = await this.prisma.hqAccount.findUnique({ + where: { id }, + select: { + id: true, + phone: true, + loginName: true, + passwordHash: true, + name: true, + adminRole: true, + status: true, + lastLoginAt: true, + createdAt: true, + }, + }); if (!account) throw new NotFoundException('HQ 账号不存在'); - return serializeBigInt(account); + return serializeBigInt(mapHqAccountRow(account)); } async create(dto: CreateHqAccountDto) { - const exists = await this.prisma.hqAccount.findUnique({ where: { phone: dto.phone } }); - if (exists) throw new BadRequestException('手机号已存在'); + const adminRole = (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE'; + + if (dto.credentialType === 'phone') { + if (!dto.phone?.trim()) throw new BadRequestException('请填写手机号'); + const phone = dto.phone.trim(); + const exists = await this.prisma.hqAccount.findUnique({ where: { phone } }); + if (exists) throw new BadRequestException('手机号已存在'); + const account = await this.prisma.hqAccount.create({ + data: { phone, name: dto.name, adminRole }, + }); + return serializeBigInt(mapHqAccountRow({ ...account, passwordHash: null })); + } + + if (!dto.loginName?.trim() || !dto.password) { + throw new BadRequestException('账号密码模式需填写用户名和密码'); + } + const loginName = dto.loginName.trim(); + const loginTaken = await this.prisma.hqAccount.findUnique({ where: { loginName } }); + if (loginTaken) throw new BadRequestException('用户名已存在'); + + const phone = dto.phone?.trim() || (await this.generatePlaceholderPhone()); + const phoneTaken = await this.prisma.hqAccount.findUnique({ where: { phone } }); + if (phoneTaken) throw new BadRequestException('手机号已存在'); + const account = await this.prisma.hqAccount.create({ data: { - phone: dto.phone, + phone, + loginName, + passwordHash: hashPassword(dto.password), name: dto.name, - adminRole: (dto.adminRole ?? 'OPS') as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE', + adminRole, }, }); - return serializeBigInt(account); + return serializeBigInt(mapHqAccountRow(account)); } async update(id: bigint, dto: UpdateHqAccountDto) { + const current = await this.prisma.hqAccount.findUnique({ where: { id } }); + if (!current) throw new NotFoundException('HQ 账号不存在'); + + if (dto.loginName !== undefined) { + const loginName = dto.loginName.trim(); + if (!loginName) throw new BadRequestException('用户名不能为空'); + const conflict = await this.prisma.hqAccount.findFirst({ + where: { loginName, id: { not: id } }, + }); + if (conflict) throw new BadRequestException('用户名已存在'); + } + const account = await this.prisma.hqAccount.update({ where: { id }, data: { ...(dto.name !== undefined ? { name: dto.name } : {}), + ...(dto.loginName !== undefined ? { loginName: dto.loginName.trim() } : {}), + ...(dto.password ? { passwordHash: hashPassword(dto.password) } : {}), ...(dto.adminRole !== undefined ? { adminRole: dto.adminRole as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' } : {}), ...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}), }, + select: { + id: true, + phone: true, + loginName: true, + passwordHash: true, + name: true, + adminRole: true, + status: true, + lastLoginAt: true, + createdAt: true, + }, }); - return serializeBigInt(account); + return serializeBigInt(mapHqAccountRow(account)); + } + + private async generatePlaceholderPhone(): Promise { + for (let i = 0; i < 8; i += 1) { + const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`.slice(-8); + const phone = `199${suffix}`; + const exists = await this.prisma.hqAccount.findUnique({ where: { phone } }); + if (!exists) return phone; + } + throw new BadRequestException('无法生成占位手机号,请手动填写'); } } diff --git a/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts b/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts new file mode 100644 index 0000000..345bb38 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-hq-permissions.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Param, Put, UseGuards } from '@nestjs/common'; +import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; +import { SuperAdminGuard } from '../../common/guards/super-admin.guard'; +import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; +import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; +import { AdminHqPermissionsService } from './admin-hq-permissions.service'; +import { SaveHqAccountPermissionsDto, SaveHqRolePermissionsDto } from './dto/admin-mutate.dto'; + +@Controller('admin/hq-permissions') +@UseGuards(HqAuthGuard, SuperAdminGuard) +export class AdminHqPermissionsController { + constructor(private readonly service: AdminHqPermissionsService) {} + + @Get('catalog') + catalog() { + return this.service.catalog(); + } + + @Get('roles/:role') + getRolePermissions(@Param('role') role: string) { + return this.service.getRolePermissions(role); + } + + @Put('roles/:role') + @HqOperation({ + action: HqOperationAction.HQ_PERMISSION_UPDATE, + refType: 'HQ_ROLE', + refIdParam: 'role', + includeBody: true, + }) + saveRolePermissions(@Param('role') role: string, @Body() dto: SaveHqRolePermissionsDto) { + return this.service.saveRolePermissions(role, dto.permissionKeys); + } + + @Get('accounts/:id') + getAccountPermissions(@Param('id') id: string) { + return this.service.getAccountPermissions(BigInt(id)); + } + + @Put('accounts/:id') + @HqOperation({ + action: HqOperationAction.HQ_PERMISSION_UPDATE, + refType: 'HQ_ACCOUNT', + refIdParam: 'id', + includeBody: true, + }) + saveAccountPermissions(@Param('id') id: string, @Body() dto: SaveHqAccountPermissionsDto) { + return this.service.saveAccountPermissions(BigInt(id), dto.permissionKeys); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts b/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts new file mode 100644 index 0000000..452162c --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-hq-permissions.service.ts @@ -0,0 +1,120 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { + HQ_PERMISSION_CATALOG, + HQ_ROLE_DEFAULT_PERMISSIONS, + type HqPermissionKey, +} from '@dukang/shared-types'; +import { PrismaService } from '../../common/prisma/prisma.module'; +import { serializeBigInt } from '../../common/decorators/current-user.decorator'; + +const VALID_PERMISSION_KEYS = new Set(HQ_PERMISSION_CATALOG.map((p) => p.key)); + +function assertPermissionKeys(keys: string[]) { + const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key)); + if (invalid.length) { + throw new BadRequestException(`无效权限项: ${invalid.join(', ')}`); + } +} + +@Injectable() +export class AdminHqPermissionsService { + constructor(private readonly prisma: PrismaService) {} + + catalog() { + return { + permissions: HQ_PERMISSION_CATALOG, + roles: Object.entries(HQ_ROLE_DEFAULT_PERMISSIONS).map(([role, permissionKeys]) => ({ + role, + permissionKeys, + })), + }; + } + + async getRolePermissions(role: string) { + const rows = await this.prisma.hqRolePermission.findMany({ + where: { adminRole: role as 'SUPER_ADMIN' | 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE' }, + select: { permissionKey: true }, + }); + const permissionKeys = + rows.length > 0 + ? rows.map((r) => r.permissionKey) + : [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])]; + return { role, permissionKeys }; + } + + async saveRolePermissions(role: string, permissionKeys: string[]) { + if (role === 'SUPER_ADMIN') { + throw new BadRequestException('超级管理员拥有全部权限,无需配置'); + } + assertPermissionKeys(permissionKeys); + const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE'; + await this.prisma.$transaction([ + this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }), + ...(permissionKeys.length + ? [ + this.prisma.hqRolePermission.createMany({ + data: permissionKeys.map((permissionKey) => ({ adminRole, permissionKey })), + }), + ] + : []), + ]); + return this.getRolePermissions(role); + } + + async getAccountPermissions(accountId: bigint) { + const account = await this.prisma.hqAccount.findUnique({ + where: { id: accountId }, + select: { id: true, name: true, phone: true, loginName: true, adminRole: true, status: true }, + }); + if (!account) throw new NotFoundException('HQ 账号不存在'); + if (account.adminRole === 'SUPER_ADMIN') { + return serializeBigInt({ + account, + permissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), + rolePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), + userPermissionKeys: [], + effectivePermissionKeys: HQ_PERMISSION_CATALOG.map((p) => p.key), + }); + } + + const [rolePerms, userPerms] = await Promise.all([ + this.getRolePermissions(account.adminRole), + this.prisma.hqAccountPermission.findMany({ + where: { hqAccountId: accountId }, + select: { permissionKey: true }, + }), + ]); + const userPermissionKeys = userPerms.map((p) => p.permissionKey); + const effectivePermissionKeys = [ + ...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]), + ] as HqPermissionKey[]; + + return serializeBigInt({ + account, + permissionKeys: userPermissionKeys, + rolePermissionKeys: rolePerms.permissionKeys, + userPermissionKeys, + effectivePermissionKeys, + }); + } + + async saveAccountPermissions(accountId: bigint, permissionKeys: string[]) { + const account = await this.prisma.hqAccount.findUnique({ where: { id: accountId } }); + if (!account) throw new NotFoundException('HQ 账号不存在'); + if (account.adminRole === 'SUPER_ADMIN') { + throw new BadRequestException('超级管理员拥有全部权限,无需配置'); + } + assertPermissionKeys(permissionKeys); + await this.prisma.$transaction([ + this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }), + ...(permissionKeys.length + ? [ + this.prisma.hqAccountPermission.createMany({ + data: permissionKeys.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })), + }), + ] + : []), + ]); + return this.getAccountPermissions(accountId); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-stores.controller.ts b/server/dukang-api/src/modules/ops/admin-stores.controller.ts index 4341045..9c0762d 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.controller.ts @@ -28,11 +28,6 @@ export class AdminStoresController { return this.service.listStores(query); } - @Post('phone/sms/send') - sendOpenSms(@Body() body: { phone: string }) { - return this.service.sendStoreOpenSms(body.phone); - } - @Get(':id') detail(@Param('id') id: string) { return this.service.detailStore(BigInt(id)); diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 2881d97..52575b5 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -1,10 +1,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { ClientApp, SmsScene } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { mapStoreCompat } from '../../common/compat/v31-compat'; -import { AuthService } from '../iam/auth.service'; import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto'; import type { CreateStoreAccountDto, @@ -18,16 +16,7 @@ import type { @Injectable() export class AdminStoresService { - constructor( - private readonly prisma: PrismaService, - private readonly authService: AuthService, - ) {} - - async sendStoreOpenSms(phone: string) { - return this.authService.sendSms(phone, SmsScene.STORE_ACCOUNT_OPEN, { - clientApp: ClientApp.HQ_WEB, - }); - } + constructor(private readonly prisma: PrismaService) {} async listStores(query: AdminStoresQueryDto) { const page = query.page ?? 1; @@ -168,7 +157,6 @@ export class AdminStoresService { if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) { throw new BadRequestException('请输入正确的手机号码'); } - await this.authService.verifySmsCode(normalizedPhone, dto.smsCode, SmsScene.STORE_ACCOUNT_OPEN); const existingAccount = await this.prisma.storeAccount.findUnique({ where: { phone: normalizedPhone }, }); 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-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 6c2b944..71f0694 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -1,5 +1,17 @@ import { Type } from 'class-transformer'; -import { IsArray, IsBoolean, IsIn, IsNotEmpty, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsIn, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsString, + Min, + MinLength, + ValidateIf, +} from 'class-validator'; export class UpdateStoreStatusDto { @IsString() @@ -24,10 +36,6 @@ export class CreateStoreDto { @IsNotEmpty() phone: string; - @IsString() - @IsNotEmpty() - smsCode: string; - @IsOptional() @IsString() categoryId?: string; @@ -417,9 +425,22 @@ export class UpdateDeliveryDto { } export class CreateHqAccountDto { + @IsIn(['phone', 'password']) + credentialType: 'phone' | 'password'; + + @IsOptional() + @IsString() + phone?: string; + + @ValidateIf((o: CreateHqAccountDto) => o.credentialType === 'password') @IsString() @IsNotEmpty() - phone: string; + loginName?: string; + + @ValidateIf((o: CreateHqAccountDto) => o.credentialType === 'password') + @IsString() + @MinLength(6) + password?: string; @IsString() @IsNotEmpty() @@ -431,6 +452,14 @@ export class CreateHqAccountDto { } export class UpdateHqAccountDto { + @IsOptional() + @IsString() + loginName?: string; + + @IsOptional() + @IsString() + @MinLength(6) + password?: string; @IsOptional() @IsString() name?: string; @@ -444,6 +473,18 @@ export class UpdateHqAccountDto { status?: string; } +export class SaveHqRolePermissionsDto { + @IsArray() + @IsString({ each: true }) + permissionKeys: string[]; +} + +export class SaveHqAccountPermissionsDto { + @IsArray() + @IsString({ each: true }) + permissionKeys: string[]; +} + export class CreateProductDto { @IsString() @IsNotEmpty() 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..84a2de0 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -44,6 +44,10 @@ 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'; +import { AdminHqPermissionsController } from './admin-hq-permissions.controller'; +import { AdminHqPermissionsService } from './admin-hq-permissions.service'; @Module({ imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule], @@ -72,6 +76,8 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service'; AdminProductDetailTemplatesController, AdminRedeemDebugController, AdminPromoCodesController, + AdminWechatBindingsController, + AdminHqPermissionsController, ], providers: [ AdminDashboardService, @@ -94,6 +100,8 @@ import { AdminRedeemDebugService } from './admin-redeem-debug.service'; AdminProductDetailTemplatesService, AdminRedeemDebugService, AdminPromoCodesService, + AdminWechatBindingsService, + AdminHqPermissionsService, SuperAdminGuard, ], })