diff --git a/apps/admin/src/pages/OperationLogs/index.tsx b/apps/admin/src/pages/OperationLogs/index.tsx index ab77076..60d83b2 100644 --- a/apps/admin/src/pages/OperationLogs/index.tsx +++ b/apps/admin/src/pages/OperationLogs/index.tsx @@ -1,8 +1,12 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { validateResponse } from '../../utils/validate'; +import { operationLogsSchema } from '../../api/schemas'; import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd'; import dayjs from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { getErrorMessage } from '../../utils/error'; const { RangePicker } = DatePicker; @@ -22,36 +26,38 @@ const statusMap: Record = { }; const OperationLogsPage: React.FC = () => { - const [data, setData] = useState([]); - const [total, setTotal] = useState(0); - const [loading, setLoading] = useState(false); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [filterModule, setFilterModule] = useState(); const [dateRange, setDateRange] = useState<[string, string] | null>(null); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const params: any = { page, pageSize }; - if (filterModule) params.module = filterModule; - if (dateRange) { - params.startDate = dateRange[0]; - params.endDate = dateRange[1]; + const { + data: fetchResult = { data: [], total: 0 }, + isLoading, + isFetching, + } = useQuery<{ data: any[]; total: number }>({ + queryKey: ['operation-logs', page, pageSize, filterModule, dateRange], + queryFn: async () => { + try { + const params: any = { page, pageSize }; + if (filterModule) params.module = filterModule; + if (dateRange) { + params.startDate = dateRange[0]; + params.endDate = dateRange[1]; + } + return validateResponse<{ data: any[]; total: number }>( + operationLogsSchema, + await api.get('/operation-logs', { params }), + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return { data: [], total: 0 }; } - const res: any = await api.get('/operation-logs', { params }); - setData(res.data); - setTotal(res.total); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [page, pageSize, filterModule, dateRange]); - - useEffect(() => { - fetchData(); - }, [fetchData]); + }, + }); + const data = fetchResult.data; + const total = fetchResult.total; + const loading = isLoading || isFetching; const columns = useMemo( () => [ diff --git a/apps/admin/src/pages/Permissions/index.tsx b/apps/admin/src/pages/Permissions/index.tsx index 53c8118..4b7b511 100644 --- a/apps/admin/src/pages/Permissions/index.tsx +++ b/apps/admin/src/pages/Permissions/index.tsx @@ -1,7 +1,11 @@ -import React, { useEffect, useState } from 'react'; +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { validateResponse } from '../../utils/validate'; +import { permissionTreeSchema } from '../../api/schemas'; import { Card, Tag, Input, Space, Spin, Empty } from 'antd'; import api from '../../api'; import { message } from '../../ui/app-message'; +import { getErrorMessage } from '../../utils/error'; interface PermissionItem { id: number; @@ -12,10 +16,25 @@ interface PermissionItem { } const PermissionsPage: React.FC = () => { - const [permTree, setPermTree] = useState<{ group: string; permissions: PermissionItem[] }[]>([]); - const [loading, setLoading] = useState(false); const [search, setSearch] = useState(''); + const { data: permTree = [], isLoading } = useQuery({ + queryKey: ['rbac', 'permissions', 'tree'], + queryFn: async () => { + try { + return validateResponse<{ group: string; permissions: PermissionItem[] }[]>( + permissionTreeSchema, + await api.get<{ group: string; permissions: PermissionItem[] }[]>( + '/rbac/permissions/tree', + ), + ); + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载权限失败')); + return []; + } + }, + }); + const groupNames: Record = { dashboard: '数据面板', student: '学生管理', @@ -44,18 +63,6 @@ const PermissionsPage: React.FC = () => { 'ai-chat': 'AI 助手', }; - useEffect(() => { - setLoading(true); - api - .get('/rbac/permissions/tree') - .then((res: any) => setPermTree(res)) - .catch((e: unknown) => { - const err = e as { message?: string }; - message.error(err?.message || '加载权限失败'); - }) - .finally(() => setLoading(false)); - }, []); - const filteredTree = search ? permTree .map((g) => ({ @@ -67,7 +74,7 @@ const PermissionsPage: React.FC = () => { .filter((g) => g.permissions.length > 0) : permTree; - if (loading) return ; + if (isLoading) return ; return (
diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index bbeae9e..e3da544 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -1,10 +1,15 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { Table, Modal, Form, Input, Space, Tag, Popconfirm, Card, Checkbox, Empty } from 'antd'; import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { permissionTreeSchema, rolesSchema } from '../../api/schemas'; +import { getErrorMessage } from '../../utils/error'; interface PermissionItem { id: number; @@ -23,36 +28,62 @@ interface RoleItem { } const RolesPage: React.FC = () => { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); - const [allPerms, setAllPerms] = useState<{ group: string; permissions: PermissionItem[] }[]>([]); const [form] = Form.useForm(); const [selectedPermIds, setSelectedPermIds] = useState([]); const [saving, setSaving] = useState(false); - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [roles, permTree] = await Promise.all([ - api.get('/rbac/roles') as Promise, - api.get('/rbac/permissions/tree') as Promise< - { group: string; permissions: PermissionItem[] }[] - >, - ]); - setData(roles); - setAllPerms(permTree); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, []); + const { + data: fetchResult = { roles: [], permTree: [] }, + isLoading, + isFetching, + } = useQuery<{ + roles: RoleItem[]; + permTree: { group: string; permissions: PermissionItem[] }[]; + }>({ + queryKey: ['rbac', 'roles', 'permission-tree'], + queryFn: async () => { + try { + const [roles, permTree] = await Promise.all([ + api.get('/rbac/roles') as Promise, + api.get('/rbac/permissions/tree') as Promise< + { group: string; permissions: PermissionItem[] }[] + >, + ]); + return { + roles: validateResponse(rolesSchema, roles), + permTree: validateResponse<{ group: string; permissions: PermissionItem[] }[]>( + permissionTreeSchema, + permTree, + ), + }; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return { roles: [], permTree: [] }; + } + }, + }); + const data = fetchResult.roles; + const allPerms = fetchResult.permTree; + const loading = isLoading || isFetching; - useEffect(() => { - fetchData(); - }, [fetchData]); + const saveMutation = useApiMutation( + async (values: { name: string; description?: string; permissionIds: number[] }) => + editing + ? api.put(`/rbac/roles/${editing.id}`, values) + : api.post('/rbac/roles', values), + { invalidate: [['rbac', 'roles', 'permission-tree']] }, + ); + const disableMutation = useApiMutation( + async (id: number) => api.delete(`/rbac/roles/${id}`), + { invalidate: [['rbac', 'roles', 'permission-tree']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: RoleItem; field: string; value: unknown }) => + api.put(`/rbac/roles/${record.id}`, { [field]: value }), + { invalidate: [['rbac', 'roles', 'permission-tree']] }, + ); const handleAdd = () => { setEditing(null); @@ -72,25 +103,15 @@ const RolesPage: React.FC = () => { setSaving(true); const values = await form.validateFields(); try { - if (editing) { - await api.put(`/rbac/roles/${editing.id}`, { - name: values.name, - description: values.description, - permissionIds: selectedPermIds, - }); - message.success('角色更新成功'); - } else { - await api.post('/rbac/roles', { - name: values.name, - description: values.description, - permissionIds: selectedPermIds, - }); - message.success('角色创建成功'); - } + await saveMutation.mutateAsync({ + name: values.name, + description: values.description, + permissionIds: selectedPermIds, + }); + message.success(editing ? '角色更新成功' : '角色创建成功'); setModalOpen(false); - fetchData(); - } catch (e: any) { - message.error(e.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -98,11 +119,10 @@ const RolesPage: React.FC = () => { const handleDisable = async (id: number) => { try { - await api.delete(`/rbac/roles/${id}`); + await disableMutation.mutateAsync(id); message.success('角色已停用'); - fetchData(); - } catch (e: any) { - message.error(e.message || '停用失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; @@ -144,11 +164,14 @@ const RolesPage: React.FC = () => { ); const saveCell = useCallback( async (record: RoleItem, field: string, value: unknown) => { - await api.put(`/rbac/roles/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }, - [fetchData], + [saveCellMutation], ); const columns = useMemo( diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index edcf68d..e1ef1c2 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { Table, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm } from 'antd'; +import React, { useState, useMemo, useCallback } from 'react'; +import { App, Button, Table, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm } from 'antd'; import { PlusOutlined, EditOutlined, @@ -13,11 +13,25 @@ import PermissionButton from '../../components/PermissionButton'; import EditableCell from '../../components/EditableCell'; import { message } from '../../ui/app-message'; import { userProfileResponseToFormValues, type UserProfileResponse } from './user-profile-form'; +import { usePermission } from '../../hooks/usePermission'; +import { useQuery } from '@tanstack/react-query'; +import { useApiMutation } from '../../hooks/useApiMutation'; +import { validateResponse } from '../../utils/validate'; +import { rolesSchema, usersSchema } from '../../api/schemas'; +import { getErrorMessage } from '../../utils/error'; + +const USER_FIELDS = { + username: 'username', + name: 'name', + phone: 'phone', + email: 'email', + status: 'status', +} as const; const UsersPage: React.FC = () => { - const [data, setData] = useState([]); - const [roles, setRoles] = useState([]); - const [loading, setLoading] = useState(false); + const { modal } = App.useApp(); + const { hasPermission } = usePermission(); + const canPurgeUser = hasPermission('user:purge'); const [modalOpen, setModalOpen] = useState(false); const [pwdModalOpen, setPwdModalOpen] = useState(false); const [editing, setEditing] = useState(null); @@ -45,36 +59,73 @@ const UsersPage: React.FC = () => { setSaving(true); const values = await profileForm.validateFields(); try { - await api.put(`/rbac/users/${profileUser.id}/profile`, values); + await profileMutation.mutateAsync({ id: profileUser.id, values }); message.success('档案更新成功'); setProfileModalOpen(false); - fetchData(); - } catch (e: any) { - message.error(e.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; - const fetchData = useCallback(async () => { - setLoading(true); - try { - const [users, rolesRes] = await Promise.all([ - api.get(`/rbac/users?isArchived=${showArchived}`) as Promise, - api.get('/rbac/roles') as Promise, - ]); - setData(users); - setRoles(rolesRes); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '加载失败,请稍后重试'); - } - setLoading(false); - }, [showArchived]); + const { + data: fetchResult = { users: [], roles: [] }, + isLoading, + isFetching, + } = useQuery<{ users: any[]; roles: any[] }>({ + queryKey: ['rbac', 'users', showArchived], + queryFn: async () => { + try { + const [users, rolesRes] = await Promise.all([ + api.get(`/rbac/users?isArchived=${showArchived}`) as Promise, + api.get('/rbac/roles') as Promise, + ]); + return { + users: validateResponse(usersSchema, users), + roles: validateResponse(rolesSchema, rolesRes), + }; + } catch (e: unknown) { + message.error(getErrorMessage(e, '加载失败,请稍后重试')); + return { users: [], roles: [] }; + } + }, + }); + const data = fetchResult.users; + const roles = fetchResult.roles; + const loading = isLoading || isFetching; - useEffect(() => { - fetchData(); - }, [fetchData]); + const profileMutation = useApiMutation( + async ({ id, values }: { id: number; values: any }) => + api.put(`/rbac/users/${id}/profile`, values), + { invalidate: [['rbac', 'users']] }, + ); + const saveMutation = useApiMutation( + async (values: { username: string; password?: string; name: string; roleIds: number[] }) => + editing + ? api.put(`/rbac/users/${editing.id}`, values) + : api.post('/rbac/users', values), + { invalidate: [['rbac', 'users']] }, + ); + const archiveMutation = useApiMutation( + async ({ id, archive }: { id: number; archive: boolean }) => + api.put(`/rbac/users/${id}/${archive ? 'archive' : 'restore'}`), + { invalidate: [['rbac', 'users']] }, + ); + const purgeMutation = useApiMutation( + async (id: number) => api.delete(`/rbac/users/${id}/permanent`), + { invalidate: [['rbac', 'users']] }, + ); + const pwdMutation = useApiMutation( + async ({ id, password }: { id: number; password: string }) => + api.put(`/rbac/users/${id}/password`, { password }), + { invalidate: [['rbac', 'users']] }, + ); + const saveCellMutation = useApiMutation( + async ({ record, field, value }: { record: any; field: string; value: unknown }) => + api.put(`/rbac/users/${record.id}`, { [field]: value }), + { invalidate: [['rbac', 'users']] }, + ); const handleAdd = () => { setEditing(null); @@ -96,41 +147,48 @@ const UsersPage: React.FC = () => { setSaving(true); const values = await form.validateFields(); try { - if (editing) { - await api.put(`/rbac/users/${editing.id}`, { - username: values.username, - name: values.name, - roleIds: values.roleIds || [], - }); - message.success('更新成功'); - } else { - await api.post('/rbac/users', { - username: values.username, - password: values.password, - name: values.name, - roleIds: values.roleIds || [], - }); - message.success('创建成功'); - } + await saveMutation.mutateAsync({ + username: values.username, + password: values.password, + name: values.name, + roleIds: values.roleIds || [], + }); + message.success(editing ? '更新成功' : '创建成功'); setModalOpen(false); - fetchData(); - } catch (e: any) { - message.error(e.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } }; const handleArchive = async (id: number, archive: boolean) => { try { - await api.put(`/rbac/users/${id}/${archive ? 'archive' : 'restore'}`); + await archiveMutation.mutateAsync({ id, archive }); message.success(archive ? '已归档' : '已恢复'); - fetchData(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } }; + const handlePurge = (record: any) => { + modal.confirm({ + title: `永久删除账号「${record.name || record.username}」?`, + content: + '删除后不可恢复,关联学生、任教、排课或考勤操作时将无法删除;角色绑定、通知和 AI 会话将被清除,操作日志保留。确定继续?', + okText: '永久删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await purgeMutation.mutateAsync(record.id); + message.success('已永久删除(不可恢复)'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } + }, + }); + }; + const handleResetPwd = (record: any) => { setResetTarget(record); pwdForm.resetFields(); @@ -141,12 +199,11 @@ const UsersPage: React.FC = () => { setSaving(true); const values = await pwdForm.validateFields(); try { - await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password }); + await pwdMutation.mutateAsync({ id: resetTarget.id, password: values.password }); message.success('密码已重置'); setPwdModalOpen(false); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '操作失败'); + } catch { + // 错误提示由 useApiMutation 统一处理 } finally { setSaving(false); } @@ -154,11 +211,14 @@ const UsersPage: React.FC = () => { const saveCell = useCallback( async (record: any, field: string, value: unknown) => { - await api.put(`/rbac/users/${record.id}`, { [field]: value }); - message.success('已保存'); - await fetchData(); + try { + await saveCellMutation.mutateAsync({ record, field, value }); + message.success('已保存'); + } catch { + // 错误提示由 useApiMutation 统一处理 + } }, - [fetchData], + [saveCellMutation], ); const columns = useMemo( @@ -166,7 +226,7 @@ const UsersPage: React.FC = () => { { title: 'ID', dataIndex: 'id', width: 60 }, { title: '用户名', - dataIndex: 'username', + dataIndex: USER_FIELDS.username, width: 120, render: (v: string, r: any) => ( { required permission="user:edit" disabled={r.isArchived} - onSave={(next) => saveCell(r, 'username', next)} + onSave={(next) => saveCell(r, USER_FIELDS.username, next)} > {v} @@ -182,7 +242,7 @@ const UsersPage: React.FC = () => { }, { title: '姓名', - dataIndex: 'name', + dataIndex: USER_FIELDS.name, width: 120, render: (v: string, r: any) => ( { required permission="user:edit" disabled={r.isArchived} - onSave={(next) => saveCell(r, 'name', next)} + onSave={(next) => saveCell(r, USER_FIELDS.name, next)} > {v} @@ -267,11 +327,18 @@ const UsersPage: React.FC = () => { 重置密码 {record.isArchived ? ( - handleArchive(record.id, false)}> - - 恢复 - - + <> + handleArchive(record.id, false)}> + + 恢复 + + + {canPurgeUser ? ( + + ) : null} + ) : ( { ), }, ], - [roles, saveCell], + [roles, saveCell, canPurgeUser, handlePurge], ); return ( diff --git a/apps/server/src/authorization/authorization.service.ts b/apps/server/src/authorization/authorization.service.ts index b512bde..d6182a0 100644 --- a/apps/server/src/authorization/authorization.service.ts +++ b/apps/server/src/authorization/authorization.service.ts @@ -1,5 +1,4 @@ -import { Injectable } from '@nestjs/common'; -import { ForbiddenException } from '@nestjs/common'; +import { Injectable, ForbiddenException } from '@nestjs/common'; import { CaslAbilityFactory } from './casl-ability.factory'; import { AppAbility, AppSubject, AuthorizationRequest } from './interfaces'; import { CaslAction, permissionCodeSubject } from './casl.constants'; @@ -69,9 +68,7 @@ export class AuthorizationService { */ assertPermission(ability: AppAbility, permissionCode: string): void { if (!this.canPermission(ability, permissionCode)) { - throw new ForbiddenException( - `权限不足:缺少权限码 ${permissionCode}`, - ); + throw new ForbiddenException(`权限不足:缺少权限码 ${permissionCode}`); } } diff --git a/apps/server/src/authorization/casl.constants.ts b/apps/server/src/authorization/casl.constants.ts index e928bf5..e35718a 100644 --- a/apps/server/src/authorization/casl.constants.ts +++ b/apps/server/src/authorization/casl.constants.ts @@ -66,11 +66,6 @@ export function permissionCodeSubject(code: string): string { return `PermissionCode:${code}`; } -// --------------------------------------------------------------------------- -// Domain-level action mapping: permission code → CASL action -// Used ONLY for the domain layer — not for exact-code access checks. -// --------------------------------------------------------------------------- - function permissionToAction(permission: string): CaslAction | null { const actionSegment = permission.split(':')[1] ?? permission; diff --git a/apps/server/src/authorization/interfaces.ts b/apps/server/src/authorization/interfaces.ts index 15660f1..e978a5f 100644 --- a/apps/server/src/authorization/interfaces.ts +++ b/apps/server/src/authorization/interfaces.ts @@ -1,10 +1,6 @@ import { MongoAbility } from '@casl/ability'; import { CaslAction } from './casl.constants'; -// --------------------------------------------------------------------------- -// Subject type union — all entity classes we protect with CASL. -// --------------------------------------------------------------------------- - // CASL expects the subject to be either the class constructor or a string. // We use string subjects (SubjectName) for simplicity when no instance is // available, and concrete instance types for per-resource checks. @@ -12,10 +8,6 @@ export type AppSubject = string | Record; export type AppAbility = MongoAbility<[CaslAction, AppSubject]>; -// --------------------------------------------------------------------------- -// Authenticated user — what the JWT strategy places on `request.user`. -// --------------------------------------------------------------------------- - export interface AuthenticatedUser { id: number; username: string; @@ -31,17 +23,16 @@ export interface AuthenticatedUser { * Minimum authorization principal — the subset of AuthenticatedUser * needed by CaslAbilityFactory and AuthorizationService. */ -export type AuthPrincipal = { readonly permissions: readonly string[]; readonly isSuperAdmin: boolean }; +export type AuthPrincipal = { + readonly permissions: readonly string[]; + readonly isSuperAdmin: boolean; +}; /** Request-like carrier populated only by the trusted authentication layer. */ export interface AuthorizationRequest { user?: AuthPrincipal; } -// --------------------------------------------------------------------------- -// Policy handler types for @CheckPolicies() -// --------------------------------------------------------------------------- - /** * Interface for class-based policy handlers. * diff --git a/apps/server/src/rbac/rbac-presets.ts b/apps/server/src/rbac/rbac-presets.ts new file mode 100644 index 0000000..e8deccc --- /dev/null +++ b/apps/server/src/rbac/rbac-presets.ts @@ -0,0 +1,268 @@ +export const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [ + { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, + { code: 'notification:view', name: '查看通知', group: 'notification' }, + { code: 'student:view', name: '查看学生管理', group: 'student' }, + { code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' }, + { code: 'teacher-workspace:view', name: '查看教师工作台', group: 'teacher-workspace' }, + { code: 'teacher:view', name: '查看教师', group: 'teacher' }, + { code: 'teacher:edit', name: '编辑教师', group: 'teacher' }, + { code: 'student:create', name: '新增学生', group: 'student' }, + { code: 'student:edit', name: '编辑学生', group: 'student' }, + { code: 'student:delete', name: '归档学生', group: 'student' }, + { code: 'student:import', name: '导入学生', group: 'student' }, + { code: 'student:export', name: '导出学生', group: 'student' }, + { code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' }, + { code: 'room:view', name: '查看宿舍', group: 'room' }, + { code: 'room:inspect', name: '宿舍查寝', group: 'room' }, + { code: 'room:create', name: '新增宿舍', group: 'room' }, + { code: 'room:edit', name: '编辑宿舍', group: 'room' }, + { code: 'room:delete', name: '归档宿舍', group: 'room' }, + { code: 'occupancy:view', name: '查看入住', group: 'occupancy' }, + { code: 'occupancy:checkin', name: '办理入住', group: 'occupancy' }, + { code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' }, + { code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' }, + { code: 'occupancy:delete', name: '归档入住记录', group: 'occupancy' }, + { code: 'expense:view', name: '查看费用', group: 'expense' }, + { code: 'expense:create', name: '录入费用', group: 'expense' }, + { code: 'expense:edit', name: '编辑费用', group: 'expense' }, + { code: 'expense:delete', name: '归档费用', group: 'expense' }, + { code: 'bill:view', name: '查看账单', group: 'bill' }, + { code: 'bill:generate', name: '生成账单', group: 'bill' }, + { code: 'bill:confirm', name: '确认账单', group: 'bill' }, + { code: 'bill:delete', name: '归档账单', group: 'bill' }, + { code: 'bill:export-excel', name: '导出 Excel', group: 'bill' }, + { code: 'bill:export-pdf', name: '导出 PDF', group: 'bill' }, + { code: 'deposit:view', name: '查看押金', group: 'deposit' }, + { code: 'deposit:create', name: '新增押金', group: 'deposit' }, + { code: 'deposit:edit', name: '编辑押金', group: 'deposit' }, + { code: 'deposit:delete', name: '归档押金', group: 'deposit' }, + { code: 'deposit:refund', name: '直接退还押金', group: 'deposit' }, + { code: 'wallet:view', name: '查看学生余额', group: 'wallet' }, + { code: 'wallet:edit', name: '充值和调账', group: 'wallet' }, + { code: 'classroom:view', name: '查看教室', group: 'classroom' }, + { code: 'classroom:create', name: '新增教室', group: 'classroom' }, + { code: 'classroom:edit', name: '编辑教室', group: 'classroom' }, + { code: 'classroom:delete', name: '归档教室', group: 'classroom' }, + { code: 'organization:view', name: '查看机构', group: 'organization' }, + { code: 'organization:create', name: '新增机构', group: 'organization' }, + { code: 'organization:edit', name: '编辑机构', group: 'organization' }, + { code: 'organization:delete', name: '归档机构', group: 'organization' }, + { code: 'rental:view', name: '查看租赁订单', group: 'rental' }, + { code: 'rental:create', name: '新增租赁订单', group: 'rental' }, + { code: 'rental:edit', name: '编辑租赁订单', group: 'rental' }, + { code: 'rental:delete', name: '归档租赁订单', group: 'rental' }, + { code: 'log:view', name: '查看操作日志', group: 'log' }, + { code: 'log:create', name: '写入操作日志', group: 'log' }, + { code: 'user:view', name: '查看用户', group: 'user' }, + { code: 'user:create', name: '创建用户', group: 'user' }, + { code: 'user:edit', name: '编辑用户', group: 'user' }, + { code: 'user:reset-password', name: '重置密码', group: 'user' }, + { code: 'role:view', name: '查看角色', group: 'role' }, + { code: 'role:create', name: '创建角色', group: 'role' }, + { code: 'role:edit', name: '编辑角色', group: 'role' }, + { code: 'role:delete', name: '停用角色', group: 'role' }, + { code: 'class:view', name: '查看班级', group: 'class' }, + { code: 'class:create', name: '创建班级', group: 'class' }, + { code: 'class:edit', name: '编辑班级', group: 'class' }, + { code: 'class:delete', name: '归档班级', group: 'class' }, + { code: 'schedule:view', name: '查看排课', group: 'schedule' }, + { code: 'schedule:create', name: '创建排课', group: 'schedule' }, + { code: 'schedule:edit', name: '编辑排课', group: 'schedule' }, + { code: 'schedule:delete', name: '停用排课', group: 'schedule' }, + { code: 'attendance:view', name: '查看考勤', group: 'attendance' }, + { code: 'attendance:create', name: '新增考勤', group: 'attendance' }, + { code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' }, + { code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' }, + { code: 'attendance:export', name: '导出考勤', group: 'attendance' }, + { code: 'sync:trigger', name: '触发数据同步', group: 'sync' }, + { code: 'sync:read', name: '查看同步状态', group: 'sync' }, + { code: 'integration:trigger', name: '触发集成', group: 'integration' }, + { code: 'integration:read', name: '查看集成状态', group: 'integration' }, + // 永久删除(两步删除:先归档/取消,再在已归档视图物理删除) + { code: 'student:purge', name: '永久删除学生', group: 'purge' }, + { code: 'room:purge', name: '永久删除宿舍', group: 'purge' }, + { code: 'classroom:purge', name: '永久删除教室', group: 'purge' }, + { code: 'occupancy:purge', name: '永久删除入住记录', group: 'purge' }, + { code: 'expense:purge', name: '永久删除费用', group: 'purge' }, + { code: 'exam:purge', name: '永久删除考试', group: 'purge' }, + { code: 'bill:purge', name: '永久删除账单', group: 'purge' }, + { code: 'deposit:purge', name: '永久删除押金', group: 'purge' }, + { code: 'organization:purge', name: '永久删除机构', group: 'purge' }, + { code: 'rental:purge', name: '永久删除租赁订单', group: 'purge' }, + { code: 'class:purge', name: '永久删除班级', group: 'purge' }, + { code: 'user:purge', name: '永久删除用户', group: 'purge' }, + { code: 'archive:purge', name: '永久删除档案记录', group: 'purge' }, + { code: 'ai:config:read', name: '查看 AI 配置', group: 'ai' }, + { code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' }, + { code: 'ai:config:test', name: '测试 AI 连接', group: 'ai' }, + { code: 'ai:chat:use', name: '使用 AI 助手', group: 'ai-chat' }, +]; + +export const DEPRECATED_PERMISSION_CODES = [ + 'profile:view', + 'attendance:generate', + 'learning:create', + 'learning:edit', + 'learning:delete', + 'exam:create', + 'exam:edit', + 'exam:delete', + 'department:view', + 'department:edit', + 'department:delete', + // Legacy permission codes from older admin UI / seed data. + 'student:add', + 'student:update', + 'room:add', + 'room:update', + 'occupancy:add', + 'occupancy:update', + 'attendance:add', + 'attendance:update', + 'attendance:delete', + 'attendance:batch', + 'bill:export', + 'deposit:collect', + 'expense:add', + 'expense:update', + 'class:add', + 'class:update', + 'schedule:add', + 'schedule:update', + 'classroom:add', + 'classroom:update', + 'rental:add', + 'rental:update', + 'role:add', + 'role:update', + 'user:add', + 'user:update', + 'archive:view', + 'archive:import', + 'archive:export', + 'report:generate', +] as const; + +export const DEPRECATED_PERMISSION_CODE_SET = new Set(DEPRECATED_PERMISSION_CODES); + +export function getChinaDateParts(date = new Date()): { date: string; weekDay: number } { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + weekday: 'short', + }) + .formatToParts(date) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, part.value]), + ); + const weekDays: Record = { + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + Sun: 7, + }; + return { + date: `${parts.year}-${parts.month}-${parts.day}`, + weekDay: weekDays[parts.weekday], + }; +} + +export const PRESET_ROLES: Array<{ + name: string; + code: string; + description: string; + isSystem: boolean; + permissionGroups: string[]; + extraPermissions?: string[]; + legacyNames?: string[]; + legacyCodes?: string[]; +}> = [ + { + name: '超级管理员', + code: 'super_admin', + description: '系统初始化、应急维护和全局权限处理', + isSystem: true, + permissionGroups: [], + legacyNames: ['超管', 'super_admin'], + }, + { + name: '任课老师', + code: 'teacher', + description: '查看自己的排课、今日课程和任教班级考勤', + isSystem: true, + permissionGroups: ['notification'], + extraPermissions: [ + 'teacher-workspace:view', + 'schedule:view', + 'attendance:view', + 'attendance:create', + 'attendance:self-edit', + ], + legacyNames: ['老师'], + }, + { + name: '教务管理员', + code: 'academic', + description: '管理学生、班级、教师、全局排课和历史考勤', + isSystem: true, + permissionGroups: [ + 'student', + 'exam', + 'class', + 'schedule', + 'attendance', + 'classroom', + 'dashboard', + 'notification', + ], + extraPermissions: [ + 'teacher-workspace:view', + 'teacher:view', + 'teacher:edit', + 'sync:read', + 'sync:trigger', + ], + legacyNames: ['教务'], + }, + { + name: '住宿运营管理员', + code: 'accommodation_operations', + description: '管理宿舍、入住、住宿费用、账单、押金和退宿结算', + isSystem: true, + permissionGroups: [ + 'room', + 'occupancy', + 'expense', + 'bill', + 'deposit', + 'wallet', + 'dashboard', + 'notification', + ], + extraPermissions: ['student:basic-view'], + legacyNames: ['宿管老师', '宿管', '财务'], + legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'], + }, + { + name: '教室运营管理员', + code: 'classroom_operations', + description: '管理教室、教室排期、外部机构和租赁订单', + isSystem: true, + permissionGroups: ['classroom', 'rental', 'organization', 'notification'], + legacyNames: ['机构负责人'], + legacyCodes: ['institution_head'], + }, + { + name: '系统管理员', + code: 'system_admin', + description: '管理账号、角色、日志、同步和系统配置', + isSystem: true, + permissionGroups: ['user', 'role', 'log', 'integration', 'sync', 'ai', 'notification'], + }, +]; diff --git a/apps/server/src/rbac/rbac-seed.service.ts b/apps/server/src/rbac/rbac-seed.service.ts new file mode 100644 index 0000000..402081e --- /dev/null +++ b/apps/server/src/rbac/rbac-seed.service.ts @@ -0,0 +1,298 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession } from '../entities'; +import { + PRESET_ROLES, + PRESET_PERMISSIONS, + DEPRECATED_PERMISSION_CODES, + DEPRECATED_PERMISSION_CODE_SET, + getChinaDateParts, +} from './rbac-presets'; + +@Injectable() +export class RbacService { + private readonly logger = new Logger(RbacService.name); + + constructor( + @InjectRepository(Permission) private permRepo: Repository, + @InjectRepository(Role) private roleRepo: Repository, + @InjectRepository(User) private userRepo: Repository, + @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(ClassStudent) private classStudentRepo: Repository, + @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + @InjectRepository(ClassSchedule) private classScheduleRepo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(AttendanceSession) + private attendanceSessionRepo: Repository, + ) {} + + async findAllRoles(): Promise { + return this.roleRepo.find({ + relations: ['permissions'], + order: { id: 'ASC' }, + }); + } + + async findRoleById(id: number): Promise { + return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] }); + } + + private async resolvePermissions(permissionIds: number[]): Promise { + const uniqueIds = [...new Set(permissionIds)]; + const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : []; + if (permissions.length !== uniqueIds.length) { + const foundIds = new Set(permissions.map((permission) => permission.id)); + const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); + throw new Error(`权限不存在: ${missingIds.join(',')}`); + } + return permissions; + } + + async getTeacherWorkspace(userId: number) { + // Find all classes where this user is a teacher + const teacherAssignments = await this.classTeacherRepo.find({ + where: { userId }, + relations: ['class'], + }); + + const classIds = [...new Set(teacherAssignments.map((t) => t.classId))]; + + if (classIds.length === 0) { + return { assignedClasses: [], todaySchedules: [], myStudents: [] }; + } + + const assignedClasses = teacherAssignments.map((t) => ({ + classId: t.classId, + className: t.class?.name || '', + classCode: t.class?.code || '', + roleType: t.roleType, + subject: t.subject, + })); + + // Get today's China business date and day of week (1=Monday, 7=Sunday) + const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts(); + + const todaySchedules = await this.classScheduleRepo + .createQueryBuilder('cs') + .where('cs.classId IN (:...classIds)', { classIds }) + .andWhere('cs.weekDay = :weekDay', { weekDay: adjustedWeekDay }) + .andWhere('cs.startDate <= :today', { today: todayStr }) + .andWhere('cs.endDate >= :today', { today: todayStr }) + .andWhere('cs.status = :status', { status: 'active' }) + .orderBy('cs.startTime', 'ASC') + .getMany(); + + const classStudents = await this.classStudentRepo.find({ + where: { classId: In(classIds), status: 'active' }, + relations: ['student', 'class'], + }); + + const myStudents = classStudents.map((cs) => ({ + studentId: cs.studentId, + studentName: cs.student?.name || '', + studentNo: cs.student?.studentNo || '', + className: cs.class?.name || '', + classId: cs.classId, + joinDate: cs.joinDate, + })); + + return { + assignedClasses, + todaySchedules: todaySchedules.map((s) => ({ + id: s.id, + classId: s.classId, + classroomId: s.classroomId, + teacherId: s.teacherId, + weekDay: s.weekDay, + startTime: s.startTime, + endTime: s.endTime, + subject: s.subject, + scheduleType: s.scheduleType, + })), + myStudents, + }; + } +} + +@Injectable() +export class RbacSeedService { + private readonly logger = new Logger(RbacSeedService.name); + + constructor( + @InjectRepository(Permission) private permRepo: Repository, + @InjectRepository(Role) private roleRepo: Repository, + @InjectRepository(User) private userRepo: Repository, + ) {} + + private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise { + for (const code of preset.legacyCodes ?? []) { + const role = await this.roleRepo.findOne({ where: { code } }); + if (role) return role; + } + for (const name of preset.legacyNames ?? []) { + const role = await this.roleRepo.findOne({ where: { name } }); + if (role) return role; + } + return null; + } + + async seedData(): Promise { + const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true }); + if (restoredLegacyUsers.affected) { + this.logger.log( + `已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`, + ); + } + + for (const p of PRESET_PERMISSIONS) { + const exists = await this.permRepo.findOne({ where: { code: p.code } }); + if (!exists) { + await this.permRepo.save(this.permRepo.create(p)); + } + } + const deprecatedUserDeletePermission = await this.permRepo.findOne({ + where: { code: 'user:delete' }, + }); + const allPerms = (await this.permRepo.find()).filter( + (permission) => + permission.code !== 'user:delete' && !DEPRECATED_PERMISSION_CODE_SET.has(permission.code), + ); + + for (const r of PRESET_ROLES) { + const exists = + (await this.roleRepo.findOne({ where: { code: r.code } })) || + (await this.roleRepo.findOne({ where: { name: r.name } })) || + (await this.findLegacyPresetRole(r)); + if (!exists) { + await this.roleRepo.save( + this.roleRepo.create({ + name: r.name, + code: r.code, + description: r.description, + isSystem: r.isSystem, + }), + ); + } + } + const allRoles = await this.roleRepo.find({ relations: ['permissions', 'users'] }); + + if (deprecatedUserDeletePermission) { + for (const role of allRoles) { + const permissions = role.permissions ?? []; + if (permissions.some((permission) => permission.id === deprecatedUserDeletePermission.id)) { + role.permissions = permissions.filter( + (permission) => permission.id !== deprecatedUserDeletePermission.id, + ); + await this.roleRepo.save(role); + } + } + await this.permRepo.remove(deprecatedUserDeletePermission); + } + + const deprecatedPermissions = await this.permRepo.find({ + where: { code: In([...DEPRECATED_PERMISSION_CODES]) }, + }); + if (deprecatedPermissions.length > 0) { + const deprecatedIds = new Set(deprecatedPermissions.map((permission) => permission.id)); + for (const role of allRoles) { + const permissions = role.permissions ?? []; + if (permissions.some((permission) => deprecatedIds.has(permission.id))) { + role.permissions = permissions.filter((permission) => !deprecatedIds.has(permission.id)); + await this.roleRepo.save(role); + } + } + await this.permRepo.remove(deprecatedPermissions); + this.logger.log(`已清理废弃权限点: ${deprecatedPermissions.map((p) => p.code).join(', ')}`); + } + + for (const preset of PRESET_ROLES) { + const matchesPreset = (role: Role) => + role.name === preset.name || + role.code === preset.code || + preset.legacyNames?.includes(role.name) || + preset.legacyCodes?.includes(role.code); + const candidates = allRoles.filter(matchesPreset); + const role = candidates.find((candidate) => candidate.code === preset.code) ?? candidates[0]; + if (!role) continue; + + const duplicateRoles = candidates.filter((candidate) => candidate.id !== role.id); + if (duplicateRoles.length > 0) { + for (const duplicate of duplicateRoles) { + for (const relatedUser of duplicate.users ?? []) { + const user = await this.userRepo.findOne({ + where: { id: relatedUser.id }, + relations: ['roles'], + }); + if (!user) continue; + const remainingRoles = (user.roles ?? []).filter( + (assignedRole) => assignedRole.id !== duplicate.id && assignedRole.id !== role.id, + ); + user.roles = [...remainingRoles, role]; + await this.userRepo.save(user); + } + await this.roleRepo.remove(duplicate); + } + } + + if ( + role.code !== preset.code || + role.name !== preset.name || + role.description !== preset.description + ) { + role.code = preset.code; + role.name = preset.name; + role.description = preset.description; + role.isSystem = preset.isSystem; + role.status = 1; + await this.roleRepo.save(role); + } + + let perms: Permission[]; + if (preset.permissionGroups.length === 0) { + // 超管:全部权限 + perms = allPerms; + } else { + // 按 group 匹配 + 额外权限(如老师的 student:view) + const byGroup = allPerms.filter((p) => preset.permissionGroups.includes(p.group)); + const byExtra = preset.extraPermissions + ? allPerms.filter((p) => preset.extraPermissions!.includes(p.code)) + : []; + perms = [...byGroup, ...byExtra].filter( + (p, i, arr) => arr.findIndex((x) => x.id === p.id) === i, + ); + } + + // 系统预置角色必须严格遵循职责矩阵;额外授权请创建自定义角色叠加。 + const currentIds = role.permissions.map((permission) => permission.id).sort((a, b) => a - b); + const targetIds = perms.map((permission) => permission.id).sort((a, b) => a - b); + if (currentIds.join(',') !== targetIds.join(',')) { + role.permissions = perms; + await this.roleRepo.save(role); + } + } + + const count = await this.userRepo.count(); + if (count === 0) { + const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; + const hash = await bcrypt.hash(adminPassword, 10); + const adminUser = this.userRepo.create({ + username: 'admin', + passwordHash: hash, + name: '管理员', + }); + const superAdminRole = allRoles.find((r) => r.code === 'super_admin'); + if (superAdminRole) { + adminUser.roles = [superAdminRole]; + } + await this.userRepo.save(adminUser); + this.logger.log( + `已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`, + ); + } + + this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`); + } + +} diff --git a/apps/server/src/rbac/rbac-user.service.ts b/apps/server/src/rbac/rbac-user.service.ts new file mode 100644 index 0000000..5831775 --- /dev/null +++ b/apps/server/src/rbac/rbac-user.service.ts @@ -0,0 +1,269 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import * as bcrypt from 'bcryptjs'; +import { User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, AttendanceSession, Permission } from '../entities'; +import { Role } from '../entities/role.entity'; + +@Injectable() +export class RbacUserService { + private readonly logger = new Logger(RbacUserService.name); + + constructor( + @InjectRepository(Permission) private permRepo: Repository, + @InjectRepository(Role) private roleRepo: Repository, + @InjectRepository(User) private userRepo: Repository, + @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(ClassStudent) private classStudentRepo: Repository, + @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + @InjectRepository(ClassSchedule) private classScheduleRepo: Repository, + @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository, + ) {} + + async resolvePermissions(permissionIds: number[]): Promise { + const uniqueIds = [...new Set(permissionIds)]; + const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : []; + if (permissions.length !== uniqueIds.length) { + const foundIds = new Set(permissions.map((permission) => permission.id)); + const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); + throw new Error(`权限不存在: ${missingIds.join(',')}`); + } + return permissions; + } + + private async resolveRoles(roleIds: number[]): Promise { + const uniqueIds = [...new Set(roleIds)]; + const roles = uniqueIds.length > 0 ? await this.roleRepo.findByIds(uniqueIds) : []; + if (roles.length !== uniqueIds.length) { + const foundIds = new Set(roles.map((role) => role.id)); + const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); + throw new Error(`角色不存在: ${missingIds.join(',')}`); + } + return roles; + } + + async findAllUsers(isArchived = false) { + const users = await this.userRepo.find({ + where: { isArchived }, + relations: ['roles'], + order: { createdAt: 'DESC' }, + }); + const userIds = users.map((u) => u.id); + const students = await this.studentRepo.find({ + where: { userId: In(userIds) }, + select: ['userId', 'status'], + }); + const statusMap = new Map(students.map((s) => [s.userId, s.status])); + return users.map((u) => ({ + id: u.id, + username: u.username, + name: u.name, + isArchived: u.isArchived, + studentStatus: statusMap.get(u.id) || null, + lastLoginAt: u.lastLoginAt, + createdAt: u.createdAt, + updatedAt: u.updatedAt, + roles: u.roles?.map((r) => ({ id: r.id, code: r.code, name: r.name })) || [], + profile: u.profile || {}, + })); + } + + async createUser(dto: { username: string; password: string; name: string; roleIds?: number[] }) { + const exists = await this.userRepo.findOne({ where: { username: dto.username } }); + if (exists) throw new Error('用户名已存在'); + const hash = await bcrypt.hash(dto.password, 10); + const user = this.userRepo.create({ + username: dto.username, + passwordHash: hash, + name: dto.name, + }); + if (dto.roleIds && dto.roleIds.length > 0) { + user.roles = await this.resolveRoles(dto.roleIds); + } + await this.userRepo.save(user); + return { message: '用户创建成功' }; + } + + async updateUser(id: number, dto: { username?: string; name?: string; roleIds?: number[] }) { + const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] }); + if (!user) throw new Error('用户不存在'); + if (dto.username !== undefined && dto.username !== user.username) { + const exists = await this.userRepo.findOne({ where: { username: dto.username } }); + if (exists) throw new Error('用户名已存在'); + user.username = dto.username; + } + if (dto.name !== undefined) user.name = dto.name; + if (dto.roleIds !== undefined) { + user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : []; + } + await this.userRepo.save(user); + return { message: '更新成功' }; + } + + async resetPassword(id: number, newPassword: string) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + user.passwordHash = await bcrypt.hash(newPassword, 10); + await this.userRepo.save(user); + return { message: '密码已重置' }; + } + + async archiveUser(id: number) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + if (user.username === 'admin') throw new Error('不能归档默认管理员'); + await this.userRepo.update(id, { isArchived: true }); + return { message: '用户已归档' }; + } + + async restoreUser(id: number) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + await this.userRepo.update(id, { isArchived: false, isActive: true }); + return { message: '用户已恢复' }; + } + + async purgeUser(id: number, currentUserId: number) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + if (!user.isArchived) throw new Error('仅已归档用户可以永久删除,请先归档'); + if (user.id === currentUserId) throw new Error('不能永久删除当前登录用户'); + if (user.username === 'admin') throw new Error('不能永久删除默认管理员'); + + const [studentCount, classTeacherCount, scheduleCount, sessionStarted, sessionCompleted] = + await Promise.all([ + this.studentRepo.count({ where: { userId: id } }), + this.classTeacherRepo.count({ where: { userId: id } }), + this.classScheduleRepo.count({ where: { teacherId: id } }), + this.attendanceSessionRepo.count({ where: { startedBy: id } }), + this.attendanceSessionRepo.count({ where: { completedBy: id } }), + ]); + const classHeadCount = await this.classRepo.count({ + where: [{ headTeacherId: id }, { lifeTeacherId: id }, { academicTeacherId: id }], + }); + const references: string[] = []; + if (studentCount > 0) references.push('关联学生'); + if (classTeacherCount > 0) references.push('任教班级'); + if (scheduleCount > 0) references.push('排课'); + if (classHeadCount > 0) references.push('班主任班级'); + if (sessionStarted > 0 || sessionCompleted > 0) references.push('考勤课次操作记录'); + if (references.length > 0) { + throw new Error(`该用户存在关联数据(${references.join('、')}),无法永久删除`); + } + await this.userRepo.delete(id); + return { message: '用户已永久删除(不可恢复)' }; + } + + async markAsStaff(userId: number) { + const student = await this.studentRepo.findOne({ where: { userId } }); + if (!student) throw new Error('该用户没有学员记录'); + await this.studentRepo.update(student.id, { status: 'staff' }); + this.logger.log(`User ${userId} Student ${student.id} marked as staff`); + return { message: '已标记为教职工' }; + } + + async markAsStudent(userId: number) { + const student = await this.studentRepo.findOne({ where: { userId } }); + if (!student) throw new Error('该用户没有学员记录'); + await this.studentRepo.update(student.id, { status: 'active' }); + this.logger.log(`User ${userId} Student ${student.id} restored to student`); + return { message: '已恢复为学员' }; + } + + async getUserProfile(id: number) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + return { + id: user.id, + username: user.username, + name: user.name, + profile: user.profile || {}, + }; + } + + async updateUserProfile( + id: number, + dto: { subjects?: string[]; joinedAt?: string; qualifications?: string }, + ) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new Error('用户不存在'); + const current = user.profile || {}; + user.profile = { + subjects: dto.subjects !== undefined ? dto.subjects : current.subjects, + joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt, + qualifications: + dto.qualifications !== undefined ? dto.qualifications : current.qualifications, + }; + await this.userRepo.save(user); + return { message: '资料已更新', profile: user.profile }; + } + + + async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) { + const page = query?.page || 1; + const pageSize = query?.pageSize || 20; + const teacherRoleCodes = ['teacher']; + const teacherRoleNames = ['任课老师', '老师']; + + const qb = this.userRepo + .createQueryBuilder('u') + .leftJoinAndSelect('u.roles', 'role') + .where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', { + roleCodes: teacherRoleCodes, + roleNames: teacherRoleNames, + }) + .andWhere('u.isArchived = :isArchived', { isArchived: false }); + + if (query?.search) { + qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); + } + + const total = await qb.getCount(); + const users = await qb + .orderBy('u.name', 'ASC') + .skip((page - 1) * pageSize) + .take(pageSize) + .getMany(); + + const userIds = users.map((user) => user.id); + const assignments = + userIds.length > 0 + ? await this.classTeacherRepo.find({ where: { userId: In(userIds) }, relations: ['class'] }) + : []; + const assignmentsByUser = new Map(); + for (const assignment of assignments) { + const list = assignmentsByUser.get(assignment.userId) || []; + list.push(assignment); + assignmentsByUser.set(assignment.userId, list); + } + + const list = users.map((u) => ({ + id: u.id, + username: u.username, + name: u.name, + profile: u.profile, + lastLoginAt: u.lastLoginAt, + roles: u.roles || [], + classAssignments: (assignmentsByUser.get(u.id) || []).map((assignment) => ({ + id: assignment.id, + classId: assignment.classId, + roleType: assignment.roleType, + subject: assignment.subject, + className: assignment.class?.name || null, + })), + })); + + return { list, total }; + } + + async updateTeacherProfile( + id: number, + profile: { subjects?: string[]; joinedAt?: string; qualifications?: string }, + ) { + const user = await this.userRepo.findOne({ where: { id } }); + if (!user) throw new NotFoundException('用户不存在'); + user.profile = { ...user.profile, ...profile }; + return this.userRepo.save(user); + } +} diff --git a/apps/server/src/rbac/rbac.controller.ts b/apps/server/src/rbac/rbac.controller.ts index a933b63..c4dee8f 100644 --- a/apps/server/src/rbac/rbac.controller.ts +++ b/apps/server/src/rbac/rbac.controller.ts @@ -23,7 +23,7 @@ import { import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { extractRequestInfo } from '../common/request-utils'; +import { logAudit } from '../common/with-audit-log'; @UseGuards(JwtAuthGuard) @Controller('rbac') @@ -33,8 +33,6 @@ export class RbacController { private logService: OperationLogsService, ) {} - // ==================== 角色管理 ==================== - @Get('roles') @RequirePermission('role:view') findAllRoles() { @@ -50,16 +48,9 @@ export class RbacController { @Post('roles') @RequirePermission('role:create') async createRole(@Body() dto: CreateRoleDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.rbacService.createRole(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: 'RBAC', - action: '创建角色', - detail: `角色: ${dto.name}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`, }); return result; } @@ -67,19 +58,10 @@ export class RbacController { @Put('roles/:id') @RequirePermission('role:edit') async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.updateRole(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: 'RBAC', - action: '编辑角色', - targetId: +id, - targetType: 'role', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto), }); return result; } catch (e: any) { @@ -90,18 +72,10 @@ export class RbacController { @Delete('roles/:id') @RequirePermission('role:delete') async deleteRole(@Param('id') id: string, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.deleteRole(+id); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: 'RBAC', - action: '停用角色', - targetId: +id, - targetType: 'role', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: 'RBAC', action: '停用角色', targetId: +id, targetType: 'role', }); return result; } catch (e: any) { @@ -109,8 +83,6 @@ export class RbacController { } } - // ==================== 权限管理 ==================== - @Get('permissions') @RequirePermission('role:view') findAllPermissions() { @@ -123,8 +95,6 @@ export class RbacController { return this.rbacService.getPermissionTree(); } - // ==================== 用户管理 ==================== - @Get('users') @RequirePermission('user:view', 'teacher:view') getUsers(@Query('isArchived') isArchived?: string) { @@ -135,17 +105,10 @@ export class RbacController { @Post('users') @RequirePermission('user:create') async createUser(@Body() dto: CreateUserDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.createUser(dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账号', - action: '创建账号', - detail: `用户名: ${dto.username}`, - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`, }); return result; } catch (e: any) { @@ -156,19 +119,10 @@ export class RbacController { @Put('users/:id') @RequirePermission('user:edit') async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.updateUser(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账号', - action: '更新账号', - targetId: +id, - targetType: 'user', - detail: JSON.stringify(dto), - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto), }); return result; } catch (e: any) { @@ -179,18 +133,10 @@ export class RbacController { @Put('users/:id/password') @RequirePermission('user:reset-password') async resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto, @Request() req: any) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.resetPassword(+id, dto.password); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账号', - action: '重置密码', - targetId: +id, - targetType: 'user', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账号', action: '重置密码', targetId: +id, targetType: 'user', }); return result; } catch (e: any) { @@ -220,6 +166,21 @@ export class RbacController { } } + @Delete('users/:id/permanent') + @RequirePermission('user:purge') + async purgeUser(@Param('id') id: string, @Request() req: any) { + try { + const result = await this.rbacService.purgeUser(+id, req.user?.id); + await logAudit(this.logService, req, { + module: '账号', action: '永久删除用户', targetId: +id, targetType: 'user', detail: '物理删除,不可恢复', + }); + return result; + } catch (e: unknown) { + const err = e as { message?: string }; + throw new BadRequestException(err?.message); + } + } + @Put('users/:id/mark-staff') @RequirePermission('user:edit') async markAsStaff(@Param('id') id: string) { @@ -242,8 +203,6 @@ export class RbacController { } } - // ---- 用户资料 ---- - @Get('users/:id/profile') @RequirePermission('user:view') getUserProfile(@Param('id') id: string) { @@ -257,18 +216,10 @@ export class RbacController { @Body() dto: UpdateProfileDto, @Request() req: any, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); try { const result = await this.rbacService.updateUserProfile(+id, dto); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '账号', - action: '更新资料', - targetId: +id, - targetType: 'user', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '账号', action: '更新资料', targetId: +id, targetType: 'user', }); return result; } catch (e: any) { @@ -276,16 +227,12 @@ export class RbacController { } } - // ---- 教师工作台 ---- - @Get('teacher-workspace') @RequirePermission('teacher-workspace:view') async getTeacherWorkspace(@Request() req: any) { return this.rbacService.getTeacherWorkspace(req.user?.id); } - // ---- 教师管理 ---- - @Get('teachers') @RequirePermission('teacher:view') async getTeachers( @@ -307,18 +254,9 @@ export class RbacController { @Body() profile: UpdateProfileDto, @Request() req: { user?: { id: number; username: string } }, ) { - const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.rbacService.updateTeacherProfile(+id, profile); - await this.logService.log({ - userId: req.user?.id, - username: req.user?.username, - module: '教师管理', - action: '编辑档案', - targetId: +id, - targetType: 'user', - detail: '更新教师档案', - ipAddress, - userAgent, + await logAudit(this.logService, req, { + module: '教师管理', action: '编辑档案', targetId: +id, targetType: 'user', detail: '更新教师档案', }); return result; } diff --git a/apps/server/src/rbac/rbac.module.ts b/apps/server/src/rbac/rbac.module.ts index 4db07c0..f5bd07b 100644 --- a/apps/server/src/rbac/rbac.module.ts +++ b/apps/server/src/rbac/rbac.module.ts @@ -1,14 +1,16 @@ import { Module, OnModuleInit, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping } from '../entities'; +import { Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping, AttendanceSession } from '../entities'; import { RbacService } from './rbac.service'; +import { RbacSeedService } from './rbac-seed.service'; +import { RbacUserService } from './rbac-user.service'; import { RbacController } from './rbac.controller'; import { AuthModule } from '../auth/auth.module'; @Module({ - imports: [TypeOrmModule.forFeature([Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping]), forwardRef(() => AuthModule)], + imports: [TypeOrmModule.forFeature([Permission, Role, User, Class, ClassStudent, ClassTeacher, ClassSchedule, Student, StudentDingMapping, AttendanceSession]), forwardRef(() => AuthModule)], controllers: [RbacController], - providers: [RbacService], + providers: [RbacService, RbacSeedService, RbacUserService], exports: [RbacService], }) export class RbacModule implements OnModuleInit { diff --git a/apps/server/src/rbac/rbac.permissions.spec.ts b/apps/server/src/rbac/rbac.permissions.spec.ts index 5cbf2d5..6ee39e9 100644 --- a/apps/server/src/rbac/rbac.permissions.spec.ts +++ b/apps/server/src/rbac/rbac.permissions.spec.ts @@ -1,4 +1,4 @@ -import { PRESET_ROLES } from './rbac.service'; +import { PRESET_ROLES } from './rbac-presets'; function permissionsFor(roleCode: string): { groups: string[]; extras: string[] } { const role = PRESET_ROLES.find((item) => item.code === roleCode); diff --git a/apps/server/src/rbac/rbac.purge.controller.spec.ts b/apps/server/src/rbac/rbac.purge.controller.spec.ts new file mode 100644 index 0000000..fad0724 --- /dev/null +++ b/apps/server/src/rbac/rbac.purge.controller.spec.ts @@ -0,0 +1,25 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { RbacController } from './rbac.controller'; + +describe('RbacController purge user route', () => { + it('requires user:purge on permanent delete route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, RbacController.prototype.purgeUser)).toEqual([ + 'user:purge', + ]); + }); + + it('writes permanent delete audit logs', async () => { + const rbacService = { + purgeUser: jest.fn().mockResolvedValue({ message: '用户已永久删除(不可恢复)' }), + }; + const log = jest.fn().mockResolvedValue(undefined); + const controller = new RbacController(rbacService as never, { log } as never); + const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} }; + await controller.purgeUser('2', req); + expect(rbacService.purgeUser).toHaveBeenCalledWith(2, 1); + expect(log).toHaveBeenCalledWith( + expect.objectContaining({ module: '账号', action: '永久删除用户', targetId: 2 }), + ); + }); +}); diff --git a/apps/server/src/rbac/rbac.purge.spec.ts b/apps/server/src/rbac/rbac.purge.spec.ts new file mode 100644 index 0000000..cacda71 --- /dev/null +++ b/apps/server/src/rbac/rbac.purge.spec.ts @@ -0,0 +1,64 @@ +import { RbacService } from './rbac.service'; + +describe('RbacService.purgeUser', () => { + const createService = (overrides?: { + user?: Record; + counts?: Record; + }) => { + const user = { + id: 2, + username: 'teacher1', + isArchived: true, + ...overrides?.user, + }; + const userRepo = { + findOne: jest.fn().mockResolvedValue(user), + delete: jest.fn().mockResolvedValue({ affected: 1 }), + }; + const counts = overrides?.counts ?? {}; + const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0); + const service = new RbacService( + {} as never, + {} as never, + userRepo as never, + { count: countFor('headTeacher') } as never, + {} as never, + { count: countFor('classTeacher') } as never, + { count: countFor('schedule') } as never, + { count: countFor('student') } as never, + { count: countFor('session') } as never, + ); + return { service, userRepo }; + }; + + it('rejects the current user, the admin user, and non-archived users', async () => { + const current = createService(); + await expect(current.service.purgeUser(2, 2)).rejects.toThrow( + '不能永久删除当前登录用户', + ); + + const admin = createService({ user: { username: 'admin' } }); + await expect(admin.service.purgeUser(2, 1)).rejects.toThrow('不能永久删除默认管理员'); + + const active = createService({ user: { isArchived: false } }); + await expect(active.service.purgeUser(2, 1)).rejects.toThrow( + '仅已归档用户可以永久删除,请先归档', + ); + }); + + it('rejects users with student, class, schedule, or attendance references', async () => { + const { service, userRepo } = createService({ counts: { student: 1 } }); + await expect(service.purgeUser(2, 1)).rejects.toThrow( + '该用户存在关联数据(关联学生),无法永久删除', + ); + expect(userRepo.delete).not.toHaveBeenCalled(); + }); + + it('deletes an archived user with no references', async () => { + const { service, userRepo } = createService(); + await expect(service.purgeUser(2, 1)).resolves.toEqual({ + message: '用户已永久删除(不可恢复)', + }); + expect(userRepo.delete).toHaveBeenCalledWith(2); + }); +}); diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 638f301..84a689e 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -1,7 +1,10 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, Optional } from '@nestjs/common'; +import type { UpdateProfileDto } from './dto/rbac.dto'; +import { RbacSeedService } from './rbac-seed.service'; +import { RbacUserService } from './rbac-user.service'; +import { getChinaDateParts } from './rbac-presets'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; -import * as bcrypt from 'bcryptjs'; import { Permission, Role, @@ -11,263 +14,9 @@ import { ClassTeacher, ClassSchedule, Student, + AttendanceSession, } from '../entities'; -const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = [ - { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, - { code: 'notification:view', name: '查看通知', group: 'notification' }, - { code: 'student:view', name: '查看学生管理', group: 'student' }, - { code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' }, - { code: 'teacher-workspace:view', name: '查看教师工作台', group: 'teacher-workspace' }, - { code: 'teacher:view', name: '查看教师', group: 'teacher' }, - { code: 'teacher:edit', name: '编辑教师', group: 'teacher' }, - { code: 'student:create', name: '新增学生', group: 'student' }, - { code: 'student:edit', name: '编辑学生', group: 'student' }, - { code: 'student:delete', name: '归档学生', group: 'student' }, - { code: 'student:import', name: '导入学生', group: 'student' }, - { code: 'student:export', name: '导出学生', group: 'student' }, - { code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' }, - { code: 'room:view', name: '查看宿舍', group: 'room' }, - { code: 'room:inspect', name: '宿舍查寝', group: 'room' }, - { code: 'room:create', name: '新增宿舍', group: 'room' }, - { code: 'room:edit', name: '编辑宿舍', group: 'room' }, - { code: 'room:delete', name: '归档宿舍', group: 'room' }, - { code: 'occupancy:view', name: '查看入住', group: 'occupancy' }, - { code: 'occupancy:checkin', name: '办理入住', group: 'occupancy' }, - { code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' }, - { code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' }, - { code: 'occupancy:delete', name: '归档入住记录', group: 'occupancy' }, - { code: 'expense:view', name: '查看费用', group: 'expense' }, - { code: 'expense:create', name: '录入费用', group: 'expense' }, - { code: 'expense:edit', name: '编辑费用', group: 'expense' }, - { code: 'expense:delete', name: '归档费用', group: 'expense' }, - { code: 'bill:view', name: '查看账单', group: 'bill' }, - { code: 'bill:generate', name: '生成账单', group: 'bill' }, - { code: 'bill:confirm', name: '确认账单', group: 'bill' }, - { code: 'bill:delete', name: '归档账单', group: 'bill' }, - { code: 'bill:export-excel', name: '导出 Excel', group: 'bill' }, - { code: 'bill:export-pdf', name: '导出 PDF', group: 'bill' }, - { code: 'deposit:view', name: '查看押金', group: 'deposit' }, - { code: 'deposit:create', name: '新增押金', group: 'deposit' }, - { code: 'deposit:edit', name: '编辑押金', group: 'deposit' }, - { code: 'deposit:delete', name: '归档押金', group: 'deposit' }, - { code: 'deposit:refund', name: '直接退还押金', group: 'deposit' }, - { code: 'wallet:view', name: '查看学生余额', group: 'wallet' }, - { code: 'wallet:edit', name: '充值和调账', group: 'wallet' }, - { code: 'classroom:view', name: '查看教室', group: 'classroom' }, - { code: 'classroom:create', name: '新增教室', group: 'classroom' }, - { code: 'classroom:edit', name: '编辑教室', group: 'classroom' }, - { code: 'classroom:delete', name: '归档教室', group: 'classroom' }, - { code: 'organization:view', name: '查看机构', group: 'organization' }, - { code: 'organization:create', name: '新增机构', group: 'organization' }, - { code: 'organization:edit', name: '编辑机构', group: 'organization' }, - { code: 'organization:delete', name: '归档机构', group: 'organization' }, - { code: 'rental:view', name: '查看租赁订单', group: 'rental' }, - { code: 'rental:create', name: '新增租赁订单', group: 'rental' }, - { code: 'rental:edit', name: '编辑租赁订单', group: 'rental' }, - { code: 'rental:delete', name: '归档租赁订单', group: 'rental' }, - { code: 'log:view', name: '查看操作日志', group: 'log' }, - { code: 'log:create', name: '写入操作日志', group: 'log' }, - { code: 'user:view', name: '查看用户', group: 'user' }, - { code: 'user:create', name: '创建用户', group: 'user' }, - { code: 'user:edit', name: '编辑用户', group: 'user' }, - { code: 'user:reset-password', name: '重置密码', group: 'user' }, - { code: 'role:view', name: '查看角色', group: 'role' }, - { code: 'role:create', name: '创建角色', group: 'role' }, - { code: 'role:edit', name: '编辑角色', group: 'role' }, - { code: 'role:delete', name: '停用角色', group: 'role' }, - { code: 'class:view', name: '查看班级', group: 'class' }, - { code: 'class:create', name: '创建班级', group: 'class' }, - { code: 'class:edit', name: '编辑班级', group: 'class' }, - { code: 'class:delete', name: '归档班级', group: 'class' }, - { code: 'schedule:view', name: '查看排课', group: 'schedule' }, - { code: 'schedule:create', name: '创建排课', group: 'schedule' }, - { code: 'schedule:edit', name: '编辑排课', group: 'schedule' }, - { code: 'schedule:delete', name: '停用排课', group: 'schedule' }, - { code: 'attendance:view', name: '查看考勤', group: 'attendance' }, - { code: 'attendance:create', name: '新增考勤', group: 'attendance' }, - { code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' }, - { code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' }, - { code: 'attendance:export', name: '导出考勤', group: 'attendance' }, - { code: 'sync:trigger', name: '触发数据同步', group: 'sync' }, - { code: 'sync:read', name: '查看同步状态', group: 'sync' }, - { code: 'integration:trigger', name: '触发集成', group: 'integration' }, - { code: 'integration:read', name: '查看集成状态', group: 'integration' }, - { code: 'ai:config:read', name: '查看 AI 配置', group: 'ai' }, - { code: 'ai:config:write', name: '修改 AI 配置', group: 'ai' }, - { code: 'ai:config:test', name: '测试 AI 连接', group: 'ai' }, - { code: 'ai:chat:use', name: '使用 AI 助手', group: 'ai-chat' }, -]; - -const DEPRECATED_PERMISSION_CODES = [ - 'profile:view', - 'attendance:generate', - 'learning:create', - 'learning:edit', - 'learning:delete', - 'exam:create', - 'exam:edit', - 'exam:delete', - 'department:view', - 'department:edit', - 'department:delete', - // Legacy permission codes from older admin UI / seed data. - 'student:add', - 'student:update', - 'room:add', - 'room:update', - 'occupancy:add', - 'occupancy:update', - 'attendance:add', - 'attendance:update', - 'attendance:delete', - 'attendance:batch', - 'bill:export', - 'deposit:collect', - 'expense:add', - 'expense:update', - 'class:add', - 'class:update', - 'schedule:add', - 'schedule:update', - 'classroom:add', - 'classroom:update', - 'rental:add', - 'rental:update', - 'role:add', - 'role:update', - 'user:add', - 'user:update', - 'archive:view', - 'archive:import', - 'archive:export', - 'report:generate', -] as const; - -const DEPRECATED_PERMISSION_CODE_SET = new Set(DEPRECATED_PERMISSION_CODES); - -function getChinaDateParts(date = new Date()): { date: string; weekDay: number } { - const parts = Object.fromEntries( - new Intl.DateTimeFormat('en-CA', { - timeZone: 'Asia/Shanghai', - year: 'numeric', - month: '2-digit', - day: '2-digit', - weekday: 'short', - }) - .formatToParts(date) - .filter((part) => part.type !== 'literal') - .map((part) => [part.type, part.value]), - ); - const weekDays: Record = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }; - return { - date: `${parts.year}-${parts.month}-${parts.day}`, - weekDay: weekDays[parts.weekday], - }; -} - -export const PRESET_ROLES: Array<{ - name: string; - code: string; - description: string; - isSystem: boolean; - permissionGroups: string[]; - extraPermissions?: string[]; - legacyNames?: string[]; - legacyCodes?: string[]; -}> = [ - { - name: '超级管理员', - code: 'super_admin', - description: '系统初始化、应急维护和全局权限处理', - isSystem: true, - permissionGroups: [], - legacyNames: ['超管', 'super_admin'], - }, - { - name: '任课老师', - code: 'teacher', - description: '查看自己的排课、今日课程和任教班级考勤', - isSystem: true, - permissionGroups: ['notification'], - extraPermissions: [ - 'teacher-workspace:view', - 'schedule:view', - 'attendance:view', - 'attendance:create', - 'attendance:self-edit', - ], - legacyNames: ['老师'], - }, - { - name: '教务管理员', - code: 'academic', - description: '管理学生、班级、教师、全局排课和历史考勤', - isSystem: true, - permissionGroups: [ - 'student', - 'exam', - 'class', - 'schedule', - 'attendance', - 'classroom', - 'dashboard', - 'notification', - ], - extraPermissions: [ - 'teacher-workspace:view', - 'teacher:view', - 'teacher:edit', - 'sync:read', - 'sync:trigger', - ], - legacyNames: ['教务'], - }, - { - name: '住宿运营管理员', - code: 'accommodation_operations', - description: '管理宿舍、入住、住宿费用、账单、押金和退宿结算', - isSystem: true, - permissionGroups: [ - 'room', - 'occupancy', - 'expense', - 'bill', - 'deposit', - 'wallet', - 'dashboard', - 'notification', - ], - extraPermissions: ['student:basic-view'], - legacyNames: ['宿管老师', '宿管', '财务'], - legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'], - }, - { - name: '教室运营管理员', - code: 'classroom_operations', - description: '管理教室、教室排期、外部机构和租赁订单', - isSystem: true, - permissionGroups: ['classroom', 'rental', 'organization', 'notification'], - legacyNames: ['机构负责人'], - legacyCodes: ['institution_head'], - }, - { - name: '系统管理员', - code: 'system_admin', - description: '管理账号、角色、日志、同步和系统配置', - isSystem: true, - permissionGroups: [ - 'user', - 'role', - 'log', - 'integration', - 'sync', - 'ai', - 'notification', - ], - }, -]; - @Injectable() export class RbacService { private readonly logger = new Logger(RbacService.name); @@ -281,179 +30,34 @@ export class RbacService { @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(ClassSchedule) private classScheduleRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, + @InjectRepository(AttendanceSession) + private attendanceSessionRepo: Repository, + @Optional() private seedService?: RbacSeedService, + @Optional() private userService?: RbacUserService, ) {} - private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise { - for (const code of preset.legacyCodes ?? []) { - const role = await this.roleRepo.findOne({ where: { code } }); - if (role) return role; + private get seedOps(): RbacSeedService { + if (!this.seedService) { + this.seedService = new RbacSeedService(this.permRepo, this.roleRepo, this.userRepo); } - for (const name of preset.legacyNames ?? []) { - const role = await this.roleRepo.findOne({ where: { name } }); - if (role) return role; - } - return null; + return this.seedService; } - async seedData(): Promise { - const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true }); - if (restoredLegacyUsers.affected) { - this.logger.log( - `已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`, + private get userOps(): RbacUserService { + if (!this.userService) { + this.userService = new RbacUserService( + this.permRepo, + this.roleRepo, + this.userRepo, + this.classRepo, + this.classStudentRepo, + this.classTeacherRepo, + this.classScheduleRepo, + this.studentRepo, + this.attendanceSessionRepo, ); } - - // Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL) - for (const p of PRESET_PERMISSIONS) { - const exists = await this.permRepo.findOne({ where: { code: p.code } }); - if (!exists) { - await this.permRepo.save(this.permRepo.create(p)); - } - } - const deprecatedUserDeletePermission = await this.permRepo.findOne({ - where: { code: 'user:delete' }, - }); - const allPerms = (await this.permRepo.find()).filter( - (permission) => - permission.code !== 'user:delete' && !DEPRECATED_PERMISSION_CODE_SET.has(permission.code), - ); - - // Step 2: 幂等插入预置角色 - for (const r of PRESET_ROLES) { - const exists = - (await this.roleRepo.findOne({ where: { code: r.code } })) || - (await this.roleRepo.findOne({ where: { name: r.name } })) || - (await this.findLegacyPresetRole(r)); - if (!exists) { - await this.roleRepo.save( - this.roleRepo.create({ - name: r.name, - code: r.code, - description: r.description, - isSystem: r.isSystem, - }), - ); - } - } - const allRoles = await this.roleRepo.find({ relations: ['permissions', 'users'] }); - - if (deprecatedUserDeletePermission) { - for (const role of allRoles) { - const permissions = role.permissions ?? []; - if (permissions.some((permission) => permission.id === deprecatedUserDeletePermission.id)) { - role.permissions = permissions.filter( - (permission) => permission.id !== deprecatedUserDeletePermission.id, - ); - await this.roleRepo.save(role); - } - } - await this.permRepo.remove(deprecatedUserDeletePermission); - } - - const deprecatedPermissions = await this.permRepo.find({ - where: { code: In([...DEPRECATED_PERMISSION_CODES]) }, - }); - if (deprecatedPermissions.length > 0) { - const deprecatedIds = new Set(deprecatedPermissions.map((permission) => permission.id)); - for (const role of allRoles) { - const permissions = role.permissions ?? []; - if (permissions.some((permission) => deprecatedIds.has(permission.id))) { - role.permissions = permissions.filter((permission) => !deprecatedIds.has(permission.id)); - await this.roleRepo.save(role); - } - } - await this.permRepo.remove(deprecatedPermissions); - this.logger.log(`已清理废弃权限点: ${deprecatedPermissions.map((p) => p.code).join(', ')}`); - } - - // Step 3: 合并旧角色并构建新的职责权限矩阵 - for (const preset of PRESET_ROLES) { - const matchesPreset = (role: Role) => - role.name === preset.name || - role.code === preset.code || - preset.legacyNames?.includes(role.name) || - preset.legacyCodes?.includes(role.code); - const candidates = allRoles.filter(matchesPreset); - const role = candidates.find((candidate) => candidate.code === preset.code) ?? candidates[0]; - if (!role) continue; - - const duplicateRoles = candidates.filter((candidate) => candidate.id !== role.id); - if (duplicateRoles.length > 0) { - for (const duplicate of duplicateRoles) { - for (const relatedUser of duplicate.users ?? []) { - const user = await this.userRepo.findOne({ - where: { id: relatedUser.id }, - relations: ['roles'], - }); - if (!user) continue; - const remainingRoles = (user.roles ?? []).filter( - (assignedRole) => assignedRole.id !== duplicate.id && assignedRole.id !== role.id, - ); - user.roles = [...remainingRoles, role]; - await this.userRepo.save(user); - } - await this.roleRepo.remove(duplicate); - } - } - - if ( - role.code !== preset.code || - role.name !== preset.name || - role.description !== preset.description - ) { - role.code = preset.code; - role.name = preset.name; - role.description = preset.description; - role.isSystem = preset.isSystem; - role.status = 1; - await this.roleRepo.save(role); - } - - let perms: Permission[]; - if (preset.permissionGroups.length === 0) { - // 超管:全部权限 - perms = allPerms; - } else { - // 按 group 匹配 + 额外权限(如老师的 student:view) - const byGroup = allPerms.filter((p) => preset.permissionGroups.includes(p.group)); - const byExtra = preset.extraPermissions - ? allPerms.filter((p) => preset.extraPermissions!.includes(p.code)) - : []; - perms = [...byGroup, ...byExtra].filter( - (p, i, arr) => arr.findIndex((x) => x.id === p.id) === i, - ); - } - - // 系统预置角色必须严格遵循职责矩阵;额外授权请创建自定义角色叠加。 - const currentIds = role.permissions.map((permission) => permission.id).sort((a, b) => a - b); - const targetIds = perms.map((permission) => permission.id).sort((a, b) => a - b); - if (currentIds.join(',') !== targetIds.join(',')) { - role.permissions = perms; - await this.roleRepo.save(role); - } - } - - // Step 4: 初始化 admin 用户 - const count = await this.userRepo.count(); - if (count === 0) { - const adminPassword = process.env.ADMIN_PASSWORD || 'admin123'; - const hash = await bcrypt.hash(adminPassword, 10); - const adminUser = this.userRepo.create({ - username: 'admin', - passwordHash: hash, - name: '管理员', - }); - const superAdminRole = allRoles.find((r) => r.code === 'super_admin'); - if (superAdminRole) { - adminUser.roles = [superAdminRole]; - } - await this.userRepo.save(adminUser); - this.logger.log( - `已创建默认管理员: admin / ${adminPassword === 'admin123' ? 'admin123 (请尽快修改!)' : '******'}`, - ); - } - - this.logger.log(`种子数据初始化完成: ${allPerms.length} 权限点, ${allRoles.length} 角色`); + return this.userService; } async findAllRoles(): Promise { @@ -467,28 +71,6 @@ export class RbacService { return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] }); } - private async resolvePermissions(permissionIds: number[]): Promise { - const uniqueIds = [...new Set(permissionIds)]; - const permissions = uniqueIds.length > 0 ? await this.permRepo.findByIds(uniqueIds) : []; - if (permissions.length !== uniqueIds.length) { - const foundIds = new Set(permissions.map((permission) => permission.id)); - const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); - throw new Error(`权限不存在: ${missingIds.join(',')}`); - } - return permissions; - } - - private async resolveRoles(roleIds: number[]): Promise { - const uniqueIds = [...new Set(roleIds)]; - const roles = uniqueIds.length > 0 ? await this.roleRepo.findByIds(uniqueIds) : []; - if (roles.length !== uniqueIds.length) { - const foundIds = new Set(roles.map((role) => role.id)); - const missingIds = uniqueIds.filter((id) => !foundIds.has(id)); - throw new Error(`角色不存在: ${missingIds.join(',')}`); - } - return roles; - } - async createRole(dto: { name: string; description?: string; @@ -496,7 +78,7 @@ export class RbacService { }): Promise { const role = this.roleRepo.create({ name: dto.name, description: dto.description }); if (dto.permissionIds && dto.permissionIds.length > 0) { - role.permissions = await this.resolvePermissions(dto.permissionIds); + role.permissions = await this.userOps.resolvePermissions(dto.permissionIds); } return this.roleRepo.save(role); } @@ -513,7 +95,7 @@ export class RbacService { if (dto.description !== undefined) role.description = dto.description; if (dto.permissionIds !== undefined) { role.permissions = - dto.permissionIds.length > 0 ? await this.resolvePermissions(dto.permissionIds) : []; + dto.permissionIds.length > 0 ? await this.userOps.resolvePermissions(dto.permissionIds) : []; } return this.roleRepo.save(role); } @@ -557,139 +139,6 @@ export class RbacService { return Array.from(codes); } - // ---- 用户管理 ---- - - async findAllUsers(isArchived = false) { - const users = await this.userRepo.find({ - where: { isArchived }, - relations: ['roles'], - order: { createdAt: 'DESC' }, - }); - const userIds = users.map((u) => u.id); - const students = await this.studentRepo.find({ - where: { userId: In(userIds) }, - select: ['userId', 'status'], - }); - const statusMap = new Map(students.map((s) => [s.userId, s.status])); - return users.map((u) => ({ - id: u.id, - username: u.username, - name: u.name, - isArchived: u.isArchived, - studentStatus: statusMap.get(u.id) || null, - lastLoginAt: u.lastLoginAt, - createdAt: u.createdAt, - updatedAt: u.updatedAt, - roles: u.roles?.map((r) => ({ id: r.id, code: r.code, name: r.name })) || [], - profile: u.profile || {}, - })); - } - - async createUser(dto: { username: string; password: string; name: string; roleIds?: number[] }) { - const exists = await this.userRepo.findOne({ where: { username: dto.username } }); - if (exists) throw new Error('用户名已存在'); - const hash = await bcrypt.hash(dto.password, 10); - const user = this.userRepo.create({ - username: dto.username, - passwordHash: hash, - name: dto.name, - }); - if (dto.roleIds && dto.roleIds.length > 0) { - user.roles = await this.resolveRoles(dto.roleIds); - } - await this.userRepo.save(user); - return { message: '用户创建成功' }; - } - - async updateUser( - id: number, - dto: { username?: string; name?: string; roleIds?: number[] }, - ) { - const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] }); - if (!user) throw new Error('用户不存在'); - if (dto.username !== undefined && dto.username !== user.username) { - const exists = await this.userRepo.findOne({ where: { username: dto.username } }); - if (exists) throw new Error('用户名已存在'); - user.username = dto.username; - } - if (dto.name !== undefined) user.name = dto.name; - if (dto.roleIds !== undefined) { - user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : []; - } - await this.userRepo.save(user); - return { message: '更新成功' }; - } - - async resetPassword(id: number, newPassword: string) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - user.passwordHash = await bcrypt.hash(newPassword, 10); - await this.userRepo.save(user); - return { message: '密码已重置' }; - } - - async archiveUser(id: number) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - if (user.username === 'admin') throw new Error('不能归档默认管理员'); - await this.userRepo.update(id, { isArchived: true }); - return { message: '用户已归档' }; - } - - async restoreUser(id: number) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - await this.userRepo.update(id, { isArchived: false, isActive: true }); - return { message: '用户已恢复' }; - } - - async markAsStaff(userId: number) { - const student = await this.studentRepo.findOne({ where: { userId } }); - if (!student) throw new Error('该用户没有学员记录'); - await this.studentRepo.update(student.id, { status: 'staff' }); - this.logger.log(`User ${userId} Student ${student.id} marked as staff`); - return { message: '已标记为教职工' }; - } - - async markAsStudent(userId: number) { - const student = await this.studentRepo.findOne({ where: { userId } }); - if (!student) throw new Error('该用户没有学员记录'); - await this.studentRepo.update(student.id, { status: 'active' }); - this.logger.log(`User ${userId} Student ${student.id} restored to student`); - return { message: '已恢复为学员' }; - } - - // ---- 用户资料 ---- - - async getUserProfile(id: number) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - return { - id: user.id, - username: user.username, - name: user.name, - profile: user.profile || {}, - }; - } - - async updateUserProfile( - id: number, - dto: { subjects?: string[]; joinedAt?: string; qualifications?: string }, - ) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new Error('用户不存在'); - const current = user.profile || {}; - user.profile = { - subjects: dto.subjects !== undefined ? dto.subjects : current.subjects, - joinedAt: dto.joinedAt !== undefined ? dto.joinedAt : current.joinedAt, - qualifications: - dto.qualifications !== undefined ? dto.qualifications : current.qualifications, - }; - await this.userRepo.save(user); - return { message: '资料已更新', profile: user.profile }; - } - - // ---- 教师工作台 ---- async getTeacherWorkspace(userId: number) { // Find all classes where this user is a teacher @@ -704,7 +153,6 @@ export class RbacService { return { assignedClasses: [], todaySchedules: [], myStudents: [] }; } - // Get assigned classes const assignedClasses = teacherAssignments.map((t) => ({ classId: t.classId, className: t.class?.name || '', @@ -716,7 +164,6 @@ export class RbacService { // Get today's China business date and day of week (1=Monday, 7=Sunday) const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts(); - // Get today's schedules for assigned classes const todaySchedules = await this.classScheduleRepo .createQueryBuilder('cs') .where('cs.classId IN (:...classIds)', { classIds }) @@ -727,7 +174,6 @@ export class RbacService { .orderBy('cs.startTime', 'ASC') .getMany(); - // Get students in assigned classes const classStudents = await this.classStudentRepo.find({ where: { classId: In(classIds), status: 'active' }, relations: ['student', 'class'], @@ -758,71 +204,60 @@ export class RbacService { myStudents, }; } + async seedData(): Promise { + return this.seedOps.seedData(); + } + + async findAllUsers(isArchived = false) { + return this.userOps.findAllUsers(isArchived); + } + + async createUser(dto: { username: string; password: string; name: string; roleIds?: number[] }) { + return this.userOps.createUser(dto); + } + + async updateUser(id: number, dto: { username?: string; name?: string; roleIds?: number[] }) { + return this.userOps.updateUser(id, dto); + } + + async resetPassword(id: number, newPassword: string) { + return this.userOps.resetPassword(id, newPassword); + } + + async archiveUser(id: number) { + return this.userOps.archiveUser(id); + } + + async restoreUser(id: number) { + return this.userOps.restoreUser(id); + } + + async purgeUser(id: number, currentUserId: number) { + return this.userOps.purgeUser(id, currentUserId); + } + + async markAsStaff(userId: number) { + return this.userOps.markAsStaff(userId); + } + + async markAsStudent(userId: number) { + return this.userOps.markAsStudent(userId); + } + + async getUserProfile(id: number) { + return this.userOps.getUserProfile(id); + } + + async updateUserProfile(id: number, dto: UpdateProfileDto) { + return this.userOps.updateUserProfile(id, dto); + } async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) { - const page = query?.page || 1; - const pageSize = query?.pageSize || 20; - const teacherRoleCodes = ['teacher']; - const teacherRoleNames = ['任课老师', '老师']; - - const qb = this.userRepo - .createQueryBuilder('u') - .leftJoinAndSelect('u.roles', 'role') - .where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', { - roleCodes: teacherRoleCodes, - roleNames: teacherRoleNames, - }) - .andWhere('u.isArchived = :isArchived', { isArchived: false }); - - if (query?.search) { - qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` }); - } - - const total = await qb.getCount(); - const users = await qb - .orderBy('u.name', 'ASC') - .skip((page - 1) * pageSize) - .take(pageSize) - .getMany(); - - const userIds = users.map((user) => user.id); - const assignments = - userIds.length > 0 - ? await this.classTeacherRepo.find({ where: { userId: In(userIds) }, relations: ['class'] }) - : []; - const assignmentsByUser = new Map(); - for (const assignment of assignments) { - const list = assignmentsByUser.get(assignment.userId) || []; - list.push(assignment); - assignmentsByUser.set(assignment.userId, list); - } - - const list = users.map((u) => ({ - id: u.id, - username: u.username, - name: u.name, - profile: u.profile, - lastLoginAt: u.lastLoginAt, - roles: u.roles || [], - classAssignments: (assignmentsByUser.get(u.id) || []).map((assignment) => ({ - id: assignment.id, - classId: assignment.classId, - roleType: assignment.roleType, - subject: assignment.subject, - className: assignment.class?.name || null, - })), - })); - - return { list, total }; + return this.userOps.getTeachers(query); } - async updateTeacherProfile( - id: number, - profile: { subjects?: string[]; joinedAt?: string; qualifications?: string }, - ) { - const user = await this.userRepo.findOne({ where: { id } }); - if (!user) throw new NotFoundException('用户不存在'); - user.profile = { ...user.profile, ...profile }; - return this.userRepo.save(user); + async updateTeacherProfile(id: number, dto: UpdateProfileDto) { + return this.userOps.updateTeacherProfile(id, dto); } + }