feat: 完善 RBAC 权限体系与权限管理页面
This commit is contained in:
@@ -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 { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { getErrorMessage } from '../../utils/error';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@@ -22,36 +26,38 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OperationLogsPage: React.FC = () => {
|
const OperationLogsPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [filterModule, setFilterModule] = useState<string | undefined>();
|
const [filterModule, setFilterModule] = useState<string | undefined>();
|
||||||
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const {
|
||||||
setLoading(true);
|
data: fetchResult = { data: [], total: 0 },
|
||||||
try {
|
isLoading,
|
||||||
const params: any = { page, pageSize };
|
isFetching,
|
||||||
if (filterModule) params.module = filterModule;
|
} = useQuery<{ data: any[]; total: number }>({
|
||||||
if (dateRange) {
|
queryKey: ['operation-logs', page, pageSize, filterModule, dateRange],
|
||||||
params.startDate = dateRange[0];
|
queryFn: async () => {
|
||||||
params.endDate = dateRange[1];
|
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);
|
const data = fetchResult.data;
|
||||||
} catch (e: unknown) {
|
const total = fetchResult.total;
|
||||||
const err = e as { message?: string };
|
const loading = isLoading || isFetching;
|
||||||
message.error(err?.message || '加载失败,请稍后重试');
|
|
||||||
}
|
|
||||||
setLoading(false);
|
|
||||||
}, [page, pageSize, filterModule, dateRange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@@ -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 { Card, Tag, Input, Space, Spin, Empty } from 'antd';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { getErrorMessage } from '../../utils/error';
|
||||||
|
|
||||||
interface PermissionItem {
|
interface PermissionItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,10 +16,25 @@ interface PermissionItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PermissionsPage: React.FC = () => {
|
const PermissionsPage: React.FC = () => {
|
||||||
const [permTree, setPermTree] = useState<{ group: string; permissions: PermissionItem[] }[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [search, setSearch] = useState('');
|
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<string, string> = {
|
const groupNames: Record<string, string> = {
|
||||||
dashboard: '数据面板',
|
dashboard: '数据面板',
|
||||||
student: '学生管理',
|
student: '学生管理',
|
||||||
@@ -44,18 +63,6 @@ const PermissionsPage: React.FC = () => {
|
|||||||
'ai-chat': 'AI 助手',
|
'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
|
const filteredTree = search
|
||||||
? permTree
|
? permTree
|
||||||
.map((g) => ({
|
.map((g) => ({
|
||||||
@@ -67,7 +74,7 @@ const PermissionsPage: React.FC = () => {
|
|||||||
.filter((g) => g.permissions.length > 0)
|
.filter((g) => g.permissions.length > 0)
|
||||||
: permTree;
|
: permTree;
|
||||||
|
|
||||||
if (loading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
|
if (isLoading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -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 { Table, Modal, Form, Input, Space, Tag, Popconfirm, Card, Checkbox, Empty } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, StopOutlined } from '@ant-design/icons';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
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 {
|
interface PermissionItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -23,36 +28,62 @@ interface RoleItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RolesPage: React.FC = () => {
|
const RolesPage: React.FC = () => {
|
||||||
const [data, setData] = useState<RoleItem[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<RoleItem | null>(null);
|
const [editing, setEditing] = useState<RoleItem | null>(null);
|
||||||
const [allPerms, setAllPerms] = useState<{ group: string; permissions: PermissionItem[] }[]>([]);
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const {
|
||||||
setLoading(true);
|
data: fetchResult = { roles: [], permTree: [] },
|
||||||
try {
|
isLoading,
|
||||||
const [roles, permTree] = await Promise.all([
|
isFetching,
|
||||||
api.get('/rbac/roles') as Promise<RoleItem[]>,
|
} = useQuery<{
|
||||||
api.get('/rbac/permissions/tree') as Promise<
|
roles: RoleItem[];
|
||||||
{ group: string; permissions: PermissionItem[] }[]
|
permTree: { group: string; permissions: PermissionItem[] }[];
|
||||||
>,
|
}>({
|
||||||
]);
|
queryKey: ['rbac', 'roles', 'permission-tree'],
|
||||||
setData(roles);
|
queryFn: async () => {
|
||||||
setAllPerms(permTree);
|
try {
|
||||||
} catch (e: unknown) {
|
const [roles, permTree] = await Promise.all([
|
||||||
const err = e as { message?: string };
|
api.get('/rbac/roles') as Promise<RoleItem[]>,
|
||||||
message.error(err?.message || '加载失败,请稍后重试');
|
api.get('/rbac/permissions/tree') as Promise<
|
||||||
}
|
{ group: string; permissions: PermissionItem[] }[]
|
||||||
setLoading(false);
|
>,
|
||||||
}, []);
|
]);
|
||||||
|
return {
|
||||||
|
roles: validateResponse<RoleItem[]>(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(() => {
|
const saveMutation = useApiMutation(
|
||||||
fetchData();
|
async (values: { name: string; description?: string; permissionIds: number[] }) =>
|
||||||
}, [fetchData]);
|
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 = () => {
|
const handleAdd = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -72,25 +103,15 @@ const RolesPage: React.FC = () => {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
await saveMutation.mutateAsync({
|
||||||
await api.put(`/rbac/roles/${editing.id}`, {
|
name: values.name,
|
||||||
name: values.name,
|
description: values.description,
|
||||||
description: values.description,
|
permissionIds: selectedPermIds,
|
||||||
permissionIds: selectedPermIds,
|
});
|
||||||
});
|
message.success(editing ? '角色更新成功' : '角色创建成功');
|
||||||
message.success('角色更新成功');
|
|
||||||
} else {
|
|
||||||
await api.post('/rbac/roles', {
|
|
||||||
name: values.name,
|
|
||||||
description: values.description,
|
|
||||||
permissionIds: selectedPermIds,
|
|
||||||
});
|
|
||||||
message.success('角色创建成功');
|
|
||||||
}
|
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
fetchData();
|
} catch {
|
||||||
} catch (e: any) {
|
// 错误提示由 useApiMutation 统一处理
|
||||||
message.error(e.message || '操作失败');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -98,11 +119,10 @@ const RolesPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleDisable = async (id: number) => {
|
const handleDisable = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await api.delete(`/rbac/roles/${id}`);
|
await disableMutation.mutateAsync(id);
|
||||||
message.success('角色已停用');
|
message.success('角色已停用');
|
||||||
fetchData();
|
} catch {
|
||||||
} catch (e: any) {
|
// 错误提示由 useApiMutation 统一处理
|
||||||
message.error(e.message || '停用失败');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -144,11 +164,14 @@ const RolesPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
const saveCell = useCallback(
|
const saveCell = useCallback(
|
||||||
async (record: RoleItem, field: string, value: unknown) => {
|
async (record: RoleItem, field: string, value: unknown) => {
|
||||||
await api.put(`/rbac/roles/${record.id}`, { [field]: value });
|
try {
|
||||||
message.success('已保存');
|
await saveCellMutation.mutateAsync({ record, field, value });
|
||||||
await fetchData();
|
message.success('已保存');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[fetchData],
|
[saveCellMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Table, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm } from 'antd';
|
import { App, Button, Table, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm } from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
@@ -13,11 +13,25 @@ import PermissionButton from '../../components/PermissionButton';
|
|||||||
import EditableCell from '../../components/EditableCell';
|
import EditableCell from '../../components/EditableCell';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { userProfileResponseToFormValues, type UserProfileResponse } from './user-profile-form';
|
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 UsersPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const { modal } = App.useApp();
|
||||||
const [roles, setRoles] = useState<any[]>([]);
|
const { hasPermission } = usePermission();
|
||||||
const [loading, setLoading] = useState(false);
|
const canPurgeUser = hasPermission('user:purge');
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [pwdModalOpen, setPwdModalOpen] = useState(false);
|
const [pwdModalOpen, setPwdModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<any>(null);
|
const [editing, setEditing] = useState<any>(null);
|
||||||
@@ -45,36 +59,73 @@ const UsersPage: React.FC = () => {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await profileForm.validateFields();
|
const values = await profileForm.validateFields();
|
||||||
try {
|
try {
|
||||||
await api.put(`/rbac/users/${profileUser.id}/profile`, values);
|
await profileMutation.mutateAsync({ id: profileUser.id, values });
|
||||||
message.success('档案更新成功');
|
message.success('档案更新成功');
|
||||||
setProfileModalOpen(false);
|
setProfileModalOpen(false);
|
||||||
fetchData();
|
} catch {
|
||||||
} catch (e: any) {
|
// 错误提示由 useApiMutation 统一处理
|
||||||
message.error(e.message || '操作失败');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const {
|
||||||
setLoading(true);
|
data: fetchResult = { users: [], roles: [] },
|
||||||
try {
|
isLoading,
|
||||||
const [users, rolesRes] = await Promise.all([
|
isFetching,
|
||||||
api.get(`/rbac/users?isArchived=${showArchived}`) as Promise<any[]>,
|
} = useQuery<{ users: any[]; roles: any[] }>({
|
||||||
api.get('/rbac/roles') as Promise<any[]>,
|
queryKey: ['rbac', 'users', showArchived],
|
||||||
]);
|
queryFn: async () => {
|
||||||
setData(users);
|
try {
|
||||||
setRoles(rolesRes);
|
const [users, rolesRes] = await Promise.all([
|
||||||
} catch (e: unknown) {
|
api.get(`/rbac/users?isArchived=${showArchived}`) as Promise<any[]>,
|
||||||
const err = e as { message?: string };
|
api.get('/rbac/roles') as Promise<any[]>,
|
||||||
message.error(err?.message || '加载失败,请稍后重试');
|
]);
|
||||||
}
|
return {
|
||||||
setLoading(false);
|
users: validateResponse<any[]>(usersSchema, users),
|
||||||
}, [showArchived]);
|
roles: validateResponse<any[]>(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(() => {
|
const profileMutation = useApiMutation(
|
||||||
fetchData();
|
async ({ id, values }: { id: number; values: any }) =>
|
||||||
}, [fetchData]);
|
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 = () => {
|
const handleAdd = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
@@ -96,41 +147,48 @@ const UsersPage: React.FC = () => {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
await saveMutation.mutateAsync({
|
||||||
await api.put(`/rbac/users/${editing.id}`, {
|
username: values.username,
|
||||||
username: values.username,
|
password: values.password,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
roleIds: values.roleIds || [],
|
roleIds: values.roleIds || [],
|
||||||
});
|
});
|
||||||
message.success('更新成功');
|
message.success(editing ? '更新成功' : '创建成功');
|
||||||
} else {
|
|
||||||
await api.post('/rbac/users', {
|
|
||||||
username: values.username,
|
|
||||||
password: values.password,
|
|
||||||
name: values.name,
|
|
||||||
roleIds: values.roleIds || [],
|
|
||||||
});
|
|
||||||
message.success('创建成功');
|
|
||||||
}
|
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
fetchData();
|
} catch {
|
||||||
} catch (e: any) {
|
// 错误提示由 useApiMutation 统一处理
|
||||||
message.error(e.message || '操作失败');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleArchive = async (id: number, archive: boolean) => {
|
const handleArchive = async (id: number, archive: boolean) => {
|
||||||
try {
|
try {
|
||||||
await api.put(`/rbac/users/${id}/${archive ? 'archive' : 'restore'}`);
|
await archiveMutation.mutateAsync({ id, archive });
|
||||||
message.success(archive ? '已归档' : '已恢复');
|
message.success(archive ? '已归档' : '已恢复');
|
||||||
fetchData();
|
} catch {
|
||||||
} catch (e: unknown) {
|
// 错误提示由 useApiMutation 统一处理
|
||||||
const err = e as { message?: string };
|
|
||||||
message.error(err?.message || '操作失败');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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) => {
|
const handleResetPwd = (record: any) => {
|
||||||
setResetTarget(record);
|
setResetTarget(record);
|
||||||
pwdForm.resetFields();
|
pwdForm.resetFields();
|
||||||
@@ -141,12 +199,11 @@ const UsersPage: React.FC = () => {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
const values = await pwdForm.validateFields();
|
const values = await pwdForm.validateFields();
|
||||||
try {
|
try {
|
||||||
await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password });
|
await pwdMutation.mutateAsync({ id: resetTarget.id, password: values.password });
|
||||||
message.success('密码已重置');
|
message.success('密码已重置');
|
||||||
setPwdModalOpen(false);
|
setPwdModalOpen(false);
|
||||||
} catch (e: unknown) {
|
} catch {
|
||||||
const err = e as { message?: string };
|
// 错误提示由 useApiMutation 统一处理
|
||||||
message.error(err?.message || '操作失败');
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -154,11 +211,14 @@ const UsersPage: React.FC = () => {
|
|||||||
|
|
||||||
const saveCell = useCallback(
|
const saveCell = useCallback(
|
||||||
async (record: any, field: string, value: unknown) => {
|
async (record: any, field: string, value: unknown) => {
|
||||||
await api.put(`/rbac/users/${record.id}`, { [field]: value });
|
try {
|
||||||
message.success('已保存');
|
await saveCellMutation.mutateAsync({ record, field, value });
|
||||||
await fetchData();
|
message.success('已保存');
|
||||||
|
} catch {
|
||||||
|
// 错误提示由 useApiMutation 统一处理
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[fetchData],
|
[saveCellMutation],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
@@ -166,7 +226,7 @@ const UsersPage: React.FC = () => {
|
|||||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||||
{
|
{
|
||||||
title: '用户名',
|
title: '用户名',
|
||||||
dataIndex: 'username',
|
dataIndex: USER_FIELDS.username,
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v: string, r: any) => (
|
render: (v: string, r: any) => (
|
||||||
<EditableCell
|
<EditableCell
|
||||||
@@ -174,7 +234,7 @@ const UsersPage: React.FC = () => {
|
|||||||
required
|
required
|
||||||
permission="user:edit"
|
permission="user:edit"
|
||||||
disabled={r.isArchived}
|
disabled={r.isArchived}
|
||||||
onSave={(next) => saveCell(r, 'username', next)}
|
onSave={(next) => saveCell(r, USER_FIELDS.username, next)}
|
||||||
>
|
>
|
||||||
{v}
|
{v}
|
||||||
</EditableCell>
|
</EditableCell>
|
||||||
@@ -182,7 +242,7 @@ const UsersPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '姓名',
|
title: '姓名',
|
||||||
dataIndex: 'name',
|
dataIndex: USER_FIELDS.name,
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v: string, r: any) => (
|
render: (v: string, r: any) => (
|
||||||
<EditableCell
|
<EditableCell
|
||||||
@@ -190,7 +250,7 @@ const UsersPage: React.FC = () => {
|
|||||||
required
|
required
|
||||||
permission="user:edit"
|
permission="user:edit"
|
||||||
disabled={r.isArchived}
|
disabled={r.isArchived}
|
||||||
onSave={(next) => saveCell(r, 'name', next)}
|
onSave={(next) => saveCell(r, USER_FIELDS.name, next)}
|
||||||
>
|
>
|
||||||
{v}
|
{v}
|
||||||
</EditableCell>
|
</EditableCell>
|
||||||
@@ -267,11 +327,18 @@ const UsersPage: React.FC = () => {
|
|||||||
重置密码
|
重置密码
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
{record.isArchived ? (
|
{record.isArchived ? (
|
||||||
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(record.id, false)}>
|
<>
|
||||||
<PermissionButton permission="user:edit" type="link" size="small">
|
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(record.id, false)}>
|
||||||
恢复
|
<PermissionButton permission="user:edit" type="link" size="small">
|
||||||
</PermissionButton>
|
恢复
|
||||||
</Popconfirm>
|
</PermissionButton>
|
||||||
|
</Popconfirm>
|
||||||
|
{canPurgeUser ? (
|
||||||
|
<Button size="small" danger type="link" onClick={() => handlePurge(record)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="归档后可恢复,确认归档?"
|
title="归档后可恢复,确认归档?"
|
||||||
@@ -286,7 +353,7 @@ const UsersPage: React.FC = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[roles, saveCell],
|
[roles, saveCell, canPurgeUser, handlePurge],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, ForbiddenException } from '@nestjs/common';
|
||||||
import { ForbiddenException } from '@nestjs/common';
|
|
||||||
import { CaslAbilityFactory } from './casl-ability.factory';
|
import { CaslAbilityFactory } from './casl-ability.factory';
|
||||||
import { AppAbility, AppSubject, AuthorizationRequest } from './interfaces';
|
import { AppAbility, AppSubject, AuthorizationRequest } from './interfaces';
|
||||||
import { CaslAction, permissionCodeSubject } from './casl.constants';
|
import { CaslAction, permissionCodeSubject } from './casl.constants';
|
||||||
@@ -69,9 +68,7 @@ export class AuthorizationService {
|
|||||||
*/
|
*/
|
||||||
assertPermission(ability: AppAbility, permissionCode: string): void {
|
assertPermission(ability: AppAbility, permissionCode: string): void {
|
||||||
if (!this.canPermission(ability, permissionCode)) {
|
if (!this.canPermission(ability, permissionCode)) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(`权限不足:缺少权限码 ${permissionCode}`);
|
||||||
`权限不足:缺少权限码 ${permissionCode}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,6 @@ export function permissionCodeSubject(code: string): string {
|
|||||||
return `PermissionCode:${code}`;
|
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 {
|
function permissionToAction(permission: string): CaslAction | null {
|
||||||
const actionSegment = permission.split(':')[1] ?? permission;
|
const actionSegment = permission.split(':')[1] ?? permission;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { MongoAbility } from '@casl/ability';
|
import { MongoAbility } from '@casl/ability';
|
||||||
import { CaslAction } from './casl.constants';
|
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.
|
// CASL expects the subject to be either the class constructor or a string.
|
||||||
// We use string subjects (SubjectName) for simplicity when no instance is
|
// We use string subjects (SubjectName) for simplicity when no instance is
|
||||||
// available, and concrete instance types for per-resource checks.
|
// available, and concrete instance types for per-resource checks.
|
||||||
@@ -12,10 +8,6 @@ export type AppSubject = string | Record<string, unknown>;
|
|||||||
|
|
||||||
export type AppAbility = MongoAbility<[CaslAction, AppSubject]>;
|
export type AppAbility = MongoAbility<[CaslAction, AppSubject]>;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Authenticated user — what the JWT strategy places on `request.user`.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface AuthenticatedUser {
|
export interface AuthenticatedUser {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -31,17 +23,16 @@ export interface AuthenticatedUser {
|
|||||||
* Minimum authorization principal — the subset of AuthenticatedUser
|
* Minimum authorization principal — the subset of AuthenticatedUser
|
||||||
* needed by CaslAbilityFactory and AuthorizationService.
|
* 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. */
|
/** Request-like carrier populated only by the trusted authentication layer. */
|
||||||
export interface AuthorizationRequest {
|
export interface AuthorizationRequest {
|
||||||
user?: AuthPrincipal;
|
user?: AuthPrincipal;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Policy handler types for @CheckPolicies()
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interface for class-based policy handlers.
|
* Interface for class-based policy handlers.
|
||||||
*
|
*
|
||||||
|
|||||||
268
apps/server/src/rbac/rbac-presets.ts
Normal file
268
apps/server/src/rbac/rbac-presets.ts
Normal file
@@ -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<string>(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<string, number> = {
|
||||||
|
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'],
|
||||||
|
},
|
||||||
|
];
|
||||||
298
apps/server/src/rbac/rbac-seed.service.ts
Normal file
298
apps/server/src/rbac/rbac-seed.service.ts
Normal file
@@ -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<Permission>,
|
||||||
|
@InjectRepository(Role) private roleRepo: Repository<Role>,
|
||||||
|
@InjectRepository(User) private userRepo: Repository<User>,
|
||||||
|
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||||
|
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||||
|
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||||
|
@InjectRepository(ClassSchedule) private classScheduleRepo: Repository<ClassSchedule>,
|
||||||
|
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||||
|
@InjectRepository(AttendanceSession)
|
||||||
|
private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAllRoles(): Promise<Role[]> {
|
||||||
|
return this.roleRepo.find({
|
||||||
|
relations: ['permissions'],
|
||||||
|
order: { id: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findRoleById(id: number): Promise<Role> {
|
||||||
|
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolvePermissions(permissionIds: number[]): Promise<Permission[]> {
|
||||||
|
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<Permission>,
|
||||||
|
@InjectRepository(Role) private roleRepo: Repository<Role>,
|
||||||
|
@InjectRepository(User) private userRepo: Repository<User>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise<Role | null> {
|
||||||
|
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<void> {
|
||||||
|
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} 角色`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
269
apps/server/src/rbac/rbac-user.service.ts
Normal file
269
apps/server/src/rbac/rbac-user.service.ts
Normal file
@@ -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<Permission>,
|
||||||
|
@InjectRepository(Role) private roleRepo: Repository<Role>,
|
||||||
|
@InjectRepository(User) private userRepo: Repository<User>,
|
||||||
|
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||||
|
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||||
|
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||||
|
@InjectRepository(ClassSchedule) private classScheduleRepo: Repository<ClassSchedule>,
|
||||||
|
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||||
|
@InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolvePermissions(permissionIds: number[]): Promise<Permission[]> {
|
||||||
|
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<Role[]> {
|
||||||
|
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<number, ClassTeacher[]>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { logAudit } from '../common/with-audit-log';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('rbac')
|
@Controller('rbac')
|
||||||
@@ -33,8 +33,6 @@ export class RbacController {
|
|||||||
private logService: OperationLogsService,
|
private logService: OperationLogsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ==================== 角色管理 ====================
|
|
||||||
|
|
||||||
@Get('roles')
|
@Get('roles')
|
||||||
@RequirePermission('role:view')
|
@RequirePermission('role:view')
|
||||||
findAllRoles() {
|
findAllRoles() {
|
||||||
@@ -50,16 +48,9 @@ export class RbacController {
|
|||||||
@Post('roles')
|
@Post('roles')
|
||||||
@RequirePermission('role:create')
|
@RequirePermission('role:create')
|
||||||
async createRole(@Body() dto: CreateRoleDto, @Request() req: any) {
|
async createRole(@Body() dto: CreateRoleDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
const result = await this.rbacService.createRole(dto);
|
const result = await this.rbacService.createRole(dto);
|
||||||
await this.logService.log({
|
await logAudit(this.logService, req, {
|
||||||
userId: req.user?.id,
|
module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`,
|
||||||
username: req.user?.username,
|
|
||||||
module: 'RBAC',
|
|
||||||
action: '创建角色',
|
|
||||||
detail: `角色: ${dto.name}`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -67,19 +58,10 @@ export class RbacController {
|
|||||||
@Put('roles/:id')
|
@Put('roles/:id')
|
||||||
@RequirePermission('role:edit')
|
@RequirePermission('role:edit')
|
||||||
async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto, @Request() req: any) {
|
async updateRole(@Param('id') id: string, @Body() dto: UpdateRoleDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.updateRole(+id, dto);
|
const result = await this.rbacService.updateRole(+id, dto);
|
||||||
await this.logService.log({
|
await logAudit(this.logService, req, {
|
||||||
userId: req.user?.id,
|
module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto),
|
||||||
username: req.user?.username,
|
|
||||||
module: 'RBAC',
|
|
||||||
action: '编辑角色',
|
|
||||||
targetId: +id,
|
|
||||||
targetType: 'role',
|
|
||||||
detail: JSON.stringify(dto),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -90,18 +72,10 @@ export class RbacController {
|
|||||||
@Delete('roles/:id')
|
@Delete('roles/:id')
|
||||||
@RequirePermission('role:delete')
|
@RequirePermission('role:delete')
|
||||||
async deleteRole(@Param('id') id: string, @Request() req: any) {
|
async deleteRole(@Param('id') id: string, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.deleteRole(+id);
|
const result = await this.rbacService.deleteRole(+id);
|
||||||
await this.logService.log({
|
await logAudit(this.logService, req, {
|
||||||
userId: req.user?.id,
|
module: 'RBAC', action: '停用角色', targetId: +id, targetType: 'role',
|
||||||
username: req.user?.username,
|
|
||||||
module: 'RBAC',
|
|
||||||
action: '停用角色',
|
|
||||||
targetId: +id,
|
|
||||||
targetType: 'role',
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -109,8 +83,6 @@ export class RbacController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 权限管理 ====================
|
|
||||||
|
|
||||||
@Get('permissions')
|
@Get('permissions')
|
||||||
@RequirePermission('role:view')
|
@RequirePermission('role:view')
|
||||||
findAllPermissions() {
|
findAllPermissions() {
|
||||||
@@ -123,8 +95,6 @@ export class RbacController {
|
|||||||
return this.rbacService.getPermissionTree();
|
return this.rbacService.getPermissionTree();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 用户管理 ====================
|
|
||||||
|
|
||||||
@Get('users')
|
@Get('users')
|
||||||
@RequirePermission('user:view', 'teacher:view')
|
@RequirePermission('user:view', 'teacher:view')
|
||||||
getUsers(@Query('isArchived') isArchived?: string) {
|
getUsers(@Query('isArchived') isArchived?: string) {
|
||||||
@@ -135,17 +105,10 @@ export class RbacController {
|
|||||||
@Post('users')
|
@Post('users')
|
||||||
@RequirePermission('user:create')
|
@RequirePermission('user:create')
|
||||||
async createUser(@Body() dto: CreateUserDto, @Request() req: any) {
|
async createUser(@Body() dto: CreateUserDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.createUser(dto);
|
const result = await this.rbacService.createUser(dto);
|
||||||
await this.logService.log({
|
await logAudit(this.logService, req, {
|
||||||
userId: req.user?.id,
|
module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`,
|
||||||
username: req.user?.username,
|
|
||||||
module: '账号',
|
|
||||||
action: '创建账号',
|
|
||||||
detail: `用户名: ${dto.username}`,
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -156,19 +119,10 @@ export class RbacController {
|
|||||||
@Put('users/:id')
|
@Put('users/:id')
|
||||||
@RequirePermission('user:edit')
|
@RequirePermission('user:edit')
|
||||||
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto, @Request() req: any) {
|
async updateUser(@Param('id') id: string, @Body() dto: UpdateUserDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.updateUser(+id, dto);
|
const result = await this.rbacService.updateUser(+id, dto);
|
||||||
await this.logService.log({
|
await logAudit(this.logService, req, {
|
||||||
userId: req.user?.id,
|
module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto),
|
||||||
username: req.user?.username,
|
|
||||||
module: '账号',
|
|
||||||
action: '更新账号',
|
|
||||||
targetId: +id,
|
|
||||||
targetType: 'user',
|
|
||||||
detail: JSON.stringify(dto),
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -179,18 +133,10 @@ export class RbacController {
|
|||||||
@Put('users/:id/password')
|
@Put('users/:id/password')
|
||||||
@RequirePermission('user:reset-password')
|
@RequirePermission('user:reset-password')
|
||||||
async resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto, @Request() req: any) {
|
async resetPassword(@Param('id') id: string, @Body() dto: ResetPasswordDto, @Request() req: any) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.resetPassword(+id, dto.password);
|
const result = await this.rbacService.resetPassword(+id, dto.password);
|
||||||
await this.logService.log({
|
await logAudit(this.logService, req, {
|
||||||
userId: req.user?.id,
|
module: '账号', action: '重置密码', targetId: +id, targetType: 'user',
|
||||||
username: req.user?.username,
|
|
||||||
module: '账号',
|
|
||||||
action: '重置密码',
|
|
||||||
targetId: +id,
|
|
||||||
targetType: 'user',
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} 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')
|
@Put('users/:id/mark-staff')
|
||||||
@RequirePermission('user:edit')
|
@RequirePermission('user:edit')
|
||||||
async markAsStaff(@Param('id') id: string) {
|
async markAsStaff(@Param('id') id: string) {
|
||||||
@@ -242,8 +203,6 @@ export class RbacController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 用户资料 ----
|
|
||||||
|
|
||||||
@Get('users/:id/profile')
|
@Get('users/:id/profile')
|
||||||
@RequirePermission('user:view')
|
@RequirePermission('user:view')
|
||||||
getUserProfile(@Param('id') id: string) {
|
getUserProfile(@Param('id') id: string) {
|
||||||
@@ -257,18 +216,10 @@ export class RbacController {
|
|||||||
@Body() dto: UpdateProfileDto,
|
@Body() dto: UpdateProfileDto,
|
||||||
@Request() req: any,
|
@Request() req: any,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.updateUserProfile(+id, dto);
|
const result = await this.rbacService.updateUserProfile(+id, dto);
|
||||||
await this.logService.log({
|
await logAudit(this.logService, req, {
|
||||||
userId: req.user?.id,
|
module: '账号', action: '更新资料', targetId: +id, targetType: 'user',
|
||||||
username: req.user?.username,
|
|
||||||
module: '账号',
|
|
||||||
action: '更新资料',
|
|
||||||
targetId: +id,
|
|
||||||
targetType: 'user',
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -276,16 +227,12 @@ export class RbacController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 教师工作台 ----
|
|
||||||
|
|
||||||
@Get('teacher-workspace')
|
@Get('teacher-workspace')
|
||||||
@RequirePermission('teacher-workspace:view')
|
@RequirePermission('teacher-workspace:view')
|
||||||
async getTeacherWorkspace(@Request() req: any) {
|
async getTeacherWorkspace(@Request() req: any) {
|
||||||
return this.rbacService.getTeacherWorkspace(req.user?.id);
|
return this.rbacService.getTeacherWorkspace(req.user?.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 教师管理 ----
|
|
||||||
|
|
||||||
@Get('teachers')
|
@Get('teachers')
|
||||||
@RequirePermission('teacher:view')
|
@RequirePermission('teacher:view')
|
||||||
async getTeachers(
|
async getTeachers(
|
||||||
@@ -307,18 +254,9 @@ export class RbacController {
|
|||||||
@Body() profile: UpdateProfileDto,
|
@Body() profile: UpdateProfileDto,
|
||||||
@Request() req: { user?: { id: number; username: string } },
|
@Request() req: { user?: { id: number; username: string } },
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
|
||||||
const result = await this.rbacService.updateTeacherProfile(+id, profile);
|
const result = await this.rbacService.updateTeacherProfile(+id, profile);
|
||||||
await this.logService.log({
|
await logAudit(this.logService, req, {
|
||||||
userId: req.user?.id,
|
module: '教师管理', action: '编辑档案', targetId: +id, targetType: 'user', detail: '更新教师档案',
|
||||||
username: req.user?.username,
|
|
||||||
module: '教师管理',
|
|
||||||
action: '编辑档案',
|
|
||||||
targetId: +id,
|
|
||||||
targetType: 'user',
|
|
||||||
detail: '更新教师档案',
|
|
||||||
ipAddress,
|
|
||||||
userAgent,
|
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Module, OnModuleInit, forwardRef } from '@nestjs/common';
|
import { Module, OnModuleInit, forwardRef } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
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 { RbacService } from './rbac.service';
|
||||||
|
import { RbacSeedService } from './rbac-seed.service';
|
||||||
|
import { RbacUserService } from './rbac-user.service';
|
||||||
import { RbacController } from './rbac.controller';
|
import { RbacController } from './rbac.controller';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
|
||||||
@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],
|
controllers: [RbacController],
|
||||||
providers: [RbacService],
|
providers: [RbacService, RbacSeedService, RbacUserService],
|
||||||
exports: [RbacService],
|
exports: [RbacService],
|
||||||
})
|
})
|
||||||
export class RbacModule implements OnModuleInit {
|
export class RbacModule implements OnModuleInit {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PRESET_ROLES } from './rbac.service';
|
import { PRESET_ROLES } from './rbac-presets';
|
||||||
|
|
||||||
function permissionsFor(roleCode: string): { groups: string[]; extras: string[] } {
|
function permissionsFor(roleCode: string): { groups: string[]; extras: string[] } {
|
||||||
const role = PRESET_ROLES.find((item) => item.code === roleCode);
|
const role = PRESET_ROLES.find((item) => item.code === roleCode);
|
||||||
|
|||||||
25
apps/server/src/rbac/rbac.purge.controller.spec.ts
Normal file
25
apps/server/src/rbac/rbac.purge.controller.spec.ts
Normal file
@@ -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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
apps/server/src/rbac/rbac.purge.spec.ts
Normal file
64
apps/server/src/rbac/rbac.purge.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { RbacService } from './rbac.service';
|
||||||
|
|
||||||
|
describe('RbacService.purgeUser', () => {
|
||||||
|
const createService = (overrides?: {
|
||||||
|
user?: Record<string, unknown>;
|
||||||
|
counts?: Record<string, number>;
|
||||||
|
}) => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
import * as bcrypt from 'bcryptjs';
|
|
||||||
import {
|
import {
|
||||||
Permission,
|
Permission,
|
||||||
Role,
|
Role,
|
||||||
@@ -11,263 +14,9 @@ import {
|
|||||||
ClassTeacher,
|
ClassTeacher,
|
||||||
ClassSchedule,
|
ClassSchedule,
|
||||||
Student,
|
Student,
|
||||||
|
AttendanceSession,
|
||||||
} from '../entities';
|
} 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<string>(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<string, number> = { 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()
|
@Injectable()
|
||||||
export class RbacService {
|
export class RbacService {
|
||||||
private readonly logger = new Logger(RbacService.name);
|
private readonly logger = new Logger(RbacService.name);
|
||||||
@@ -281,179 +30,34 @@ export class RbacService {
|
|||||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||||
@InjectRepository(ClassSchedule) private classScheduleRepo: Repository<ClassSchedule>,
|
@InjectRepository(ClassSchedule) private classScheduleRepo: Repository<ClassSchedule>,
|
||||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||||
|
@InjectRepository(AttendanceSession)
|
||||||
|
private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||||
|
@Optional() private seedService?: RbacSeedService,
|
||||||
|
@Optional() private userService?: RbacUserService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise<Role | null> {
|
private get seedOps(): RbacSeedService {
|
||||||
for (const code of preset.legacyCodes ?? []) {
|
if (!this.seedService) {
|
||||||
const role = await this.roleRepo.findOne({ where: { code } });
|
this.seedService = new RbacSeedService(this.permRepo, this.roleRepo, this.userRepo);
|
||||||
if (role) return role;
|
|
||||||
}
|
}
|
||||||
for (const name of preset.legacyNames ?? []) {
|
return this.seedService;
|
||||||
const role = await this.roleRepo.findOne({ where: { name } });
|
|
||||||
if (role) return role;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async seedData(): Promise<void> {
|
private get userOps(): RbacUserService {
|
||||||
const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true });
|
if (!this.userService) {
|
||||||
if (restoredLegacyUsers.affected) {
|
this.userService = new RbacUserService(
|
||||||
this.logger.log(
|
this.permRepo,
|
||||||
`已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`,
|
this.roleRepo,
|
||||||
|
this.userRepo,
|
||||||
|
this.classRepo,
|
||||||
|
this.classStudentRepo,
|
||||||
|
this.classTeacherRepo,
|
||||||
|
this.classScheduleRepo,
|
||||||
|
this.studentRepo,
|
||||||
|
this.attendanceSessionRepo,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return this.userService;
|
||||||
// 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} 角色`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAllRoles(): Promise<Role[]> {
|
async findAllRoles(): Promise<Role[]> {
|
||||||
@@ -467,28 +71,6 @@ export class RbacService {
|
|||||||
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
|
return this.roleRepo.findOneOrFail({ where: { id }, relations: ['permissions'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolvePermissions(permissionIds: number[]): Promise<Permission[]> {
|
|
||||||
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<Role[]> {
|
|
||||||
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: {
|
async createRole(dto: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
@@ -496,7 +78,7 @@ export class RbacService {
|
|||||||
}): Promise<Role> {
|
}): Promise<Role> {
|
||||||
const role = this.roleRepo.create({ name: dto.name, description: dto.description });
|
const role = this.roleRepo.create({ name: dto.name, description: dto.description });
|
||||||
if (dto.permissionIds && dto.permissionIds.length > 0) {
|
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);
|
return this.roleRepo.save(role);
|
||||||
}
|
}
|
||||||
@@ -513,7 +95,7 @@ export class RbacService {
|
|||||||
if (dto.description !== undefined) role.description = dto.description;
|
if (dto.description !== undefined) role.description = dto.description;
|
||||||
if (dto.permissionIds !== undefined) {
|
if (dto.permissionIds !== undefined) {
|
||||||
role.permissions =
|
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);
|
return this.roleRepo.save(role);
|
||||||
}
|
}
|
||||||
@@ -557,139 +139,6 @@ export class RbacService {
|
|||||||
return Array.from(codes);
|
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) {
|
async getTeacherWorkspace(userId: number) {
|
||||||
// Find all classes where this user is a teacher
|
// Find all classes where this user is a teacher
|
||||||
@@ -704,7 +153,6 @@ export class RbacService {
|
|||||||
return { assignedClasses: [], todaySchedules: [], myStudents: [] };
|
return { assignedClasses: [], todaySchedules: [], myStudents: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get assigned classes
|
|
||||||
const assignedClasses = teacherAssignments.map((t) => ({
|
const assignedClasses = teacherAssignments.map((t) => ({
|
||||||
classId: t.classId,
|
classId: t.classId,
|
||||||
className: t.class?.name || '',
|
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)
|
// Get today's China business date and day of week (1=Monday, 7=Sunday)
|
||||||
const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts();
|
const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts();
|
||||||
|
|
||||||
// Get today's schedules for assigned classes
|
|
||||||
const todaySchedules = await this.classScheduleRepo
|
const todaySchedules = await this.classScheduleRepo
|
||||||
.createQueryBuilder('cs')
|
.createQueryBuilder('cs')
|
||||||
.where('cs.classId IN (:...classIds)', { classIds })
|
.where('cs.classId IN (:...classIds)', { classIds })
|
||||||
@@ -727,7 +174,6 @@ export class RbacService {
|
|||||||
.orderBy('cs.startTime', 'ASC')
|
.orderBy('cs.startTime', 'ASC')
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
// Get students in assigned classes
|
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = await this.classStudentRepo.find({
|
||||||
where: { classId: In(classIds), status: 'active' },
|
where: { classId: In(classIds), status: 'active' },
|
||||||
relations: ['student', 'class'],
|
relations: ['student', 'class'],
|
||||||
@@ -758,71 +204,60 @@ export class RbacService {
|
|||||||
myStudents,
|
myStudents,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
async seedData(): Promise<void> {
|
||||||
|
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 }) {
|
async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) {
|
||||||
const page = query?.page || 1;
|
return this.userOps.getTeachers(query);
|
||||||
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<number, ClassTeacher[]>();
|
|
||||||
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(
|
async updateTeacherProfile(id: number, dto: UpdateProfileDto) {
|
||||||
id: number,
|
return this.userOps.updateTeacherProfile(id, dto);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user