= [
{ 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 && (
+
+ )}
@@ -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 (
+
+
权限分配
+
+ 按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。
+
+
+
+
+
+
+ {role === 'SUPER_ADMIN' ? (
+
+ ) : (
+ <>
+
+
+
+
+ >
+ )}
+
+ ),
+ },
+ {
+ key: 'user',
+ label: '按用户分配',
+ children: (
+
+
+
+
+ {!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 时按单账号分组。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 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,
],
})