fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling

- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers
- H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables
- M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps)
- M2: split handleSave try/catch — save errors vs reload errors shown distinctly
- M3: added provider field validation before AI config test request
- Added SSE scoping regression tests (import service + controller)
- Added FK check failure rollback test (database-migrations.spec)
- Updated controller spec expectations for userId parameter

Co-authored-by: Code Review <branch-review>
This commit is contained in:
2026-07-12 22:59:03 +08:00
parent b6fca99390
commit cc4f4dae4e
69 changed files with 6262 additions and 1980 deletions

View File

@@ -32,95 +32,37 @@ import { usePermission } from '../hooks/usePermission';
import api from '../api';
import { writePermissions } from '../auth/permission-store';
import NotificationBell from '../components/NotificationBell';
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
const { Header, Sider, Content } = Layout;
interface MenuItemType {
key: string;
icon: React.ReactNode;
label: string;
permission?: string;
children?: MenuItemType[];
}
const allMenuItems: MenuItemType[] = [
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '数据面板',
permission: 'dashboard:view',
},
{
key: 'dorm-group',
icon: <HomeOutlined />,
label: '宿舍运营',
permission: 'room:view',
children: [
{ key: '/room-visual', icon: <AppstoreOutlined />, label: '宿舍总览', permission: 'room:view' },
{ key: '/rooms', icon: <HomeOutlined />, label: '宿舍管理', permission: 'room:view' },
{ key: '/occupancies', icon: <SwapOutlined />, label: '入住管理', permission: 'occupancy:view' },
],
},
{
key: 'student-group',
icon: <TeamOutlined />,
label: '学员管理',
permission: 'student:view',
children: [
{ key: '/students', icon: <TeamOutlined />, label: '学生管理', permission: 'student:view' },
{ key: '/classes', icon: <TeamOutlined />, label: '班级管理', permission: 'class:view' },
{ key: '/attendance', icon: <CheckCircleOutlined />, label: '考勤管理', permission: 'attendance:view' },
],
},
{
key: 'academic-group',
icon: <CalendarOutlined />,
label: '教务管理',
permission: 'schedule:view',
children: [
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class:view' },
],
},
{
key: 'classroom-group',
icon: <ReadOutlined />,
label: '教室管理',
permission: 'classroom:view',
children: [
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'rental:view' },
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
{ key: '/organizations', icon: <TagsOutlined />, label: '机构管理', permission: 'organization:view' },
],
},
{
key: 'finance-group',
icon: <DollarOutlined />,
label: '财务管理',
permission: 'expense:view',
children: [
{ key: '/expenses', icon: <DollarOutlined />, label: '费用录入', permission: 'expense:view' },
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
],
},
{
key: 'system-group',
icon: <SettingOutlined />,
label: '系统管理',
permission: 'log:view',
children: [
{ key: '/notifications', icon: <BellOutlined />, label: '通知中心', permission: 'notification:view' },
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },
{ key: '/integration-config', icon: <ApiOutlined />, label: '钉钉集成配置', permission: 'integration:read' },
{ key: '/ai-config', icon: <RobotOutlined />, label: 'AI 模型配置', permission: 'ai:config:read' },
{ key: '/users', icon: <SettingOutlined />, label: '账号管理', permission: 'user:view' },
],
},
];
const iconMap: Record<string, React.ReactNode> = {
dashboard: <DashboardOutlined />,
calendar: <CalendarOutlined />,
workspace: <LaptopOutlined />,
attendance: <CheckCircleOutlined />,
academic: <TeamOutlined />,
students: <TeamOutlined />,
classes: <TeamOutlined />,
teachers: <UserOutlined />,
home: <HomeOutlined />,
overview: <AppstoreOutlined />,
occupancy: <SwapOutlined />,
expense: <DollarOutlined />,
bill: <FileTextOutlined />,
deposit: <WalletOutlined />,
classroom: <ReadOutlined />,
rental: <FileProtectOutlined />,
organization: <TagsOutlined />,
settings: <SettingOutlined />,
users: <UserOutlined />,
role: <SafetyOutlined />,
permission: <KeyOutlined />,
log: <AuditOutlined />,
integration: <ApiOutlined />,
ai: <RobotOutlined />,
notification: <BellOutlined />,
};
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
@@ -129,8 +71,10 @@ const MainLayout: React.FC = () => {
const prevPathname = useRef('');
const navigate = useNavigate();
const location = useLocation();
const user = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []);
const { hasPermission } = usePermission();
const [user, setUser] = useState<{ name?: string; username?: string; roles?: string[] }>(() =>
JSON.parse(localStorage.getItem('user') || '{}'),
);
const { permissions, hasPermission } = usePermission();
useEffect(() => {
let cancelled = false;
@@ -139,7 +83,9 @@ const MainLayout: React.FC = () => {
if (cancelled) return;
writePermissions(profile.permissions || []);
const cachedUser = JSON.parse(localStorage.getItem('user') || '{}');
localStorage.setItem('user', JSON.stringify({ ...cachedUser, ...profile }));
const nextUser = { ...cachedUser, ...profile };
localStorage.setItem('user', JSON.stringify(nextUser));
setUser(nextUser);
})
.catch(() => {
// The API interceptor handles expired/invalid sessions.
@@ -152,22 +98,10 @@ const MainLayout: React.FC = () => {
const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px
const isDesktop = !!screens.lg; // >= 992px
// 按 permission 过滤菜单
const filterByPermission = (items: MenuItemType[]): MenuItemType[] => {
return items
.map((item) => {
if (item.children) {
const kids = filterByPermission(item.children);
if (kids.length === 0) return null;
return { ...item, children: kids };
}
if (!item.permission) return item;
return hasPermission(item.permission) ? item : null;
})
.filter(Boolean) as MenuItemType[];
};
const menuItems = useMemo(() => filterByPermission(allMenuItems), [hasPermission]);
const menuItems = useMemo(
() => buildMenu(user.roles ?? [], permissions),
[user.roles, permissions],
);
const handleLogout = useCallback(() => {
localStorage.removeItem('token');
@@ -181,7 +115,7 @@ const MainLayout: React.FC = () => {
if (isMobile) setDrawerOpen(false);
}, [navigate, isMobile]);
const findSelectedKeys = (items: MenuItemType[], pathname: string): string[] => {
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) {
if (item.key === pathname) return [item.key];
if (item.children) {
@@ -192,7 +126,7 @@ const MainLayout: React.FC = () => {
return [pathname];
};
const findOpenKeys = (items: MenuItemType[], pathname: string): string[] => {
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
for (const item of items) {
if (item.children) {
if (item.children.some((c) => c.key === pathname || (c.children && c.children.some((gc) => gc.key === pathname)))) {
@@ -220,10 +154,10 @@ const MainLayout: React.FC = () => {
const transformToMenuItems = (items: MenuItemType[]): any[] => {
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
return items.map((item) => ({
key: item.key,
icon: item.icon,
icon: item.icon ? iconMap[item.icon] : undefined,
label: item.label,
children: item.children ? transformToMenuItems(item.children) : undefined,
}));