7 Commits

Author SHA1 Message Date
1c8bd08be4 feat: settle course attendance automatically 2026-07-13 10:26:38 +08:00
b9b295c997 refactor: remove unused classroom fields 2026-07-13 09:34:15 +08:00
a266d450e1 chore: apply eslint --fix autofixes (remove unnecessary as never casts in tests) 2026-07-12 23:01:37 +08:00
b5f4b8747c fix: schedules lookups test mock missing classroomRepo constructor arg
The SchedulesService constructor expects 5 repository arguments.
The test only provided 4, passing {} as never for classroomRepo.
Added classroomRepo mock with find() returning full selected columns.

Fixes: schedules.lookups.spec.ts — 'returns scoped classes and
minimal classrooms for schedule viewers'
2026-07-12 23:01:00 +08:00
cc4f4dae4e 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>
2026-07-12 22:59:03 +08:00
b6fca99390 fix: align permission navigation and page access 2026-07-11 14:55:49 +08:00
1e1c476bc3 feat: add CASL authorization and AI configuration 2026-07-11 14:25:34 +08:00
143 changed files with 15356 additions and 2109 deletions

View File

@@ -13,3 +13,19 @@ DB_SYNCHRONIZE=false
JWT_SECRET=change-me-to-a-random-string-at-least-32-chars
JWT_EXPIRES_IN=24h
PORT=3000
# ---- AI 模型配置 ----
# AES-256-GCM 加密主密钥,用于加密存储 API Key
# 生产环境必须设置生成方式openssl rand -hex 32
# 格式64 位 hex推荐或 base64 编码后恰好 32 字节
# 示例 hexopenssl rand -hex 32
# 示例 base64openssl rand -base64 32
AI_CONFIG_ENCRYPTION_KEY=
# AI API Key 环境变量回退(可选)
# 若数据库未保存 Key将从该环境变量读取
# 环境变量 Key 不可从页面覆盖或清除
# AI_API_KEY=
# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl仅内网部署使用
# AI_ALLOW_PRIVATE_BASE_URL=true

View File

@@ -4,6 +4,7 @@ import { ConfigProvider, App as AntdApp, Spin } from 'antd';
import zhCN from 'antd/es/locale/zh_CN';
import MainLayout from './layouts/MainLayout';
import PermissionRoute from './components/PermissionRoute';
import DefaultRoute from './components/DefaultRoute';
import AppMessageBridge from './ui/AppMessageBridge';
const LoginPage = lazy(() => import('./pages/Login'));
@@ -32,6 +33,7 @@ const AttendancePage = lazy(() => import('./pages/Attendance'));
const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
const NotificationsPage = lazy(() => import('./pages/Notifications'));
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
const AiConfigPage = lazy(() => import('./pages/AiConfig'));
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const token = localStorage.getItem('token');
@@ -66,7 +68,7 @@ const App: React.FC = () => {
</PrivateRoute>
}
>
<Route index element={<Navigate to="/dashboard" />} />
<Route index element={<DefaultRoute />} />
<Route
path="dashboard"
element={
@@ -191,7 +193,7 @@ const App: React.FC = () => {
<Route
path="teachers"
element={
<PermissionRoute permission="user:view">
<PermissionRoute permission="teacher:view">
<TeachersPage />
</PermissionRoute>
}
@@ -223,7 +225,7 @@ const App: React.FC = () => {
<Route
path="classroom-schedule"
element={
<PermissionRoute permission="classroom:view">
<PermissionRoute permission="rental:view">
<ClassroomSchedulePage />
</PermissionRoute>
}
@@ -250,7 +252,7 @@ const App: React.FC = () => {
<Route
path="teacher-workspace"
element={
<PermissionRoute permission="class:view">
<PermissionRoute permission="teacher-workspace:view">
<TeacherWorkspacePage />
</PermissionRoute>
}
@@ -274,6 +276,15 @@ const App: React.FC = () => {
</PermissionRoute>
}
/>
<Route
path="ai-config"
element={
<PermissionRoute permission="ai:config:read">
<AiConfigPage />
</PermissionRoute>
}
/>
</Route>
</Routes>
</Suspense>

View File

@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import { buildMenu, collectMenuPaths, findRoleAwareLandingPath } from './menu-policy';
const teacherPermissions = [
'teacher-workspace:view',
'schedule:view',
'attendance:view',
'notification:view',
];
const academicPermissions = [
'dashboard:view',
'student:view',
'class:view',
'teacher:view',
'schedule:view',
'attendance:view',
'classroom:view',
'notification:view',
];
const accommodationPermissions = [
'dashboard:view',
'room:view',
'occupancy:view',
'expense:view',
'bill:view',
'deposit:view',
'notification:view',
];
const systemPermissions = [
'user:view',
'role:view',
'log:view',
'integration:read',
'ai:config:read',
'notification:view',
];
describe('role-aware menu policy', () => {
it('builds a teacher flow without global student or class management', () => {
const menu = buildMenu(['任课老师'], teacherPermissions);
expect(menu.map((item) => item.label)).toEqual(['教学工作', '通知中心']);
expect(collectMenuPaths(menu)).toEqual([
'/teacher-workspace',
'/schedules',
'/attendance',
'/notifications',
]);
expect(findRoleAwareLandingPath(['任课老师'], teacherPermissions)).toBe(
'/teacher-workspace',
);
});
it('places schedules and attendance only once in academic management', () => {
const menu = buildMenu(['教务管理员'], academicPermissions);
const paths = collectMenuPaths(menu);
expect(menu.map((item) => item.label)).toEqual(['数据面板', '教务管理', '通知中心']);
expect(paths.filter((path) => path === '/schedules')).toHaveLength(1);
expect(paths.filter((path) => path === '/attendance')).toHaveLength(1);
expect(paths).not.toContain('/teacher-workspace');
});
it('keeps accommodation billing in one business workspace', () => {
const menu = buildMenu(['住宿运营管理员'], accommodationPermissions);
expect(menu.map((item) => item.label)).toEqual(['数据面板', '住宿运营', '通知中心']);
expect(collectMenuPaths(menu)).toEqual([
'/dashboard',
'/room-visual',
'/rooms',
'/occupancies',
'/expenses',
'/bills',
'/deposits',
'/notifications',
]);
});
it('lands system administrators on account management rather than notifications', () => {
expect(findRoleAwareLandingPath(['系统管理员'], systemPermissions)).toBe('/users');
});
it('builds a super-admin menu with unique routes and no teacher workspace', () => {
const allPermissions = [
...academicPermissions,
...accommodationPermissions,
...systemPermissions,
'rental:view',
'organization:view',
];
const menu = buildMenu(['超级管理员'], [...new Set(allPermissions)]);
const paths = collectMenuPaths(menu);
expect(new Set(paths).size).toBe(paths.length);
expect(paths).not.toContain('/teacher-workspace');
expect(paths.filter((path) => path === '/attendance')).toHaveLength(1);
expect(paths.filter((path) => path === '/schedules')).toHaveLength(1);
});
it('deduplicates routes when an account has multiple business roles', () => {
const menu = buildMenu(
['教务管理员', '住宿运营管理员'],
[...academicPermissions, ...accommodationPermissions],
);
const paths = collectMenuPaths(menu);
expect(new Set(paths).size).toBe(paths.length);
});
});

View File

@@ -0,0 +1,186 @@
export interface AppMenuItem {
key: string;
label: string;
icon?: string;
children?: AppMenuItem[];
}
interface MenuEntry extends AppMenuItem {
permission: string;
}
interface MenuSection extends AppMenuItem {
roles: string[];
children: MenuEntry[];
}
const ROLE_ALIASES: Record<string, string> = {
: 'teacher',
: 'teacher',
teacher: 'teacher',
: 'academic',
: 'academic',
academic: 'academic',
宿: 'accommodation',
宿: 'accommodation',
: 'accommodation',
宿: 'accommodation',
accommodation_operations: 'accommodation',
: 'classroom',
: 'classroom',
classroom_operations: 'classroom',
: 'system',
system_admin: 'system',
: 'super',
: 'super',
super_admin: 'super',
};
const SECTIONS: MenuSection[] = [
{
key: 'teaching-group',
label: '教学工作',
icon: 'calendar',
roles: ['teacher'],
children: [
{ key: '/teacher-workspace', label: '今日教学', icon: 'workspace', permission: 'teacher-workspace:view' },
{ key: '/schedules', label: '我的排课', icon: 'calendar', permission: 'schedule:view' },
{ key: '/attendance', label: '课程考勤', icon: 'attendance', permission: 'attendance:view' },
],
},
{
key: 'academic-group',
label: '教务管理',
icon: 'academic',
roles: ['academic', 'super'],
children: [
{ key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' },
{ key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' },
{ key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' },
{ key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' },
{ key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' },
{ key: '/classrooms', label: '教室查看', icon: 'classroom', permission: 'classroom:view' },
],
},
{
key: 'accommodation-group',
label: '住宿运营',
icon: 'home',
roles: ['accommodation', 'super'],
children: [
{ key: '/room-visual', label: '住宿总览', icon: 'overview', permission: 'room:view' },
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
],
},
{
key: 'classroom-group',
label: '教室运营',
icon: 'classroom',
roles: ['classroom', 'super'],
children: [
{ key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' },
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
{ key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' },
],
},
{
key: 'system-group',
label: '系统管理',
icon: 'settings',
roles: ['system', 'super'],
children: [
{ key: '/users', label: '账号管理', icon: 'users', permission: 'user:view' },
{ key: '/roles', label: '角色管理', icon: 'role', permission: 'role:view' },
{ key: '/permissions', label: '权限一览', icon: 'permission', permission: 'role:view' },
{ key: '/operation-logs', label: '操作日志', icon: 'log', permission: 'log:view' },
{ key: '/integration-config', label: '钉钉集成', icon: 'integration', permission: 'integration:read' },
{ key: '/ai-config', label: 'AI 配置', icon: 'ai', permission: 'ai:config:read' },
],
},
];
export function getRoleDomains(roles: readonly string[], permissions: readonly string[]): Set<string> {
const normalized = new Set(roles.map((role) => ROLE_ALIASES[role]).filter(Boolean));
// 权限可以来自多个叠加角色,因此业务域按能力累加,而不是只选择一个。
if (permissions.includes('student:view') || permissions.includes('class:view')) {
normalized.add('academic');
}
if (
permissions.includes('room:view') &&
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))
) {
normalized.add('accommodation');
}
if (permissions.includes('rental:view') || permissions.includes('organization:view')) {
normalized.add('classroom');
}
if (permissions.includes('user:view') || permissions.includes('role:view')) {
normalized.add('system');
}
const hasAdministrativeDomain = [...normalized].some((role) => role !== 'teacher');
if (!hasAdministrativeDomain && permissions.includes('teacher-workspace:view')) {
normalized.add('teacher');
}
return normalized;
}
function dedupeMenu(items: AppMenuItem[]): AppMenuItem[] {
const usedPaths = new Set<string>();
const result: AppMenuItem[] = [];
for (const item of items) {
if (item.children) {
const children = item.children.filter((child) => {
if (usedPaths.has(child.key)) return false;
usedPaths.add(child.key);
return true;
});
if (children.length > 0) result.push({ ...item, children });
continue;
}
if (!usedPaths.has(item.key)) {
usedPaths.add(item.key);
result.push(item);
}
}
return result;
}
export function buildMenu(roles: readonly string[], permissions: readonly string[]): AppMenuItem[] {
const roleSet = getRoleDomains(roles, permissions);
const permissionSet = new Set(permissions);
const sections: AppMenuItem[] = [];
if (permissionSet.has('dashboard:view') && !roleSet.has('teacher')) {
sections.push({ key: '/dashboard', label: '数据面板', icon: 'dashboard' });
}
for (const section of SECTIONS) {
if (!section.roles.some((role) => roleSet.has(role))) continue;
const children = section.children
.filter((child) => permissionSet.has(child.permission))
.map(({ permission: _, ...child }) => child);
if (children.length > 0) sections.push({ ...section, children, roles: undefined } as AppMenuItem);
}
if (permissionSet.has('notification:view')) {
sections.push({ key: '/notifications', label: '通知中心', icon: 'notification' });
}
return dedupeMenu(sections);
}
export function collectMenuPaths(items: readonly AppMenuItem[]): string[] {
return items.flatMap((item) => (item.children ? collectMenuPaths(item.children) : [item.key]));
}
export function findRoleAwareLandingPath(
roles: readonly string[],
permissions: readonly string[],
): string | null {
return collectMenuPaths(buildMenu(roles, permissions))[0] ?? null;
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
findFirstAccessiblePath,
getRequiredPermission,
canAccessPath,
} from './permission-navigation';
describe('permission navigation', () => {
it('does not default to dashboard when dashboard permission is absent', () => {
const permissions = ['class:view', 'schedule:view'];
expect(findFirstAccessiblePath(permissions)).toBe('/classes');
});
it('lands teachers on the teacher workspace without global student or class access', () => {
expect(
findFirstAccessiblePath([
'teacher-workspace:view',
'schedule:view',
'attendance:view',
]),
).toBe('/teacher-workspace');
expect(canAccessPath('/students', ['teacher-workspace:view'])).toBe(false);
expect(canAccessPath('/classes', ['teacher-workspace:view'])).toBe(false);
});
it('uses dashboard when it is the first accessible page', () => {
expect(findFirstAccessiblePath(['dashboard:view', 'student:view'])).toBe('/dashboard');
});
it('returns null when the user has no page permissions', () => {
expect(findFirstAccessiblePath(['profile:view'])).toBeNull();
});
it('keeps route permission lookup aligned for nested detail routes', () => {
expect(getRequiredPermission('/classes/12')).toBe('class:view');
expect(getRequiredPermission('/students/8/profile')).toBe('student:view');
expect(canAccessPath('/ai-config', ['ai:config:read'])).toBe(true);
expect(canAccessPath('/ai-config', ['integration:read'])).toBe(false);
});
});

View File

@@ -0,0 +1,53 @@
export interface PermissionPage {
path: string;
permission: string;
matches?: (pathname: string) => boolean;
}
/**
* Single source of truth for page-level navigation permissions.
* Order also defines the landing-page priority after login.
*/
export const PERMISSION_PAGES: readonly PermissionPage[] = [
{ path: '/dashboard', permission: 'dashboard:view' },
{ path: '/room-visual', permission: 'room:view' },
{ path: '/rooms', permission: 'room:view' },
{ path: '/occupancies', permission: 'occupancy:view' },
{ path: '/teacher-workspace', permission: 'teacher-workspace:view' },
{ path: '/students', permission: 'student:view', matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p) },
{ path: '/classes', permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p) },
{ path: '/attendance', permission: 'attendance:view' },
{ path: '/schedules', permission: 'schedule:view' },
{ path: '/classroom-schedule', permission: 'rental:view' },
{ path: '/classrooms', permission: 'classroom:view' },
{ path: '/classroom-rentals', permission: 'rental:view' },
{ path: '/organizations', permission: 'organization:view' },
{ path: '/expenses', permission: 'expense:view' },
{ path: '/deposits', permission: 'deposit:view' },
{ path: '/bills', permission: 'bill:view' },
{ path: '/notifications', permission: 'notification:view' },
{ path: '/operation-logs', permission: 'log:view' },
{ path: '/roles', permission: 'role:view' },
{ path: '/permissions', permission: 'role:view' },
{ path: '/integration-config', permission: 'integration:read' },
{ path: '/ai-config', permission: 'ai:config:read' },
{ path: '/users', permission: 'user:view' },
{ path: '/teachers', permission: 'teacher:view' },
] as const;
function matchesPage(page: PermissionPage, pathname: string): boolean {
return page.matches ? page.matches(pathname) : page.path === pathname;
}
export function getRequiredPermission(pathname: string): string | null {
return PERMISSION_PAGES.find((page) => matchesPage(page, pathname))?.permission ?? null;
}
export function canAccessPath(pathname: string, permissions: readonly string[]): boolean {
const required = getRequiredPermission(pathname);
return required === null || permissions.includes(required);
}
export function findFirstAccessiblePath(permissions: readonly string[]): string | null {
return PERMISSION_PAGES.find((page) => permissions.includes(page.permission))?.path ?? null;
}

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { filterTabsByPermission } from './permission-tabs';
const tabs = [
{ key: 'records', requiredPermission: 'deposit:view' },
{ key: 'refund', requiredPermission: 'deposit:refund' },
];
describe('permission tabs', () => {
it('shows only tabs allowed by exact permissions', () => {
expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual([
'records',
]);
expect(
filterTabsByPermission(tabs, ['deposit:view', 'deposit:refund']).map((tab) => tab.key),
).toEqual(['records', 'refund']);
});
});

View File

@@ -0,0 +1,13 @@
export interface PermissionTab {
key: string;
requiredPermission?: string;
}
export function filterTabsByPermission<T extends PermissionTab>(
tabs: readonly T[],
permissions: readonly string[],
): T[] {
return tabs.filter(
(tab) => !tab.requiredPermission || permissions.includes(tab.requiredPermission),
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { Result } from 'antd';
import { usePermission } from '../hooks/usePermission';
import { findRoleAwareLandingPath } from '../auth/menu-policy';
const DefaultRoute: React.FC = () => {
const { permissions } = usePermission();
const roles = (() => {
try {
return JSON.parse(localStorage.getItem('user') || '{}').roles || [];
} catch {
return [];
}
})();
const firstPath = findRoleAwareLandingPath(roles, permissions);
if (firstPath) return <Navigate to={firstPath} replace />;
return <Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />;
};
export default DefaultRoute;

View File

@@ -1,5 +1,7 @@
import React from 'react';
import { Result } from 'antd';
import { Result, Button } from 'antd';
import { useNavigate } from 'react-router-dom';
import { findRoleAwareLandingPath } from '../auth/menu-policy';
import { usePermission } from '../hooks/usePermission';
interface PermissionRouteProps {
@@ -8,9 +10,24 @@ interface PermissionRouteProps {
}
const PermissionRoute: React.FC<PermissionRouteProps> = ({ permission, children }) => {
const { hasPermission } = usePermission();
const { permissions, hasPermission } = usePermission();
const navigate = useNavigate();
if (!hasPermission(permission)) {
return <Result status="403" title="无权访问" subTitle="您没有访问此页面的权限" />;
let roles: string[] = [];
try {
roles = JSON.parse(localStorage.getItem('user') || '{}').roles || [];
} catch {
roles = [];
}
const firstPath = findRoleAwareLandingPath(roles, permissions);
return (
<Result
status="403"
title="无权访问"
subTitle="您没有访问此页面的权限"
extra={firstPath ? <Button type="primary" onClick={() => navigate(firstPath, { replace: true })}>访</Button> : undefined}
/>
);
}
return <>{children}</>;
};

View File

@@ -26,99 +26,43 @@ import {
LaptopOutlined,
BellOutlined,
ApiOutlined,
RobotOutlined,
} from '@ant-design/icons';
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: 'classroom: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: '/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);
@@ -127,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;
@@ -137,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.
@@ -150,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');
@@ -179,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) {
@@ -190,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)))) {
@@ -218,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,
}));

View File

@@ -0,0 +1,115 @@
import { describe, it, expect } from 'vitest';
import {
shouldAutoSwapBaseUrl,
formatDateTime,
sourceLabel,
sourceColor,
PROVIDER_DEFAULTS,
extractErrorMessage,
} from './helpers';
describe('AiConfig helpers', () => {
describe('shouldAutoSwapBaseUrl', () => {
it('swaps to default on first provider selection', () => {
const result = shouldAutoSwapBaseUrl('DEEPSEEK', '', null);
expect(result.shouldSwap).toBe(true);
expect(result.baseUrl).toBe(PROVIDER_DEFAULTS.DEEPSEEK);
});
it('swaps when current baseUrl matches previous provider default', () => {
const result = shouldAutoSwapBaseUrl(
'DEEPSEEK',
'https://api.openai.com/v1',
'OPENAI',
);
expect(result.shouldSwap).toBe(true);
expect(result.baseUrl).toBe(PROVIDER_DEFAULTS.DEEPSEEK);
});
it('swaps when current baseUrl is empty', () => {
const result = shouldAutoSwapBaseUrl('DEEPSEEK', '', 'OPENAI');
expect(result.shouldSwap).toBe(true);
});
it('keeps custom baseUrl unchanged', () => {
const result = shouldAutoSwapBaseUrl(
'OPENAI',
'https://custom.api.com/v1',
'DEEPSEEK',
);
expect(result.shouldSwap).toBe(false);
expect(result.baseUrl).toBe('https://custom.api.com/v1');
});
});
describe('formatDateTime', () => {
it('returns hyphen for null', () => {
expect(formatDateTime(null)).toBe('-');
});
it('returns Chinese locale string for ISO date', () => {
expect(formatDateTime('2024-01-15T10:30:00Z')).toContain('2024');
});
});
describe('sourceLabel', () => {
it('returns Chinese labels', () => {
expect(sourceLabel('database')).toBe('数据库');
expect(sourceLabel('environment')).toBe('环境变量');
expect(sourceLabel('none')).toBe('未配置');
});
});
describe('sourceColor', () => {
it('returns correct colors', () => {
expect(sourceColor('database')).toBe('green');
expect(sourceColor('environment')).toBe('blue');
expect(sourceColor('none')).toBe('default');
});
});
describe('extractErrorMessage', () => {
it('extracts message from server error response (interceptor unwraps to { message })', () => {
// The Axios interceptor at api/index.ts does Promise.reject(err.response?.data || err).
// For server errors, the rejection value is err.response.data — typically { message: '...' }.
const err = { message: 'API出错' };
expect(extractErrorMessage(err)).toBe('API出错');
});
it('falls back to message property', () => {
const err = { message: 'Network error' };
expect(extractErrorMessage(err)).toBe('Network error');
});
it('falls back to default on unknown type', () => {
expect(extractErrorMessage('unknown string')).toBe('操作失败');
expect(extractErrorMessage(null)).toBe('操作失败');
expect(extractErrorMessage(undefined)).toBe('操作失败');
});
it('sanitizes: newlines replaced with spaces', () => {
const err = { message: 'line1\nline2\r\nline3' };
expect(extractErrorMessage(err)).toBe('line1 line2 line3');
});
it('sanitizes: message > 120 chars truncated with ellipsis', () => {
const long = 'x'.repeat(200);
const err = { message: long };
const result = extractErrorMessage(err);
expect(result).toHaveLength(121); // 120 + '…' (1 char)
expect(result.endsWith('\u2026')).toBe(true);
});
it('sanitizes: empty trimmed message falls back', () => {
const err = { message: ' ' };
expect(extractErrorMessage(err)).toBe('操作失败');
});
it('sanitizes: plain object message property sanitized', () => {
const err = {
message: ' some \n\nerror \r\nmessage ',
};
expect(extractErrorMessage(err)).toBe('some error message');
});
});
});

View File

@@ -0,0 +1,83 @@
// ---------------------------------------------------------------------------
// AiConfig helpers — pure functions, no React / DOM dependencies
// ---------------------------------------------------------------------------
export type AiProvider = 'OPENAI' | 'DEEPSEEK' | 'OPENAI_COMPATIBLE';
export const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [
{ value: 'OPENAI', label: 'OpenAI' },
{ value: 'DEEPSEEK', label: 'DeepSeek' },
{ value: 'OPENAI_COMPATIBLE', label: 'OpenAI 兼容' },
];
export const PROVIDER_DEFAULTS: Record<AiProvider, string> = {
OPENAI: 'https://api.openai.com/v1',
DEEPSEEK: 'https://api.deepseek.com',
OPENAI_COMPATIBLE: '',
} as const;
export const FIXED_PROVIDERS: AiProvider[] = ['OPENAI', 'DEEPSEEK'];
export function formatDateTime(iso: string | null): string {
if (!iso) return '-';
return new Date(iso).toLocaleString('zh-CN');
}
export function sourceLabel(source: string): string {
switch (source) {
case 'database':
return '数据库';
case 'environment':
return '环境变量';
default:
return '未配置';
}
}
export function sourceColor(source: string): 'green' | 'blue' | 'default' {
switch (source) {
case 'database':
return 'green';
case 'environment':
return 'blue';
default:
return 'default';
}
}
export function shouldAutoSwapBaseUrl(
provider: AiProvider,
currentBaseUrl: string,
lastProvider: AiProvider | null,
): { baseUrl: string; shouldSwap: boolean } {
if (!lastProvider) {
return { baseUrl: PROVIDER_DEFAULTS[provider], shouldSwap: true };
}
const prevDefault = PROVIDER_DEFAULTS[lastProvider];
if (!currentBaseUrl || currentBaseUrl === prevDefault) {
return { baseUrl: PROVIDER_DEFAULTS[provider], shouldSwap: true };
}
return { baseUrl: currentBaseUrl, shouldSwap: false };
}
/** Extract a safe user-facing error message from any caught value.
*
* The Axios interceptor at `api/index.ts` unwraps errors before rejection:
* `Promise.reject(err.response?.data || err)`. So server errors arrive as
* `{ message: '...' }` (the unwrapped data) and network errors as the raw
* `Error` object — never as a raw AxiosError with a `.response` property. */
export function extractErrorMessage(err: unknown, fallback: string = '操作失败'): string {
let msg = '';
// standard Error or any object with a string message property
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
msg = err.message;
}
// sanitize: trim, collapse whitespace, strip newlines, truncate
const trimmed = msg.trim();
if (!trimmed) return fallback;
const singleLine = trimmed.replace(/[\n\r]+/g, ' ').replace(/ {2,}/g, ' ');
return singleLine.length > 120 ? singleLine.slice(0, 120) + '\u2026' : singleLine;
}

View File

@@ -0,0 +1,73 @@
.container {
max-width: 1200px;
margin: 0 auto;
padding: 16px;
}
.header {
margin-bottom: 16px;
}
.header h2 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
}
.headerDesc {
color: #666;
font-size: 13px;
margin: 0;
}
.statusRow {
margin-top: 8px;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
@media (max-width: 768px) {
.grid {
grid-template-columns: 1fr;
}
}
.cardTitle {
font-size: 15px;
font-weight: 600;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.testResult {
margin-top: 16px;
}
.safetyNote {
margin-top: 12px;
padding: 8px 12px;
background: #f6ffed;
border: 1px solid #b7eb8f;
border-radius: 6px;
font-size: 12px;
color: #389e0d;
}
.safetyNoteKey {
margin-top: 8px;
padding: 8px 12px;
background: #fff7e6;
border: 1px solid #ffd591;
border-radius: 6px;
font-size: 12px;
color: #d46b08;
}

View File

@@ -0,0 +1,514 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import {
App,
Card,
Form,
Input,
Button,
Select,
Switch,
InputNumber,
Tag,
Descriptions,
Spin,
Alert,
Typography,
Tooltip,
Space,
} from 'antd';
import {
SaveOutlined,
ApiOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
KeyOutlined,
DeleteOutlined,
WarningOutlined,
} from '@ant-design/icons';
import api from '../../api';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import type { AiProvider } from './helpers';
import {
PROVIDER_OPTIONS,
PROVIDER_DEFAULTS,
FIXED_PROVIDERS,
formatDateTime,
sourceLabel,
sourceColor,
shouldAutoSwapBaseUrl,
extractErrorMessage,
} from './helpers';
import styles from './index.module.css';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface AiConfigData {
id: number;
provider: AiProvider;
baseUrl: string;
hasApiKey: boolean;
hasDatabaseKey: boolean;
maskedApiKey: string | null;
keySource: 'database' | 'environment' | 'none';
defaultModel: string | null;
enabled: boolean;
timeoutMs: number;
verified: boolean;
lastTestedAt: string | null;
lastTestLatencyMs: number | null;
createdAt: string;
updatedAt: string;
}
interface TestResult {
success: boolean;
latencyMs: number | null;
modelCount: number | null;
modelAvailable: boolean;
testedAt: string;
message: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
message?: string;
}
// ---------------------------------------------------------------------------
// Page Component
// ---------------------------------------------------------------------------
const AiConfigPage: React.FC = () => {
const { hasPermission } = usePermission();
const { modal } = App.useApp();
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [config, setConfig] = useState<AiConfigData | null>(null);
const [testResult, setTestResult] = useState<TestResult | null>(null);
const [error, setError] = useState<string | null>(null);
const lastProviderRef = useRef<AiProvider | null>(null);
const canWrite = hasPermission('ai:config:write');
const canTest = hasPermission('ai:config:test');
const canRead = hasPermission('ai:config:read');
// ── Load config ──
const loadConfig = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await api.get<ApiResponse<AiConfigData>>('/ai/config');
setConfig(res.data);
form.setFieldsValue({
provider: res.data.provider,
baseUrl: res.data.baseUrl,
defaultModel: res.data.defaultModel ?? undefined,
enabled: res.data.enabled,
timeoutMs: res.data.timeoutMs,
});
lastProviderRef.current = res.data.provider;
} catch (err: unknown) {
setError(extractErrorMessage(err, '加载配置失败'));
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
loadConfig();
}, [loadConfig]);
// ── Provider change → swap baseUrl ──
const handleProviderChange = useCallback(
(provider: AiProvider) => {
const currentBaseUrl = form.getFieldValue('baseUrl') || '';
const result = shouldAutoSwapBaseUrl(provider, currentBaseUrl, lastProviderRef.current);
if (result.shouldSwap) {
form.setFieldValue('baseUrl', result.baseUrl);
}
lastProviderRef.current = provider;
},
[form],
);
const currentProvider = Form.useWatch('provider', form) as AiProvider | undefined;
const isFixedProvider = currentProvider ? FIXED_PROVIDERS.includes(currentProvider) : false;
// ── Save ──
const handleSave = useCallback(async () => {
try {
const values = await form.validateFields();
setSaving(true);
// Validate baseUrl for OPENAI_COMPATIBLE
if (values.provider === 'OPENAI_COMPATIBLE' && !values.baseUrl) {
message.error('OPENAI_COMPATIBLE 模式必须填写 Base URL');
setSaving(false);
return;
}
const body: Record<string, unknown> = {
provider: values.provider,
baseUrl: values.baseUrl,
defaultModel: values.defaultModel || undefined,
enabled: values.enabled,
timeoutMs: values.timeoutMs,
};
if (values.apiKey && values.apiKey !== '••••') {
body.apiKey = values.apiKey;
}
await api.put('/ai/config', body);
message.success('配置已保存');
form.setFieldValue('apiKey', '');
} catch (err: unknown) {
message.error(extractErrorMessage(err, '保存失败'));
setSaving(false);
return;
}
// Reload config from server (non-fatal if it fails)
try {
await loadConfig();
} catch (err: unknown) {
message.warning(extractErrorMessage(err, '配置已保存,但刷新失败'));
} finally {
setSaving(false);
}
}, [form, loadConfig]);
// ── Test connection ──
const handleTest = useCallback(async () => {
try {
// Validated fields: compatible requires baseUrl
const fieldsToValidate = ['provider', 'timeoutMs'] as string[];
if (currentProvider === 'OPENAI_COMPATIBLE') {
fieldsToValidate.push('baseUrl');
}
const values = await form.validateFields(fieldsToValidate);
setTesting(true);
setTestResult(null);
const body: Record<string, unknown> = {
timeoutMs: values.timeoutMs,
};
// Always send provider if form has it
if (currentProvider) body.provider = currentProvider;
if (values.baseUrl) body.baseUrl = values.baseUrl;
// Include defaultModel so backend checks target model
const defaultModel = form.getFieldValue('defaultModel');
if (defaultModel) body.defaultModel = defaultModel;
const typedKey = form.getFieldValue('apiKey');
if (typedKey && typedKey !== '••••') {
body.apiKey = typedKey;
}
const res = await api.post<TestResult>('/ai/config/test', body);
setTestResult(res);
await loadConfig();
} catch (err: unknown) {
setTestResult({
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: new Date().toISOString(),
message: extractErrorMessage(err, '测试请求失败'),
});
} finally {
setTesting(false);
}
}, [form, loadConfig, currentProvider]);
// ── Clear key ──
const handleClearKey = useCallback(() => {
const isEnv = config?.keySource === 'environment';
modal.confirm({
title: '确认清除密钥',
content: isEnv
? '数据库中的密钥将被清除,但环境变量 AI_API_KEY 仍可使用。确定继续?'
: '密钥将被永久清除,之后将无法使用 AI 功能。确定继续?',
okText: '确认清除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await api.post('/ai/config/clear-key');
message.success('密钥已清除');
await loadConfig();
} catch (err: unknown) {
message.error(extractErrorMessage(err, '清除失败'));
}
},
});
}, [config, loadConfig, modal]);
// ── No read permission ──
if (!canRead) {
return (
<div className={styles.container}>
<Alert type="error" title="您没有查看 AI 配置的权限" showIcon />
</div>
);
}
if (loading) {
return (
<div className={styles.container} style={{ textAlign: 'center', paddingTop: 80 }}>
<Spin size="large" />
</div>
);
}
if (error && !config) {
return (
<div className={styles.container}>
<Alert type="error" title={error} showIcon />
</div>
);
}
// ── Render ──
return (
<div className={styles.container}>
<div className={styles.header}>
<h2>AI </h2>
<p className={styles.headerDesc}></p>
<div className={styles.statusRow}>
<Space size="small">
<Tag color={config?.enabled ? 'green' : 'default'}>
{config?.enabled ? '已启用' : '未启用'}
</Tag>
{config?.verified && <Tag color="blue"></Tag>}
{config?.hasApiKey && (
<Tag color={sourceColor(config?.keySource || 'none')}>
: {sourceLabel(config?.keySource || 'none')}
</Tag>
)}
</Space>
</div>
</div>
<Form form={form} layout="vertical" initialValues={{ timeoutMs: 30000, enabled: false }}>
<div className={styles.grid}>
{/* Left: 模型路由 */}
<Card title={<span className={styles.cardTitle}></span>} extra={<ApiOutlined />}>
<Form.Item
name="provider"
label="Provider"
rules={[{ required: true, message: '请选择 Provider' }]}
>
<Select
options={PROVIDER_OPTIONS}
onChange={handleProviderChange}
disabled={!canWrite}
/>
</Form.Item>
<Form.Item
name="baseUrl"
label="Base URL"
rules={[
{ required: true, message: '请输入 Base URL' },
{ type: 'url', message: '请输入合法的 URL' },
]}
>
<Input
placeholder={
config?.provider
? PROVIDER_DEFAULTS[config.provider]
: 'https://api.openai.com/v1'
}
disabled={!canWrite || (isFixedProvider && canWrite)}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, curr) => prev.enabled !== curr.enabled}>
{({ getFieldValue }) => {
const enabled = getFieldValue('enabled');
return (
<Form.Item
name="defaultModel"
label="默认模型"
rules={enabled ? [{ required: true, message: '启用时默认模型为必填项' }] : []}
>
<Input placeholder="例如: gpt-4, deepseek-chat" disabled={!canWrite} />
</Form.Item>
);
}}
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch disabled={!canWrite} />
</Form.Item>
<Form.Item
name="timeoutMs"
label="请求超时 (毫秒)"
rules={[
{ required: true, message: '请输入超时时间' },
{ type: 'number', min: 1000, max: 120000, message: '范围: 1000-120000' },
]}
>
<InputNumber
min={1000}
max={120000}
step={1000}
style={{ width: '100%' }}
disabled={!canWrite}
/>
</Form.Item>
</Card>
{/* Right: 密钥保险库 */}
<Card
title={<span className={styles.cardTitle}></span>}
extra={<KeyOutlined />}
>
<Form.Item name="apiKey" label="API Key">
<Input.Password
placeholder={config?.hasApiKey ? '已安全保存,留空则保持不变' : '请输入 API Key'}
disabled={!canWrite}
autoComplete="new-password"
/>
</Form.Item>
{config && (
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="状态">
{config.hasApiKey ? (
<Tag color="green">{config.maskedApiKey || '••••'}</Tag>
) : (
<Tag color="default"></Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="来源">
<Tag color={sourceColor(config.keySource)}>{sourceLabel(config.keySource)}</Tag>
{config.keySource === 'environment' && (
<span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>
</span>
)}
</Descriptions.Item>
<Descriptions.Item label="最后更新">
{formatDateTime(config.updatedAt)}
</Descriptions.Item>
</Descriptions>
)}
{config?.hasDatabaseKey && canWrite && (
<div style={{ marginBottom: 8 }}>
<Button danger size="small" icon={<DeleteOutlined />} onClick={handleClearKey}>
</Button>
</div>
)}
{config?.keySource === 'environment' && !config.hasDatabaseKey && (
<div style={{ marginBottom: 8, fontSize: 12, color: '#999' }}>
</div>
)}
<div className={styles.safetyNote}>
API Key 使 AES-256-GCM 使 IV HTTPS
</div>
<div className={styles.safetyNoteKey}>
<Typography.Text code>AI_API_KEY</Typography.Text>
</div>
</Card>
</div>
{/* Actions */}
<div className={styles.actions}>
<Tooltip title={!canWrite ? '当前角色无写入权限' : undefined}>
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
disabled={!canWrite}
>
</Button>
</Tooltip>
<Tooltip title={!canTest ? '当前角色无测试权限' : undefined}>
<Button
icon={<ApiOutlined />}
onClick={handleTest}
loading={testing}
disabled={!canTest}
>
</Button>
</Tooltip>
</div>
</Form>
{/* Test result */}
{testResult && (
<Card size="small" className={styles.testResult}>
<Descriptions column={{ xs: 1, sm: 2 }} size="small">
<Descriptions.Item label="结果">
{testResult.success ? (
testResult.modelAvailable ? (
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
) : (
<Tag icon={<WarningOutlined />} color="warning">
</Tag>
)
) : (
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="延迟">
{testResult.latencyMs != null ? `${testResult.latencyMs} ms` : '-'}
</Descriptions.Item>
<Descriptions.Item label="模型数量">
{testResult.modelCount != null ? testResult.modelCount : '-'}
</Descriptions.Item>
<Descriptions.Item label="测试时间">
{formatDateTime(testResult.testedAt)}
</Descriptions.Item>
</Descriptions>
<Alert
type={
testResult.success ? (testResult.modelAvailable ? 'success' : 'warning') : 'error'
}
title={testResult.message}
style={{ marginTop: 8 }}
/>
</Card>
)}
</div>
);
};
export default AiConfigPage;

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import {
canPullAttendance,
getAttendanceExperience,
getSchedulePhase,
summarizeAttendance,
summarizeLessonCheckins,
} from './attendance-workspace';
describe('attendance role experience', () => {
it('routes class-scoped teachers to the teaching workspace', () => {
expect(
getAttendanceExperience(
['attendance:view', 'attendance:create', 'schedule:create'],
['老师'],
),
).toBe('teacher');
});
it('routes users with attendance administration permission to history management', () => {
expect(getAttendanceExperience(['attendance:view', 'attendance:edit'], ['教务管理员'])).toBe(
'admin',
);
});
});
describe('teacher schedule phase', () => {
it('marks a finished lesson as ready for attendance review', () => {
expect(getSchedulePhase('08:00', '09:00', new Date('2026-07-11T10:00:00'))).toBe('ended');
});
it('keeps future lessons read-only', () => {
expect(getSchedulePhase('14:00', '15:00', new Date('2026-07-11T10:00:00'))).toBe('upcoming');
});
it('allows attendance pulls once the lesson starts', () => {
expect(canPullAttendance('ongoing')).toBe(true);
expect(canPullAttendance('ended')).toBe(true);
expect(canPullAttendance('upcoming')).toBe(false);
});
});
describe('attendance summary', () => {
it('summarizes status counts for an administrator history view', () => {
expect(
summarizeAttendance([
{ status: 'present' },
{ status: 'present' },
{ status: 'late' },
{ status: 'absent' },
]),
).toEqual({ total: 4, present: 2, late: 1, absent: 1, leave: 0, pending: 0 });
});
});
describe('lesson check-in summary', () => {
it('counts late punches as checked in and missing punches as not checked in', () => {
expect(
summarizeLessonCheckins([
{ status: 'present' },
{ status: 'late' },
{ status: 'pending' },
{ status: 'absent' },
]),
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
});
});

View File

@@ -0,0 +1,84 @@
export type AttendanceExperience = 'teacher' | 'admin';
export type SchedulePhase = 'upcoming' | 'ongoing' | 'ended';
import { getRoleDomains } from '../../auth/menu-policy';
export function getAttendanceExperience(
permissions: readonly string[],
roles: readonly string[],
): AttendanceExperience {
const domains = getRoleDomains(roles, permissions);
if (
permissions.includes('attendance:manage') ||
domains.has('academic') ||
domains.has('super')
) {
return 'admin';
}
return 'teacher';
}
function toMinuteOfDay(time: string): number {
const [hour = 0, minute = 0] = time.split(':').map(Number);
return hour * 60 + minute;
}
export function getSchedulePhase(
startTime: string,
endTime: string,
now = new Date(),
): SchedulePhase {
const current = now.getHours() * 60 + now.getMinutes();
if (current < toMinuteOfDay(startTime)) return 'upcoming';
if (current <= toMinuteOfDay(endTime)) return 'ongoing';
return 'ended';
}
export function canPullAttendance(phase: SchedulePhase): boolean {
return phase !== 'upcoming';
}
export interface AttendanceSummary {
total: number;
present: number;
late: number;
absent: number;
leave: number;
pending: number;
}
export function summarizeAttendance(records: readonly { status: string }[]): AttendanceSummary {
const summary: AttendanceSummary = {
total: records.length,
present: 0,
late: 0,
absent: 0,
leave: 0,
pending: 0,
};
for (const record of records) {
if (record.status in summary && record.status !== 'total') {
summary[record.status as Exclude<keyof AttendanceSummary, 'total'>] += 1;
}
}
return summary;
}
export interface LessonCheckinSummary {
total: number;
checkedIn: number;
notCheckedIn: number;
}
export function summarizeLessonCheckins(
records: readonly { status: string }[],
): LessonCheckinSummary {
const checkedIn = records.filter(
(record) => record.status === 'present' || record.status === 'late',
).length;
return {
total: records.length,
checkedIn,
notCheckedIn: records.length - checkedIn,
};
}

View File

@@ -0,0 +1,504 @@
.attendance-page {
--ink: #172033;
--muted: #667085;
--line: #e6eaf0;
--blue: #1677ff;
color: var(--ink);
padding-bottom: 28px;
}
.attendance-hero {
position: relative;
overflow: hidden;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
min-height: 188px;
padding: 34px 38px;
margin-bottom: 18px;
border-radius: 18px;
}
.attendance-hero::after {
content: '';
position: absolute;
width: 320px;
height: 320px;
right: -105px;
top: -165px;
border: 64px solid rgb(255 255 255 / 8%);
border-radius: 50%;
pointer-events: none;
}
.attendance-hero--teacher {
color: white;
background: linear-gradient(125deg, #122c5a 0%, #174c93 58%, #1e78c8 100%);
box-shadow: 0 14px 34px rgb(24 76 147 / 18%);
}
.attendance-hero--admin {
color: #172033;
background: linear-gradient(120deg, #f7f9fc 0%, #eef3f9 100%);
border: 1px solid #e2e8f0;
}
.attendance-hero h1 {
margin: 7px 0 8px;
font-size: clamp(28px, 3vw, 40px);
line-height: 1.15;
letter-spacing: -1.2px;
}
.attendance-hero p {
max-width: 650px;
margin: 0;
color: inherit;
opacity: 0.72;
font-size: 15px;
}
.attendance-eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 1.7px;
opacity: 0.72;
}
.teacher-overview {
margin-bottom: 30px;
}
.teacher-kpi {
height: 112px;
padding: 21px 24px;
border: 1px solid var(--line);
border-radius: 14px;
background: white;
box-shadow: 0 5px 16px rgb(19 33 68 / 5%);
}
.teacher-kpi > span,
.teacher-kpi > small {
display: block;
color: var(--muted);
}
.teacher-kpi > strong {
display: inline-block;
margin: 6px 7px 0 0;
font-size: 30px;
line-height: 1;
}
.teacher-kpi--next {
border-color: #cfe1fb;
background: #f4f8ff;
}
.attendance-section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin: 0 2px 14px;
}
.attendance-section-heading span {
color: var(--muted);
font-size: 12px;
}
.attendance-section-heading h2 {
margin: 2px 0 0;
font-size: 22px;
}
.lesson-timeline {
display: grid;
gap: 12px;
}
.lesson-card {
display: grid;
grid-template-columns: 54px 120px minmax(220px, 1fr) auto;
align-items: center;
min-height: 116px;
padding: 18px 20px 18px 12px;
border: 1px solid var(--line);
border-left: 4px solid #c9d2df;
border-radius: 14px;
background: white;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
}
.lesson-card:hover {
transform: translateY(-1px);
box-shadow: 0 10px 26px rgb(24 39 75 / 9%);
}
.lesson-card--ongoing {
border-left-color: #1677ff;
background: linear-gradient(90deg, #f6f9ff, #fff 32%);
}
.lesson-card--ended {
border-left-color: #32a46d;
}
.lesson-sequence {
align-self: start;
padding-top: 3px;
color: #a6b0bf;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
}
.lesson-time {
display: grid;
grid-template-columns: auto 1px auto;
align-items: center;
gap: 10px;
padding-right: 22px;
}
.lesson-time strong {
font-size: 15px;
}
.lesson-time span {
height: 28px;
background: #d9e0e9;
}
.lesson-main {
padding-left: 24px;
border-left: 1px solid var(--line);
}
.lesson-title-row {
display: flex;
align-items: center;
gap: 10px;
}
.lesson-title-row h3 {
margin: 0;
font-size: 18px;
}
.lesson-main p {
display: flex;
gap: 18px;
margin: 8px 0 0;
color: var(--muted);
}
.lesson-action {
padding-left: 20px;
}
.attendance-empty-card {
padding: 28px;
border-radius: 14px;
}
.attendance-empty-card strong {
display: block;
color: var(--ink);
font-size: 16px;
}
.attendance-empty-card p {
margin: 4px 0 0;
color: var(--muted);
}
.lesson-record-header {
padding: 8px 0 22px;
}
.lesson-record-header h2 {
margin: 6px 0;
font-size: 28px;
}
.lesson-record-header p {
margin: 0;
color: var(--muted);
}
.attendance-summary-strip {
display: grid;
grid-template-columns: minmax(190px, 1.35fr) repeat(4, minmax(100px, 1fr));
align-items: center;
gap: 0;
min-height: 106px;
margin-bottom: 18px;
padding: 16px 8px;
border: 1px solid var(--line);
border-radius: 14px;
background: white;
box-shadow: 0 5px 18px rgb(23 32 51 / 4%);
}
.attendance-rate,
.attendance-summary-cell {
display: flex;
align-items: center;
gap: 12px;
min-height: 66px;
padding: 0 20px;
}
.attendance-rate {
border-right: 1px solid var(--line);
}
.attendance-rate span,
.attendance-summary-cell span {
display: block;
color: var(--muted);
font-size: 12px;
}
.attendance-rate strong,
.attendance-summary-cell strong {
display: block;
margin-top: 3px;
font-size: 20px;
}
.attendance-summary-icon {
display: grid !important;
flex: 0 0 auto;
width: 36px;
height: 36px;
place-items: center;
border-radius: 10px;
font-weight: 700;
}
.is-present { color: #198754 !important; background: #eaf8f1; }
.is-late { color: #b56b00 !important; background: #fff4db; }
.is-absent { color: #cf3030 !important; background: #fff0f0; }
.is-leave { color: #2874c6 !important; background: #edf5ff; }
.is-pending { color: #667085 !important; background: #f1f3f6; }
.attendance-status {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 4px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.attendance-status__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.archive-alert {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 13px 16px;
color: #8c5d05;
border: 1px solid #f3d79c;
border-radius: 12px;
background: #fffbf0;
}
.archive-alert > span.anticon {
font-size: 20px;
}
.archive-alert div {
flex: 1;
}
.archive-alert strong,
.archive-alert span {
display: block;
}
.archive-alert span {
margin-top: 2px;
color: #8a7452;
font-size: 12px;
}
.archive-card {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: 0 6px 24px rgb(23 32 51 / 5%);
}
.archive-card .ant-card-body {
padding: 0;
}
.archive-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 20px;
border-bottom: 1px solid var(--line);
background: #fbfcfe;
}
.archive-toolbar__title {
display: flex;
align-items: center;
gap: 11px;
min-width: 150px;
}
.archive-toolbar__title > span.anticon {
color: var(--blue);
font-size: 21px;
}
.archive-toolbar__title strong,
.archive-toolbar__title span {
display: block;
}
.archive-toolbar__title span {
color: var(--muted);
font-size: 11px;
}
.archive-card .ant-table-wrapper {
padding: 0 20px 10px;
}
.archive-card .ant-table-thead > tr > th {
color: #667085;
font-size: 12px;
font-weight: 600;
background: white;
}
.student-cell {
display: flex;
align-items: center;
gap: 10px;
}
.student-cell .ant-avatar {
color: #245b9e;
background: #e8f1fd;
}
.student-cell strong,
.student-cell span {
display: block;
}
.student-cell span {
margin-top: 2px;
color: var(--muted);
font-size: 11px;
}
.muted-text {
color: #a1a9b5;
}
@media (max-width: 900px) {
.attendance-hero,
.archive-toolbar {
align-items: flex-start;
flex-direction: column;
}
.attendance-summary-strip {
grid-template-columns: repeat(2, 1fr);
}
.attendance-rate {
grid-column: 1 / -1;
border-right: 0;
border-bottom: 1px solid var(--line);
padding-bottom: 14px;
}
.lesson-card {
grid-template-columns: 42px 1fr auto;
}
.lesson-time {
grid-column: 2;
}
.lesson-main {
grid-column: 2 / -1;
margin-top: 12px;
padding: 12px 0 0;
border-top: 1px solid var(--line);
border-left: 0;
}
.lesson-action {
grid-column: 2 / -1;
padding: 14px 0 0;
}
}
@media (max-width: 576px) {
.attendance-hero {
padding: 26px 22px;
border-radius: 14px;
}
.attendance-hero h1 {
font-size: 28px;
}
.attendance-summary-strip {
grid-template-columns: 1fr 1fr;
}
.attendance-summary-cell {
padding: 10px 14px;
}
.lesson-card {
grid-template-columns: 1fr;
padding: 18px;
}
.lesson-sequence {
display: none;
}
.lesson-time,
.lesson-main,
.lesson-action {
grid-column: 1;
}
.lesson-main p {
flex-direction: column;
gap: 4px;
}
}
.attendance-marking-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.attendance-marking-actions .ant-btn {
min-width: 54px;
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@ import api from '../../api';
import { downloadBlob } from '../../utils/download';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
interface UnavailableDatesResponse {
dates: string[];
@@ -29,6 +30,7 @@ export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
`${classroomId}:${date.format('YYYY-MM')}`;
const ClassroomRentalsPage: React.FC = () => {
const { hasAnyPermission } = usePermission();
const [data, setData] = useState<any[]>([]);
const [classrooms, setClassrooms] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
@@ -82,8 +84,8 @@ const ClassroomRentalsPage: React.FC = () => {
};
useEffect(() => {
fetchMeta();
}, []);
if (hasAnyPermission('rental:create', 'rental:edit')) fetchMeta();
}, [hasAnyPermission]);
useEffect(() => {
fetchData();
}, [filterMonth]);

View File

@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { CLASSROOM_VISIBLE_FIELDS } from './classroom-fields';
describe('classroom visible fields', () => {
it('excludes obsolete course and supervisor metadata', () => {
expect(CLASSROOM_VISIBLE_FIELDS).toEqual([
'name',
'building',
'floor',
'roomType',
'capacity',
'status',
'notes',
]);
});
});

View File

@@ -0,0 +1,9 @@
export const CLASSROOM_VISIBLE_FIELDS = [
'name',
'building',
'floor',
'roomType',
'capacity',
'status',
'notes',
] as const;

View File

@@ -154,8 +154,6 @@ const ClassroomsPage: React.FC = () => {
render: (v: string) => <Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag>,
},
{ title: '容量', dataIndex: 'capacity', width: 80 },
{ title: '课程类型', dataIndex: 'courseType', width: 100, render: (v: string) => v || '-' },
{ title: '负责人', dataIndex: 'supervisor', width: 100, render: (v: string) => v || '-' },
{
title: '状态', width: 100,
dataIndex: 'status',
@@ -325,12 +323,6 @@ const ClassroomsPage: React.FC = () => {
<Form.Item name="capacity" label="容量">
<InputNumber min={1} max={500} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="courseType" label="课程类型" tooltip="如:尊享培优班 / 专业课集训班">
<Input />
</Form.Item>
<Form.Item name="supervisor" label="负责人/班主任">
<Input />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>

View File

@@ -10,12 +10,11 @@ import {
Space,
Tag,
Popconfirm,
Tabs,
List,
Card,
Empty,
} from 'antd';
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
@@ -30,8 +29,8 @@ const statusMap: Record<string, { text: string; color: string }> = {
};
const refundStatusMap: Record<string, { text: string; color: string }> = {
pending: { text: '待班主任审批', color: 'orange' },
head_teacher_approved: { text: '待财务审批', color: 'blue' },
pending: { text: '历史退款处理中', color: 'orange' },
head_teacher_approved: { text: '历史退款处理中', color: 'blue' },
finance_approved: { text: '已退款', color: 'green' },
refunded: { text: '已退款', color: 'green' },
};
@@ -41,14 +40,6 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
};
interface PendingRefund {
id: number;
student?: { name?: string };
amount?: number;
paidDate?: string;
refundStatus?: string;
refundRequestedAt?: string;
}
const DepositsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
@@ -58,22 +49,20 @@ const DepositsPage: React.FC = () => {
const [refundModal, setRefundModal] = useState<any>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
const [pendingRefunds, setPendingRefunds] = useState<PendingRefund[]>([]);
const [pendingLoading, setPendingLoading] = useState(false);
const [rejectModal, setRejectModal] = useState<any>(null);
const [rejectReason, setRejectReason] = useState('');
const [createForm] = Form.useForm();
const [refundForm] = Form.useForm();
const [installmentForm] = Form.useForm();
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [activeTab, setActiveTab] = useState<string>('all');
const [saving, setSaving] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const [d, s]: any[] = await Promise.all([api.get('/deposits'), api.get('/students')]);
const [d, s]: any[] = await Promise.all([
api.get('/deposits'),
api.get('/deposits/student-lookups'),
]);
setData(d);
setStudents(s);
} catch (e: any) {
@@ -82,16 +71,7 @@ const DepositsPage: React.FC = () => {
setLoading(false);
};
const fetchPendingRefunds = async () => {
setPendingLoading(true);
try {
const res = await api.get<PendingRefund[]>('/deposits/pending-refunds');
setPendingRefunds(res || []);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}
setPendingLoading(false);
};
useEffect(() => {
fetchData();
@@ -161,44 +141,6 @@ const DepositsPage: React.FC = () => {
}
};
const handleRequestRefund = async (record: any) => {
try {
await api.post(`/deposits/${record.id}/request-refund`);
message.success('退款申请已提交');
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleApproveRefund = async (record: any) => {
try {
await api.put(`/deposits/${record.id}/approve-refund`);
message.success('审批通过');
fetchPendingRefunds();
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleRejectRefund = async () => {
if (!rejectModal) return;
setSaving(true);
try {
await api.put(`/deposits/${rejectModal.id}/reject-refund`, { reason: rejectReason || '未说明原因' });
message.success('已驳回退款申请');
setRejectModal(null);
setRejectReason('');
fetchPendingRefunds();
fetchData();
} catch (e: any) {
message.error(e?.message || '驳回失败');
} finally {
setSaving(false);
}
};
const handleAddInstallment = async () => {
if (installmentModal == null) return;
const values = await installmentForm.validateFields();
@@ -249,7 +191,7 @@ const DepositsPage: React.FC = () => {
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '退款审批',
title: '退款状态',
dataIndex: 'refundStatus',
render: (s: string) =>
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
@@ -284,7 +226,7 @@ const DepositsPage: React.FC = () => {
{record.status === 'paid' && !record.refundStatus && (
<>
<PermissionButton
permission="deposit:edit"
permission="deposit:refund"
size="small"
type="primary"
onClick={() => {
@@ -294,13 +236,6 @@ const DepositsPage: React.FC = () => {
>
退
</PermissionButton>
<PermissionButton
permission="deposit:edit"
size="small"
onClick={() => handleRequestRefund(record)}
>
退
</PermissionButton>
</>
)}
<Popconfirm
@@ -327,139 +262,67 @@ const DepositsPage: React.FC = () => {
</Space>
),
},
], [handleRequestRefund, fetchData]);
], [fetchData]);
const pendingColumns = [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
{
title: '审批状态', width: 120,
dataIndex: 'refundStatus',
render: (s: string) => <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag>,
},
{
title: '申请时间', width: 160,
dataIndex: 'refundRequestedAt',
render: (v: any) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
},
{
title: '操作', width: 200,
render: (_: unknown, record: PendingRefund) => (
<Space>
<PermissionButton
permission="deposit:approve"
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => handleApproveRefund(record)}
>
</PermissionButton>
<PermissionButton
permission="deposit:approve"
size="small"
danger
onClick={() => {
setRejectModal(record);
setRejectReason('');
}}
>
</PermissionButton>
</Space>
),
},
];
return (
<div>
<Tabs
activeKey={activeTab}
onChange={(key) => {
setActiveTab(key);
if (key === 'pending') fetchPendingRefunds();
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名"
allowClear
style={{ width: 180 }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
items={[
{
key: 'all',
label: '押金列表',
children: (
<>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名"
allowClear
style={{ width: 180 }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'paid', label: '已缴' },
{ value: 'refunded', label: '已全退' },
{ value: 'partial_refund', label: '部分退还' },
{ value: 'deducted', label: '已全扣' },
]}
/>
</Space>
<PermissionButton
permission="deposit:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
</>
),
},
{
key: 'pending',
label: '待审批退款',
children: (
<Table
columns={pendingColumns}
dataSource={pendingRefunds}
rowKey="id"
loading={pendingLoading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
),
},
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'paid', label: '已缴' },
{ value: 'refunded', label: '已全退' },
{ value: 'partial_refund', label: '部分退还' },
{ value: 'deducted', label: '已全扣' },
]}
/>
</Space>
<PermissionButton
permission="deposit:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
scroll={{ x: 1200 }}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
locale={{ emptyText: <Empty description="暂无数据" /> }}
/>
{/* Create Modal */}
<Modal
@@ -549,7 +412,7 @@ const DepositsPage: React.FC = () => {
</p>
{detailModal.refundStatus && (
<p>
<strong>退:</strong>{' '}
<strong>退:</strong>{' '}
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
</Tag>
@@ -637,23 +500,7 @@ const DepositsPage: React.FC = () => {
</Form>
</Modal>
{/* Reject Refund Modal */}
<Modal
title={`驳回退款申请 - ${rejectModal?.student?.name || ''}`}
open={!!rejectModal}
onOk={handleRejectRefund}
onCancel={() => { setRejectModal(null); setRejectReason(''); }}
okText="确认驳回"
okButtonProps={{ danger: true }}
confirmLoading={saving}
>
<Input.TextArea
aria-label="驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
rows={3}
/>
</Modal>
</div>
);
};

View File

@@ -114,16 +114,15 @@ const ExpensesPage: React.FC = () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [re, pe, rm, st]: any[] = await Promise.all([
const [re, pe, lookups]: any[] = await Promise.all([
api.get('/expenses/room'),
api.get('/expenses/personal'),
api.get('/rooms'),
api.get('/students'),
api.get('/expenses/lookups').catch(() => ({ rooms: [], students: [] })),
]);
setRoomExpenses(re);
setPersonalExpenses(pe);
setRooms(rm);
setStudents(st);
setRooms(lookups.rooms || []);
setStudents(lookups.students || []);
} catch (e: any) {
message.error(e?.message || '加载失败,请稍后重试');
}

View File

@@ -7,11 +7,14 @@ import {
import {
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
SyncOutlined, BankOutlined, UserOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import type { TreeSelectProps } from 'antd/es/tree-select';
import api from '../../api';
import { message } from '../../ui/app-message';
import { usePermission } from '../../hooks/usePermission';
import PermissionButton from '../../components/PermissionButton';
interface DingTalkConfig {
agentId: string;
@@ -62,7 +65,30 @@ interface ImportResult {
skipped: number;
}
interface DingTalkAttendanceGroup {
group_id: number;
group_name: string;
type: string;
member_count: number;
}
interface AttendanceGroupResponse {
success: boolean;
data: DingTalkAttendanceGroup[];
}
interface DeleteAttendanceGroupsResponse {
success: boolean;
data: {
total: number;
deleted: Array<{ groupId: number; groupName: string }>;
failed: Array<{ groupId: number; groupName: string; error: string }>;
};
}
const IntegrationConfigPage: React.FC = () => {
const { hasAllPermissions } = usePermission();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
@@ -82,6 +108,10 @@ const IntegrationConfigPage: React.FC = () => {
const [classes, setClasses] = useState<ClassItem[]>([]);
const [classForm] = Form.useForm();
const [classModalOpen, setClassModalOpen] = useState(false);
const [attendanceGroups, setAttendanceGroups] = useState<DingTalkAttendanceGroup[]>([]);
const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false);
const [loadingGroups, setLoadingGroups] = useState(false);
const [deletingGroups, setDeletingGroups] = useState(false);
const fetchConfig = async () => {
@@ -191,6 +221,7 @@ const IntegrationConfigPage: React.FC = () => {
}
};
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
return nodes.map((node) => {
const users = node.users ?? [];
@@ -279,7 +310,42 @@ const IntegrationConfigPage: React.FC = () => {
}
};
const syncTabItems = config
const openDeleteAllGroups = async () => {
setLoadingGroups(true);
try {
const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');
setAttendanceGroups(response.data);
setDeleteGroupsOpen(true);
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '获取钉钉考勤组失败');
} finally {
setLoadingGroups(false);
}
};
const deleteAllGroups = async () => {
setDeletingGroups(true);
try {
const response = await api.post<DeleteAttendanceGroupsResponse>(
'/sync/dingtalk/attendance-groups/delete-all',
);
setDeleteGroupsOpen(false);
setAttendanceGroups([]);
if (response.data.failed.length > 0) {
message.warning(
`已删除 ${response.data.deleted.length} 个,失败 ${response.data.failed.length}`,
);
} else {
message.success(`已删除钉钉全部 ${response.data.deleted.length} 个考勤组`);
}
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '删除钉钉考勤组失败');
} finally {
setDeletingGroups(false);
}
};
const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
? [
{
key: 'sync-users',
@@ -311,6 +377,15 @@ const IntegrationConfigPage: React.FC = () => {
>
</Button>
<PermissionButton
permission="sync:trigger"
danger
icon={<DeleteOutlined />}
loading={loadingGroups}
onClick={openDeleteAllGroups}
>
</PermissionButton>
</Space>
{drawerOpen && (
@@ -411,6 +486,38 @@ const IntegrationConfigPage: React.FC = () => {
</Modal>
</Drawer>
)}
<Modal
title="确认删除钉钉全部考勤组"
open={deleteGroupsOpen}
okText="确认全部删除"
okButtonProps={{ danger: true, disabled: attendanceGroups.length === 0 }}
cancelText="取消"
confirmLoading={deletingGroups}
onOk={deleteAllGroups}
onCancel={() => setDeleteGroupsOpen(false)}
>
<Alert
type="error"
showIcon
message={`将永久删除钉钉上的 ${attendanceGroups.length} 个考勤组`}
description="本地班级和排课不会删除。删除后需在排课管理中重新同步,才能重建考勤组。"
style={{ marginBottom: 12 }}
/>
<List
size="small"
bordered
dataSource={attendanceGroups}
style={{ maxHeight: 280, overflow: 'auto' }}
renderItem={(group) => (
<List.Item>
<List.Item.Meta
title={group.group_name}
description={`ID ${group.group_id} · ${group.member_count}`}
/>
</List.Item>
)}
/>
</Modal>
</div>
),
},
@@ -461,9 +568,9 @@ const IntegrationConfigPage: React.FC = () => {
<Switch />
</Form.Item>
<Space>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
<PermissionButton permission="integration:trigger" type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
</Button>
</PermissionButton>
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
</Button>

View File

@@ -5,6 +5,7 @@ import { UserOutlined, LockOutlined } from '@ant-design/icons';
import api from '../../api';
import { message } from '../../ui/app-message';
import { writePermissions } from '../../auth/permission-store';
import { findRoleAwareLandingPath } from '../../auth/menu-policy';
const { Title } = Typography;
@@ -18,9 +19,10 @@ const LoginPage: React.FC = () => {
const res: any = await api.post('/auth/login', values);
localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user));
writePermissions(res.user.permissions || []);
const permissions = res.user.permissions || [];
writePermissions(permissions);
message.success('登录成功');
navigate('/dashboard');
navigate(findRoleAwareLandingPath(res.user.roles || [], permissions) || '/', { replace: true });
} catch (err: any) {
message.error(err?.message || '登录失败');
} finally {

View File

@@ -65,7 +65,7 @@ const OccupanciesPage: React.FC = () => {
try {
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
api.get('/students'),
api.get('/students/basic-lookups'),
api.get('/rooms/overview'),
api.get('/organizations'),
])) as PromiseSettledResult<any>[];

View File

@@ -19,6 +19,10 @@ const PermissionsPage: React.FC = () => {
const groupNames: Record<string, string> = {
dashboard: '数据面板',
student: '学生管理',
'student-scope': '学生数据范围',
teacher: '教师管理',
'teacher-workspace': '教师工作台',
'attendance-scope': '考勤数据范围',
room: '宿舍管理',
occupancy: '入住管理',
expense: '费用管理',
@@ -30,6 +34,17 @@ const PermissionsPage: React.FC = () => {
log: '操作日志',
user: '用户管理',
role: '角色管理',
class: '班级管理',
schedule: '排课管理',
attendance: '考勤管理',
learning: '学习记录',
exam: '考试管理',
sync: '数据同步',
integration: '集成配置',
department: '部门管理',
notification: '通知中心',
profile: '个人资料',
ai: 'AI 模型配置',
};
useEffect(() => {

View File

@@ -34,18 +34,21 @@ import {
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { usePermission } from '../../hooks/usePermission';
import { message } from '../../ui/app-message';
import {
buildSchedulePayload,
scheduleToFormValues,
type ScheduleFormValues,
} from './schedule-form';
import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility';
import { classifySyncResult } from './sync-result';
// ---- Types ----
interface ClassScheduleItem {
id: number;
classId: number;
id: number | null;
classId: number | null;
classroomId: number;
weekDay: number;
startTime: string;
@@ -59,6 +62,7 @@ interface ClassScheduleItem {
notes: string | null;
createdAt: string;
updatedAt: string;
canViewDetails?: boolean;
}
interface ClassroomItem {
@@ -90,6 +94,9 @@ interface ScheduleSyncResult {
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
failedBatchCount: number;
failedItems: number;
errors: string[];
groups: Array<{ className: string; groupId: number; itemCount: number }>;
}
@@ -99,6 +106,7 @@ const WEEKDAY_NUMBERS = [1, 2, 3, 4, 5, 6, 7];
// ---- Component ----
const SchedulesPage: React.FC = () => {
const { hasPermission } = usePermission();
// View mode and navigation
const [viewMode, setViewMode] = useState<'week' | 'month'>('week');
const [viewDate, setViewDate] = useState<Dayjs>(() => dayjs().weekday(1).startOf('day'));
@@ -142,6 +150,9 @@ const SchedulesPage: React.FC = () => {
groupCount: number;
syncedItems: number;
skippedNoMapping: number;
failedBatchCount: number;
failedItems: number;
errors: string[];
groups: Array<{ className: string; groupId: number; itemCount: number }>;
} | null>(null);
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
@@ -178,7 +189,14 @@ const SchedulesPage: React.FC = () => {
},
});
setSyncResult(res.data);
message.success(`同步完成:${res.data.syncedItems} 条排班已写入钉钉`);
const classification = classifySyncResult(res.data);
if (classification.level === 'error') {
message.error(classification.message);
} else if (classification.level === 'warning') {
message.warning(classification.message);
} else {
message.success(classification.message);
}
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '同步失败');
@@ -235,9 +253,11 @@ const SchedulesPage: React.FC = () => {
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [classroomsRes, classesRes, schedulesRes] = await Promise.all([
api.get('/classrooms') as Promise<ClassroomItem[]>,
api.get('/classes') as Promise<ClassItem[]>,
const [lookups, schedulesRes] = await Promise.all([
api.get('/class-schedules/lookups') as Promise<{
classrooms: ClassroomItem[];
classes: ClassItem[];
}>,
api.get('/class-schedules/weekly', {
params: {
startDate: startDateStr,
@@ -247,8 +267,8 @@ const SchedulesPage: React.FC = () => {
}) as Promise<Record<string, Record<string, ClassScheduleItem[]>>>,
]);
setClassrooms(classroomsRes);
setClasses(classesRes);
setClassrooms(lookups.classrooms);
setClasses(lookups.classes);
// Convert string keys to numbers
const typedMatrix: Record<number, Record<number, ClassScheduleItem[]>> = {};
@@ -288,7 +308,7 @@ const SchedulesPage: React.FC = () => {
const classroomId = Number(cId);
filtered[classroomId] = {};
for (const [wd, schedules] of Object.entries(dayMap)) {
const matched = schedules.filter((s) => s.classId === filterClassId);
const matched = filterSchedulesForClass(schedules, filterClassId);
if (matched.length > 0) {
filtered[classroomId][Number(wd)] = matched;
}
@@ -326,7 +346,7 @@ const SchedulesPage: React.FC = () => {
setSelectedSchedules(schedules);
setModalMode('detail');
setModalOpen(true);
} else {
} else if (hasPermission('schedule:create')) {
setSelectedSchedules([]);
setEditingSchedule(null);
setModalMode('create');
@@ -421,19 +441,22 @@ const SchedulesPage: React.FC = () => {
};
const openEditSchedule = (schedule: ClassScheduleItem) => {
if (isMaskedSchedule(schedule) || schedule.id === null || schedule.classId === null) return;
if (schedule.scheduleType === 'RENTAL') {
message.warning('租赁排课请在租赁订单中修改');
return;
}
const editableSchedule = { ...schedule, id: schedule.id, classId: schedule.classId };
setEditingSchedule(schedule);
setModalMode('edit');
form.setFieldsValue(scheduleToFormValues(schedule));
void loadClassTeachers(schedule.classId);
form.setFieldsValue(scheduleToFormValues(editableSchedule));
void loadClassTeachers(editableSchedule.classId);
};
// ---- Delete schedule ----
const handleDelete = async (id: number) => {
const handleDelete = async (id: number | null) => {
if (id === null) return;
try {
await api.delete(`/class-schedules/${id}`);
message.success('排课已删除');
@@ -680,20 +703,31 @@ const SchedulesPage: React.FC = () => {
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{schedules.map((s) => (
<Tooltip
key={s.id}
title={`${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`}
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
title={
isMaskedSchedule(s)
? `已占用 · ${s.startTime}-${s.endTime}`
: `${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`
}
>
<div
style={{
background: '#e6f4ff',
border: '1px solid #91caff',
background: isMaskedSchedule(s) ? '#f5f5f5' : '#e6f4ff',
border: isMaskedSchedule(s)
? '1px solid #d9d9d9'
: '1px solid #91caff',
borderRadius: 4,
padding: '2px 6px',
fontSize: 12,
lineHeight: '18px',
}}
>
<div style={{ fontWeight: 600, color: '#1677ff' }}>
<div
style={{
fontWeight: 600,
color: isMaskedSchedule(s) ? '#595959' : '#1677ff',
}}
>
{s.subject}
</div>
<div style={{ color: '#595959' }}>
@@ -948,7 +982,8 @@ const SchedulesPage: React.FC = () => {
}}
>
<span style={{ fontWeight: 500 }}></span>
<Button
<PermissionButton
permission="schedule:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
@@ -964,14 +999,14 @@ const SchedulesPage: React.FC = () => {
}}
>
</Button>
</PermissionButton>
</div>
{selectedSchedules.length === 0 ? (
<Empty description="该时段暂无排课" />
) : (
selectedSchedules.map((s) => (
<Card
key={s.id}
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
size="small"
style={{ marginBottom: 8 }}
styles={{ body: { padding: 12 } }}
@@ -985,14 +1020,16 @@ const SchedulesPage: React.FC = () => {
>
<div>
<div>
<strong></strong>
<Tag color="blue">{s.subject}</Tag>
<strong>{isMaskedSchedule(s) ? '状态:' : '科目:'}</strong>
<Tag color={isMaskedSchedule(s) ? 'default' : 'blue'}>{s.subject}</Tag>
</div>
<div>
<strong></strong>
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
</div>
{s.teacherId != null && (
{!isMaskedSchedule(s) && (
<div>
<strong></strong>
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
</div>
)}
{!isMaskedSchedule(s) && s.teacherId != null && (
<div>
<strong></strong>
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
@@ -1008,46 +1045,50 @@ const SchedulesPage: React.FC = () => {
<strong></strong>
{s.startDate} ~ {s.endDate}
</div>
{s.notes && (
{!isMaskedSchedule(s) && s.notes && (
<div>
<strong></strong>
{s.notes}
</div>
)}
<div>
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
</Tag>
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
</div>
</div>
<Space>
{s.scheduleType !== 'RENTAL' && (
<PermissionButton
permission="schedule:edit"
size="small"
icon={<EditOutlined />}
onClick={() => openEditSchedule(s)}
>
</PermissionButton>
{!isMaskedSchedule(s) && (
<div>
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
</Tag>
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
</div>
)}
<Popconfirm
title="确认删除该排课?"
onConfirm={() => handleDelete(s.id)}
okText="删除"
cancelText="取消"
>
<PermissionButton
permission="schedule:delete"
size="small"
danger
icon={<DeleteOutlined />}
</div>
{!isMaskedSchedule(s) && (
<Space>
{s.scheduleType !== 'RENTAL' && (
<PermissionButton
permission="schedule:edit"
size="small"
icon={<EditOutlined />}
onClick={() => openEditSchedule(s)}
>
</PermissionButton>
)}
<Popconfirm
title="确认删除该排课?"
onConfirm={() => handleDelete(s.id)}
okText="删除"
cancelText="取消"
>
</PermissionButton>
</Popconfirm>
</Space>
<PermissionButton
permission="schedule:delete"
size="small"
danger
icon={<DeleteOutlined />}
>
</PermissionButton>
</Popconfirm>
</Space>
)}
</div>
</Card>
))
@@ -1131,6 +1172,30 @@ const SchedulesPage: React.FC = () => {
showIcon
/>
)}
{syncResult.failedBatchCount > 0 && (
<>
<Alert
type="error"
message={`${syncResult.failedBatchCount} 批写入失败,共 ${syncResult.failedItems}`}
description={
syncResult.errors.length > 0
? syncResult.errors.slice(0, 5).map((err, i) => (
<div key={i} style={{ wordBreak: 'break-all' }}>
{err}
</div>
))
: undefined
}
style={{ marginBottom: 16 }}
showIcon
/>
{syncResult.errors.length > 5 && (
<div style={{ fontSize: 12, color: '#999', marginBottom: 16, marginTop: -12 }}>
... {syncResult.errors.length - 5}
</div>
)}
</>
)}
{syncResult.groups.length > 0 && (
<div>
<div style={{ fontWeight: 500, marginBottom: 8 }}></div>

View File

@@ -11,7 +11,7 @@ export interface ScheduleFormValues {
}
export interface EditableSchedule {
id: number;
id?: number;
classId: number;
classroomId: number;
weekDay: number;

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import {
filterSchedulesForClass,
isMaskedSchedule,
type VisibleSchedule,
} from './schedule-visibility';
const ownSchedule: VisibleSchedule = {
id: 1,
classId: 8,
canViewDetails: true,
};
const otherClassBusyBlock: VisibleSchedule = {
id: null,
classId: null,
canViewDetails: false,
};
const anotherOwnVisibleSchedule: VisibleSchedule = {
id: 2,
classId: 9,
canViewDetails: true,
};
describe('schedule visibility helpers', () => {
it('keeps masked shared-room occupancy visible while filtering to one class', () => {
expect(
filterSchedulesForClass([ownSchedule, otherClassBusyBlock, anotherOwnVisibleSchedule], 8),
).toEqual([ownSchedule, otherClassBusyBlock]);
});
it('keeps a rental schedule with explicit detail access unmasked', () => {
expect(isMaskedSchedule({ id: 3, classId: null, canViewDetails: true })).toBe(false);
});
it('treats nullable IDs as masked for responses without an explicit detail flag', () => {
expect(isMaskedSchedule({ id: null, classId: null })).toBe(true);
expect(isMaskedSchedule(ownSchedule)).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
export interface VisibleSchedule {
id: number | null;
classId: number | null;
canViewDetails?: boolean;
}
export const isMaskedSchedule = (
schedule: Pick<VisibleSchedule, 'id' | 'classId' | 'canViewDetails'>,
) =>
schedule.canViewDetails === false ||
(schedule.canViewDetails === undefined && (schedule.id === null || schedule.classId === null));
export const filterSchedulesForClass = <T extends VisibleSchedule>(
schedules: T[],
classId?: number,
): T[] => {
if (classId == null) return schedules;
return schedules.filter((schedule) => isMaskedSchedule(schedule) || schedule.classId === classId);
};

View File

@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import { classifySyncResult, errorSummary } from './sync-result';
describe('classifySyncResult', () => {
const baseResult = {
scheduleCount: 10,
syncedItems: 50,
skippedNoMapping: 0,
failedBatchCount: 0,
failedItems: 0,
errors: [] as string[],
};
it('returns success when no failures', () => {
const result = classifySyncResult(baseResult);
expect(result.hasFailure).toBe(false);
expect(result.level).toBe('success');
expect(result.message).toContain('同步完成');
expect(result.message).toContain('50');
});
it('returns warning when partial failure (some synced, some failed)', () => {
const result = classifySyncResult({
...baseResult,
syncedItems: 30,
failedBatchCount: 2,
failedItems: 20,
errors: ['err1', 'err2'],
});
expect(result.hasFailure).toBe(true);
expect(result.level).toBe('warning');
expect(result.message).toContain('部分失败');
expect(result.message).toContain('30');
expect(result.message).toContain('20');
});
it('returns error when total failure (nothing synced)', () => {
const result = classifySyncResult({
...baseResult,
syncedItems: 0,
failedBatchCount: 1,
failedItems: 50,
errors: ['全部失败'],
});
expect(result.hasFailure).toBe(true);
expect(result.level).toBe('error');
expect(result.message).toContain('全部写入失败');
expect(result.message).toContain('50');
});
it('classifies attendance group failure as failure, not success', () => {
const result = classifySyncResult({
...baseResult,
syncedItems: 45,
failedBatchCount: 1,
failedItems: 5,
errors: ['考勤组 排课_强化班 创建/更新失败: permission denied'],
});
expect(result.hasFailure).toBe(true);
expect(result.level).toBe('warning');
expect(result.message).toContain('部分失败');
// must NOT be success level
expect(result.level).not.toBe('success');
});
it('returns error when all shifts failed (errors present, nothing synced)', () => {
const result = classifySyncResult({
...baseResult,
syncedItems: 0,
failedBatchCount: 2,
failedItems: 10,
errors: [
'创建班次 排课_09:00-11:00 失败: permission denied',
'创建班次 排课_14:00-16:00 失败: permission denied',
],
});
expect(result.hasFailure).toBe(true);
expect(result.level).toBe('error');
expect(result.message).toContain('全部写入失败');
});
it('returns warning when errors-only with some syncedItems (no failedBatchCount)', () => {
// Edge case: errors from shift creation that don't bump failedBatchCount
// but do leave errors[] non-empty, yet some items synced from other classes
const result = classifySyncResult({
...baseResult,
syncedItems: 30,
failedBatchCount: 0,
failedItems: 0,
errors: ['创建班次 排课_09:00-11:00 失败: network error'],
});
expect(result.hasFailure).toBe(true);
expect(result.level).toBe('warning');
expect(result.message).toContain('部分失败');
});
it('returns error when errors-only with zero syncedItems (no failedBatchCount)', () => {
const result = classifySyncResult({
...baseResult,
syncedItems: 0,
failedBatchCount: 0,
failedItems: 0,
errors: ['创建班次 排课_09:00-11:00 失败: permission denied'],
});
expect(result.hasFailure).toBe(true);
expect(result.level).toBe('error');
expect(result.message).toContain('全部写入失败');
});
});
describe('errorSummary', () => {
it('returns all errors when 5 or fewer', () => {
const errors = ['e1', 'e2', 'e3'];
expect(errorSummary(errors)).toEqual(['e1', 'e2', 'e3']);
});
it('returns first 5 when more than 5', () => {
const errors = ['e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7'];
expect(errorSummary(errors)).toEqual(['e1', 'e2', 'e3', 'e4', 'e5']);
});
it('returns empty for empty errors', () => {
expect(errorSummary([])).toEqual([]);
});
});

View File

@@ -0,0 +1,58 @@
/** 同步结果分类 — 从组件中提取以便测试。 */
export interface SyncResultInput {
scheduleCount: number;
syncedItems: number;
skippedNoMapping: number;
failedBatchCount: number;
failedItems: number;
errors: string[];
}
export interface SyncResultClassification {
/** 是否有任何失败 */
hasFailure: boolean;
/** 通知级别:'success' | 'warning' | 'error' */
level: 'success' | 'warning' | 'error';
/** 适合展示给用户的消息文本 */
message: string;
}
/**
* 将同步结果分类为成功/部分失败/全部失败。
* - (failedBatchCount > 0 || errors 非空) 且 syncedItems > 0 → warning部分失败
* - (failedBatchCount > 0 || errors 非空) 且 syncedItems === 0 → error全部失败
* - 否则 → success
*/
export function classifySyncResult(result: SyncResultInput): SyncResultClassification {
const hasFailure = result.failedBatchCount > 0 || result.errors.length > 0;
if (!hasFailure) {
return {
hasFailure: false,
level: 'success',
message: `同步完成:${result.syncedItems} 条排班已写入钉钉`,
};
}
if (result.syncedItems > 0) {
return {
hasFailure: true,
level: 'warning',
message: `同步部分失败:${result.syncedItems} 条成功,${result.failedItems} 条失败`,
};
}
return {
hasFailure: true,
level: 'error',
message: `同步失败:${result.failedItems} 条全部写入失败`,
};
}
/**
* 从错误列表中提取摘要(最多 5 条)。
*/
export function errorSummary(errors: string[]): string[] {
return errors.slice(0, 5);
}

View File

@@ -28,8 +28,12 @@ interface ProfileFormValues {
}
const ROLE_LABELS: Record<string, string> = {
super_admin: '超',
teacher: '老师',
super_admin: '超级管理员',
teacher: '任课老师',
academic: '教务管理员',
accommodation_operations: '住宿运营管理员',
classroom_operations: '教室运营管理员',
system_admin: '系统管理员',
class_teacher: '班主任',
dormitory_supervisor: '宿管',
institution_head: '机构负责人',

View File

@@ -8,10 +8,9 @@
"scripts": {
"dev": "SEED_DEV=true nest start --watch -p tsconfig.build.json",
"build": "nest build -p tsconfig.build.json",
"start:dev": "SEED_DEV=true nest start --watch",
"start:dev": "nest start --watch",
"format": "oxfmt",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
@@ -23,6 +22,7 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@casl/ability": "^7.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",

View File

@@ -0,0 +1,678 @@
import { NotFoundException } from '@nestjs/common';
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
import { AuthorizationService } from '../authorization/authorization.service';
import { AgentToolRegistry } from './agent-tool.registry';
import { AgentToolExecutor } from './agent-tool.executor';
import { AgentToolContextFactory, AgentToolContext } from './agent-tool.types';
import type { ToolDef, ToolInputResult, ToolDescriptor } from './agent-tool.types';
import type { AuthenticatedUser } from '../authorization';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const abilityFactory = new CaslAbilityFactory();
/** Create context via the factory (the ONLY valid path). */
function makeCtx(user: Partial<AuthenticatedUser> & { id: number; username: string }): AgentToolContext {
const u: AuthenticatedUser = {
id: user.id,
username: user.username,
permissions: user.permissions ?? [],
isSuperAdmin: user.isSuperAdmin ?? false,
roles: user.roles ?? [],
};
return AgentToolContextFactory.fromAuthenticatedUser(u);
}
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
const studentViewerCtx = makeCtx({
id: 2,
username: 'teacher_zhang',
permissions: ['student:view'],
});
const noPermCtx = makeCtx({ id: 3, username: 'guest', permissions: [] });
/** Create a simple mock tool. */
function makeTool(overrides: Partial<ToolDef> = {}): ToolDef {
return {
name: 'echo',
description: 'echoes input',
requiredPermission: 'student:view',
inputSchema: { type: 'object', properties: { text: { type: 'string' } }, additionalProperties: false },
validate(input: Record<string, unknown>): ToolInputResult<Record<string, unknown>> {
const forbidden = new Set(['userId', 'isSuperAdmin', 'permissions', 'roles', 'ability']);
for (const key of Object.keys(input)) {
if (forbidden.has(key)) return { ok: false, error: `禁止字段: ${key}` };
}
return { ok: true, value: input };
},
async execute(input: Record<string, unknown>): Promise<unknown> {
return { echoed: input };
},
...overrides,
};
}
function makeExecutor(opLogMock?: { log: jest.Mock }): {
executor: AgentToolExecutor;
registry: AgentToolRegistry;
opLog: { log: jest.Mock };
} {
const authz = new AuthorizationService(abilityFactory);
const registry = new AgentToolRegistry();
const opLog = opLogMock ?? { log: jest.fn().mockResolvedValue(undefined) };
const executor = new AgentToolExecutor(registry, abilityFactory, authz, opLog as never);
return { executor, registry, opLog };
}
// ---------------------------------------------------------------------------
// Fix 2: AgentToolContext — immutability & forgery resistance
// ---------------------------------------------------------------------------
describe('AgentToolContext — immutability & forgery resistance', () => {
it('context is fully frozen (cannot add/remove/modify properties)', () => {
const ctx = AgentToolContextFactory.fromAuthenticatedUser({
id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [],
});
// Frozen object throws in strict mode on mutation
expect(() => {
(ctx as Record<string, unknown>).isSuperAdmin = true;
}).toThrow();
expect(() => {
(ctx as Record<string, unknown>).userId = 999;
}).toThrow();
expect(() => {
(ctx as Record<string, unknown>).newField = 'injected';
}).toThrow();
});
it('permissions array is frozen (cannot push/splice)', () => {
const ctx = AgentToolContextFactory.fromAuthenticatedUser({
id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [],
});
expect(() => {
(ctx.permissions as string[]).push('student:delete');
}).toThrow();
});
it('mutating original AuthenticatedUser does NOT affect context', () => {
const user: AuthenticatedUser = {
id: 1, username: 'admin', permissions: ['student:view'], isSuperAdmin: false, roles: [],
};
const ctx = AgentToolContextFactory.fromAuthenticatedUser(user);
user.permissions.push('superadmin:hack');
user.isSuperAdmin = true;
expect(ctx.permissions).toEqual(['student:view']);
expect(ctx.isSuperAdmin).toBe(false);
});
it('hand-crafted plain-object context is rejected by executor.execute', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const fakeCtx = {
userId: 1,
username: 'hacker',
permissions: Object.freeze(['student:view', 'student:delete']),
isSuperAdmin: true,
} as AgentToolContext;
const result = await executor.execute('echo', { text: 'hi' }, fakeCtx);
expect(result.status).toBe('denied');
expect(result.error).toBe('权限不足');
expect(result.result).toBeUndefined();
});
it('hand-crafted plain-object context is rejected by executor.listAvailable', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const fakeCtx = {
userId: 1,
username: 'hacker',
permissions: Object.freeze(['student:delete']),
isSuperAdmin: true,
} as AgentToolContext;
expect(() => executor.listAvailable(fakeCtx)).toThrow('DENIED');
});
it('Object.create(prototype) without factory is rejected', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
// Even if you get the prototype right, it's not in the WeakSet
const fakeCtx2 = Object.create(AgentToolContext.prototype) as AgentToolContext;
Object.defineProperties(fakeCtx2, {
userId: { value: 1 },
username: { value: 'hacker' },
permissions: { value: Object.freeze(['student:view', 'student:delete']) },
isSuperAdmin: { value: true },
});
Object.freeze(fakeCtx2);
const result = await executor.execute('echo', { text: 'hi' }, fakeCtx2);
expect(result.status).toBe('denied');
});
it('context does NOT expose ability field', () => {
const ctx = superAdminCtx;
expect((ctx as Record<string, unknown>).ability).toBeUndefined();
});
it('passing superAdmin-like permissions on non-superAdmin user does NOT grant superAdmin', () => {
const ctx = makeCtx({ id: 5, username: 'fake', permissions: ['superadmin:all'], isSuperAdmin: false });
expect(ctx.isSuperAdmin).toBe(false);
const ability = abilityFactory.createForUser({
permissions: ctx.permissions,
isSuperAdmin: ctx.isSuperAdmin,
});
expect(ability.can('manage', 'all')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Fix 1: listAvailable via Executor (not Registry)
// ---------------------------------------------------------------------------
describe('listAvailable via Executor', () => {
it('returns ToolDescriptors with name, description, inputSchema — but NOT execute/validate', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const tools: ToolDescriptor[] = executor.listAvailable(studentViewerCtx);
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('student_search');
expect(tools[0].description).toBeTruthy();
expect(tools[0].inputSchema).toBeDefined();
expect((tools[0] as Record<string, unknown>).execute).toBeUndefined();
expect((tools[0] as Record<string, unknown>).validate).toBeUndefined();
expect((tools[0] as Record<string, unknown>).requiredPermission).toBeUndefined();
});
it('super admin sees all tools', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 't1', requiredPermission: 'student:view' }));
registry.register(makeTool({ name: 't2', requiredPermission: 'bill:export' }));
const tools = executor.listAvailable(superAdminCtx);
expect(tools).toHaveLength(2);
});
it('hides tool when principal lacks required permission', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const tools = executor.listAvailable(noPermCtx);
expect(tools).toHaveLength(0);
});
it('filters by exact permission code', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
registry.register(makeTool({ name: 'bill_export', requiredPermission: 'bill:export-excel' }));
const tools = executor.listAvailable(studentViewerCtx);
expect(tools).toHaveLength(1);
expect(tools[0].name).toBe('student_search');
});
it('descriptors include inputSchema when present', () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'get_student',
requiredPermission: 'student:view',
inputSchema: { type: 'object', properties: { studentId: { type: 'integer' } }, required: ['studentId'], additionalProperties: false },
}),
);
const tools = executor.listAvailable(studentViewerCtx);
expect(tools[0].inputSchema).toBeDefined();
expect(tools[0].inputSchema!.required).toContain('studentId');
});
});
// ---------------------------------------------------------------------------
// rawInput type guards
// ---------------------------------------------------------------------------
describe('rawInput type guards', () => {
it('rejects null input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', null, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects array input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', [1, 2, 3], studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects string input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', 'just a string', studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects number input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', 42, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('validator that throws is caught and returns safe error', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'crash_validate',
requiredPermission: 'student:view',
validate(): never {
throw new Error('INTERNAL: validator crashed with raw SQL');
},
}),
);
const result = await executor.execute('crash_validate', { x: 1 }, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
expect(result.error).not.toContain('SQL');
expect(result.error).not.toContain('INTERNAL');
});
});
// ---------------------------------------------------------------------------
// Error & audit sanitization
// ---------------------------------------------------------------------------
describe('Error & audit sanitization', () => {
it('tool throw with phone/SQL in message does NOT leak to result', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'leaky',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new Error('phone=13800138000, idNumber=320106199001011234, SQL: SELECT * FROM students WHERE id=1');
},
}),
);
const result = await executor.execute('leaky', {}, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('工具执行失败');
expect(result.error).not.toContain('13800138000');
expect(result.error).not.toContain('320106');
expect(result.error).not.toContain('SQL');
expect(result.error).not.toContain('SELECT');
});
it('malicious tool name is sanitized in audit action', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor } = makeExecutor(opLog);
const result = await executor.execute(
'evil\n<script>alert(1)</script>!@#$%^&*()very_long_name_exceeding_64_chars_padding_padding_padding_padding_END',
{},
studentViewerCtx,
);
expect(result.toolName).not.toContain('\n');
expect(result.toolName).not.toContain('<script>');
expect(result.toolName).not.toContain('!');
expect(result.toolName.length).toBeLessThanOrEqual(64);
expect(opLog.log).toHaveBeenCalled();
const call = opLog.log.mock.calls[0][0];
expect(call.action).not.toContain('\n');
expect(call.action).not.toContain('<script>');
expect(call.action).not.toContain('!');
expect(call.action).toContain('denied');
});
it('audit detail never contains exception messages', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(
makeTool({
name: 'crash',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new Error('DB error: table students at 10.0.0.1:5432');
},
}),
);
await executor.execute('crash', {}, studentViewerCtx);
const call = opLog.log.mock.calls[0][0];
expect(call.detail).toBe('执行失败');
expect(call.detail).not.toContain('DB error');
expect(call.detail).not.toContain('10.0.0.1');
expect(call.detail).not.toContain('5432');
});
it('unknown tool returns generic denied, not raw tool name detail', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor } = makeExecutor(opLog);
const result = await executor.execute('hack_tool_with_pii_13800138000', {}, studentViewerCtx);
expect(result.error).toBe('未知工具');
expect(result.error).not.toContain('13800138000');
const call = opLog.log.mock.calls[0][0];
expect(call.detail).toBe('拒绝访问');
expect(call.detail).not.toContain('13800138000');
});
});
// ---------------------------------------------------------------------------
// NotFoundException → not_found
// ---------------------------------------------------------------------------
describe('NotFoundException → not_found', () => {
it('NotFound from tool returns not_found with safe message', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'find_student',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new NotFoundException('原始内部消息: student 999 not in scope');
},
}),
);
const result = await executor.execute('find_student', {}, studentViewerCtx);
expect(result.status).toBe('not_found');
expect(result.error).toBe('记录不存在或无权访问');
expect(result.error).not.toContain('999');
expect(result.error).not.toContain('原始内部消息');
});
it('generic Error from tool returns failed with safe message', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'crash',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new Error('random runtime error');
},
}),
);
const result = await executor.execute('crash', {}, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('工具执行失败');
});
});
// ---------------------------------------------------------------------------
// execute — core behavior
// ---------------------------------------------------------------------------
describe('execute — unknown tool', () => {
it('returns denied for unknown tool name', async () => {
const { executor } = makeExecutor();
const result = await executor.execute('nonexistent', {}, superAdminCtx);
expect(result.status).toBe('denied');
expect(result.toolName).toBe('nonexistent');
expect(result.error).toBe('未知工具');
});
});
describe('execute — double-check authorization', () => {
it('denies even if tool is registered but principal lacks permission', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute('student_search', { q: 'test' }, noPermCtx);
expect(result.status).toBe('denied');
expect(result.error).toBe('权限不足');
});
it('allows execution when principal has required permission', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute('student_search', { q: 'test' }, studentViewerCtx);
expect(result.status).toBe('success');
});
});
describe('execute — forged input rejection', () => {
it('rejects userId in input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute(
'student_search',
{ userId: 999, q: 'test' },
studentViewerCtx,
);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects isSuperAdmin in input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute(
'student_search',
{ isSuperAdmin: true, q: 'test' },
studentViewerCtx,
);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
it('rejects permissions in input', async () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
const result = await executor.execute(
'student_search',
{ permissions: ['student:delete'], q: 'test' },
studentViewerCtx,
);
expect(result.status).toBe('failed');
expect(result.error).toBe('输入参数无效');
});
});
describe('execute — success', () => {
it('returns result on success', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'echo',
requiredPermission: 'student:view',
async execute(input: Record<string, unknown>): Promise<unknown> {
return { message: input.text };
},
}),
);
const result = await executor.execute('echo', { text: 'hello' }, studentViewerCtx);
expect(result.status).toBe('success');
expect(result.result).toEqual({ message: 'hello' });
});
});
describe('execute — tool error handling', () => {
it('returns failed status with safe message on tool throw', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'crashy',
requiredPermission: 'student:view',
async execute(): Promise<unknown> {
throw new Error('数据库连接失败: connection refused at 10.0.0.1:5432');
},
}),
);
const result = await executor.execute('crashy', {}, studentViewerCtx);
expect(result.status).toBe('failed');
expect(result.error).toBe('工具执行失败');
expect(result.error).not.toContain('数据库连接失败');
expect(result.error).not.toContain('10.0.0.1');
});
});
// ---------------------------------------------------------------------------
// Fix 3: Audit — awaited, best-effort
// ---------------------------------------------------------------------------
describe('audit logging — awaited best-effort', () => {
it('logs success with userId/username from context', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
expect(opLog.log).toHaveBeenCalledTimes(1);
const call = opLog.log.mock.calls[0][0];
expect(call.userId).toBe(2);
expect(call.username).toBe('teacher_zhang');
expect(call.module).toBe('AI Agent Tool');
expect(call.action).toContain('echo');
expect(call.action).toContain('success');
});
it('logs denied with status', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
await executor.execute('student_search', {}, noPermCtx);
const call = opLog.log.mock.calls[0][0];
expect(call.action).toContain('denied');
expect(call.detail).toBe('拒绝访问');
});
it('logs failed on validation error', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'student_search', requiredPermission: 'student:view' }));
await executor.execute('student_search', { isSuperAdmin: true }, studentViewerCtx);
const call = opLog.log.mock.calls[0][0];
expect(call.action).toContain('failed');
});
it('audit detail NEVER contains phone/ID/sensitive fields', async () => {
const opLog = { log: jest.fn().mockResolvedValue(undefined) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
await executor.execute('echo', { phone: '13800138000', name: 'test' }, studentViewerCtx);
const call = opLog.log.mock.calls[0][0];
const detail = call.detail as string;
expect(detail).not.toContain('13800138000');
expect(detail).not.toContain('phone');
expect(detail).not.toContain('idNumber');
expect(detail).not.toContain('password');
});
it('audit write failure does not break successful tool call', async () => {
const opLog = { log: jest.fn().mockRejectedValue(new Error('DB write error')) };
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
expect(result.status).toBe('success');
expect(result.result).toEqual({ echoed: { text: 'hi' } });
});
it('execute awaits audit before returning (delayed audit does not drop)', async () => {
let auditResolved = false;
const opLog = {
log: jest.fn().mockImplementation(() => {
return new Promise<void>((resolve) => {
setTimeout(() => {
auditResolved = true;
resolve();
}, 50);
});
}),
};
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
// At call time, audit hasn't resolved
expect(auditResolved).toBe(false);
const result = await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
// After execute returns, audit IS resolved (awaited)
expect(auditResolved).toBe(true);
expect(result.status).toBe('success');
});
it('audit rejection still resolves execute with correct result', async () => {
const opLog = {
log: jest.fn().mockImplementation(() => {
return new Promise<void>((_, reject) => {
setTimeout(() => reject(new Error('audit write failed')), 10);
});
}),
};
const { executor, registry } = makeExecutor(opLog);
registry.register(makeTool({ name: 'echo', requiredPermission: 'student:view' }));
const result = await executor.execute('echo', { text: 'hi' }, studentViewerCtx);
expect(result.status).toBe('success');
expect(result.result).toEqual({ echoed: { text: 'hi' } });
});
});
// ---------------------------------------------------------------------------
// Super admin
// ---------------------------------------------------------------------------
describe('super admin', () => {
it('super admin can execute any tool regardless of permission', async () => {
const { executor, registry } = makeExecutor();
registry.register(
makeTool({
name: 'admin_only',
requiredPermission: 'nuclear:launch',
}),
);
const result = await executor.execute('admin_only', {}, superAdminCtx);
expect(result.status).toBe('success');
});
it('listAvailable returns all tools for super admin', () => {
const { executor, registry } = makeExecutor();
registry.register(makeTool({ name: 't1', requiredPermission: 'ghost:action' }));
registry.register(makeTool({ name: 't2', requiredPermission: 'custom:code' }));
const tools = executor.listAvailable(superAdminCtx);
expect(tools).toHaveLength(2);
});
});

View File

@@ -0,0 +1,251 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
import { AuthorizationService } from '../authorization';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AgentToolRegistry } from './agent-tool.registry';
import { AgentToolContextFactory } from './agent-tool.types';
import type { AgentToolContext, ToolExecutionResult, ToolStatus, ToolDescriptor } from './agent-tool.types';
/** Safe tool name: alphanumeric + underscore, max 64 chars. */
const TOOL_NAME_RE = /^[a-zA-Z0-9_]+$/;
const TOOL_NAME_MAX_LEN = 64;
/** Safe user-facing messages that never leak internals. */
const SAFE_MESSAGES = {
unknownTool: '未知工具',
permissionDenied: '权限不足',
invalidInput: '输入参数无效',
executionFailed: '工具执行失败',
notFound: '记录不存在或无权访问',
} as const;
/**
* Executes Agent Tools with double-check authorization, input validation,
* context trust validation, and audit logging.
*
* ## Security guarantees
*
* 1. Context trust is validated at runtime via
* {@link AgentToolContextFactory.assertTrusted} — forged/plain-object
* contexts are rejected.
* 2. The ability is constructed fresh from the principal in the context
* — callers cannot pre-forge it.
* 3. Permission is checked AGAIN at execute time (not just at list time).
* 4. Unknown tools are rejected with a generic message, and the tool name
* is sanitized in audit logs.
* 5. `rawInput` is `unknown` — null, arrays, and strings are caught before
* validation.
* 6. All tool & validator exceptions are caught and mapped to safe messages.
* 7. Audit logs never include raw input, stack traces, or internal error text.
* 8. Audit log is awaited best-effort — failure does NOT fail the tool call.
*/
@Injectable()
export class AgentToolExecutor {
constructor(
private readonly registry: AgentToolRegistry,
private readonly abilityFactory: CaslAbilityFactory,
private readonly authz: AuthorizationService,
private readonly opLog: OperationLogsService,
) {}
/**
* List tools available to the given context.
*
* Returns read-only {@link ToolDescriptor}s — never exposes
* `execute`, `validate`, or `requiredPermission`.
*
* This is the ONLY public entry point for tool discovery.
* SDK consumers MUST use this instead of direct Registry access.
*
* @param context — trusted context from
* {@link AgentToolContextFactory.fromAuthenticatedUser}.
*/
listAvailable(context: AgentToolContext): ToolDescriptor[] {
AgentToolContextFactory.assertTrusted(context);
const ability = this.abilityFactory.createForUser({
permissions: context.permissions,
isSuperAdmin: context.isSuperAdmin,
});
return this.registry
.listAvailableInternal(ability)
.map(({ name, description, inputSchema }) => ({
name,
description,
...(inputSchema ? { inputSchema } : {}),
}));
}
/**
* Execute a tool by name.
*
* @param name — tool name (e.g. "search_students"). Must pass sanitization.
* @param rawInput — raw input from the model (may be any JSON value).
* @param context — trusted context from
* {@link AgentToolContextFactory.fromAuthenticatedUser}.
*/
async execute(
name: string,
rawInput: unknown,
context: AgentToolContext,
): Promise<ToolExecutionResult> {
// 0. Context trust validation — must be first
try {
AgentToolContextFactory.assertTrusted(context);
} catch {
return { status: 'denied', toolName: '_denied', error: SAFE_MESSAGES.permissionDenied };
}
// 1. Sanitize tool name — model-controlled input
const safeName = this.sanitizeName(name);
const tool = this.registry.getForExecution(name);
if (!tool) {
return this.auditAndReturn(
safeName,
'denied',
undefined,
SAFE_MESSAGES.unknownTool,
context,
);
}
// 2. Build ability from principal fields — never trust a pre-built one
const ability = this.abilityFactory.createForUser({
permissions: context.permissions,
isSuperAdmin: context.isSuperAdmin,
});
// 3. Double-check authorization at execute time
if (!this.authz.canPermission(ability, tool.requiredPermission)) {
return this.auditAndReturn(
safeName,
'denied',
undefined,
SAFE_MESSAGES.permissionDenied,
context,
);
}
// 4. Guard: rawInput must be a plain object
if (rawInput === null || Array.isArray(rawInput) || typeof rawInput !== 'object') {
return this.auditAndReturn(
safeName,
'failed',
undefined,
SAFE_MESSAGES.invalidInput,
context,
);
}
// 5. Validate and parse input — validator exceptions are caught
let parsed: { ok: true; value: unknown } | { ok: false };
try {
parsed = tool.validate(rawInput as Record<string, unknown>);
} catch {
return this.auditAndReturn(
safeName,
'failed',
undefined,
SAFE_MESSAGES.invalidInput,
context,
);
}
if (!parsed.ok) {
return this.auditAndReturn(
safeName,
'failed',
undefined,
SAFE_MESSAGES.invalidInput,
context,
);
}
// 6. Execute
try {
const result = await tool.execute(parsed.value, context);
return this.auditAndReturn(safeName, 'success', result, undefined, context);
} catch (err: unknown) {
// NotFoundException → not_found with safe message
if (err instanceof NotFoundException) {
return this.auditAndReturn(
safeName,
'not_found',
undefined,
SAFE_MESSAGES.notFound,
context,
);
}
// All other errors → generic failed message
return this.auditAndReturn(
safeName,
'failed',
undefined,
SAFE_MESSAGES.executionFailed,
context,
);
}
}
/**
* Sanitize a tool name from model input.
*
* Only allows `[a-zA-Z0-9_]`, max {@link TOOL_NAME_MAX_LEN} chars.
* Returns the sanitized name or a safe fallback.
*/
private sanitizeName(name: string): string {
if (typeof name !== 'string') return '_invalid';
const trimmed = name.slice(0, TOOL_NAME_MAX_LEN);
if (TOOL_NAME_RE.test(trimmed)) return trimmed;
// Replace unsafe chars with underscore
return trimmed.replace(/[^a-zA-Z0-9_]/g, '_').slice(0, TOOL_NAME_MAX_LEN);
}
/**
* Build result + best-effort awaited audit log.
* Audit write failure is caught and never propagated — it must not
* turn a successful data read into a failure.
*/
private async auditAndReturn(
toolName: string,
status: ToolStatus,
result: unknown,
error: string | undefined,
context: AgentToolContext,
): Promise<ToolExecutionResult> {
// Await audit (best-effort — failure is silently swallowed)
try {
await this.opLog.log({
userId: context.userId,
username: context.username,
module: 'AI Agent Tool',
action: `${toolName} [${status}]`,
detail: this.buildAuditDetail(status),
status,
});
} catch {
// Swallow — audit failure must not break the tool call
}
return { status, toolName, result, error };
}
/**
* Build a safe audit detail string.
* NEVER includes raw input, exception messages, phone numbers, or other PII.
* Only writes safe category labels.
*/
private buildAuditDetail(status: ToolStatus): string {
switch (status) {
case 'success':
return '执行成功';
case 'denied':
return '拒绝访问';
case 'not_found':
return '记录不存在或无权访问';
default:
return '执行失败';
}
}
}

View File

@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { CaslAction } from '../authorization/casl.constants';
import type { AppAbility } from '../authorization';
import type { ToolDef } from './agent-tool.types';
/**
* Internal tool registry — NOT exported from the module.
*
* Holds all registered Agent Tools. Lookups are delegated from
* {@link AgentToolExecutor}, which handles authorization, context
* validation, and audit logging.
*
* SDK consumers MUST NOT access this directly — use
* {@link AgentToolExecutor.listAvailable} and
* {@link AgentToolExecutor.execute} instead.
*/
@Injectable()
export class AgentToolRegistry {
private readonly tools: ToolDef[] = [];
/** Register a tool (called once at module init). */
register(tool: ToolDef): void {
const idx = this.tools.findIndex((t) => t.name === tool.name);
if (idx >= 0) {
this.tools[idx] = tool;
} else {
this.tools.push(tool);
}
}
/**
* Return tools whose required permission the given ability satisfies.
* The ability is built by the caller (Executor) — this is a pure
* filter, not an authorization decision.
*/
listAvailableInternal(ability: AppAbility): ToolDef[] {
return this.tools.filter((tool) => {
// Super admin ability has manage all — passes everything
if (ability.can(CaslAction.Manage, 'all')) return true;
// Exact permission-code check via CASL Access
return ability.can(CaslAction.Access, `PermissionCode:${tool.requiredPermission}`);
});
}
/**
* Look up an internal {@link ToolDef} by name.
* Returns `undefined` if not found.
*/
getForExecution(name: string): ToolDef | undefined {
return this.tools.find((t) => t.name === name);
}
}

View File

@@ -0,0 +1,180 @@
import type { AuthenticatedUser } from '../authorization';
// ---------------------------------------------------------------------------
// AgentToolContext — trusted server-side principal (NO ability)
// ---------------------------------------------------------------------------
// Module-private brand and trusted set for runtime forgery resistance
const trustedContexts = new WeakSet<AgentToolContext>();
const CONTEXT_BRAND = Symbol('AgentToolContext');
/**
* Execution context for Agent Tool invocations — branded to prevent
* forgery. Only {@link AgentToolContextFactory} can create trusted
* instances; {@link AgentToolExecutor} enforces this at runtime via
* {@link AgentToolContextFactory.assertTrusted}.
*
* All fields come from the trusted server-side authentication layer.
* The CASL ability is deliberately OMITTED — callers cannot inject a
* pre-forged ability.
*/
export class AgentToolContext {
/** The authenticated user's numeric ID. */
readonly userId!: number;
/** The authenticated user's login name (for audit). */
readonly username!: string;
/**
* Flat list of `resource:action` permission codes.
* Frozen at creation — downstream code cannot mutate it.
*/
readonly permissions!: readonly string[];
/** Whether the user has a super-admin role. */
readonly isSuperAdmin!: boolean;
/** @internal Module-private brand — set only by the Factory. */
private readonly _brand = CONTEXT_BRAND;
private constructor() {
// Construction is only via AgentToolContextFactory
}
}
/**
* Creates a trusted {@link AgentToolContext} from the authenticated
* user record populated by the JWT strategy.
*
* This is the ONLY way to create an AgentToolContext — never construct
* it by hand. The returned context is frozen and registered in an
* internal WeakSet; {@link assertTrusted} rejects any context not
* created through this factory.
*/
export class AgentToolContextFactory {
/**
* Build a frozen, branded context from an authenticated user.
*
* @param user — the user record placed on the request by JWT auth.
*/
static fromAuthenticatedUser(user: AuthenticatedUser): AgentToolContext {
const ctx = Object.create(AgentToolContext.prototype) as AgentToolContext;
Object.defineProperties(ctx, {
userId: { value: user.id, enumerable: true, writable: false, configurable: false },
username: { value: user.username, enumerable: true, writable: false, configurable: false },
permissions: {
value: Object.freeze([...user.permissions]),
enumerable: true,
writable: false,
configurable: false,
},
isSuperAdmin: { value: user.isSuperAdmin, enumerable: true, writable: false, configurable: false },
_brand: { value: CONTEXT_BRAND, enumerable: false, writable: false, configurable: false },
});
Object.freeze(ctx);
trustedContexts.add(ctx);
return ctx;
}
/**
* Runtime check: reject forged/plain-object contexts.
*
* Called at the entry of {@link AgentToolExecutor.execute} and
* {@link AgentToolExecutor.listAvailable}. Throws if the argument
* was not created by {@link fromAuthenticatedUser}.
*/
static assertTrusted(context: unknown): asserts context is AgentToolContext {
if (
!(context instanceof AgentToolContext) ||
!trustedContexts.has(context)
) {
throw new Error('DENIED: untrusted execution context');
}
}
}
// ---------------------------------------------------------------------------
// ToolDescriptor — public, non-executable tool surface
// ---------------------------------------------------------------------------
/**
* A read-only descriptor of an agent tool returned to SDK consumers.
*
* Does NOT expose `execute`, `validate`, or `requiredPermission` —
* callers must go through {@link AgentToolExecutor} for double-check
* authorization, input validation, and audit logging.
*/
export interface ToolDescriptor {
/** Unique tool name exposed to the LLM (e.g. "search_students"). */
readonly name: string;
/** Human-readable description for the model. */
readonly description: string;
/**
* Optional provider-neutral JSON Schema-like input description.
* Never exposes execution internals or permission details.
*/
readonly inputSchema?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// ToolDef — internal tool definition (NOT for SDK consumers)
// ---------------------------------------------------------------------------
/**
* Result of input validation — either success with parsed input,
* or an error message.
*/
export type ToolInputResult<T> =
| { readonly ok: true; readonly value: T }
| { readonly ok: false; readonly error: string };
/**
* A single Agent Tool definition — internal use only.
*
* SDK consumers MUST receive a {@link ToolDescriptor}, never a `ToolDef`.
* Tool execution always goes through {@link AgentToolExecutor}.
*
* @typeParam TInput — the parsed & validated input shape the `execute`
* function receives.
*/
export interface ToolDef<TInput = unknown> {
/** Unique tool name exposed to the LLM (e.g. "search_students"). */
readonly name: string;
/** Human-readable description for the model. */
readonly description: string;
/**
* The exact `resource:action` permission code required to use this tool.
* Checked via {@link AuthorizationService.canPermission}.
*/
readonly requiredPermission: string;
/**
* Optional provider-neutral JSON Schema-like input description.
*/
readonly inputSchema?: Record<string, unknown>;
/**
* Validate and parse raw input from the model.
* Reject unknown/sensitive fields (userId, permissions, isSuperAdmin, …).
*/
validate(input: Record<string, unknown>): ToolInputResult<TInput>;
/**
* Execute the tool with parsed input and the trusted context.
* MUST NOT trust `context` to come from input.
*/
execute(input: TInput, context: AgentToolContext): Promise<unknown>;
}
// ---------------------------------------------------------------------------
// Tool execution status (for audit)
// ---------------------------------------------------------------------------
export type ToolStatus = 'success' | 'denied' | 'failed' | 'not_found';
/**
* Result returned by {@link AgentToolExecutor.execute}.
*/
export interface ToolExecutionResult {
readonly status: ToolStatus;
readonly toolName: string;
/** Set on success; `undefined` on denied / failed / not_found. */
readonly result?: unknown;
/** Set on denied / failed / not_found; `undefined` on success.
* Always a safe, human-readable message — never raw exception text. */
readonly error?: string;
}

View File

@@ -0,0 +1,43 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { StudentsModule } from '../students/students.module';
import { AgentToolRegistry } from './agent-tool.registry';
import { AgentToolExecutor } from './agent-tool.executor';
import { SearchStudentsTool } from './tools/search-students.tool';
import { GetStudentBasicTool } from './tools/get-student-basic.tool';
/**
* Agent Tools feature module.
*
* Provides a provider-neutral tool executor for LLM agent frameworks.
* SDK consumers interact ONLY with {@link AgentToolExecutor}.
*
* `AgentToolRegistry` is an internal provider — it is NOT exported from
* this module. All tool listing and execution goes through the executor,
* which enforces double-check authorization, audit logging, and context
* trust validation.
*
* Imports `StudentsModule` for student data access and relies on the
* globally available `AuthorizationModule` and `OperationLogsModule`.
*/
@Module({
imports: [StudentsModule],
providers: [
AgentToolRegistry,
AgentToolExecutor,
SearchStudentsTool,
GetStudentBasicTool,
],
exports: [AgentToolExecutor],
})
export class AgentToolsModule implements OnModuleInit {
constructor(
private readonly registry: AgentToolRegistry,
private readonly searchTool: SearchStudentsTool,
private readonly getTool: GetStudentBasicTool,
) {}
onModuleInit(): void {
this.registry.register(this.searchTool);
this.registry.register(this.getTool);
}
}

View File

@@ -0,0 +1,4 @@
export { AgentToolsModule } from './agent-tools.module';
export { AgentToolExecutor } from './agent-tool.executor';
export { AgentToolContextFactory, AgentToolContext } from './agent-tool.types';
export type { ToolDescriptor, ToolExecutionResult, ToolStatus } from './agent-tool.types';

View File

@@ -0,0 +1,180 @@
import { NotFoundException } from '@nestjs/common';
import { GetStudentBasicTool } from './get-student-basic.tool';
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
import { AgentToolContextFactory } from '../agent-tool.types';
import type { AgentToolContext } from '../agent-tool.types';
import type { AuthenticatedUser } from '../../authorization';
const abilityFactory = new CaslAbilityFactory();
const scopeFactory = new StudentAccessScopeFactory(abilityFactory);
function makeCtx(overrides: Partial<AuthenticatedUser> & { id: number; username: string }): AgentToolContext {
const user: AuthenticatedUser = {
id: overrides.id,
username: overrides.username,
permissions: overrides.permissions ?? [],
isSuperAdmin: overrides.isSuperAdmin ?? false,
roles: overrides.roles ?? [],
};
return AgentToolContextFactory.fromAuthenticatedUser(user);
}
const studentViewerCtx = makeCtx({ id: 2, username: 'teacher', permissions: ['student:view'] });
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
const classEditorCtx = makeCtx({
id: 4,
username: 'class_editor',
permissions: ['student:view', 'class:edit'],
});
function makeTool(svcOverride?: { agentGetStudentBasic: jest.Mock }): GetStudentBasicTool {
const svc = svcOverride ?? { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
return new GetStudentBasicTool(svc as never, scopeFactory);
}
const basicOutput = {
id: 1,
name: '张三',
studentNo: 'S001',
gender: '男',
status: 'active',
organizationId: 10,
organizationName: '杭州校区',
classIds: [5],
};
describe('GetStudentBasicTool', () => {
it('has name "get_student_basic"', () => {
const tool = makeTool();
expect(tool.name).toBe('get_student_basic');
});
it('requires permission "student:view"', () => {
const tool = makeTool();
expect(tool.requiredPermission).toBe('student:view');
});
// -----------------------------------------------------------------------
// Validation
// -----------------------------------------------------------------------
describe('validate', () => {
it('accepts valid studentId', () => {
const tool = makeTool();
const result = tool.validate({ studentId: 1 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.studentId).toBe(1);
});
it('rejects missing studentId', () => {
const tool = makeTool();
const result = tool.validate({});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('studentId');
});
it('rejects non-integer studentId', () => {
const tool = makeTool();
const result = tool.validate({ studentId: 'abc' });
expect(result.ok).toBe(false);
});
it('rejects extra unknown fields', () => {
const tool = makeTool();
const result = tool.validate({ studentId: 1, extraField: 'hack' });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('extraField');
});
it('rejects userId', () => {
const tool = makeTool();
const result = tool.validate({ studentId: 1, userId: 999 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('userId');
});
});
// -----------------------------------------------------------------------
// P2-2: Scope construction
// -----------------------------------------------------------------------
describe('P2-2: scope', () => {
it('super admin uses manageAll scope', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
const tool = makeTool(mockSvc);
await tool.execute({ studentId: 1 }, superAdminCtx);
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
{ type: 'manageAll' },
1,
);
});
it('non-admin uses teacher scope', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
const tool = makeTool(mockSvc);
await tool.execute({ studentId: 1 }, studentViewerCtx);
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
{ type: 'teacher', userId: 2 },
1,
);
});
it('class:edit uses manageAll scope', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
const tool = makeTool(mockSvc);
await tool.execute({ studentId: 1 }, classEditorCtx);
expect(mockSvc.agentGetStudentBasic).toHaveBeenCalledWith(
{ type: 'manageAll' },
1,
);
});
});
// -----------------------------------------------------------------------
// P2-1: NotFoundException for null result
// -----------------------------------------------------------------------
describe('P2-1: NotFoundException', () => {
it('null from service throws NotFoundException (not returned as success)', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
const tool = makeTool(mockSvc);
await expect(tool.execute({ studentId: 999 }, studentViewerCtx)).rejects.toThrow(
NotFoundException,
);
});
it('service NotFound message is "记录不存在或无权访问"', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(null) };
const tool = makeTool(mockSvc);
await expect(tool.execute({ studentId: 999 }, studentViewerCtx)).rejects.toThrow(
'记录不存在或无权访问',
);
});
});
// -----------------------------------------------------------------------
// Execute — happy path
// -----------------------------------------------------------------------
describe('execute', () => {
it('returns formatted student data', async () => {
const mockSvc = { agentGetStudentBasic: jest.fn().mockResolvedValue(basicOutput) };
const tool = makeTool(mockSvc);
const result = await tool.execute({ studentId: 1 }, superAdminCtx);
expect(result).toEqual(basicOutput);
const keys = Object.keys(result as Record<string, unknown>);
expect(keys).not.toContain('phone');
expect(keys).not.toContain('idNumber');
});
});
});

View File

@@ -0,0 +1,82 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { StudentsService } from '../../students/students.service';
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
interface GetStudentBasicInput {
studentId: number;
}
/** Forbidden input keys — if the model sends these, validation fails. */
const FORBIDDEN_INPUT_KEYS = new Set([
'userId',
'isSuperAdmin',
'permissions',
'roles',
'ability',
'user',
'password',
'token',
]);
@Injectable()
export class GetStudentBasicTool implements ToolDef<GetStudentBasicInput> {
readonly inputSchema = {
type: 'object',
properties: {
studentId: {
type: 'integer',
description: '学生ID',
minimum: 1,
},
},
required: ['studentId'],
additionalProperties: false,
};
readonly name = 'get_student_basic';
readonly description = '获取单个学生基本信息。仅返回基础公开字段。';
readonly requiredPermission = 'student:view';
constructor(
private readonly studentsService: StudentsService,
private readonly scopeFactory: StudentAccessScopeFactory,
) {}
validate(input: Record<string, unknown>): ToolInputResult<GetStudentBasicInput> {
for (const key of Object.keys(input)) {
if (FORBIDDEN_INPUT_KEYS.has(key)) {
return { ok: false, error: `不允许的输入字段: ${key}` };
}
}
if (input.studentId === undefined) {
return { ok: false, error: '缺少必填字段: studentId' };
}
const studentId = Number(input.studentId);
if (!Number.isInteger(studentId) || studentId <= 0) {
return { ok: false, error: 'studentId 必须是正整数' };
}
// Reject unexpected keys
const allowedKeys = new Set(['studentId']);
for (const key of Object.keys(input)) {
if (!allowedKeys.has(key)) {
return { ok: false, error: `不允许的输入字段: ${key}` };
}
}
return { ok: true, value: { studentId } };
}
async execute(input: GetStudentBasicInput, context: AgentToolContext): Promise<unknown> {
const scope = this.scopeFactory.buildScope(context);
const result = await this.studentsService.agentGetStudentBasic(scope, input.studentId);
if (result === null) {
throw new NotFoundException('记录不存在或无权访问');
}
return result;
}
}

View File

@@ -0,0 +1,181 @@
import { SearchStudentsTool } from './search-students.tool';
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
import { AgentToolContextFactory } from '../agent-tool.types';
import type { AgentToolContext } from '../agent-tool.types';
import type { AuthenticatedUser } from '../../authorization';
const abilityFactory = new CaslAbilityFactory();
const scopeFactory = new StudentAccessScopeFactory(abilityFactory);
function makeCtx(
overrides: Partial<AuthenticatedUser> & { id: number; username: string },
): AgentToolContext {
const user: AuthenticatedUser = {
id: overrides.id,
username: overrides.username,
permissions: overrides.permissions ?? [],
isSuperAdmin: overrides.isSuperAdmin ?? false,
roles: overrides.roles ?? [],
};
return AgentToolContextFactory.fromAuthenticatedUser(user);
}
const studentViewerCtx = makeCtx({ id: 2, username: 'teacher', permissions: ['student:view'] });
const noPermCtx = makeCtx({ id: 3, username: 'guest', permissions: [] });
const superAdminCtx = makeCtx({ id: 1, username: 'admin', isSuperAdmin: true });
const classEditorCtx = makeCtx({
id: 4,
username: 'class_editor',
permissions: ['student:view', 'class:edit'],
});
function makeTool(svcOverride?: { agentSearchStudents: jest.Mock }): SearchStudentsTool {
const svc = svcOverride ?? { agentSearchStudents: jest.fn().mockResolvedValue([]) };
return new SearchStudentsTool(svc as never, scopeFactory);
}
describe('SearchStudentsTool', () => {
// -----------------------------------------------------------------------
// Tool metadata
// -----------------------------------------------------------------------
it('has name "search_students"', () => {
const tool = makeTool();
expect(tool.name).toBe('search_students');
});
it('requires permission "student:view"', () => {
const tool = makeTool();
expect(tool.requiredPermission).toBe('student:view');
});
// -----------------------------------------------------------------------
// Input validation
// -----------------------------------------------------------------------
describe('validate', () => {
it('accepts valid input with keyword', () => {
const tool = makeTool();
const result = tool.validate({ keyword: '张三' });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.keyword).toBe('张三');
});
it('accepts valid input with classId', () => {
const tool = makeTool();
const result = tool.validate({ classId: 5 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.classId).toBe(5);
});
it('accepts valid input with organizationId', () => {
const tool = makeTool();
const result = tool.validate({ organizationId: 10 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.organizationId).toBe(10);
});
it('accepts valid input with limit', () => {
const tool = makeTool();
const result = tool.validate({ limit: 30 });
expect(result.ok).toBe(true);
if (result.ok) expect(result.value.limit).toBe(30);
});
it('rejects userId', () => {
const tool = makeTool();
const result = tool.validate({ userId: 999 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('userId');
});
it('rejects isSuperAdmin', () => {
const tool = makeTool();
const result = tool.validate({ isSuperAdmin: true });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('isSuperAdmin');
});
it('rejects permissions', () => {
const tool = makeTool();
const result = tool.validate({ permissions: ['student:delete'] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('permissions');
});
it('rejects roles', () => {
const tool = makeTool();
const result = tool.validate({ roles: ['admin'] });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('roles');
});
it('rejects ability', () => {
const tool = makeTool();
const result = tool.validate({ ability: {} });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.error).toContain('ability');
});
it('rejects unknown fields to match additionalProperties false', () => {
const tool = makeTool();
const result = tool.validate({ debug: true });
expect(result.ok).toBe(false);
});
it('rejects limit above the advertised maximum', () => {
const tool = makeTool();
const result = tool.validate({ limit: 51 });
expect(result.ok).toBe(false);
});
it('rejects non-integer classId', () => {
const tool = makeTool();
const result = tool.validate({ classId: 'abc' });
expect(result.ok).toBe(false);
});
it('rejects non-integer organizationId', () => {
const tool = makeTool();
const result = tool.validate({ organizationId: 1.5 });
expect(result.ok).toBe(false);
});
});
// -----------------------------------------------------------------------
// P2-2: Scope construction via StudentAccessScopeFactory
// -----------------------------------------------------------------------
describe('P2-2: scope construction', () => {
it('super admin uses manageAll scope', async () => {
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
const tool = makeTool(mockSvc);
await tool.execute({}, superAdminCtx);
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {});
});
it('non-admin uses teacher scope with userId', async () => {
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
const tool = makeTool(mockSvc);
await tool.execute({ keyword: 'test' }, studentViewerCtx);
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith(
{ type: 'teacher', userId: 2 },
{ keyword: 'test' },
);
});
it('class:edit permission grants manageAll scope (not teacher)', async () => {
const mockSvc = { agentSearchStudents: jest.fn().mockResolvedValue([]) };
const tool = makeTool(mockSvc);
await tool.execute({}, classEditorCtx);
expect(mockSvc.agentSearchStudents).toHaveBeenCalledWith({ type: 'manageAll' }, {});
});
});
});

View File

@@ -0,0 +1,122 @@
import { Injectable } from '@nestjs/common';
import { StudentsService } from '../../students/students.service';
import { StudentAccessScopeFactory } from '../../students/student-access-scope.factory';
import type { AgentToolContext, ToolDef, ToolInputResult } from '../agent-tool.types';
/** Whitelisted input shape for search_students. */
interface SearchStudentsInput {
keyword?: string;
classId?: number;
organizationId?: number;
limit?: number;
}
/** Forbidden input keys — if the model sends these, validation fails. */
const FORBIDDEN_INPUT_KEYS = new Set([
'userId',
'isSuperAdmin',
'permissions',
'roles',
'ability',
'user',
'password',
'token',
]);
@Injectable()
export class SearchStudentsTool implements ToolDef<SearchStudentsInput> {
readonly name = 'search_students';
readonly inputSchema = {
type: 'object',
properties: {
keyword: {
type: 'string',
description: '搜索关键词(姓名/学号)',
maxLength: 100,
},
classId: {
type: 'integer',
description: '班级ID',
minimum: 1,
},
organizationId: {
type: 'integer',
description: '校区ID',
minimum: 1,
},
limit: {
type: 'integer',
description: '返回条数上限',
minimum: 1,
maximum: 50,
},
},
additionalProperties: false,
};
readonly description = '搜索学生,支持关键词、班级、校区筛选。仅返回基础公开字段。';
readonly requiredPermission = 'student:view';
constructor(
private readonly studentsService: StudentsService,
private readonly scopeFactory: StudentAccessScopeFactory,
) {}
validate(input: Record<string, unknown>): ToolInputResult<SearchStudentsInput> {
// Reject forbidden keys
for (const key of Object.keys(input)) {
if (FORBIDDEN_INPUT_KEYS.has(key)) {
return {
ok: false,
error: `不允许的输入字段: ${key}`,
};
}
}
const allowedKeys = new Set(['keyword', 'classId', 'organizationId', 'limit']);
for (const key of Object.keys(input)) {
if (!allowedKeys.has(key)) {
return { ok: false, error: `不允许的输入字段: ${key}` };
}
}
const result: SearchStudentsInput = {};
if (input.keyword !== undefined) {
if (typeof input.keyword !== 'string' || input.keyword.length > 100) {
return { ok: false, error: 'keyword 必须是字符串且长度不超过100' };
}
result.keyword = input.keyword;
}
if (input.classId !== undefined) {
const id = Number(input.classId);
if (!Number.isInteger(id) || id <= 0) {
return { ok: false, error: 'classId 必须是正整数' };
}
result.classId = id;
}
if (input.organizationId !== undefined) {
const id = Number(input.organizationId);
if (!Number.isInteger(id) || id <= 0) {
return { ok: false, error: 'organizationId 必须是正整数' };
}
result.organizationId = id;
}
if (input.limit !== undefined) {
const limit = Number(input.limit);
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return { ok: false, error: 'limit 必须是 1 到 50 的整数' };
}
result.limit = limit;
}
return { ok: true, value: result };
}
async execute(input: SearchStudentsInput, context: AgentToolContext): Promise<unknown> {
const scope = this.scopeFactory.buildScope(context);
return this.studentsService.agentSearchStudents(scope, input);
}
}

View File

@@ -0,0 +1,302 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AiConfigController } from './ai-config.controller';
import { AiConfigService } from './ai-config.service';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { AiProvider } from './ai-config.entity';
describe('AiConfigController', () => {
let controller: AiConfigController;
let service: jest.Mocked<Pick<AiConfigService, 'getConfig' | 'saveConfig' | 'testConnection' | 'clearKey'>>;
let opLog: jest.Mocked<Pick<OperationLogsService, 'log'>>;
const mockConfig = {
id: 1,
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/v1',
hasApiKey: true,
hasDatabaseKey: true,
maskedApiKey: '••••1234',
keySource: 'database' as const,
defaultModel: 'gpt-4',
enabled: true,
timeoutMs: 30000,
verified: true,
lastTestedAt: '2024-01-01T00:00:00.000Z',
lastTestLatencyMs: 250,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
};
const mockReq = {
user: { id: 1, username: 'admin' },
headers: { 'user-agent': 'test', 'x-forwarded-for': '1.2.3.4' },
connection: { remoteAddress: '1.2.3.4' },
};
beforeEach(async () => {
service = {
getConfig: jest.fn(),
saveConfig: jest.fn(),
testConnection: jest.fn(),
clearKey: jest.fn(),
};
opLog = {
log: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AiConfigController],
providers: [
{ provide: AiConfigService, useValue: service },
{ provide: OperationLogsService, useValue: opLog },
],
}).compile();
controller = module.get<AiConfigController>(AiConfigController);
});
// ── GET /ai/config ────────────────────────────────────────────────────
describe('GET /ai/config', () => {
it('returns config with success wrapper', async () => {
service.getConfig.mockResolvedValue(mockConfig);
const result = await controller.getConfig();
expect(result.success).toBe(true);
expect(result.data).toEqual(mockConfig);
});
it('calls service.getConfig', async () => {
service.getConfig.mockResolvedValue(mockConfig);
await controller.getConfig();
expect(service.getConfig).toHaveBeenCalledTimes(1);
});
});
// ── PUT /ai/config ────────────────────────────────────────────────────
describe('PUT /ai/config', () => {
const saveDto = {
provider: AiProvider.OPENAI,
apiKey: 'sk-new-key',
enabled: true,
};
it('saves config and logs operation', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
service.saveConfig.mockResolvedValue(saved);
const result = await controller.saveConfig(saveDto, mockReq);
expect(result.success).toBe(true);
expect(opLog.log).toHaveBeenCalledWith(
expect.objectContaining({
module: 'ai-config',
action: 'save',
userId: 1,
username: 'admin',
detail: expect.stringContaining('provider=OPENAI'),
}),
);
});
it('operation log detail does NOT contain apiKey', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
service.saveConfig.mockResolvedValue(saved);
await controller.saveConfig(saveDto, mockReq);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).not.toContain('sk-new-key');
expect(logCall.detail).not.toContain(saveDto.apiKey);
});
it('operation log detail does NOT contain full baseUrl', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
service.saveConfig.mockResolvedValue(saved);
await controller.saveConfig(saveDto, mockReq);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).not.toContain('api.openai.com/v1');
// Only hostname should be present
expect(logCall.detail).toContain('host=api.openai.com');
});
it('operation log detail logs model as configured/not-set not raw value', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1', ...saveDto } as any;
service.saveConfig.mockResolvedValue(saved);
await controller.saveConfig(
{ provider: AiProvider.OPENAI, defaultModel: 'gpt-4' },
mockReq,
);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).toContain('model=configured');
expect(logCall.detail).not.toContain('gpt-4');
});
it('operation log detail logs model=not-set when no defaultModel', async () => {
const saved = { id: 1, baseUrl: 'https://api.openai.com/v1' } as any;
service.saveConfig.mockResolvedValue(saved);
await controller.saveConfig(
{ provider: AiProvider.OPENAI },
mockReq,
);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).toContain('model=not-set');
});
});
// ── POST /ai/config/test ──────────────────────────────────────────────
describe('POST /ai/config/test', () => {
const testDto = { provider: AiProvider.OPENAI };
const testResult = {
success: true,
latencyMs: 200,
modelCount: 5,
modelAvailable: true,
testedAt: '2024-01-01T00:00:00.000Z',
message: '连接成功',
};
it('returns test result and logs operation', async () => {
service.testConnection.mockResolvedValue(testResult);
const result = await controller.testConnection(testDto, mockReq);
expect(result).toEqual(testResult);
expect(opLog.log).toHaveBeenCalledWith(
expect.objectContaining({
module: 'ai-config',
action: 'test',
detail: expect.stringContaining('success=true'),
status: 'success',
}),
);
});
it('logs status=failure on test failure', async () => {
const failResult = { ...testResult, success: false, message: '认证失败' };
service.testConnection.mockResolvedValue(failResult);
await controller.testConnection(testDto, mockReq);
expect(opLog.log).toHaveBeenCalledWith(
expect.objectContaining({
status: 'failure',
}),
);
});
it('operation log detail does NOT contain sensitive info', async () => {
const dtoWithKey = { provider: AiProvider.OPENAI, apiKey: 'sk-secret-key' };
service.testConnection.mockResolvedValue(testResult);
await controller.testConnection(dtoWithKey, mockReq);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).not.toContain('sk-secret-key');
expect(logCall.detail).not.toContain('Authorization');
});
it('operation log detail does NOT contain modelCount', async () => {
service.testConnection.mockResolvedValue(testResult);
await controller.testConnection(testDto, mockReq);
const logCall = opLog.log.mock.calls[0][0];
expect(logCall.detail).not.toContain('modelCount');
});
});
// ── POST /ai/config/clear-key ─────────────────────────────────────────
describe('POST /ai/config/clear-key', () => {
it('clears key and logs operation', async () => {
service.clearKey.mockResolvedValue({
...mockConfig,
hasApiKey: false,
hasDatabaseKey: false,
maskedApiKey: null,
keySource: 'none',
});
const result = await controller.clearKey(mockReq);
expect(result.success).toBe(true);
expect(result.data.keySource).toBe('none');
expect(result.data.hasDatabaseKey).toBe(false);
expect(opLog.log).toHaveBeenCalledWith(
expect.objectContaining({
module: 'ai-config',
action: 'clear-key',
}),
);
});
});
// ── Permission decorators ─────────────────────────────────────────────
describe('route permissions', () => {
it('GET /ai/config requires ai:config:read', () => {
const permissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.getConfig,
);
expect(permissions).toContain('ai:config:read');
});
it('PUT /ai/config requires ai:config:write', () => {
const permissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.saveConfig,
);
expect(permissions).toContain('ai:config:write');
});
it('POST /ai/config/test requires ai:config:test', () => {
const permissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.testConnection,
);
expect(permissions).toContain('ai:config:test');
});
it('POST /ai/config/clear-key requires ai:config:write', () => {
const permissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.clearKey,
);
expect(permissions).toContain('ai:config:write');
});
it('read permission cannot write', () => {
const getPermissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.getConfig,
);
const savePermissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.saveConfig,
);
expect(getPermissions).not.toEqual(savePermissions);
});
it('write and test permissions are distinct', () => {
const writePermissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.saveConfig,
);
const testPermissions = Reflect.getMetadata(
'permissions',
AiConfigController.prototype.testConnection,
);
expect(writePermissions).not.toEqual(testPermissions);
});
});
});

View File

@@ -0,0 +1,93 @@
import {
Controller,
Get,
Put,
Post,
Body,
UseGuards,
Req,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { AiConfigService } from './ai-config.service';
import { SaveAiConfigDto, TestAiConfigDto } from './dto/ai-config.dto';
interface AuthenticatedRequest {
user?: { id: number; username: string };
headers: Record<string, string | string[] | undefined>;
connection?: { remoteAddress?: string };
}
@Controller('ai/config')
@UseGuards(JwtAuthGuard)
export class AiConfigController {
constructor(
private readonly service: AiConfigService,
private readonly opLog: OperationLogsService,
) {}
@Get()
@RequirePermission('ai:config:read')
async getConfig() {
const data = await this.service.getConfig();
return { success: true, data };
}
@Put()
@RequirePermission('ai:config:write')
async saveConfig(@Body() body: SaveAiConfigDto, @Req() req: AuthenticatedRequest) {
const config = await this.service.saveConfig(body);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.opLog.log({
userId: req.user?.id,
username: req.user?.username,
module: 'ai-config',
action: 'save',
targetId: config.id,
targetType: 'AiConfig',
detail: `provider=${body.provider} host=${new URL(config.baseUrl).hostname} model=${body.defaultModel ? 'configured' : 'not-set'} enabled=${body.enabled ?? config.enabled}`,
ipAddress,
userAgent,
});
return { success: true, message: '配置已保存' };
}
@Post('test')
@RequirePermission('ai:config:test')
async testConnection(@Body() body: TestAiConfigDto, @Req() req: AuthenticatedRequest) {
const result = await this.service.testConnection(body);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.opLog.log({
userId: req.user?.id,
username: req.user?.username,
module: 'ai-config',
action: 'test',
targetType: 'AiConfig',
detail: `provider=${body.provider ?? '-'} success=${result.success} latency=${result.latencyMs ?? '-'}`,
ipAddress,
userAgent,
status: result.success ? 'success' : 'failure',
});
return result;
}
@Post('clear-key')
@RequirePermission('ai:config:write')
async clearKey(@Req() req: AuthenticatedRequest) {
const data = await this.service.clearKey();
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.opLog.log({
userId: req.user?.id,
username: req.user?.username,
module: 'ai-config',
action: 'clear-key',
targetType: 'AiConfig',
detail: `keySource=${data.keySource}`,
ipAddress,
userAgent,
});
return { success: true, message: '密钥已清除', data };
}
}

View File

@@ -0,0 +1,68 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
export enum AiProvider {
OPENAI = 'OPENAI',
DEEPSEEK = 'DEEPSEEK',
OPENAI_COMPATIBLE = 'OPENAI_COMPATIBLE',
}
export const SINGLETON_KEY = 'GLOBAL';
@Entity('ai_config')
@Index('uq_ai_config_singleton', ['singletonKey'], { unique: true })
export class AiConfig {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'singleton_key', type: 'varchar', length: 20, default: SINGLETON_KEY })
singletonKey: string;
@Column({ type: 'varchar', length: 50, default: AiProvider.OPENAI })
provider: AiProvider;
@Column({ name: 'base_url', type: 'varchar', length: 500, nullable: true })
baseUrl: string;
@Column({ name: 'encrypted_api_key', type: 'text', nullable: true })
encryptedApiKey: string | null;
@Column({ name: 'api_key_iv', type: 'varchar', length: 50, nullable: true })
apiKeyIv: string | null;
@Column({ name: 'api_key_auth_tag', type: 'varchar', length: 50, nullable: true })
apiKeyAuthTag: string | null;
@Column({ name: 'key_last4', type: 'varchar', length: 4, nullable: true })
keyLast4: string | null;
@Column({ name: 'default_model', type: 'varchar', length: 100, nullable: true })
defaultModel: string | null;
@Column({ type: 'boolean', default: false })
enabled: boolean;
@Column({ name: 'timeout_ms', type: 'int', default: 30000 })
timeoutMs: number;
@Column({ type: 'boolean', default: false })
verified: boolean;
@Column({ name: 'last_tested_at', type: 'datetime', nullable: true })
lastTestedAt: Date | null;
@Column({ name: 'last_test_latency_ms', type: 'int', nullable: true })
lastTestLatencyMs: number | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiConfig } from './ai-config.entity';
import { AiConfigService } from './ai-config.service';
import { AiConfigController } from './ai-config.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([AiConfig]), OperationLogsModule],
controllers: [AiConfigController],
providers: [AiConfigService],
exports: [AiConfigService],
})
export class AiConfigModule {}

View File

@@ -0,0 +1,781 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BadRequestException, InternalServerErrorException } from '@nestjs/common';
import {
createCipheriv,
createDecipheriv,
randomBytes,
} from 'node:crypto';
import { AiConfigService } from './ai-config.service';
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
// ---------------------------------------------------------------------------
// Helpers for testing encryption directly
// ---------------------------------------------------------------------------
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
function encryptWithKey(key: Buffer, plaintext: string) {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
authTag: tag.toString('base64'),
};
}
function decryptWithKey(
key: Buffer,
ciphertextB64: string,
ivB64: string,
authTagB64: string,
): string {
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(authTagB64, 'base64');
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(ciphertextB64, 'base64')),
decipher.final(),
]);
return decrypted.toString('utf-8');
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AiConfigService', () => {
let service: AiConfigService;
let repo: jest.Mocked<Pick<Repository<AiConfig>, 'findOne' | 'save' | 'create'>>;
// Use a known encryption key so tests are deterministic
const TEST_KEY_BYTES_32 = Buffer.alloc(32, 'a'); // 32 bytes of 'a'
const TEST_KEY_HEX = TEST_KEY_BYTES_32.toString('hex'); // 64 hex chars
function makeConfig(overrides: Partial<AiConfig> = {}): AiConfig {
return {
id: 1,
singletonKey: SINGLETON_KEY,
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/v1',
encryptedApiKey: null,
apiKeyIv: null,
apiKeyAuthTag: null,
keyLast4: null,
defaultModel: null,
enabled: false,
timeoutMs: 30000,
verified: false,
lastTestedAt: null,
lastTestLatencyMs: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
beforeEach(async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
delete process.env.AI_API_KEY;
repo = {
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
service = module.get<AiConfigService>(AiConfigService);
});
afterEach(() => {
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
delete process.env.AI_API_KEY;
});
// ── Encryption ────────────────────────────────────────────────────────
describe('encryption', () => {
it('roundtrip: encrypt then decrypt returns original text', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const { ciphertext, iv, authTag } = encryptWithKey(TEST_KEY_BYTES_32, plaintext);
const decrypted = decryptWithKey(TEST_KEY_BYTES_32, ciphertext, iv, authTag);
expect(decrypted).toBe(plaintext);
});
it('random IV: same key + same plaintext produces different ciphertexts', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const key = TEST_KEY_BYTES_32;
const r1 = encryptWithKey(key, plaintext);
const r2 = encryptWithKey(key, plaintext);
expect(r1.iv).not.toBe(r2.iv);
expect(r1.ciphertext).not.toBe(r2.ciphertext);
expect(decryptWithKey(key, r1.ciphertext, r1.iv, r1.authTag)).toBe(plaintext);
expect(decryptWithKey(key, r2.ciphertext, r2.iv, r2.authTag)).toBe(plaintext);
});
it('wrong key fails decryption', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const correctKey = TEST_KEY_BYTES_32;
const wrongKey = Buffer.alloc(32, 'b');
const { ciphertext, iv, authTag } = encryptWithKey(correctKey, plaintext);
expect(() =>
decryptWithKey(wrongKey, ciphertext, iv, authTag),
).toThrow();
});
it('tampered auth tag fails decryption', () => {
const plaintext = 'sk-test-key-1234567890abcdef';
const key = TEST_KEY_BYTES_32;
const { ciphertext, iv, authTag } = encryptWithKey(key, plaintext);
const tamperedTag = Buffer.from(authTag, 'base64');
tamperedTag[0] ^= 1;
expect(() =>
decryptWithKey(key, ciphertext, iv, tamperedTag.toString('base64')),
).toThrow();
});
});
// ── Encryption key validation ─────────────────────────────────────────
describe('encryption key validation', () => {
it('rejects plain 32-char string (not hex or base64)', async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = 'a'.repeat(32);
repo.findOne.mockResolvedValue(makeConfig());
// Re-create service with new key
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('rejects invalid base64 input', async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!invalid!!!';
repo.findOne.mockResolvedValue(makeConfig());
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('accepts valid base64 32-byte key', async () => {
const b64key = TEST_KEY_BYTES_32.toString('base64');
process.env.AI_CONFIG_ENCRYPTION_KEY = b64key;
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
const result = await svc.saveConfig({ provider: AiProvider.OPENAI });
expect(result).toBeDefined();
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('rejects non-canonical base64 (extra padding)', async () => {
const canonicalB64 = TEST_KEY_BYTES_32.toString('base64');
// Non-canonical: add extra padding
const badB64 = canonicalB64 + '==';
process.env.AI_CONFIG_ENCRYPTION_KEY = badB64;
repo.findOne.mockResolvedValue(makeConfig());
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('rejects base64 with invalid characters', async () => {
process.env.AI_CONFIG_ENCRYPTION_KEY = '!!!!aaaa';
repo.findOne.mockResolvedValue(makeConfig());
const module: TestingModule = await Test.createTestingModule({
providers: [
AiConfigService,
{ provide: getRepositoryToken(AiConfig), useValue: repo },
],
}).compile();
const svc = module.get<AiConfigService>(AiConfigService);
await expect(
svc.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
it('throws in production when no key set', async () => {
process.env.NODE_ENV = 'production';
delete process.env.AI_CONFIG_ENCRYPTION_KEY;
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({ provider: AiProvider.OPENAI, apiKey: 'sk-test' }),
).rejects.toThrow(InternalServerErrorException);
delete process.env.NODE_ENV;
process.env.AI_CONFIG_ENCRYPTION_KEY = TEST_KEY_HEX;
});
});
// ── Config management ─────────────────────────────────────────────────
describe('getOrCreateConfig', () => {
it('returns existing config when found', async () => {
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
const result = await service.getOrCreateConfig();
expect(result).toBe(existing);
expect(repo.findOne).toHaveBeenCalledWith({ where: { singletonKey: SINGLETON_KEY } });
expect(repo.create).not.toHaveBeenCalled();
});
it('creates default config when none exists', async () => {
repo.findOne.mockResolvedValue(null);
const created = makeConfig();
repo.create.mockReturnValue(created);
repo.save.mockResolvedValue(created);
const result = await service.getOrCreateConfig();
expect(repo.create).toHaveBeenCalled();
expect(result.provider).toBe(AiProvider.OPENAI);
expect(result.enabled).toBe(false);
});
});
describe('getConfig', () => {
it('returns masked key info with source=none when no key', async () => {
repo.findOne.mockResolvedValue(makeConfig());
const result = await service.getConfig();
expect(result.hasApiKey).toBe(false);
expect(result.hasDatabaseKey).toBe(false);
expect(result.maskedApiKey).toBeNull();
expect(result.keySource).toBe('none');
});
it('returns hasApiKey=true and hasDatabaseKey=true when DB key exists', async () => {
const key = 'sk-abcdefghij1234';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
key,
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '1234',
}),
);
const result = await service.getConfig();
expect(result.hasApiKey).toBe(true);
expect(result.hasDatabaseKey).toBe(true);
expect(result.maskedApiKey).toBe('••••1234');
expect(result.keySource).toBe('database');
});
it('never returns plaintext key in GET response', async () => {
const key = 'sk-topsecret1234';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
key,
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '1234',
}),
);
const result = await service.getConfig();
const json = JSON.stringify(result);
expect(json).not.toContain('topsecret');
expect(json).not.toContain('sk-');
});
it('env key fallback: source=environment, hasDatabaseKey=false', async () => {
process.env.AI_API_KEY = 'sk-env-key-1234';
repo.findOne.mockResolvedValue(makeConfig());
const result = await service.getConfig();
expect(result.hasApiKey).toBe(true);
expect(result.hasDatabaseKey).toBe(false);
expect(result.keySource).toBe('environment');
delete process.env.AI_API_KEY;
});
});
// ── Save config ───────────────────────────────────────────────────────
describe('saveConfig', () => {
it('saves provider and baseUrl', async () => {
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.DEEPSEEK,
baseUrl: 'https://api.deepseek.com',
});
expect(result.provider).toBe(AiProvider.DEEPSEEK);
expect(result.baseUrl).toBe('https://api.deepseek.com');
});
it('encrypts and saves apiKey', async () => {
const existing = makeConfig();
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const apiKey = 'sk-saved-key-5678';
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
apiKey,
});
expect(result.encryptedApiKey).toBeTruthy();
expect(result.apiKeyIv).toBeTruthy();
expect(result.apiKeyAuthTag).toBeTruthy();
expect(result.keyLast4).toBe('5678');
const encKey = result.encryptedApiKey;
const encIv = result.apiKeyIv;
const encTag = result.apiKeyAuthTag;
expect(encKey).toBeTruthy();
expect(encIv).toBeTruthy();
expect(encTag).toBeTruthy();
if (!encKey || !encIv || !encTag) throw new Error('encrypted fields missing');
const decrypted = decryptWithKey(TEST_KEY_BYTES_32, encKey, encIv, encTag);
expect(decrypted).toBe(apiKey);
});
it('empty apiKey preserves existing key', async () => {
const existingKey = 'sk-existing-9999';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
existingKey,
);
const existing = makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '9999',
});
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
apiKey: '',
});
expect(result.encryptedApiKey).toBe(ciphertext);
expect(result.keyLast4).toBe('9999');
});
it('undefined apiKey preserves existing key', async () => {
const existingKey = 'sk-existing-9999';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
existingKey,
);
const existing = makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: '9999',
});
repo.findOne.mockResolvedValue(existing);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
});
expect(result.encryptedApiKey).toBe(ciphertext);
});
it('rejects enabled=true without any key', async () => {
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
}),
).rejects.toThrow(BadRequestException);
});
it('allows enabled=true when DB key exists and defaultModel is set', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-existing-key',
);
repo.findOne.mockResolvedValue(
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag, defaultModel: 'gpt-4' }),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
});
expect(result.enabled).toBe(true);
});
it('allows enabled=true with env key fallback and defaultModel', async () => {
process.env.AI_API_KEY = 'sk-env-key';
repo.findOne.mockResolvedValue(makeConfig({ defaultModel: 'gpt-4' }));
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
});
expect(result.enabled).toBe(true);
delete process.env.AI_API_KEY;
});
it('provider switch replaces default baseUrl', async () => {
repo.findOne.mockResolvedValue(makeConfig({ baseUrl: 'https://api.openai.com/v1' }));
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.DEEPSEEK,
});
expect(result.baseUrl).toBe('https://api.deepseek.com');
});
it('OPENAI_COMPATIBLE requires baseUrl', async () => {
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
}),
).rejects.toThrow(BadRequestException);
});
it('enabled=true with defaultModel in DTO works even if config has none', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-existing-key',
);
repo.findOne.mockResolvedValue(
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
defaultModel: 'gpt-4',
});
expect(result.enabled).toBe(true);
});
it('enabled=true rejects when defaultModel is empty everywhere', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-existing-key',
);
repo.findOne.mockResolvedValue(
makeConfig({ encryptedApiKey: ciphertext, apiKeyIv: iv, apiKeyAuthTag: authTag }),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
enabled: true,
}),
).rejects.toThrow(BadRequestException);
});
});
// ── Clear key ─────────────────────────────────────────────────────────
describe('clearKey', () => {
it('clears DB key and disables when no env key', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-to-clear',
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: 'lear',
enabled: true,
}),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.clearKey();
expect(result.hasApiKey).toBe(false);
expect(result.hasDatabaseKey).toBe(false);
expect(result.keySource).toBe('none');
expect(result.maskedApiKey).toBeNull();
});
it('clear DB key falls back to env source', async () => {
process.env.AI_API_KEY = 'sk-env-after-clear';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-to-clear',
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
keyLast4: 'lear',
}),
);
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.clearKey();
expect(result.keySource).toBe('environment');
expect(result.hasDatabaseKey).toBe(false);
delete process.env.AI_API_KEY;
});
});
// ── URL / SSRF validation ──────────────────────────────────────────────
describe('baseUrl validation', () => {
it('rejects non-http protocol', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'ftp://evil.com',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects URL with username/password', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://user:pass@evil.com',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects URL with search/query string', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://evil.com/v1?proxy=internal',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects URL with hash/fragment', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://evil.com/v1#section',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects localhost for OPENAI_COMPATIBLE', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'http://localhost:8080/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects 127.0.0.1 for OPENAI_COMPATIBLE', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://127.0.0.1:8080',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects .local hostname', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'https://myservice.local/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('rejects IPv6 loopback ::1', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'http://[::1]:8080/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('allows private IP when AI_ALLOW_PRIVATE_BASE_URL=true', async () => {
process.env.AI_ALLOW_PRIVATE_BASE_URL = 'true';
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI_COMPATIBLE,
baseUrl: 'http://localhost:8080/v1',
});
expect(result.baseUrl).toBe('http://localhost:8080/v1');
delete process.env.AI_ALLOW_PRIVATE_BASE_URL;
});
it('OPENAI rejects non-openai hostname', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
baseUrl: 'https://evil.com/v1',
}),
).rejects.toThrow(BadRequestException);
});
it('OPENAI rejects wrong pathname', async () => {
repo.findOne.mockResolvedValue(makeConfig());
await expect(
service.saveConfig({
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/evil-proxy',
}),
).rejects.toThrow(BadRequestException);
});
it('normalizes trailing slash', async () => {
repo.findOne.mockResolvedValue(makeConfig());
repo.save.mockImplementation((c) => Promise.resolve(c));
const result = await service.saveConfig({
provider: AiProvider.OPENAI,
baseUrl: 'https://api.openai.com/v1/',
});
expect(result.baseUrl).toBe('https://api.openai.com/v1');
});
});
// ── getRuntimeConfig ──────────────────────────────────────────────────
describe('getRuntimeConfig', () => {
it('returns config with plaintext key', async () => {
const apiKey = 'sk-runtime-key';
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
apiKey,
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
enabled: true,
defaultModel: 'gpt-4',
}),
);
const runtime = await service.getRuntimeConfig();
expect(runtime.apiKey).toBe(apiKey);
expect(runtime.enabled).toBe(true);
});
it('throws when not enabled', async () => {
repo.findOne.mockResolvedValue(makeConfig({ enabled: false }));
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
});
it('throws when no key available', async () => {
repo.findOne.mockResolvedValue(makeConfig({ enabled: true }));
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
});
it('throws when defaultModel is empty', async () => {
const { ciphertext, iv, authTag } = encryptWithKey(
TEST_KEY_BYTES_32,
'sk-runtime-key',
);
repo.findOne.mockResolvedValue(
makeConfig({
encryptedApiKey: ciphertext,
apiKeyIv: iv,
apiKeyAuthTag: authTag,
enabled: true,
defaultModel: null,
}),
);
await expect(service.getRuntimeConfig()).rejects.toThrow(BadRequestException);
});
it('throws when no config row exists', async () => {
repo.findOne.mockResolvedValue(null);
await expect(service.getRuntimeConfig()).rejects.toThrow(InternalServerErrorException);
});
});
});

View File

@@ -0,0 +1,753 @@
import {
Injectable,
Logger,
BadRequestException,
InternalServerErrorException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
import { lookup } from 'node:dns';
import { isIP } from 'node:net';
import * as http from 'node:http';
import * as https from 'node:https';
import { AiConfig, AiProvider, SINGLETON_KEY } from './ai-config.entity';
import {
SaveAiConfigDto,
TestAiConfigDto,
AiConfigResponseDto,
AiConfigTestResultDto,
AiRuntimeConfig,
DEFAULT_BASE_URLS,
} from './dto/ai-config.dto';
// ---------------------------------------------------------------------------
// Key derivation
// ---------------------------------------------------------------------------
let _encryptionWarned = false;
function getEncryptionKey(): Buffer {
const raw = process.env.AI_CONFIG_ENCRYPTION_KEY;
if (!raw) {
if (process.env.NODE_ENV !== 'production') {
if (!_encryptionWarned) {
_encryptionWarned = true;
Logger.warn(
'AI_CONFIG_ENCRYPTION_KEY 未设置,使用开发回退密钥。生产环境必须配置!',
'AiConfigService',
);
}
// 32 hex pairs → 32 bytes
return Buffer.from('ff'.repeat(32), 'hex');
}
throw new InternalServerErrorException('AI_CONFIG_ENCRYPTION_KEY 未配置,无法加解密 API Key');
}
// Hex: exactly 64 hex chars
if (/^[0-9a-fA-F]{64}$/.test(raw)) {
return Buffer.from(raw, 'hex');
}
// Base64: decode then re-encode to normalize padding; reject non-canonical forms
if (/^[A-Za-z0-9+/]+=*$/.test(raw)) {
const buf = Buffer.from(raw, 'base64');
if (buf.length !== 32) {
throw new InternalServerErrorException(
'AI_CONFIG_ENCRYPTION_KEY 格式无效base64 解码后须为 32 字节',
);
}
// Re-encode to canonical base64 (no line breaks) and compare
const canonical = buf.toString('base64');
if (raw !== canonical) {
throw new InternalServerErrorException(
'AI_CONFIG_ENCRYPTION_KEY 格式无效base64 编码须为标准格式(无多余 padding',
);
}
return buf;
}
throw new InternalServerErrorException(
'AI_CONFIG_ENCRYPTION_KEY 格式无效:需为 64 位 hex 或 base64 编码的 32 字节密钥',
);
}
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
function encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
const key = getEncryptionKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
authTag: tag.toString('base64'),
};
}
function decrypt(ciphertextB64: string, ivB64: string, authTagB64: string): string {
const key = getEncryptionKey();
const iv = Buffer.from(ivB64, 'base64');
const authTag = Buffer.from(authTagB64, 'base64');
const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH });
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([
decipher.update(Buffer.from(ciphertextB64, 'base64')),
decipher.final(),
]);
return decrypted.toString('utf-8');
}
// ---------------------------------------------------------------------------
// URL / SSRF helpers
// ---------------------------------------------------------------------------
const PRIVATE_IPV4_RANGES = [
/^127\./,
/^10\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^192\.168\./,
/^169\.254\./,
/^0\./,
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
];
function isPrivateHost(hostname: string): boolean {
// Strip IPv6 brackets from URL.hostname
if (hostname.startsWith('[') && hostname.endsWith(']')) {
hostname = hostname.slice(1, -1);
}
if (hostname === 'localhost' || hostname === '0.0.0.0') return true;
if (hostname.endsWith('.local')) return true;
if (isIP(hostname) === 6) {
// IPv6 private/loopback
if (hostname === '::1' || hostname === '::') return true;
const lower = hostname.toLowerCase();
if (lower.startsWith('fc') || lower.startsWith('fd')) return true; // fc00::/7
if (
lower.startsWith('fe8') ||
lower.startsWith('fe9') ||
lower.startsWith('fea') ||
lower.startsWith('feb')
)
return true; // fe80::/10
// IPv4-mapped IPv6: ::ffff:0:0/96
if (lower.startsWith('::ffff:') && isIP(lower.slice(7)) === 4) {
return PRIVATE_IPV4_RANGES.some((re) => re.test(lower.slice(7)));
}
return false;
}
if (isIP(hostname) === 4) {
return PRIVATE_IPV4_RANGES.some((re) => re.test(hostname));
}
return false;
}
// Known provider hosts — only these are allowed for fixed providers
const PROVIDER_HOSTS: Partial<Record<AiProvider, readonly string[]>> = {
[AiProvider.OPENAI]: ['api.openai.com'],
[AiProvider.DEEPSEEK]: ['api.deepseek.com'],
};
// Required pathname for fixed providers
const PROVIDER_REQUIRED_PATHS: Partial<Record<AiProvider, string>> = {
[AiProvider.OPENAI]: '/v1',
[AiProvider.DEEPSEEK]: '/',
};
function validateAndNormalizeBaseUrl(url: string | undefined, provider: AiProvider): string {
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
const raw = url?.trim() || DEFAULT_BASE_URLS[provider];
if (!raw) {
throw new BadRequestException('OPENAI_COMPATIBLE 模式必须提供 baseUrl');
}
// Reject search/query and hash/fragment
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new BadRequestException('请求参数无效');
}
if (parsed.search || parsed.hash) {
throw new BadRequestException('请求参数无效');
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new BadRequestException('请求参数无效');
}
if (process.env.NODE_ENV === 'production' && parsed.protocol === 'http:') {
throw new BadRequestException('生产环境禁止使用 http://');
}
if (parsed.username || parsed.password) {
throw new BadRequestException('请求参数无效');
}
const normalized = parsed.origin + parsed.pathname.replace(/\/+$/, '');
// Provider-specific host check
const allowedHosts = PROVIDER_HOSTS[provider];
if (allowedHosts) {
if (!allowedHosts.includes(parsed.hostname)) {
throw new BadRequestException(`${provider} 必须使用固定域名`);
}
// Enforce exact path for fixed providers
const requiredPath = PROVIDER_REQUIRED_PATHS[provider];
if (
requiredPath !== undefined &&
parsed.pathname.replace(/\/+$/, '') !== requiredPath.replace(/\/+$/, '')
) {
throw new BadRequestException(`请求参数无效`);
}
} else {
// OPENAI_COMPATIBLE — SSRF check
if (!allowPrivate && isPrivateHost(parsed.hostname)) {
throw new BadRequestException('不允许使用内网地址');
}
}
return normalized;
}
async function resolveHostnames(hostname: string): Promise<{ address: string; family: number }[]> {
return new Promise((resolve, reject) => {
lookup(hostname, { all: true, family: 0 }, (err, addresses) => {
if (err) {
reject(err);
return;
}
if (!addresses || addresses.length === 0) {
reject(new Error('DNS 解析返回空结果'));
return;
}
resolve(
addresses.map((a) => ({
address: a.address,
family: a.family,
})),
);
});
});
}
async function validateDnsNotPrivate(hostname: string): Promise<void> {
const allowPrivate = process.env.AI_ALLOW_PRIVATE_BASE_URL === 'true';
if (allowPrivate) return;
let addresses: { address: string; family: number }[];
try {
addresses = await resolveHostnames(hostname);
} catch {
throw new BadRequestException('无法解析域名');
}
for (const { address } of addresses) {
if (isPrivateHost(address)) {
throw new BadRequestException('域名解析到内网地址');
}
}
}
// ---------------------------------------------------------------------------
// Connection test — uses node:http/https with DNS pinning to prevent rebinding
// ---------------------------------------------------------------------------
const MAX_RESPONSE_BYTES = 1_048_576; // 1 MiB
/**
* Perform a pinned HTTP GET request.
* DNS resolves once; the resolved IP is used for connection, preventing DNS rebinding.
* Redirects are forbidden. HTTPS certificate validation is enforced.
*/
function pinnedGet(
url: string,
headers: Record<string, string>,
timeoutMs: number,
): Promise<{ status: number; contentType: string | null; body: string; latencyMs: number }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:';
const port = parsed.port ? parseInt(parsed.port, 10) : isHttps ? 443 : 80;
const hostname = parsed.hostname;
const path = parsed.pathname + parsed.search;
lookup(hostname, { all: true, family: 0 }, (dnsErr, addresses) => {
if (dnsErr || !addresses || addresses.length === 0) {
reject(new Error('DNS 解析失败'));
return;
}
const resolved = addresses.find((a) => !isPrivateHost(a.address));
if (!resolved && process.env.AI_ALLOW_PRIVATE_BASE_URL !== 'true') {
reject(new Error('解析到内网地址'));
return;
}
const targetIp = resolved ? resolved.address : addresses[0].address;
const family = resolved ? resolved.family : addresses[0].family;
const transport = isHttps ? https : http;
const requestStart = Date.now();
const req = transport.request(
{
hostname: targetIp,
port,
path,
method: 'GET',
headers: { ...headers, Host: hostname },
servername: isHttps ? hostname : undefined,
rejectUnauthorized: isHttps,
family: family === 6 ? 6 : 4,
timeout: timeoutMs,
},
(res) => {
const latencyMs = Date.now() - requestStart;
const status = res.statusCode ?? 500;
if (status >= 300 && status < 400 && res.headers.location) {
res.resume();
res.destroy();
return reject(new Error('禁止重定向'));
}
const contentType = res.headers['content-type'] ?? null;
const chunks: Buffer[] = [];
let totalBytes = 0;
res.on('data', (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > MAX_RESPONSE_BYTES) {
res.destroy();
reject(new Error('响应过大'));
return;
}
chunks.push(chunk);
});
res.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
resolve({ status, contentType, body, latencyMs });
});
res.on('error', reject);
},
);
req.on('timeout', () => {
req.destroy();
reject(new Error('连接超时'));
});
req.on('error', reject);
req.end();
});
});
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
@Injectable()
export class AiConfigService {
private readonly logger = new Logger(AiConfigService.name);
constructor(
@InjectRepository(AiConfig)
private readonly repo: Repository<AiConfig>,
) {}
/** Resolve the effective API key: DB first, then env, then none */
private resolveApiKey(config: AiConfig | null): {
plaintext: string | null;
source: 'database' | 'environment' | 'none';
} {
// DB stored key
if (config?.encryptedApiKey && config?.apiKeyIv && config?.apiKeyAuthTag) {
try {
const plaintext = decrypt(config.encryptedApiKey, config.apiKeyIv, config.apiKeyAuthTag);
return { plaintext, source: 'database' };
} catch {
this.logger.error('解密数据库 API Key 失败,密文可能已损坏');
throw new InternalServerErrorException('无法解密 API Key');
}
}
// Environment fallback
const envKey = process.env.AI_API_KEY;
if (envKey) {
return { plaintext: envKey, source: 'environment' };
}
return { plaintext: null, source: 'none' };
}
/** Load or create the singleton config row */
async getOrCreateConfig(): Promise<AiConfig> {
let config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
if (!config) {
config = this.repo.create({
singletonKey: SINGLETON_KEY,
provider: AiProvider.OPENAI,
baseUrl: DEFAULT_BASE_URLS[AiProvider.OPENAI],
enabled: false,
timeoutMs: 30000,
});
try {
config = await this.repo.save(config);
} catch (err: unknown) {
// Unique constraint violation → another request created it first
const isErrWithCode = err !== null && typeof err === 'object' && 'code' in err;
const code = isErrWithCode ? (err as Record<string, unknown>).code : undefined;
const errno = isErrWithCode ? (err as Record<string, unknown>).errno : undefined;
// MySQL: ER_DUP_ENTRY (code 'ER_DUP_ENTRY') or errno 1062
// SQLite: SQLITE_CONSTRAINT (code 'SQLITE_CONSTRAINT')
if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
const existing = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
if (existing) return existing;
}
throw err;
}
}
return config;
}
/** Build masked key display */
private buildMaskedKey(keyLast4: string | null): string | null {
if (keyLast4 && keyLast4.length === 4) {
return `••••${keyLast4}`;
}
return null;
}
/** GET response */
async getConfig(): Promise<AiConfigResponseDto> {
const config = await this.getOrCreateConfig();
const { source } = this.resolveApiKey(config);
const hasDbKey = !!(config.encryptedApiKey && config.apiKeyIv && config.apiKeyAuthTag);
return {
id: config.id,
provider: config.provider,
baseUrl: config.baseUrl,
hasApiKey: source !== 'none',
hasDatabaseKey: hasDbKey,
maskedApiKey: config.keyLast4
? this.buildMaskedKey(config.keyLast4)
: source !== 'none'
? '••••'
: null,
keySource: source,
defaultModel: config.defaultModel ?? null,
enabled: config.enabled,
timeoutMs: config.timeoutMs,
verified: config.verified,
lastTestedAt: config.lastTestedAt?.toISOString() ?? null,
lastTestLatencyMs: config.lastTestLatencyMs ?? null,
createdAt: config.createdAt.toISOString(),
updatedAt: config.updatedAt.toISOString(),
};
}
/** PUT / save */
async saveConfig(dto: SaveAiConfigDto): Promise<AiConfig> {
const config = await this.getOrCreateConfig();
// Validate and normalize baseUrl
const normalizedBaseUrl = validateAndNormalizeBaseUrl(dto.baseUrl, dto.provider);
// DNS SSRF check for all providers
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
config.provider = dto.provider;
config.baseUrl = normalizedBaseUrl;
if (dto.defaultModel !== undefined) {
config.defaultModel = dto.defaultModel || null;
}
if (dto.timeoutMs !== undefined) {
config.timeoutMs = dto.timeoutMs;
}
// Handle apiKey — empty/undefined = keep existing
if (dto.apiKey !== undefined && dto.apiKey !== '') {
const { ciphertext, iv, authTag } = encrypt(dto.apiKey);
config.encryptedApiKey = ciphertext;
config.apiKeyIv = iv;
config.apiKeyAuthTag = authTag;
config.keyLast4 = dto.apiKey.slice(-4);
}
// enabled validation
if (dto.enabled !== undefined) {
if (dto.enabled) {
const { plaintext } = this.resolveApiKey(config);
if (!plaintext) {
throw new BadRequestException('未配置 API Key无法启用。请先保存 API Key 再启用');
}
// defaultModel is required when enabled
const effectiveDefaultModel =
dto.defaultModel !== undefined ? dto.defaultModel : config.defaultModel;
if (!effectiveDefaultModel) {
throw new BadRequestException('启用 AI 服务时必须配置默认模型');
}
}
config.enabled = dto.enabled;
}
return this.repo.save(config);
}
/** Clear DB key only */
async clearKey(): Promise<AiConfigResponseDto> {
const config = await this.getOrCreateConfig();
config.encryptedApiKey = null;
config.apiKeyIv = null;
config.apiKeyAuthTag = null;
config.keyLast4 = null;
// If no env key either, disable
const envKey = process.env.AI_API_KEY;
if (!envKey) {
config.enabled = false;
}
await this.repo.save(config);
return this.getConfig();
}
/** Test connection — uses saved config or request body overrides */
async testConnection(dto?: TestAiConfigDto): Promise<AiConfigTestResultDto> {
const config = await this.getOrCreateConfig();
const now = new Date().toISOString();
// Determine effective provider / baseUrl
const provider = dto?.provider ?? config.provider;
const rawBaseUrl = dto?.baseUrl ?? config.baseUrl;
let baseUrl: string;
try {
baseUrl = validateAndNormalizeBaseUrl(rawBaseUrl, provider);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message,
};
}
// Determine effective defaultModel
const effectiveDefaultModel = dto?.defaultModel ?? config.defaultModel ?? '';
// DNS check
try {
await validateDnsNotPrivate(new URL(baseUrl).hostname);
} catch (err: unknown) {
const message = err instanceof BadRequestException ? err.message : '请求参数无效';
return {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message,
};
}
// Determine API key
let apiKey: string;
if (dto?.apiKey) {
apiKey = dto.apiKey;
} else {
const { plaintext } = this.resolveApiKey(config);
if (!plaintext) {
return {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '未配置 API Key',
};
}
apiKey = plaintext;
}
const timeoutMs = dto?.timeoutMs ?? config.timeoutMs;
let result: AiConfigTestResultDto;
try {
const { status, contentType, body, latencyMs } = await pinnedGet(
`${baseUrl}/models`,
{ Authorization: `Bearer ${apiKey}` },
timeoutMs,
);
// Classify by HTTP status first, then content-type
if (status === 401 || status === 403) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '认证失败,请检查 API Key',
};
} else if (status >= 500) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '服务不可用',
};
} else if (status >= 400) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: `服务返回错误状态 ${status}`,
};
} else if (!contentType || !contentType.includes('application/json')) {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '响应格式无效',
};
} else {
let data: { data?: Array<{ id: string }> };
try {
const parsed: unknown = JSON.parse(body);
if (!parsed || typeof parsed !== 'object') throw new Error('invalid');
data = parsed;
} catch {
result = {
success: false,
latencyMs,
modelCount: null,
modelAvailable: false,
testedAt: now,
message: '响应格式无效',
};
config.lastTestedAt = new Date();
config.lastTestLatencyMs = latencyMs;
config.verified = false;
await this.repo.save(config);
return result;
}
const models = Array.isArray(data?.data) ? data.data : [];
const modelCount = models.length;
const modelAvailable =
!effectiveDefaultModel || models.some((m) => m.id === effectiveDefaultModel);
const message = modelAvailable
? `连接成功,目标模型 "${effectiveDefaultModel}" 可用`
: effectiveDefaultModel
? '连接成功,但未找到目标模型'
: models.length > 0
? `连接成功,可用模型 ${models.length}`
: '连接成功,但未返回可用模型';
result = {
success: true,
latencyMs,
modelCount,
modelAvailable,
testedAt: now,
message,
};
}
} catch (err: unknown) {
const message =
err instanceof Error
? err.message === '连接超时'
? '连接超时'
: err.message === '响应过大'
? '响应过大'
: err.message === '禁止重定向'
? '连接失败,请检查 Base URL'
: '连接失败,请检查 Base URL'
: '连接失败,请检查 Base URL';
result = {
success: false,
latencyMs: null,
modelCount: null,
modelAvailable: false,
testedAt: now,
message,
};
}
// Update last tested info on config
config.lastTestedAt = new Date();
config.lastTestLatencyMs = result.latencyMs;
config.verified = result.success;
await this.repo.save(config);
return result;
}
/**
* Server-only runtime config — for future AI adapters.
* Re-validates the stored base URL and DNS at runtime to guard
* against config-table tampering or DNS record changes.
* Future adapters should still use a restricted transport helper.
*/
async getRuntimeConfig(): Promise<AiRuntimeConfig> {
const config = await this.repo.findOne({ where: { singletonKey: SINGLETON_KEY } });
if (!config) {
throw new InternalServerErrorException('AI 配置未初始化');
}
if (!config.enabled) {
throw new BadRequestException('AI 服务未启用');
}
// Re-validate and normalize the stored base URL
const normalizedBaseUrl = validateAndNormalizeBaseUrl(config.baseUrl, config.provider);
// Re-check DNS at runtime
await validateDnsNotPrivate(new URL(normalizedBaseUrl).hostname);
const { plaintext } = this.resolveApiKey(config);
if (!plaintext) {
throw new BadRequestException('未配置 API Key');
}
// defaultModel is required for actual AI calls
if (!config.defaultModel) {
throw new BadRequestException('未配置默认模型');
}
return {
provider: config.provider,
baseUrl: normalizedBaseUrl,
apiKey: plaintext,
defaultModel: config.defaultModel,
timeoutMs: config.timeoutMs,
enabled: config.enabled,
};
}
}

View File

@@ -0,0 +1,129 @@
import { validate } from 'class-validator';
import { SaveAiConfigDto, TestAiConfigDto } from './ai-config.dto';
import { AiProvider } from '../ai-config.entity';
describe('SaveAiConfigDto', () => {
it('validates a correct OPENAI config', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('validates a correct DEEPSEEK config', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.DEEPSEEK;
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('fixed provider can omit baseUrl', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('fixed provider can provide baseUrl (valid string)', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.baseUrl = 'https://api.openai.com/v1';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('OPENAI_COMPATIBLE must provide baseUrl', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI_COMPATIBLE;
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
});
it('OPENAI_COMPATIBLE with baseUrl passes', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI_COMPATIBLE;
dto.baseUrl = 'https://custom.api.com/v1';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('OPENAI_COMPATIBLE with empty baseUrl fails', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI_COMPATIBLE;
dto.baseUrl = '';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('isNotEmpty');
});
it('invalid provider fails', async () => {
const dto = new SaveAiConfigDto();
(dto as Record<string, unknown>).provider = 'INVALID';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].constraints).toHaveProperty('isIn');
});
it('timeoutMs outside range fails', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.timeoutMs = 500;
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('apiKey is optional string', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.apiKey = 'sk-test-1234';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('defaultModel is optional string', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.defaultModel = 'gpt-4';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('enabled is optional boolean', async () => {
const dto = new SaveAiConfigDto();
dto.provider = AiProvider.OPENAI;
dto.enabled = true;
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe('TestAiConfigDto', () => {
it('empty DTO is valid (all fields optional)', async () => {
const dto = new TestAiConfigDto();
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('partial fields validate', async () => {
const dto = new TestAiConfigDto();
dto.provider = AiProvider.OPENAI_COMPATIBLE;
dto.baseUrl = 'https://custom.api.com/v1';
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('invalid provider fails', async () => {
const dto = new TestAiConfigDto();
(dto as Record<string, unknown>).provider = 'INVALID';
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
it('timeoutMs outside range fails', async () => {
const dto = new TestAiConfigDto();
dto.timeoutMs = 0;
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,116 @@
import {
IsString,
IsBoolean,
IsOptional,
IsInt,
Min,
Max,
IsIn,
IsNotEmpty,
ValidateIf,
} from 'class-validator';
import { AiProvider } from '../ai-config.entity';
const PROVIDERS = [AiProvider.OPENAI, AiProvider.DEEPSEEK, AiProvider.OPENAI_COMPATIBLE] as const;
const DEFAULT_BASE_URLS: Record<AiProvider, string> = {
[AiProvider.OPENAI]: 'https://api.openai.com/v1',
[AiProvider.DEEPSEEK]: 'https://api.deepseek.com',
[AiProvider.OPENAI_COMPATIBLE]: '',
};
/** DTO for PUT /api/ai/config — all fields required or validated */
export class SaveAiConfigDto {
@IsIn(PROVIDERS)
provider!: AiProvider;
@ValidateIf((o: SaveAiConfigDto) => o.provider === AiProvider.OPENAI_COMPATIBLE || o.baseUrl !== undefined)
@IsNotEmpty({ message: 'OPENAI_COMPATIBLE 模式必须提供 baseUrl' })
@IsString()
baseUrl?: string;
/** Raw API key — never returned by GET; empty / undefined = keep existing */
@IsOptional()
@IsString()
apiKey?: string;
@IsOptional()
@IsString()
defaultModel?: string;
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsInt()
@Min(1000)
@Max(120000)
timeoutMs?: number;
}
/** DTO for POST /api/ai/config/test — all fields optional, validate only when provided */
export class TestAiConfigDto {
@IsOptional()
@IsIn(PROVIDERS)
provider?: AiProvider;
@IsOptional()
@IsString()
baseUrl?: string;
@IsOptional()
@IsString()
apiKey?: string;
@IsOptional()
@IsString()
defaultModel?: string;
@IsOptional()
@IsInt()
@Min(1000)
@Max(120000)
timeoutMs?: number;
}
/** Response shape for GET /api/ai/config — NEVER includes plaintext key */
export interface AiConfigResponseDto {
id: number;
provider: AiProvider;
baseUrl: string;
hasApiKey: boolean;
hasDatabaseKey: boolean;
maskedApiKey: string | null;
keySource: 'database' | 'environment' | 'none';
defaultModel: string | null;
enabled: boolean;
timeoutMs: number;
verified: boolean;
lastTestedAt: string | null;
lastTestLatencyMs: number | null;
createdAt: string;
updatedAt: string;
}
/** Response shape for POST /api/ai/config/test */
export interface AiConfigTestResultDto {
success: boolean;
latencyMs: number | null;
modelCount: number | null;
modelAvailable: boolean;
testedAt: string;
message: string;
}
/** Server-only runtime config — NEVER exported via controller DTO */
export interface AiRuntimeConfig {
provider: AiProvider;
baseUrl: string;
apiKey: string;
defaultModel: string;
timeoutMs: number;
enabled: boolean;
}
export { DEFAULT_BASE_URLS };

View File

@@ -2,8 +2,9 @@ import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TypeOrmModule, type TypeOrmModuleOptions } from '@nestjs/typeorm';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { ScheduleModule } from '@nestjs/schedule';
import {
Student,
Room,
@@ -28,6 +29,7 @@ import {
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
DingAttendanceRaw,
SyncLog,
SyncState,
@@ -40,11 +42,14 @@ import {
ResultArchive,
ArchiveAttachment,
StudentDingMapping,
AiConfig,
} from './entities';
import { AuthModule } from './auth/auth.module';
import { AuthorizationModule } from './authorization';
import { RbacModule } from './rbac/rbac.module';
import { StudentsModule } from './students/students.module';
import { PermissionGuard } from './auth/guards/permission.guard';
import { PoliciesGuard } from './authorization/guards/policies.guard';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RoomsModule } from './rooms/rooms.module';
import { OccupanciesModule } from './occupancies/occupancies.module';
@@ -64,6 +69,8 @@ import { NotificationsModule } from './notifications/notifications.module';
import { ArchiveModule } from './archive/archive.module';
import { ExpenseTypesModule } from './expense-types/expense-types.module';
import { DatabaseMigrationsModule } from './database/database-migrations.module';
import { AgentToolsModule } from './agent-tools';
import { AiConfigModule } from './ai-config/ai-config.module';
import {
IntegrationConfig,
@@ -73,6 +80,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
@Module({
imports: [
AuthorizationModule,
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([
{
@@ -81,10 +89,11 @@ import { IntegrationConfigModule } from './integration/config/config.module';
},
]),
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService): any => {
useFactory: (config: ConfigService): TypeOrmModuleOptions => {
const dbType = config.get('DB_TYPE', 'sqlite');
const allEntities = [
Student,
@@ -110,6 +119,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
Role,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
DingAttendanceRaw,
Notification,
StudentProfile,
@@ -124,6 +134,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
StudentDingMapping,
IntegrationConfig,
IntegrationConfigDetail,
AiConfig,
];
if (dbType === 'mysql') {
return {
@@ -131,8 +142,8 @@ import { IntegrationConfigModule } from './integration/config/config.module';
host: config.get('DB_HOST', 'localhost'),
port: config.get<number>('DB_PORT', 3306),
username: config.get('DB_USERNAME', 'root'),
password: config.get('DB_PASSWORD', ''),
database: config.get('DB_DATABASE', 'dorm_billing'),
password: config.get<string>('DB_PASSWORD', ''),
database: config.get<string>('DB_DATABASE', 'dorm_billing'),
entities: allEntities,
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
charset: 'utf8mb4',
@@ -140,7 +151,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
}
return {
type: 'better-sqlite3' as const,
database: config.get('DB_DATABASE', 'dorm_billing.db'),
database: config.get<string>('DB_DATABASE', 'dorm_billing.db'),
entities: allEntities,
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
};
@@ -167,12 +178,15 @@ import { IntegrationConfigModule } from './integration/config/config.module';
NotificationsModule,
ArchiveModule,
IntegrationConfigModule,
AgentToolsModule,
ExpenseTypesModule,
AiConfigModule,
],
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: PermissionGuard },
{ provide: APP_GUARD, useClass: PoliciesGuard },
],
})
export class AppModule {}

View File

@@ -125,4 +125,101 @@ describe('AttendanceImportService', () => {
);
});
it('auto-matches previously imported duplicate records', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
},
]);
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
const result = await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
autoMatch: true,
});
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
expect(result.matched).toBe(1);
});
it('scopes SSE progress events to the importing user', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-1',
userName: '李四',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T09:00:00.000Z',
checkId: 'check-2',
checkType: 'OnDuty',
},
]);
const events: Array<{ phase: string; userId?: number }> = [];
const sub = service.progress$.subscribe((event) => {
events.push({ phase: event.phase, userId: event.userId });
});
await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
userId: 42,
});
sub.unsubscribe();
expect(events.length).toBeGreaterThan(0);
for (const event of events) {
expect(event.userId).toBe(42);
}
});
it('emits userId undefined when import has no HTTP user', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
userId: 'ding-2',
userName: '王五',
workDate: '2026-07-02',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-02T10:00:00.000Z',
checkId: 'check-3',
checkType: 'OnDuty',
},
]);
const events: Array<{ phase: string; userId?: number }> = [];
const sub = service.progress$.subscribe((event) => {
events.push({ phase: event.phase, userId: event.userId });
});
await service.importFromDingTalk({
startDate: '2026-07-02',
endDate: '2026-07-02',
userIds: ['ding-2'],
});
sub.unsubscribe();
expect(events.length).toBeGreaterThan(0);
for (const event of events) {
expect(event.userId).toBeUndefined();
}
});
});

View File

@@ -28,6 +28,8 @@ export class AttendanceImportService {
/** RxJS Subject emitting live progress during import */
private progressSubject = new Subject<ImportProgressEvent>();
private isRunning = false;
/** ID of the user who triggered the current import (for SSE scoping) */
private importingUserId?: number;
constructor(
@InjectRepository(DingAttendanceRaw)
private readonly dingRawRepo: Repository<DingAttendanceRaw>,
@@ -60,7 +62,6 @@ export class AttendanceImportService {
* 1. Fetch attendance results from DingTalk (paginated)
* 2. Parse and validate each record
* 3. Deduplicate by `dingId` (unique in DB)
* 4. Batch-save to `ding_attendance_raw`
* 5. Optionally auto-match to students by name
*/
async importFromDingTalk(params: {
@@ -68,6 +69,8 @@ export class AttendanceImportService {
endDate: string;
userIds?: string[];
autoMatch?: boolean;
/** ID of the HTTP user triggering the import (for SSE event scoping) */
userId?: number;
}): Promise<ImportResult> {
if (this.isRunning) {
throw new Error('An import is already in progress');
@@ -75,6 +78,7 @@ export class AttendanceImportService {
const startedAt = Date.now();
this.isRunning = true;
this.importingUserId = params.userId;
// Safety timeout: auto-reset isRunning after 30 minutes in case of
// an unhandled exception that bypasses the finally block (extremely rare).
@@ -106,6 +110,7 @@ export class AttendanceImportService {
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
if (newRecords.length === 0) {
if (params.autoMatch) matched = await this.autoMatchUnmatched();
this.emit('complete', imported + skipped, total, 'Nothing new to import');
return { success: true, imported, skipped, matched, errors, duration: Date.now() - startedAt };
}
@@ -147,6 +152,7 @@ export class AttendanceImportService {
} finally {
clearTimeout(safetyTimer);
this.isRunning = false;
this.importingUserId = undefined;
}
}
@@ -312,6 +318,6 @@ export class AttendanceImportService {
message: string,
error?: string,
): void {
this.progressSubject.next({ phase, current, total, message, error });
this.progressSubject.next({ phase, current, total, message, error, userId: this.importingUserId });
}
}

View File

@@ -0,0 +1,205 @@
import { AttendanceSettlementService } from './attendance-settlement.service';
const schedule = {
id: 2,
classId: 8,
teacherId: 21,
weekDay: 1,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
scheduleType: 'INTERNAL',
status: 'active',
};
const createService = () => {
const scheduleRepo = { find: jest.fn() };
const sessionRepo = {
find: jest.fn(),
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
const attendanceService = {
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({
session: { id: 90, lessonDate, startedBy: userId, status: finalize ? 'completed' : 'in_progress' },
}),
),
};
const importService = {
importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }),
};
const service = new AttendanceSettlementService(
scheduleRepo as never,
sessionRepo as never,
attendanceService as never,
importService as never,
);
return { service, scheduleRepo, sessionRepo, attendanceService, importService };
};
describe('AttendanceSettlementService', () => {
it('pulls and finalizes an ended lesson once', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
startDate: '2026-07-13',
endDate: '2026-07-13',
userIds: ['ding-1'],
autoMatch: true,
userId: 21,
});
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
1, 2, '2026-07-13', 21, false,
);
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
2, 2, '2026-07-13', 21, true,
);
});
it('does not settle a lesson before its end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T09:30:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
});
it('continues with the next lesson when one settlement fails', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]);
sessionRepo.find.mockResolvedValue([]);
importService.importFromDingTalk
.mockRejectedValueOnce(new Error('DingTalk unavailable'))
.mockResolvedValueOnce({ success: true, errors: [] });
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
2,
3,
'2026-07-13',
21,
false,
);
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenLastCalledWith(
3,
'2026-07-13',
21,
true,
);
});
it('does not finalize when an import reports partial errors', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]);
importService.importFromDingTalk.mockResolvedValue({
success: true,
errors: ['one batch failed'],
});
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalledWith(
2, '2026-07-13', 21, true,
);
});
it('retries an uncompleted daytime lesson on a later day', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([]);
sessionRepo.find.mockResolvedValue([
{
scheduleId: 2,
lessonDate: '2026-07-13',
status: 'in_progress',
schedule,
},
]);
await service.settleEndedLessons(new Date('2026-07-15T10:01:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
2,
'2026-07-13',
21,
true,
);
});
it('skips final pull when another worker already claimed the session', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([]);
sessionRepo.find.mockResolvedValue([
{
id: 90,
scheduleId: 2,
lessonDate: '2026-07-13',
status: 'in_progress',
schedule,
},
]);
sessionRepo.update.mockResolvedValue({ affected: 0 });
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
});
it('settles an overnight lesson after its next-day end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([
{ ...schedule, id: 4, weekDay: 7, startTime: '22:00', endTime: '01:00' },
]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T01:01:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
4,
'2026-07-12',
21,
true,
);
});
it('pulls an overnight lesson through its next calendar date', async () => {
const { service, scheduleRepo, sessionRepo, importService } = createService();
scheduleRepo.find.mockResolvedValue([
{ ...schedule, id: 4, weekDay: 7, startTime: '22:00', endTime: '01:00' },
]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T01:01:00+08:00'));
expect(importService.importFromDingTalk).toHaveBeenCalledWith(
expect.objectContaining({ startDate: '2026-07-12', endDate: '2026-07-13' }),
);
});
});
describe('attendance settlement timezone', () => {
it('uses Asia/Shanghai course time when the server runs in UTC', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T02:01:00.000Z'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
2,
'2026-07-13',
21,
true,
);
});
});

View File

@@ -0,0 +1,193 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { AttendanceSession, ClassSchedule, ScheduleType } from '../entities';
import { AttendanceImportService } from './attendance-import.service';
import { AttendanceService } from './attendance.service';
@Injectable()
export class AttendanceSettlementService {
private readonly logger = new Logger(AttendanceSettlementService.name);
private readonly courseTimeZone = 'Asia/Shanghai';
private running = false;
constructor(
@InjectRepository(ClassSchedule)
private readonly scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceSession)
private readonly sessionRepo: Repository<AttendanceSession>,
private readonly attendanceService: AttendanceService,
private readonly importService: AttendanceImportService,
) {}
@Cron('* * * * *')
async settleEndedLessons(now = new Date()): Promise<void> {
if (this.running) return;
this.running = true;
try {
const clock = this.getCourseClock(now);
const staleClaimBefore = new Date(now.getTime() - 35 * 60 * 1000);
await this.sessionRepo.update(
{ status: 'settling', updatedAt: LessThan(staleClaimBefore) },
{ status: 'in_progress' },
);
const today = clock.date;
const yesterday = this.shiftDate(today, -1);
const [schedules, sessions] = await Promise.all([
this.scheduleRepo.find({
where: {
scheduleType: ScheduleType.INTERNAL,
status: 'active',
startDate: LessThanOrEqual(today),
endDate: MoreThanOrEqual(yesterday),
},
}),
this.sessionRepo.find({
where: { status: In(['in_progress', 'settling']) },
relations: ['schedule'],
}),
]);
const sessionByKey = new Map(
sessions.map((session) => [`${session.scheduleId}|${session.lessonDate}`, session]),
);
const candidates = new Map<string, { schedule: ClassSchedule; lessonDate: string; session?: AttendanceSession }>();
for (const schedule of schedules) {
const lessonDate = this.getEndedOccurrenceDate(schedule, clock, today, yesterday);
if (lessonDate) {
const key = `${schedule.id}|${lessonDate}`;
candidates.set(key, { schedule, lessonDate, session: sessionByKey.get(key) });
}
}
for (const session of sessions) {
if (session.status === 'in_progress' && session.schedule) {
candidates.set(`${session.scheduleId}|${session.lessonDate}`, {
schedule: session.schedule,
lessonDate: session.lessonDate,
session,
});
}
}
for (const candidate of candidates.values()) {
await this.settleCandidate(candidate);
}
} finally {
this.running = false;
}
}
private async settleCandidate(candidate: {
schedule: ClassSchedule;
lessonDate: string;
session?: AttendanceSession;
}): Promise<void> {
const { schedule, lessonDate } = candidate;
if (schedule.classId == null || schedule.teacherId == null) {
this.logger.error(`课程${schedule.id} ${lessonDate}缺少班级或教师,无法自动结算`);
return;
}
let session = candidate.session;
try {
if (!session) {
const created = await this.attendanceService.createLessonAttendanceFromDingTalk(
schedule.id,
lessonDate,
schedule.teacherId,
false,
);
session = created.session;
}
const claimed = await this.sessionRepo.update(
{ id: session.id, status: 'in_progress' },
{ status: 'settling' },
);
if (claimed.affected !== 1) return;
const userIds = await this.attendanceService.getTeacherClassDingUserIds(
schedule.teacherId,
schedule.classId,
);
const imported = await this.importService.importFromDingTalk({
startDate: lessonDate,
endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate,
userIds,
autoMatch: true,
userId: schedule.teacherId,
});
if (!imported.success || imported.errors.length > 0) {
throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败');
}
await this.attendanceService.createLessonAttendanceFromDingTalk(
schedule.id,
lessonDate,
schedule.teacherId,
true,
);
} catch (error: unknown) {
if (session) await this.sessionRepo.update({ id: session.id, status: 'settling' }, { status: 'in_progress' });
this.logger.error(
`课程${schedule.id} ${lessonDate}自动结算失败: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private getEndedOccurrenceDate(
schedule: ClassSchedule,
clock: { weekDay: number; minutes: number },
today: string,
yesterday: string,
): string | null {
const endMinutes = this.toMinutes(schedule.endTime);
const overnight = this.isOvernight(schedule);
const yesterdayWeekDay = clock.weekDay === 1 ? 7 : clock.weekDay - 1;
if (
!overnight &&
schedule.weekDay === clock.weekDay &&
clock.minutes >= endMinutes &&
today >= schedule.startDate &&
today <= schedule.endDate
) return today;
if (
overnight &&
schedule.weekDay === yesterdayWeekDay &&
clock.minutes >= endMinutes &&
yesterday >= schedule.startDate &&
yesterday <= schedule.endDate
) return yesterday;
return null;
}
private isOvernight(schedule: ClassSchedule): boolean {
return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime);
}
private toMinutes(time: string): number {
const [hour, minute] = time.split(':').map(Number);
return hour * 60 + minute;
}
private getCourseClock(date: Date): { date: string; weekDay: number; minutes: number } {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: this.courseTimeZone,
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).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],
minutes: Number(parts.hour) * 60 + Number(parts.minute),
};
}
private shiftDate(date: string, days: number): string {
const shifted = new Date(`${date}T00:00:00.000Z`);
shifted.setUTCDate(shifted.getUTCDate() + days);
return shifted.toISOString().slice(0, 10);
}
}

View File

@@ -1,4 +1,5 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Subject } from 'rxjs';
import { AttendanceController } from './attendance.controller';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
@@ -11,10 +12,14 @@ describe('AttendanceController — DingTalk import scope', () => {
};
const importService = {
importFromDingTalk: jest.fn(),
progress$: undefined as unknown,
};
const logService = {
log: jest.fn(),
};
const authzService = {
can: jest.fn().mockReturnValue(false),
};
let controller: AttendanceController;
@@ -24,6 +29,7 @@ describe('AttendanceController — DingTalk import scope', () => {
attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService,
authzService as never,
);
importService.importFromDingTalk.mockResolvedValue({
success: true,
@@ -48,6 +54,7 @@ describe('AttendanceController — DingTalk import scope', () => {
endDate: '2026-07-10',
userIds: ['ding-today'],
autoMatch: true,
userId: 21,
});
jest.useRealTimers();
});
@@ -60,12 +67,12 @@ describe('AttendanceController — DingTalk import scope', () => {
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
);
expect(attendanceService.getTeacherClassDingUserIds).toHaveBeenCalledWith(21, 8, false);
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
startDate: '2026-07-01',
endDate: '2026-07-02',
userIds: ['ding-1', 'ding-2'],
autoMatch: true,
userId: 21,
});
});
@@ -115,6 +122,7 @@ describe('AttendanceController — DingTalk import scope', () => {
it('allows class managers to choose any importable class and supply explicit DingTalk users', async () => {
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 1, className: '一班' }]);
authzService.can.mockReturnValue(true);
await expect(
controller.getDingTalkImportClasses({
user: {
@@ -139,12 +147,223 @@ describe('AttendanceController — DingTalk import scope', () => {
},
} as never,
);
expect(importService.importFromDingTalk).toHaveBeenLastCalledWith({
startDate: '2026-07-01',
endDate: '2026-07-02',
userIds: ['ding-1', 'ding-2'],
autoMatch: true,
userId: 7,
});
});
});
describe('AttendanceController — write data scope', () => {
const attendanceService = {
assertClassAccess: jest.fn(),
getAccessibleClassIds: jest.fn(),
getTeacherClassDingUserIds: jest.fn(),
batchCreate: jest.fn(),
generateFromSchedules: jest.fn(),
findAttendanceRecord: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
getLessonAttendance: jest.fn(),
createLessonAttendanceFromDingTalk: jest.fn(),
findAttendanceSession: jest.fn(),
completeLessonAttendance: jest.fn(),
};
const importService = { importFromDingTalk: jest.fn() };
const logService = { log: jest.fn().mockResolvedValue(undefined) };
const authzService = { can: jest.fn().mockReturnValue(false) };
const req = {
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false, roles: ['teacher'] },
headers: {},
};
let controller: AttendanceController;
beforeEach(() => {
jest.clearAllMocks();
authzService.can.mockReturnValue(false);
controller = new AttendanceController(
attendanceService as unknown as AttendanceService,
importService as unknown as AttendanceImportService,
logService as unknown as OperationLogsService,
authzService as never,
);
});
it('checks every distinct class in a manual attendance batch', async () => {
attendanceService.batchCreate.mockResolvedValue({ count: 2, records: [] });
const dto = {
records: [
{
studentId: 1,
classId: 8,
attendanceDate: '2026-07-01',
session: 'morning',
status: 'present',
},
{
studentId: 2,
classId: 9,
attendanceDate: '2026-07-01',
session: 'morning',
status: 'present',
},
],
};
await controller.batchCreate(dto, req);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 9, false);
});
it('rejects classless manual attendance records for a class-scoped teacher', async () => {
attendanceService.batchCreate.mockResolvedValue({ count: 1, records: [] });
const dto = {
records: [
{
studentId: 1,
attendanceDate: '2026-07-01',
session: 'morning',
status: 'present',
},
],
};
await expect(controller.batchCreate(dto, req)).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(attendanceService.batchCreate).not.toHaveBeenCalled();
});
it('checks the schedule class before starting and completing lesson attendance', async () => {
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
importService.importFromDingTalk.mockResolvedValue({
success: true,
imported: 1,
skipped: 0,
matched: 1,
errors: [],
duration: 10,
});
attendanceService.getLessonAttendance.mockResolvedValue({
schedule: { id: 4, classId: 8 },
session: null,
records: [],
});
attendanceService.createLessonAttendanceFromDingTalk.mockResolvedValue({
schedule: { id: 4, classId: 8 },
session: { id: 90, classId: 8 },
records: [],
});
attendanceService.findAttendanceSession.mockResolvedValue({ id: 90, classId: 8 });
attendanceService.completeLessonAttendance.mockResolvedValue({
session: { id: 90, classId: 8, status: 'completed' },
records: [],
});
await controller.pullLessonAttendance('4', { date: '2026-07-11' }, req);
await controller.completeLessonAttendance('90', req);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
});
it('checks class access before generating attendance from schedules', async () => {
attendanceService.generateFromSchedules.mockResolvedValue({ count: 0, records: [] });
await controller.generateFromSchedules({ classId: 8 }, req);
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
});
it('checks the attendance record owning class before update and delete', async () => {
attendanceService.findAttendanceRecord.mockResolvedValue({ id: 4, classId: 8 });
attendanceService.update.mockResolvedValue({ id: 4, classId: 8, status: 'late' });
attendanceService.remove.mockResolvedValue({ deleted: true });
await controller.update('4', { status: 'late' }, req);
await controller.remove('4', req);
expect(attendanceService.assertClassAccess).toHaveBeenCalledTimes(2);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
});
});
describe('AttendanceController — SSE progress scoping', () => {
let progressSubject: Subject<{ phase: string; userId?: number }>;
const importService = {
importFromDingTalk: jest.fn(),
get progress$() { return progressSubject.asObservable(); },
};
const attendanceService = {} as unknown as AttendanceService;
const logService = {} as unknown as OperationLogsService;
const authzService = {} as never;
let controller: AttendanceController;
beforeEach(() => {
progressSubject = new Subject<{ phase: string; userId?: number }>();
controller = new AttendanceController(
attendanceService,
importService as unknown as AttendanceImportService,
logService,
authzService,
);
});
afterEach(() => {
progressSubject.complete();
});
it('delivers events matching the requesting user id', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
}).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
});
progressSubject.next({ phase: 'fetching', userId: 42 });
progressSubject.next({ phase: 'complete', userId: 42 });
sub.unsubscribe();
expect(received).toHaveLength(2);
});
it('excludes events from a different user', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
}).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
});
progressSubject.next({ phase: 'fetching', userId: 99 }); // different user
progressSubject.next({ phase: 'complete', userId: 42 });
sub.unsubscribe();
// Only the matching event should arrive
expect(received).toHaveLength(1);
expect(received[0].userId).toBe(42);
});
it('excludes events with undefined userId (non-HTTP callers)', () => {
const received: Array<{ phase: string; userId?: number }> = [];
const sub = controller.importProgressStream({
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
}).subscribe({
next: (e) => received.push(JSON.parse(e.data as string)),
});
progressSubject.next({ phase: 'fetching' }); // no userId
progressSubject.next({ phase: 'complete', userId: 42 });
sub.unsubscribe();
// Only the event with matching userId should arrive
expect(received).toHaveLength(1);
expect(received[0].userId).toBe(42);
});
});

View File

@@ -14,7 +14,7 @@ import {
BadRequestException,
ForbiddenException,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { Observable, filter } from 'rxjs';
import type { Request as ExpressRequest, Response } from 'express';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
@@ -29,12 +29,16 @@ import {
AttendanceReportQueryDto,
UpdateAttendanceRecordDto,
GenerateFromSchedulesDto,
LessonAttendanceQueryDto,
StartLessonAttendanceDto,
} from './dto/attendance.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import * as ExcelJS from 'exceljs';
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
import type { AuthenticatedUser } from '../authorization';
/** SSE event shape for @Sse() decorator */
interface SseEvent {
@@ -47,8 +51,9 @@ interface SseEvent {
interface RequestUser {
id: number;
username: string;
permissions?: string[];
isSuperAdmin?: boolean;
permissions: string[];
isSuperAdmin: boolean;
roles: string[];
}
@UseGuards(JwtAuthGuard)
@@ -58,6 +63,7 @@ export class AttendanceController {
private readonly service: AttendanceService,
private readonly importService: AttendanceImportService,
private readonly logService: OperationLogsService,
private readonly authz: AuthorizationService,
) {}
private getTodayDateOnly(): string {
@@ -68,16 +74,91 @@ export class AttendanceController {
return `${year}-${month}-${day}`;
}
private canManageAllAttendance(user: RequestUser): boolean {
return user.isSuperAdmin === true || user.permissions?.includes('class:edit') === true;
private canManageAllAttendance(req: { user: RequestUser }): boolean {
return (
this.authz.can(req, CaslAction.Manage, SubjectName.Attendance) ||
// Legacy: class:edit grants broad attendance access for teacher scoping
this.authz.can(req, CaslAction.Update, SubjectName.Class)
);
}
private getAccessibleClassIds(user: RequestUser) {
return this.service.getAccessibleClassIds(user.id, this.canManageAllAttendance(user));
private getAccessibleClassIds(req: { user: RequestUser }) {
return this.service.getAccessibleClassIds(req.user.id, this.canManageAllAttendance(req));
}
private assertClassAccess(user: RequestUser, classId: number) {
return this.service.assertClassAccess(user.id, classId, this.canManageAllAttendance(user));
private assertClassAccess(req: { user: RequestUser }, classId: number) {
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
}
@Get('attendance-lessons/schedules/:scheduleId')
@RequirePermission('attendance:view')
async getLessonAttendance(
@Param('scheduleId') scheduleId: string,
@Query() query: LessonAttendanceQueryDto,
@Request() req: { user: RequestUser },
) {
const result = await this.service.getLessonAttendance(+scheduleId, query.date);
await this.assertClassAccess(req, result.schedule.classId!);
return result;
}
@Post('attendance-lessons/schedules/:scheduleId/pull')
@RequirePermission('attendance:create')
async pullLessonAttendance(
@Param('scheduleId') scheduleId: string,
@Body() dto: StartLessonAttendanceDto,
@Request() req: { user: RequestUser },
) {
const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date);
await this.assertClassAccess(req, schedule.schedule.classId!);
const importClassIds = await this.service.getTeacherClassDingUserIds(
req.user.id,
schedule.schedule.classId!,
this.canManageAllAttendance(req),
);
const importResult = await this.importService.importFromDingTalk({
startDate: dto.date,
endDate: dto.date,
userIds: importClassIds,
autoMatch: true,
userId: req.user.id,
});
const result = await this.service.createLessonAttendanceFromDingTalk(
+scheduleId,
dto.date,
req.user.id,
);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考勤管理',
action: schedule.session ? '查看已拉取课程考勤' : '拉取钉钉课程考勤',
targetId: result.session.id,
targetType: 'attendanceSession',
detail: `排课${scheduleId} 日期${dto.date},钉钉新增${importResult.imported}条,匹配${importResult.matched}`,
});
return result;
}
@Post('attendance-lessons/:sessionId/complete')
@RequirePermission('attendance:create')
async completeLessonAttendance(
@Param('sessionId') sessionId: string,
@Request() req: { user: RequestUser },
) {
const session = await this.service.findAttendanceSession(+sessionId);
await this.assertClassAccess(req, session.classId);
const result = await this.service.completeLessonAttendance(+sessionId, req.user.id);
await this.logService.log({
userId: req.user.id,
username: req.user.username,
module: '考勤管理',
action: '完成课程点名',
targetId: +sessionId,
targetType: 'attendanceSession',
detail: `班级${session.classId} 日期${session.lessonDate}`,
});
return result;
}
// ── Batch create attendance records ──
@@ -85,6 +166,18 @@ export class AttendanceController {
@RequirePermission('attendance:create')
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const canManageAll = this.canManageAllAttendance(req);
if (!canManageAll && dto.records.some((record) => record.classId == null)) {
throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');
}
const classIds = [
...new Set(
dto.records.map((record) => record.classId).filter((id): id is number => id != null),
),
];
for (const classId of classIds) {
await this.service.assertClassAccess(req.user.id, classId, canManageAll);
}
const result = await this.service.batchCreate(dto);
await this.logService.log({
userId: req.user?.id,
@@ -103,6 +196,7 @@ export class AttendanceController {
@RequirePermission('attendance:create')
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.assertClassAccess(req, dto.classId);
const result = await this.service.generateFromSchedules(dto);
await this.logService.log({
userId: req.user?.id,
@@ -124,8 +218,8 @@ export class AttendanceController {
@Res() res: Response,
@Request() req: { user: RequestUser },
) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
const classIds = await this.getAccessibleClassIds(req.user);
if (query.classId) await this.assertClassAccess(req, query.classId);
const classIds = await this.getAccessibleClassIds(req);
const records = await this.service.findAllForExport(query, classIds);
const workbook = new ExcelJS.Workbook();
@@ -176,19 +270,24 @@ export class AttendanceController {
@Get('attendance-records')
@RequirePermission('attendance:view')
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
return this.service.findAll(query, await this.getAccessibleClassIds(req.user));
if (query.classId) await this.assertClassAccess(req, query.classId);
return this.service.findAll(query, await this.getAccessibleClassIds(req));
}
// ── Update a single attendance record ──
@Put('attendance-records/:id')
@RequirePermission('attendance:edit')
@RequirePermission('attendance:edit', 'attendance:self-edit')
async update(
@Param('id') id: string,
@Body() dto: UpdateAttendanceRecordDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const existing = await this.service.findAttendanceRecord(+id);
if (existing.classId == null && !this.canManageAllAttendance(req)) {
throw new ForbiddenException('无权修改未关联班级的考勤记录');
}
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
@@ -206,9 +305,14 @@ export class AttendanceController {
// ── Delete a single attendance record ──
@Delete('attendance-records/:id')
@RequirePermission('attendance:edit')
@RequirePermission('attendance:edit', 'attendance:self-edit')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const existing = await this.service.findAttendanceRecord(+id);
if (existing.classId == null && !this.canManageAllAttendance(req)) {
throw new ForbiddenException('无权删除未关联班级的考勤记录');
}
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
@@ -228,7 +332,7 @@ export class AttendanceController {
@Get('attendance-records/classes')
@RequirePermission('attendance:view')
async getClasses(@Request() req: { user: RequestUser }) {
return this.service.getClasses(await this.getAccessibleClassIds(req.user));
return this.service.getClasses(await this.getAccessibleClassIds(req));
}
// ── Attendance summary ──
@@ -238,8 +342,8 @@ export class AttendanceController {
@Query() query: AttendanceSummaryQueryDto,
@Request() req: { user: RequestUser },
) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
return this.service.getSummary(query, await this.getAccessibleClassIds(req.user));
if (query.classId) await this.assertClassAccess(req, query.classId);
return this.service.getSummary(query, await this.getAccessibleClassIds(req));
}
// ── Attendance calendar ──
@@ -249,7 +353,7 @@ export class AttendanceController {
@Query() query: AttendanceCalendarQueryDto,
@Request() req: { user: RequestUser },
) {
await this.assertClassAccess(req.user, query.classId);
await this.assertClassAccess(req, query.classId);
return this.service.getCalendar(query);
}
@@ -257,8 +361,8 @@ export class AttendanceController {
@Get('ding-attendance-raw')
@RequirePermission('attendance:view')
async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req.user));
if (query.classId) await this.assertClassAccess(req, query.classId);
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req));
}
// ── Match a dingtalk record to a student ──
@@ -293,11 +397,8 @@ export class AttendanceController {
@Res() res: Response,
@Request() req: any,
) {
if (query.classId) await this.assertClassAccess(req.user, query.classId);
const reportData = await this.service.getReport(
query,
await this.getAccessibleClassIds(req.user),
);
if (query.classId) await this.assertClassAccess(req, query.classId);
const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req));
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('考勤统计报表');
@@ -363,7 +464,7 @@ export class AttendanceController {
return this.service.getAlerts(
days ? +days : 14,
threshold ? +threshold : 3,
await this.getAccessibleClassIds(req.user),
await this.getAccessibleClassIds(req),
);
}
@@ -380,7 +481,7 @@ export class AttendanceController {
@Get('attendance-records/import/dingtalk/classes')
@RequirePermission('attendance:create')
getDingTalkImportClasses(@Request() req: { user: RequestUser }) {
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req.user));
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req));
}
/**
@@ -392,7 +493,7 @@ export class AttendanceController {
@RequirePermission('attendance:create')
async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const canManageAll = this.canManageAllAttendance(req.user);
const canManageAll = this.canManageAllAttendance(req);
let userIds: string[];
if (dto.users) {
@@ -416,12 +517,12 @@ export class AttendanceController {
const startDate = dto.start ?? this.getTodayDateOnly();
const endDate = dto.end ?? startDate;
const result = await this.importService.importFromDingTalk({
startDate,
endDate,
userIds,
autoMatch: true,
userId: req.user.id,
});
await this.logService.log({
@@ -447,17 +548,22 @@ export class AttendanceController {
*/
@Sse('attendance-records/import/dingtalk/stream')
@RequirePermission('attendance:view')
importProgressStream(): Observable<SseEvent> {
importProgressStream(@Request() req: { user: RequestUser }): Observable<SseEvent> {
const userId = req.user.id;
return new Observable<SseEvent>((subscriber) => {
const subscription = this.importService.progress$.subscribe({
next: (event) => {
subscriber.next({ data: JSON.stringify(event) });
if (event.phase === 'complete' || event.phase === 'error') {
subscriber.complete();
}
},
error: (err: unknown) => subscriber.error(err),
});
const subscription = this.importService.progress$
.pipe(
filter((event) => event.userId === userId),
)
.subscribe({
next: (event) => {
subscriber.next({ data: JSON.stringify(event) });
if (event.phase === 'complete' || event.phase === 'error') {
subscriber.complete();
}
},
error: (err: unknown) => subscriber.error(err),
});
return () => subscription.unsubscribe();
});
}

View File

@@ -0,0 +1,549 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
const createService = () => {
const attendanceRepo = {
create: jest.fn((value: Record<string, unknown>) => value),
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(async (records: unknown) => records),
remove: jest.fn(async (record: unknown) => record),
count: jest.fn(),
};
const dingRawRepo = { find: jest.fn() };
const scheduleRepo = { findOne: jest.fn() };
const classStudentRepo = { find: jest.fn() };
const sessionRepo = {
findOne: jest.fn(),
create: jest.fn((value: Record<string, unknown>) => ({ id: 90, ...value })),
save: jest.fn(async (value: unknown) => value),
};
const dataSource = {
transaction: jest.fn(
async (cb: (manager: { getRepository: jest.Mock }) => Promise<unknown>) => {
const managerGetRepo = jest.fn((entity: { name: string }) => {
if (entity.name === AttendanceSession.name) return sessionRepo;
if (entity.name === AttendanceRecord.name) return attendanceRepo;
return {};
});
return cb({ getRepository: managerGetRepo });
},
),
};
const service = new AttendanceService(
attendanceRepo as never,
dingRawRepo as never,
{} as never,
{} as never,
scheduleRepo as never,
classStudentRepo as never,
{} as never,
{} as never,
sessionRepo as never,
dataSource as unknown as DataSource,
);
return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, dataSource };
};
const endedSchedule = {
id: 4,
classId: 8,
weekDay: 6,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
subject: '\u6570\u5B66',
status: 'active',
scheduleType: 'INTERNAL',
};
describe('AttendanceService \u2014 DingTalk course attendance', () => {
it('creates one course session from DingTalk results after the lesson', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
{ studentId: 3, student: { id: 3, name: '\u738B\u4E94' } },
{ studentId: 4, student: { id: 4, name: '\u8D75\u516D' } },
]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
},
{ matchedStudentId: 3, attendanceType: 'OnDuty', timeResult: 'NotSigned' },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(sessionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
}),
);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
]);
expect(result.records).toHaveLength(4);
});
it('finalizes missing punches as absent and completes the session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'absent' }),
]);
expect(sessionRepo.save).toHaveBeenLastCalledWith(
expect.objectContaining({ status: 'completed', completedBy: 21 }),
);
expect(result.session.status).toBe('completed');
});
it('returns student relations after the first pull', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ student: { id: 1, name: '张三' } }),
]);
expect(result.records[0].student.name).toBe('张三');
});
it('uses the DingTalk punch nearest to this lesson start when a student has multiple shifts', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } }]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T02:00:00+08:00'),
},
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present' }),
]);
});
it('creates local attendance after the lesson starts', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
const now = new Date();
const weekDay = now.getDay() === 0 ? 7 : now.getDay();
scheduleRepo.findOne.mockResolvedValue({
...endedSchedule,
weekDay,
startTime: '00:00',
endTime: '23:59',
startDate: '2026-01-01',
endDate: '2026-12-31',
});
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
const today = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('-');
const result = await service.createLessonAttendanceFromDingTalk(4, today, 21);
expect(attendanceRepo.save).toHaveBeenCalled();
expect(result.records).toHaveLength(1);
});
it('returns a completed session as-is without refreshing', async () => {
const { service, attendanceRepo, scheduleRepo, sessionRepo } = createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'completed',
});
attendanceRepo.find.mockResolvedValue([{ id: 1, attendanceSessionId: 90, status: 'present' }]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(sessionRepo.save).not.toHaveBeenCalled();
expect(attendanceRepo.save).not.toHaveBeenCalled();
expect(result.records).toHaveLength(1);
});
it('refreshes an in_progress session from latest DingTalk data', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(sessionRepo.create).not.toHaveBeenCalled();
expect(attendanceRepo.save).toHaveBeenCalled();
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'present' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
]),
);
expect(result.records).toHaveLength(2);
});
it('restores students missing from an existing empty session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, student: { id: 1, name: '张三' } }),
expect.objectContaining({ studentId: 2, student: { id: 2, name: '李四' } }),
]);
expect(result.records).toHaveLength(2);
});
it('preserves manually corrected records when refreshing an in_progress session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'leave', source: 'manual' },
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'leave' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
]),
);
expect(result.records).toHaveLength(2);
});
it('final settlement overrides interim manual status using the final punch result', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
dingRawRepo.find.mockResolvedValue([]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'absent' }),
]);
});
it('rejects completion when pending records exist', async () => {
const { service, sessionRepo, attendanceRepo } = createService();
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(1);
await expect(service.completeLessonAttendance(90, 21)).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('completes a pulled attendance session after teacher review', async () => {
const { service, sessionRepo, attendanceRepo } = createService();
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(0);
attendanceRepo.find.mockResolvedValue([
{ id: 1, status: 'present' },
{ id: 2, status: 'absent' },
]);
const result = await service.completeLessonAttendance(90, 21);
expect(sessionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ id: 90, status: 'completed', completedBy: 21 }),
);
expect(result.session.status).toBe('completed');
});
it('throws when querying a missing schedule attendance session', async () => {
const { service, scheduleRepo } = createService();
scheduleRepo.findOne.mockResolvedValue(null);
await expect(service.getLessonAttendance(999, '2026-07-11')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('update() marks record source as manual so refresh preserves the correction', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
// Initial state: dingtalk-sourced record
attendanceRepo.findOne.mockResolvedValue({
id: 101,
studentId: 1,
attendanceSessionId: 90,
status: 'present',
source: 'dingtalk',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'manual' },
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
{ studentId: 2, student: { id: 2, name: '李四' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
]);
// Step 1: update the record to absent via generic update()
const updated = await service.update(101, { status: 'absent' });
expect(updated.source).toBe('manual');
// Step 2: refresh in_progress session — manual record status must stay absent
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
][0] as Array<{ studentId: number; status: string }>;
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'absent' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
]),
);
});
it('recovers from concurrent unique constraint on first session creation', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne
.mockResolvedValueOnce(null) // first check: no existing session
.mockResolvedValueOnce({
// recovery: the winning session
id: 77,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
// Simulate unique constraint on save
sessionRepo.save.mockRejectedValueOnce(
Object.assign(new Error('UNIQUE constraint failed'), {
code: 'SQLITE_CONSTRAINT',
errno: undefined,
}),
);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
dingRawRepo.find.mockResolvedValue([]);
attendanceRepo.find.mockResolvedValue([
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
expect(result.session.id).toBe(77);
expect(result.records).toHaveLength(1);
});
it('completeLessonAttendance runs inside a transaction', async () => {
const { service, sessionRepo, attendanceRepo, dataSource } = createService();
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(0);
attendanceRepo.find.mockResolvedValue([
{ id: 1, status: 'present' },
]);
await service.completeLessonAttendance(90, 21);
expect(dataSource.transaction).toHaveBeenCalled();
});
it('update() throws when the parent session is completed', async () => {
const { service, attendanceRepo, sessionRepo } = createService();
attendanceRepo.findOne.mockResolvedValue({
id: 101,
attendanceSessionId: 90,
status: 'present',
});
sessionRepo.findOne.mockResolvedValue({
id: 90,
status: 'completed',
});
await expect(service.update(101, { status: 'absent' })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('remove() throws when the parent session is completed', async () => {
const { service, attendanceRepo, sessionRepo } = createService();
attendanceRepo.findOne.mockResolvedValue({
id: 101,
attendanceSessionId: 90,
});
sessionRepo.findOne.mockResolvedValue({
id: 90,
status: 'completed',
});
await expect(service.remove(101)).rejects.toBeInstanceOf(BadRequestException);
});
it('update() allows modification when the parent session is in_progress', async () => {
const { service, attendanceRepo, sessionRepo } = createService();
attendanceRepo.findOne.mockResolvedValue({
id: 101,
attendanceSessionId: 90,
status: 'present',
});
sessionRepo.findOne.mockResolvedValue({
id: 90,
status: 'in_progress',
});
const result = await service.update(101, { status: 'absent' });
expect(result.source).toBe('manual');
});
});

View File

@@ -1,20 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
import { AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
import { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service';
import { AttendanceSettlementService } from './attendance-settlement.service';
import { AttendanceController } from './attendance.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { IntegrationModule } from '../integration/integration.module';
@Module({
imports: [
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
OperationLogsModule,
IntegrationModule,
],
controllers: [AttendanceController],
providers: [AttendanceService, AttendanceImportService],
providers: [AttendanceService, AttendanceImportService, AttendanceSettlementService],
exports: [AttendanceService, AttendanceImportService],
})
export class AttendanceModule {}

View File

@@ -1,9 +1,11 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getDataSourceToken } from '@nestjs/typeorm';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { Repository } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
import { Class } from '../entities/class.entity';
import { Student } from '../entities/student.entity';
@@ -53,6 +55,8 @@ describe('AttendanceService — batchCreate', () => {
{ provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
{ provide: getDataSourceToken(), useValue: { transaction: jest.fn() } },
],
}).compile();
@@ -130,6 +134,8 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
classStudentRepo as never,
mappingRepo as never,
classTeacherRepo as never,
{} as never,
{} as never,
);
beforeEach(() => {
@@ -203,6 +209,8 @@ describe('AttendanceService — DingTalk raw query', () => {
classStudentRepo as never,
mappingRepo as never,
{} as never,
{} as never,
{} as never,
);
await expect(
@@ -219,3 +227,205 @@ describe('AttendanceService — DingTalk raw query', () => {
expect(qb.take).toHaveBeenCalledWith(10);
});
});
// ── Session serialization tests ──
function deferred<T>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('AttendanceService — session serialization', () => {
const sessionId = 100;
const recordId = 200;
const sessionInProgress = { id: sessionId, status: 'in_progress' };
const sessionCompleted = { id: sessionId, status: 'completed' };
const record: Record<string, unknown> = {
id: recordId,
attendanceSessionId: sessionId,
studentId: 1,
status: 'present',
source: 'dingtalk',
attendanceDate: '2026-07-01',
session: 'morning',
};
function makeService(
dataSourceMock: { transaction: jest.Mock },
attendanceRepoOverrides?: Record<string, jest.Mock>,
) {
const defaultAttendanceRepo = {
findOne: jest.fn().mockResolvedValue(record),
find: jest.fn().mockResolvedValue([record]),
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
remove: jest.fn().mockResolvedValue(undefined),
create: jest.fn(),
createQueryBuilder: jest.fn(),
};
const attendanceRepo = { ...defaultAttendanceRepo, ...attendanceRepoOverrides };
return new AttendanceService(
attendanceRepo as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
dataSourceMock as never,
);
}
function makeTxManager(sessionStatus: string) {
const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
const sessionRepo = {
findOne: jest.fn().mockResolvedValue(session),
save: jest.fn().mockImplementation((s: unknown) => Promise.resolve(s)),
};
const recordRepo = {
findOne: jest.fn().mockResolvedValue(record),
find: jest.fn().mockResolvedValue([record]),
count: jest.fn().mockResolvedValue(0),
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
remove: jest.fn().mockResolvedValue(undefined),
};
return {
getRepository: jest.fn((entity: unknown) => {
if (entity === AttendanceSession) return sessionRepo;
if (entity === AttendanceRecord) return recordRepo;
throw new Error('Unexpected entity');
}),
sessionRepo,
recordRepo,
};
}
it('complete holds lock; queued update is rejected after session becomes completed', async () => {
// For this test, the manager has a completed session
// The complete callback is stalled, update queues and then finds completed
const manager = makeTxManager('completed');
const completeStall = deferred<unknown>();
const txMock = jest
.fn()
.mockImplementationOnce(() => completeStall.promise)
.mockImplementationOnce((cb: (m: unknown) => unknown) => cb(manager));
const svc = makeService({ transaction: txMock });
const completeP = svc.completeLessonAttendance(sessionId, 1);
const updateP = svc.update(recordId, { status: 'absent' });
completeStall.resolve({ session: sessionCompleted, records: [record] });
await expect(completeP).resolves.toEqual({ session: sessionCompleted, records: [record] });
await expect(updateP).rejects.toThrow(BadRequestException);
expect(manager.sessionRepo.findOne).toHaveBeenCalled();
});
it('update holds lock; complete reads current state after update finishes', async () => {
const manager = makeTxManager('in_progress');
const updateStall = deferred<unknown>();
// Track call order
const callOrder: string[] = [];
let transactionsRun = 0;
const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => {
transactionsRun++;
if (transactionsRun === 1) {
// update: stall
callOrder.push('update-tx-started');
return updateStall.promise.then((v) => {
callOrder.push('update-tx-resolved');
return v;
});
}
// complete: actually run the callback
callOrder.push('complete-tx-started');
return cb(manager);
});
const svc = makeService({ transaction: txMock });
// Start update but DON'T await — it will stall
const updateP = svc.update(recordId, { status: 'absent' });
// Give update time to enter the mutex and transaction
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
// Start complete while update is stalled
const completeP = svc.completeLessonAttendance(sessionId, 1);
// Another tick
await Promise.resolve();
// Resolve update's stalled transaction
updateStall.resolve(record);
const updateResult = await updateP;
expect(updateResult).toEqual(record);
const completeResult = await completeP;
// Verify the callback was actually run correctly
expect(callOrder).toContain('update-tx-started');
expect(callOrder).toContain('complete-tx-started');
expect(completeResult).toHaveProperty('session');
expect(completeResult).toHaveProperty('records');
});
it('records without attendanceSessionId bypass the mutex and keep original behaviour', async () => {
const noSessionRecord = { ...record, attendanceSessionId: null };
const overrides = {
findOne: jest.fn().mockResolvedValue(noSessionRecord),
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
};
const txMock = jest.fn();
const svc = makeService({ transaction: txMock }, overrides);
const result = await svc.update(recordId, { status: 'absent' });
expect(result).toEqual(noSessionRecord);
expect(txMock).not.toHaveBeenCalled();
const removeResult = await svc.remove(recordId);
expect(removeResult).toEqual({ deleted: true });
});
it('complete is idempotent: returns current state when session already completed', async () => {
const manager = makeTxManager('completed');
const txMock = jest
.fn()
.mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
const svc = makeService({ transaction: txMock });
const result = await svc.completeLessonAttendance(sessionId, 1);
expect(result).toHaveProperty('session');
expect(result).toHaveProperty('records');
expect(manager.recordRepo.count).not.toHaveBeenCalled();
});
});

View File

@@ -1,11 +1,18 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ClassTeacher, ScheduleType, StudentDingMapping } from '../entities';
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource } from 'typeorm';
import {
AttendanceRecord,
AttendanceSession,
DingAttendanceRaw,
Class,
Student,
ClassSchedule,
ClassStudent,
ClassTeacher,
ScheduleType,
StudentDingMapping,
} from '../entities';
import {
BatchCreateAttendanceDto,
AttendanceSummaryQueryDto,
@@ -18,6 +25,26 @@ import {
GenerateFromSchedulesDto,
} from './dto/attendance.dto';
/** Keyed mutex serializing operations on the same attendance session. */
class SessionMutex {
private queueTails = new Map<number, Promise<void>>();
async runExclusive<T>(sessionId: number, fn: () => Promise<T>): Promise<T> {
const tail = this.queueTails.get(sessionId) ?? Promise.resolve();
let release!: () => void;
const newTail = new Promise<void>((resolve) => { release = resolve; });
this.queueTails.set(sessionId, newTail);
await tail;
try {
return await fn();
} finally {
release();
if (this.queueTails.get(sessionId) === newTail) {
this.queueTails.delete(sessionId);
}
}
}
}
@Injectable()
export class AttendanceService {
constructor(
@@ -37,8 +64,13 @@ export class AttendanceService {
private studentDingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(ClassTeacher)
private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession)
private attendanceSessionRepo: Repository<AttendanceSession>,
private dataSource: DataSource,
) {}
private sessionMutex = new SessionMutex();
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
@@ -112,13 +144,335 @@ export class AttendanceService {
return userIds.sort();
}
private async getScheduleOccurrence(scheduleId: number, lessonDate: string) {
const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('排课记录不存在');
if (schedule.scheduleType !== ScheduleType.INTERNAL || schedule.status !== 'active') {
throw new BadRequestException('该排课不能进行课程考勤');
}
if (schedule.classId == null) throw new BadRequestException('该排课未关联班级');
if (lessonDate < schedule.startDate || lessonDate > schedule.endDate) {
throw new BadRequestException('所选日期不在排课有效期内');
}
const date = new Date(`${lessonDate}T00:00:00`);
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
if (weekDay !== schedule.weekDay) throw new BadRequestException('所选日期不是该课程的上课日');
return schedule;
}
async getLessonAttendance(scheduleId: number, lessonDate: string) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const session = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
const records = session
? await this.attendanceRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
})
: [];
return { schedule, session, records };
}
private selectDingTalkRecordsForLesson(
records: DingAttendanceRaw[],
lessonDate: string,
startTime: string,
endTime: string,
): DingAttendanceRaw[] {
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
const windowStart = start - 3 * 60 * 60 * 1000;
const windowEnd = end + 3 * 60 * 60 * 1000;
const timed = records.filter((record) => {
const time = record.checkInTime ?? record.checkOutTime;
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd;
});
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
}
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
const hasPunch = records.some((record) => record.checkInTime || record.checkOutTime);
if (hasPunch) return 'present';
return finalize ? 'absent' : 'pending';
}
async createLessonAttendanceFromDingTalk(
scheduleId: number,
lessonDate: string,
userId: number,
finalize = false,
) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const now = new Date();
const today = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('-');
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
if (lessonDate === today) {
const [hour, minute] = schedule.startTime.split(':').map(Number);
const startMinute = hour * 60 + minute;
const currentMinute = now.getHours() * 60 + now.getMinutes();
if (currentMinute < startMinute) {
throw new BadRequestException('课程尚未开始,不能拉取考勤');
}
}
const existing = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
if (existing.status === 'completed') {
const records = await this.attendanceRepo.find({
where: { attendanceSessionId: existing.id },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session: existing, records };
}
if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) {
throw new BadRequestException('课程考勤正在结算');
}
// Refresh in_progress session from latest DingTalk data
return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' },
});
const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' },
relations: ['student'],
});
const studentsById = new Map(
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
);
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
const updatedRecords = existingRecords.map((record) => {
record.student = studentsById.get(record.studentId)!;
// Preserve manual corrections only while the lesson is still in progress.
if (!finalize && record.source !== 'dingtalk') return record;
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [],
lessonDate,
schedule.startTime,
schedule.endTime,
);
record.status = this.mapDingTalkStatus(raw, finalize);
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
? null
: finalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果';
return record;
});
for (const classStudent of classStudents) {
if (existingStudentIds.has(classStudent.studentId)) continue;
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
lessonDate,
schedule.startTime,
schedule.endTime,
);
updatedRecords.push(
recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: existing.id,
attendanceDate: lessonDate,
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
}),
);
}
const saved = await recordRepo.save(updatedRecords);
if (finalize) {
existing.status = 'completed';
existing.completedBy = userId;
existing.completedAt = new Date();
await sessionRepo.save(existing);
}
return { schedule, session: existing, records: saved };
});
}
// First pull: create session and records atomically
return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' },
relations: ['student'],
});
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
let session: AttendanceSession;
try {
session = await sessionRepo.save(
sessionRepo.create({
scheduleId,
classId: schedule.classId!,
lessonDate,
status: 'in_progress',
startedBy: userId,
startedAt: new Date(),
}),
);
} catch (err: unknown) {
const code = (err as Record<string, unknown>).code;
const errno = (err as Record<string, unknown>).errno;
// MySQL: ER_DUP_ENTRY or errno 1062; SQLite: SQLITE_CONSTRAINT
if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
const existing = await sessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
session = existing;
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session, records: existingRecords };
}
}
throw err;
}
const records = classStudents.map((classStudent) => {
const raw = this.selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
lessonDate,
schedule.startTime,
schedule.endTime,
);
return recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: session.id,
attendanceDate: lessonDate,
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
});
});
const saved = await recordRepo.save(records);
if (finalize) {
session.status = 'completed';
session.completedBy = userId;
session.completedAt = new Date();
session = await sessionRepo.save(session);
}
return { schedule, session, records: saved };
});
}
private async fetchDingTalkRawByStudent(
classId: number,
lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.classStudentRepo.find({
where: { classId, status: 'active' },
});
if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId);
const rawRecords = await this.dingRawRepo.find({
where: {
attendanceDate: lessonDate,
matchedStudentId: In(studentIds),
},
});
const rawByStudent = new Map<number, DingAttendanceRaw[]>();
for (const raw of rawRecords) {
if (raw.matchedStudentId == null) continue;
const arr = rawByStudent.get(raw.matchedStudentId) ?? [];
arr.push(raw);
rawByStudent.set(raw.matchedStudentId, arr);
}
return rawByStudent;
}
async completeLessonAttendance(sessionId: number, userId: number) {
return this.sessionMutex.runExclusive(sessionId, () =>
this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const session = await sessionRepo.findOne({ where: { id: sessionId } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
// Re-check under lock: if already completed, return current state idempotently
if (session.status === 'completed') {
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session, records };
}
const pendingRecords = await recordRepo.count({
where: { attendanceSessionId: sessionId, status: 'pending' },
});
if (pendingRecords > 0) {
throw new BadRequestException('存在未处理的考勤记录,无法完成考勤');
}
session.status = 'completed';
session.completedBy = userId;
session.completedAt = new Date();
const savedSession = await sessionRepo.save(session);
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session: savedSession, records };
}),
);
}
async findAttendanceSession(id: number) {
const session = await this.attendanceSessionRepo.findOne({ where: { id } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
return session;
}
// ── Batch create attendance records ──
async batchCreate(dto: BatchCreateAttendanceDto) {
if (!dto.records || dto.records.length === 0) {
throw new BadRequestException('records array must not be empty');
}
const entities = dto.records.map((r) => {
const entity = this.attendanceRepo.create({
studentId: r.studentId,
@@ -209,7 +563,9 @@ export class AttendanceService {
}
// ── Generate attendance records from schedules (optional date range, defaults to current week) ──
async generateFromSchedules(dto: GenerateFromSchedulesDto): Promise<{ count: number; records: AttendanceRecord[] }> {
async generateFromSchedules(
dto: GenerateFromSchedulesDto,
): Promise<{ count: number; records: AttendanceRecord[] }> {
const { classId, startDate, endDate } = dto;
// Default to current week (MondaySunday)
@@ -233,7 +589,6 @@ export class AttendanceService {
});
}
private mapScheduleTimeToSession(startTime: string): string {
const hour = parseInt(startTime.slice(0, 2), 10);
if (hour < 8) return 'morning_reading';
@@ -243,14 +598,15 @@ export class AttendanceService {
return 'night_check';
}
// ── Attendance summary ──
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0)
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
@@ -337,27 +693,32 @@ export class AttendanceService {
}
// ── List attendance records with filters ──
async findAll(query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
}, accessibleClassIds?: number[]) {
async findAll(
query: {
classId?: number;
scheduleId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
page?: number;
pageSize?: number;
},
accessibleClassIds?: number[],
) {
const page = query.page || 1;
const pageSize = query.pageSize || 20;
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
if (query.scheduleId) {
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
}
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
else if (accessibleClassIds) {
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
@@ -391,7 +752,6 @@ export class AttendanceService {
.select('DISTINCT ar.classId', 'classId')
.where('ar.classId IS NOT NULL');
const rows = accessibleClassIds
? accessibleClassIds.map((classId) => ({ classId }))
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
@@ -432,7 +792,9 @@ export class AttendanceService {
const mappings = await this.studentDingMappingRepo.find({
where: { studentId: In(studentIds) },
});
const dingUserIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
const dingUserIds = [
...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)),
];
if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
}
@@ -488,22 +850,23 @@ export class AttendanceService {
}
// ── Export all attendance records with filters (no pagination) ──
async findAllForExport(query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
}, accessibleClassIds?: number[]) {
async findAllForExport(
query: {
classId?: number;
dateFrom?: string;
dateTo?: string;
session?: string;
status?: string;
source?: string;
},
accessibleClassIds?: number[],
) {
const qb = this.attendanceRepo.createQueryBuilder('ar');
qb.leftJoinAndSelect('ar.student', 'student')
.leftJoinAndSelect('ar.class', 'class');
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
else if (accessibleClassIds) {
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
@@ -528,34 +891,85 @@ export class AttendanceService {
return qb.getMany();
}
// ── Update a single attendance record ──
async update(id: number, dto: UpdateAttendanceRecordDto) {
async findAttendanceRecord(id: number) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
}
if (dto.status !== undefined) {
record.status = dto.status;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
}
return this.attendanceRepo.save(record);
return record;
}
// ── Delete a single attendance record ──
async remove(id: number) {
const record = await this.attendanceRepo.findOne({ where: { id } });
if (!record) {
throw new NotFoundException(`AttendanceRecord ${id} not found`);
// ── Update a single attendance record ──
async update(id: number, dto: UpdateAttendanceRecordDto) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
if (dto.status !== undefined) {
record.status = dto.status;
record.source = 'manual';
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
record.source = 'manual';
}
return this.attendanceRepo.save(record);
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
await this.attendanceRepo.remove(record);
return { deleted: true };
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
if (dto.status !== undefined) {
freshRecord.status = dto.status;
freshRecord.source = 'manual';
}
if (dto.remark !== undefined) {
freshRecord.remark = dto.remark;
freshRecord.source = 'manual';
}
return recordRepo.save(freshRecord);
}),
);
}
// ── Delete a single attendance record ──
async remove(id: number) {
const record = await this.findAttendanceRecord(id);
// Records without a lesson session keep original behaviour
if (record.attendanceSessionId == null) {
await this.attendanceRepo.remove(record);
return { deleted: true };
}
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
this.dataSource.transaction(async (manager) => {
const recordRepo = manager.getRepository(AttendanceRecord);
const sessionRepo = manager.getRepository(AttendanceSession);
// Re-check session status inside the transaction while holding the lock
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
if (!session || session.status === 'completed') {
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
}
const freshRecord = await recordRepo.findOne({ where: { id } });
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
await recordRepo.remove(freshRecord);
return { deleted: true };
}),
);
}
// ── Class-based attendance report ──
@@ -569,8 +983,7 @@ export class AttendanceService {
.addSelect('COUNT(*)', 'count');
if (query.classId) {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
}
else if (accessibleClassIds) {
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
@@ -585,14 +998,17 @@ export class AttendanceService {
const rawRows = await qb.getRawMany();
// Aggregate by class
const classMap = new Map<number, {
classId: number;
className: string;
present: number;
absent: number;
late: number;
leave: number;
}>();
const classMap = new Map<
number,
{
classId: number;
className: string;
present: number;
absent: number;
late: number;
leave: number;
}
>();
for (const row of rawRows) {
if (!row.classId) continue;
@@ -637,9 +1053,10 @@ export class AttendanceService {
.leftJoinAndSelect('a.student', 'student')
.leftJoinAndSelect('a.class', 'class');
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] });
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }).andWhere(
'a.status IN (:...statuses)',
{ statuses: ['absent', 'late'] },
);
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
@@ -650,11 +1067,15 @@ export class AttendanceService {
.getMany();
const alerts: Array<{
studentId: number; studentName: string; className: string;
type: string; count: number; lastDate: string;
studentId: number;
studentName: string;
className: string;
type: string;
count: number;
lastDate: string;
}> = [];
let current: typeof alerts[0] | null = null;
let current: (typeof alerts)[0] | null = null;
for (const r of records) {
const name = (r.student as any)?.name || '';
const className = (r.class as any)?.name || '';
@@ -664,10 +1085,17 @@ export class AttendanceService {
if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
} else {
if (current && current.count >= threshold) alerts.push({ ...current });
current = { studentId: r.studentId, studentName: name, className, type: status, count: 1, lastDate: r.attendanceDate };
current = {
studentId: r.studentId,
studentName: name,
className,
type: status,
count: 1,
lastDate: r.attendanceDate,
};
}
}
if (current && current.count >= threshold) alerts.push(current);
return alerts;
}
}
}

View File

@@ -55,5 +55,33 @@ describe('DingTalkService — attendance records', () => {
).rejects.toThrow('userIds');
expect(global.fetch).toBeUndefined();
});
it('keeps DingTalk work dates in China local time', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue({
errcode: 0,
errmsg: 'ok',
recordresult: [
{
id: 1,
userId: 'ding-1',
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
sourceType: 'USER',
checkType: 'OnDuty',
timeResult: 'Normal',
},
],
}),
}) as jest.MockedFunction<typeof fetch>;
const [record] = await service.fetchAttendanceResults({
startDate: '2026-07-12',
endDate: '2026-07-12',
userIds: ['ding-1'],
});
expect(record.workDate).toBe('2026-07-12');
});
});

View File

@@ -111,6 +111,11 @@ export class QueryAttendanceRecordsDto {
@Type(() => Number)
classId?: number;
@IsOptional()
@IsInt()
@Type(() => Number)
scheduleId?: number;
@IsOptional()
@IsDateString()
dateFrom?: string;
@@ -204,3 +209,15 @@ export class GenerateFromSchedulesDto {
@IsDateString()
endDate?: string;
}
export class LessonAttendanceQueryDto {
@IsDateString()
@IsNotEmpty()
date: string;
}
export class StartLessonAttendanceDto {
@IsDateString()
@IsNotEmpty()
date: string;
}

View File

@@ -47,6 +47,8 @@ export interface ImportProgressEvent {
message: string;
/** Error message (only when phase === 'error') */
error?: string;
/** ID of the user who triggered the import (undefined for non-HTTP callers) */
userId?: number;
}
/**

View File

@@ -1,50 +1,223 @@
import { PermissionGuard } from './permission.guard';
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants';
describe('PermissionGuard', () => {
const createContext = (user: unknown) =>
({
const abilityFactory = new CaslAbilityFactory();
/** Mock NestJS ExecutionContext with reflector overrides */
function createContext(
user: unknown,
overrides: {
isPublic?: boolean;
authenticatedOnly?: boolean;
permissions?: string[];
checkPolicies?: unknown[];
} = {},
) {
const meta = new Map<string, unknown>();
if (overrides.isPublic !== undefined) meta.set('isPublic', overrides.isPublic);
if (overrides.authenticatedOnly !== undefined)
meta.set('authenticatedOnly', overrides.authenticatedOnly);
if (overrides.permissions) meta.set('permissions', overrides.permissions);
if (overrides.checkPolicies) meta.set('check_policies', overrides.checkPolicies);
const reflector = {
getAllAndOverride: jest.fn((key: string) => meta.get(key) ?? undefined),
getAllAndMerge: jest.fn((key: string) => meta.get(key) ?? []),
};
const guard = new PermissionGuard(reflector as never, abilityFactory);
return guard.canActivate({
getHandler: () => function handler() {},
getClass: () => class Controller {},
switchToHttp: () => ({ getRequest: () => ({ user }) }),
}) as never;
} as never);
}
// -----------------------------------------------------------------------
// @Public / @Authenticated / undeclared
// -----------------------------------------------------------------------
it('denies routes that forgot to declare permissions', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(false),
getAllAndMerge: jest.fn().mockReturnValue(undefined),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext({ permissions: ['dashboard:view'] }))).toBe(false);
expect(createContext(undefined)).toBe(false);
expect(createContext({ permissions: [], isSuperAdmin: false })).toBe(false);
});
it('allows explicitly public routes without a user', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValue(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext(undefined))).toBe(true);
expect(createContext(undefined, { isPublic: true })).toBe(true);
});
it('allows authenticated-only routes for logged-in users without requiring profile:view', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
expect(guard.canActivate(createContext({ permissions: [] }))).toBe(true);
it('allows authenticated-only routes for logged-in users', () => {
expect(
createContext({ permissions: [], isSuperAdmin: false }, { authenticatedOnly: true }),
).toBe(true);
});
it('denies authenticated-only routes when no authenticated user is present', () => {
const reflector = {
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
getAllAndMerge: jest.fn(),
};
const guard = new PermissionGuard(reflector as never);
it('denies authenticated-only routes when no user is present', () => {
expect(createContext(undefined, { authenticatedOnly: true })).toBe(false);
});
expect(guard.canActivate(createContext(undefined))).toBe(false);
it('does not let controller-level @Authenticated bypass handler permissions', () => {
expect(
createContext(
{ permissions: [], isSuperAdmin: false },
{ authenticatedOnly: true, permissions: ['user:edit'] },
),
).toBe(false);
});
it('still enforces policies when @Authenticated and @CheckPolicies coexist', () => {
expect(
createContext(
{ permissions: [], isSuperAdmin: false },
{
authenticatedOnly: true,
checkPolicies: [(ability: any) => ability.can('read', 'Student')],
},
),
).toBe(true);
});
// -----------------------------------------------------------------------
// @CheckPolicies passthrough
// -----------------------------------------------------------------------
it('allows pass-through for @CheckPolicies routes with user present', () => {
expect(
createContext(
{ permissions: [], isSuperAdmin: false },
{ checkPolicies: [(ab: any) => ab.can('read', 'Student')] },
),
).toBe(true);
});
it('denies pass-through for @CheckPolicies routes without user', () => {
expect(
createContext(undefined, {
checkPolicies: [(ab: any) => ab.can('read', 'Student')],
}),
).toBe(false);
});
// -----------------------------------------------------------------------
// CASL exact-code authorization (collision-free)
// -----------------------------------------------------------------------
it('grants access to super admin for any permission', () => {
expect(
createContext({ permissions: [], isSuperAdmin: true }, { permissions: ['student:view'] }),
).toBe(true);
});
it('grants access when user has the exact required permission', () => {
expect(
createContext(
{ permissions: ['student:view'], isSuperAdmin: false },
{ permissions: ['student:view'] },
),
).toBe(true);
});
it('denies access when user lacks the required permission', () => {
expect(
createContext(
{ permissions: ['student:view'], isSuperAdmin: false },
{ permissions: ['class:delete'] },
),
).toBe(false);
});
it('denies access when user has no permissions', () => {
expect(
createContext({ permissions: [], isSuperAdmin: false }, { permissions: ['student:view'] }),
).toBe(false);
});
it('denies unknown permission codes (no user holds them)', () => {
expect(
createContext(
{ permissions: ['unknown:action'], isSuperAdmin: false },
{ permissions: ['other:thing'] },
),
).toBe(false);
});
it('grants exact-code access for custom permissions', () => {
expect(
createContext(
{ permissions: ['custom:special'], isSuperAdmin: false },
{ permissions: ['custom:special'] },
),
).toBe(true);
});
it('grants with OR matching: one of multiple required permissions', () => {
expect(
createContext(
{ permissions: ['class:view'], isSuperAdmin: false },
{ permissions: ['student:delete', 'class:view'] },
),
).toBe(true);
});
// ── Collision regression tests ──
it('denies bill:export-excel when user only has bill:view', () => {
const ability = abilityFactory.createForUser({
permissions: ['bill:view'],
isSuperAdmin: false,
});
// Domain layer: both would give read Bill — but exact-code check must discriminate
expect(ability.can(CaslAction.Read, 'Bill')).toBe(true);
// Exact-code check: bill:view user must NOT have bill:export-excel
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(false);
});
it('denies bill:confirm when user only has bill:view', () => {
const ability = abilityFactory.createForUser({
permissions: ['bill:view'],
isSuperAdmin: false,
});
// Domain layer: confirm → update, view → read — already distinct at domain level
expect(ability.can(CaslAction.Update, 'Bill')).toBe(false);
// Exact-code: must also fail
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:confirm'))).toBe(false);
});
it('denies deposit:approve when user only has deposit:edit', () => {
const ability = abilityFactory.createForUser({
permissions: ['deposit:edit'],
isSuperAdmin: false,
});
// Domain layer: both map to update — would pass domain check
expect(ability.can(CaslAction.Update, 'Deposit')).toBe(true);
// Exact-code: must fail — edit is not approve
expect(ability.can(CaslAction.Access, permissionCodeSubject('deposit:approve'))).toBe(false);
});
it('denies attendance:export when user only has attendance:view', () => {
const ability = abilityFactory.createForUser({
permissions: ['attendance:view'],
isSuperAdmin: false,
});
// Domain layer: both map to read
expect(ability.can(CaslAction.Read, 'Attendance')).toBe(true);
// Exact-code: must fail
expect(ability.can(CaslAction.Access, permissionCodeSubject('attendance:export'))).toBe(false);
});
it('unknown code student:nuke does not create domain ability', () => {
const ability = abilityFactory.createForUser({
permissions: ['student:nuke'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Manage, 'Student')).toBe(false);
expect(ability.can(CaslAction.Read, 'Student')).toBe(false);
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:nuke'))).toBe(true);
});
});

View File

@@ -1,52 +1,92 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CaslAbilityFactory } from '../../authorization/casl-ability.factory';
import { CaslAction, permissionCodeSubject } from '../../authorization/casl.constants';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/permission.decorator';
import { AUTHENTICATED_KEY } from '../decorators/authenticated.decorator';
import { CHECK_POLICIES_KEY } from '../../authorization/decorators/check-policies.decorator';
import type { AuthorizationRequest } from '../../authorization/interfaces';
/**
* 权限守卫 — 默认拒绝策略(安全关键)
* Permission guard — deny-by-default (security-critical).
*
* handler/controller 上不存在 @RequirePermission@Authenticated 且未标记 @Public 时,守卫拒绝访问。
* 所有路由必须显式声明公开、仅登录或所需权限。
* When a handler/controller has no @RequirePermission, @Authenticated,
* @CheckPolicies, or @Public annotation, the guard denies access.
*
* ⚠️ 新增路由时务必添加 @RequirePermission、@Authenticated 或 @Public。
* 建议配合 lint 规则确保无遗漏。
* Authorization is via CASL exact-code matching: each permission code
* the user holds is registered as `Access PermissionCode:<code>`.
* Checking `@RequirePermission('bill:export-excel')` verifies
* `ability.can('access', 'PermissionCode:bill:export-excel')` — a user
* with only `bill:view` will NOT pass.
*/
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private reflector: Reflector) {}
constructor(
private reflector: Reflector,
private abilityFactory: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
// 1. @Public() 豁免
// 1. @Public() exemption
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const request = context.switchToHttp().getRequest<AuthorizationRequest>();
const user = request.user;
// 2. @Authenticated() 只要求已登录,具体 JWT 有效性由 JwtAuthGuard 负责。
// 2. Read @Authenticated, but only use it as a fallback after checking
// more specific permission and policy declarations.
const authenticatedOnly = this.reflector.getAllAndOverride<boolean>(AUTHENTICATED_KEY, [
context.getHandler(),
context.getClass(),
]);
if (authenticatedOnly) return !!user;
// 3. 获取所需权限getAllAndMerge 合并 handler+class 层的所有 metadata
// 3. Get required permissions (handler + class merged)
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [
context.getHandler(),
context.getClass(),
]);
// 无权限声明且非 @Public/@Authenticated默认拒绝避免新增接口意外裸奔
if (!requiredPermissions || requiredPermissions.length === 0) return false;
// 4. 从 JWT payload 获取用户权限
// 4. Check for @CheckPolicies — defer to PoliciesGuard
const hasPolicies = this.reflector.getAllAndMerge<unknown[]>(CHECK_POLICIES_KEY, [
context.getHandler(),
context.getClass(),
]);
const hasCheckPolicies = Array.isArray(hasPolicies) && hasPolicies.length > 0;
// 5. @Authenticated is a fallback only when no more specific authorization
// declaration exists. This prevents controller-level @Authenticated from
// bypassing handler-level @RequirePermission or @CheckPolicies.
if (
(!requiredPermissions || requiredPermissions.length === 0) &&
!hasCheckPolicies &&
authenticatedOnly
) {
return !!user;
}
// No authorization declaration at any level → deny.
if ((!requiredPermissions || requiredPermissions.length === 0) && !hasCheckPolicies) {
return false;
}
// 6. @CheckPolicies present but no @RequirePermission → let PoliciesGuard handle it
if ((!requiredPermissions || requiredPermissions.length === 0) && hasCheckPolicies) {
return !!user; // deny unauthenticated, pass-through for PoliciesGuard
}
// 7. User must exist and have permissions array
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
// 5. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
return requiredPermissions.some((p) => user.permissions.includes(p));
// 8. CASL exact-code check — maps each required code to an exact PermissionCode subject
const ability = this.abilityFactory.createForUser(user);
return requiredPermissions.some((code: string) =>
ability.can(CaslAction.Access, permissionCodeSubject(code)),
);
}
}

View File

@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { CaslAbilityFactory } from './casl-ability.factory';
import { AuthorizationService } from './authorization.service';
import { PoliciesGuard } from './guards/policies.guard';
@Global()
@Module({
providers: [CaslAbilityFactory, AuthorizationService, PoliciesGuard],
exports: [CaslAbilityFactory, AuthorizationService, PoliciesGuard],
})
export class AuthorizationModule {}

View File

@@ -0,0 +1,180 @@
import { ForbiddenException } from '@nestjs/common';
import { AuthorizationService } from './authorization.service';
import { CaslAbilityFactory } from './casl-ability.factory';
import { CaslAction, SubjectName } from './casl.constants';
import { AuthenticatedUser } from './interfaces';
describe('AuthorizationService', () => {
const factory = new CaslAbilityFactory();
const service = new AuthorizationService(factory);
const superAdmin: AuthenticatedUser = {
id: 1,
username: 'admin',
permissions: [],
isSuperAdmin: true,
roles: ['超管'],
};
const teacher: AuthenticatedUser = {
id: 2,
username: 'teacher',
permissions: ['student:view', 'class:view'],
isSuperAdmin: false,
roles: ['老师'],
};
const emptyUserReq = (user: AuthenticatedUser) => ({
user,
});
// -----------------------------------------------------------------------
// HTTP convenience methods
// -----------------------------------------------------------------------
describe('can() — HTTP request convenience', () => {
it('returns true for super admin on any action/subject', () => {
expect(service.can(emptyUserReq(superAdmin), CaslAction.Manage, SubjectName.Student)).toBe(
true,
);
expect(service.can(emptyUserReq(superAdmin), CaslAction.Delete, 'all')).toBe(true);
});
it('returns true for user with matching permission', () => {
expect(service.can(emptyUserReq(teacher), CaslAction.Read, SubjectName.Student)).toBe(true);
});
it('returns false for user without matching permission', () => {
expect(service.can(emptyUserReq(teacher), CaslAction.Create, SubjectName.Student)).toBe(
false,
);
expect(service.can(emptyUserReq(teacher), CaslAction.Read, SubjectName.Bill)).toBe(false);
});
});
describe('assert() — HTTP request convenience', () => {
it('does not throw for super admin', () => {
expect(() =>
service.assert(emptyUserReq(superAdmin), CaslAction.Delete, SubjectName.Room),
).not.toThrow();
});
it('does not throw for user with permission', () => {
expect(() =>
service.assert(emptyUserReq(teacher), CaslAction.Read, SubjectName.Student),
).not.toThrow();
});
it('throws ForbiddenException for user without permission', () => {
expect(() =>
service.assert(emptyUserReq(teacher), CaslAction.Create, SubjectName.Student),
).toThrow(ForbiddenException);
});
});
// -----------------------------------------------------------------------
// Non-HTTP reuse (Agent Tool / background job pattern)
// -----------------------------------------------------------------------
describe('abilityForRequest()', () => {
it('builds ability from request.user', () => {
const ability = service.abilityForRequest(emptyUserReq(teacher));
expect(ability.can(CaslAction.Read, SubjectName.Class)).toBe(true);
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
});
});
describe('canAbility() / assertAbility() — non-HTTP usage', () => {
const ability = factory.createForUser(teacher);
it('canAbility returns boolean', () => {
expect(service.canAbility(ability, CaslAction.Read, SubjectName.Student)).toBe(true);
expect(service.canAbility(ability, CaslAction.Delete, SubjectName.Student)).toBe(false);
});
it('assertAbility throws on denial', () => {
expect(() =>
service.assertAbility(ability, CaslAction.Read, SubjectName.Student),
).not.toThrow();
expect(() => service.assertAbility(ability, CaslAction.Delete, SubjectName.Student)).toThrow(
ForbiddenException,
);
});
it('canAbility and assertAbility work independently of HTTP context', () => {
// This is the key Agent Tool pattern:
// 1. Build ability from a user object (no req needed)
const toolAbility = factory.createForUser({
permissions: ['attendance:view', 'attendance:create'],
isSuperAdmin: false,
});
// 2. Check / assert using the service
expect(service.canAbility(toolAbility, CaslAction.Read, SubjectName.Attendance)).toBe(true);
expect(service.canAbility(toolAbility, CaslAction.Create, SubjectName.Attendance)).toBe(true);
expect(service.canAbility(toolAbility, CaslAction.Delete, SubjectName.Attendance)).toBe(
false,
);
// 3. assertAbility for write operations
expect(() =>
service.assertAbility(toolAbility, CaslAction.Create, SubjectName.Attendance),
).not.toThrow();
expect(() =>
service.assertAbility(toolAbility, CaslAction.Delete, SubjectName.Attendance),
).toThrow(ForbiddenException);
});
});
// -----------------------------------------------------------------------
// canPermission() / assertPermission() — exact-code permission checks
// -----------------------------------------------------------------------
describe('canPermission() / assertPermission() — exact-code checks', () => {
const ability = factory.createForUser(teacher);
it('canPermission returns true for owned exact permission code', () => {
expect(service.canPermission(ability, 'student:view')).toBe(true);
expect(service.canPermission(ability, 'class:view')).toBe(true);
});
it('canPermission returns false for unowned exact permission code', () => {
expect(service.canPermission(ability, 'student:delete')).toBe(false);
expect(service.canPermission(ability, 'bill:view')).toBe(false);
});
it('canPermission uses Access + permissionCodeSubject, not domain action', () => {
// teacher has student:view and class:view. Custom code check is exact.
expect(service.canPermission(ability, 'student:export')).toBe(false);
});
it('assertPermission does not throw for owned code', () => {
expect(() => service.assertPermission(ability, 'student:view')).not.toThrow();
});
it('assertPermission throws ForbiddenException for unowned code', () => {
expect(() => service.assertPermission(ability, 'student:delete')).toThrow(
ForbiddenException,
);
});
it('assertPermission error message includes permission code', () => {
expect(() => service.assertPermission(ability, 'bill:view')).toThrow(
/bill:view/,
);
});
it('super admin canPermission returns true for any code', () => {
const saAbility = factory.createForUser(superAdmin);
expect(service.canPermission(saAbility, 'student:view')).toBe(true);
expect(service.canPermission(saAbility, 'custom:action')).toBe(true);
expect(service.canPermission(saAbility, 'bill:export-excel')).toBe(true);
});
it('super admin assertPermission never throws', () => {
const saAbility = factory.createForUser(superAdmin);
expect(() => service.assertPermission(saAbility, 'ghost:action')).not.toThrow();
});
});
});

View File

@@ -0,0 +1,95 @@
import { Injectable } from '@nestjs/common';
import { ForbiddenException } from '@nestjs/common';
import { CaslAbilityFactory } from './casl-ability.factory';
import { AppAbility, AppSubject, AuthorizationRequest } from './interfaces';
import { CaslAction, permissionCodeSubject } from './casl.constants';
/**
* Generic authorization service usable both inside and outside of HTTP
* request context.
*
* ### HTTP use
* Inject `AuthorizationService` into controllers/services and call
* `abilityForRequest(req)` to get the current user's ability.
*
* ### Non-HTTP use (Agent Tool, background job, etc.)
* Build an ability via `abilityFactory.createForUser(user)` and pass it
* to `assert` / `can` directly.
*/
@Injectable()
export class AuthorizationService {
constructor(private readonly abilityFactory: CaslAbilityFactory) {}
/**
* Build an {@link AppAbility} for the current HTTP request.
*
* @param req — Express/NestJS request with `req.user` populated by JWT.
*/
abilityForRequest(req: AuthorizationRequest): AppAbility {
if (!req.user) {
throw new ForbiddenException('缺少可信授权身份');
}
return this.abilityFactory.createForUser(req.user);
}
/**
* Assert that the given ability allows the action on the subject.
* Throws `ForbiddenException` on denial.
*/
assertAbility(ability: AppAbility, action: CaslAction, subject: AppSubject): void {
if (!ability.can(action, subject)) {
throw new ForbiddenException(
`权限不足:${action} ${typeof subject === 'string' ? subject : 'resource'}`,
);
}
}
/**
* Check whether the given ability allows the action on the subject.
* Returns boolean — never throws.
*/
canAbility(ability: AppAbility, action: CaslAction, subject: AppSubject): boolean {
return ability.can(action, subject);
}
/**
* Check whether the given ability allows the exact permission code.
* Uses `CaslAction.Access` with `permissionCodeSubject(code)` — the same
* mechanism as {@link PermissionGuard}.
*
* Returns boolean — never throws.
*/
canPermission(ability: AppAbility, permissionCode: string): boolean {
return ability.can(CaslAction.Access, permissionCodeSubject(permissionCode));
}
/**
* Assert that the given ability allows the exact permission code.
* Throws `ForbiddenException` on denial.
*/
assertPermission(ability: AppAbility, permissionCode: string): void {
if (!this.canPermission(ability, permissionCode)) {
throw new ForbiddenException(
`权限不足:缺少权限码 ${permissionCode}`,
);
}
}
/**
* Assert that the user (from request) can perform an action.
* Convenience shorthand — builds ability from request.
*/
assert(req: AuthorizationRequest, action: CaslAction, subject: AppSubject): void {
const ability = this.abilityForRequest(req);
this.assertAbility(ability, action, subject);
}
/**
* Check that the user (from request) can perform an action.
* Convenience shorthand — builds ability from request.
*/
can(req: AuthorizationRequest, action: CaslAction, subject: AppSubject): boolean {
const ability = this.abilityForRequest(req);
return this.canAbility(ability, action, subject);
}
}

View File

@@ -0,0 +1,327 @@
import { subject } from '@casl/ability';
import { CaslAbilityFactory } from './casl-ability.factory';
import {
CaslAction,
SubjectName,
permissionCodeSubject,
mapPermissionCode,
isKnownPermissionCode,
} from './casl.constants';
describe('CaslAbilityFactory', () => {
const factory = new CaslAbilityFactory();
// -----------------------------------------------------------------------
it('grants manage all for super admin regardless of permissions list', () => {
const ability = factory.createForUser({
permissions: [],
isSuperAdmin: true,
});
expect(ability.can(CaslAction.Manage, 'all')).toBe(true);
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Delete, SubjectName.Class)).toBe(true);
});
it('super admin ability can manage arbitrary subject strings', () => {
const ability = factory.createForUser({ permissions: [], isSuperAdmin: true });
expect(ability.can(CaslAction.Manage, 'FictionalEntity')).toBe(true);
});
// -----------------------------------------------------------------------
// Exact-code permissions (layer 1 — collision-free)
// -----------------------------------------------------------------------
it('grants exact-code access for specific permission codes', () => {
const ability = factory.createForUser({
permissions: ['bill:view'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:view'))).toBe(true);
});
it('does NOT grant exact-code access for a different code in same domain', () => {
const ability = factory.createForUser({
permissions: ['bill:view'],
isSuperAdmin: false,
});
// bill:view user should NOT have bill:export-excel exact code
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(false);
});
it('does NOT allow export to satisfy view or vice versa', () => {
const viewUser = factory.createForUser({
permissions: ['attendance:view'],
isSuperAdmin: false,
});
const exportUser = factory.createForUser({
permissions: ['attendance:export'],
isSuperAdmin: false,
});
expect(viewUser.can(CaslAction.Access, permissionCodeSubject('attendance:export'))).toBe(false);
expect(exportUser.can(CaslAction.Access, permissionCodeSubject('attendance:view'))).toBe(false);
});
it('does NOT allow edit to satisfy approve on same resource', () => {
const editor = factory.createForUser({
permissions: ['deposit:edit'],
isSuperAdmin: false,
});
expect(editor.can(CaslAction.Access, permissionCodeSubject('deposit:approve'))).toBe(false);
});
it('custom/unknown codes get exact-code ability but NO domain ability', () => {
const ability = factory.createForUser({
permissions: ['student:nuke'],
isSuperAdmin: false,
});
// Exact code should be granted
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:nuke'))).toBe(true);
// But no domain ability should exist
expect(ability.can(CaslAction.Manage, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(false);
});
it('unknown resource custom code gets exact-code but no domain', () => {
const ability = factory.createForUser({
permissions: ['custom:action'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Access, permissionCodeSubject('custom:action'))).toBe(true);
// No domain ability for unknown resource
const hasAnyDomain = [SubjectName.Student, SubjectName.Bill, SubjectName.Class].some((s) =>
ability.can(CaslAction.Read, s),
);
expect(hasAnyDomain).toBe(false);
});
// -----------------------------------------------------------------------
// Domain-level permissions (layer 2 — for service scoping)
// -----------------------------------------------------------------------
it('maps student:view to domain read Student', () => {
const ability = factory.createForUser({
permissions: ['student:view'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
});
it('maps student:edit to domain update Student', () => {
const ability = factory.createForUser({
permissions: ['student:edit'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Update, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(false);
});
it('maps student:create to domain create Student', () => {
const ability = factory.createForUser({
permissions: ['student:create'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(true);
});
it('maps student:delete to domain delete Student', () => {
const ability = factory.createForUser({
permissions: ['student:delete'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(true);
});
it('does not broaden occupancy:checkin into generic create ability', () => {
const ability = factory.createForUser({
permissions: ['occupancy:checkin'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Create, SubjectName.Occupancy)).toBe(false);
});
it('does not broaden bill:export-excel into generic read ability', () => {
const ability = factory.createForUser({
permissions: ['bill:export-excel'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Read, SubjectName.Bill)).toBe(false);
// But exact code is separate
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:export-excel'))).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:view'))).toBe(false);
});
it('cumulative: multiple permissions all apply at both layers', () => {
const ability = factory.createForUser({
permissions: ['student:view', 'room:create', 'bill:delete'],
isSuperAdmin: false,
});
// Domain layer
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Create, SubjectName.Room)).toBe(true);
expect(ability.can(CaslAction.Delete, SubjectName.Bill)).toBe(true);
// Exact-code layer
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('room:create'))).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('bill:delete'))).toBe(true);
});
it('recognizes CASL subject instances instead of treating them as all', () => {
const ability = factory.createForUser({
permissions: ['student:view'],
isSuperAdmin: false,
});
const student = subject(SubjectName.Student, { id: 1, classId: 7 });
expect(ability.can(CaslAction.Read, student)).toBe(true);
expect(ability.can(CaslAction.Update, student)).toBe(false);
});
it('does not let special exact-code permissions grant generic CRUD policies', () => {
const ability = factory.createForUser({
permissions: ['student:import', 'deposit:approve', 'bill:confirm'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Create, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Update, SubjectName.Deposit)).toBe(false);
expect(ability.can(CaslAction.Update, SubjectName.Bill)).toBe(false);
});
it('does not broaden archive into generic delete ability', () => {
const ability = factory.createForUser({
permissions: ['student:archive'],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Delete, SubjectName.Student)).toBe(false);
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:archive'))).toBe(true);
});
// -----------------------------------------------------------------------
// Unknown permission codes
// -----------------------------------------------------------------------
it('silently ignores unknown codes for domain but grants exact-code access', () => {
const ability = factory.createForUser({
permissions: ['unknown:stuff', 'student:view'],
isSuperAdmin: false,
});
// Domain: only known code applies
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
// Exact: both codes get access
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('unknown:stuff'))).toBe(true);
});
// -----------------------------------------------------------------------
// Profile auto-grant
// -----------------------------------------------------------------------
it('does not grant broad Profile read without an explicit permission', () => {
const ability = factory.createForUser({
permissions: [],
isSuperAdmin: false,
});
expect(ability.can(CaslAction.Read, SubjectName.Profile)).toBe(false);
// No exact-code access either
expect(ability.can(CaslAction.Access, permissionCodeSubject('profile:view'))).toBe(false);
});
// -----------------------------------------------------------------------
// createFromPermissions helper
// -----------------------------------------------------------------------
it('createFromPermissions helper works for tests', () => {
const ability = factory.createFromPermissions(['student:view']);
expect(ability.can(CaslAction.Read, SubjectName.Student)).toBe(true);
expect(ability.can(CaslAction.Access, permissionCodeSubject('student:view'))).toBe(true);
});
it('createFromPermissions helper supports isSuperAdmin flag', () => {
const ability = factory.createFromPermissions([], true);
expect(ability.can(CaslAction.Manage, 'all')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Standalone mapping function tests
// ---------------------------------------------------------------------------
describe('mapPermissionCode', () => {
it('maps known codes', () => {
expect(mapPermissionCode('student:view')).toEqual({
action: CaslAction.Read,
subject: SubjectName.Student,
});
});
it('returns null for unknown resource', () => {
expect(mapPermissionCode('ghost:action')).toBeNull();
});
it('returns null for empty string', () => {
expect(mapPermissionCode('')).toBeNull();
});
it('does not map occupancy:checkin to a generic domain action', () => {
expect(mapPermissionCode('occupancy:checkin')).toBeNull();
});
it('does not map bill:export-excel to generic read', () => {
expect(mapPermissionCode('bill:export-excel')).toBeNull();
});
it('does not map bill:generate to generic update', () => {
expect(mapPermissionCode('bill:generate')).toBeNull();
});
it('does not map deposit:approve to generic update', () => {
expect(mapPermissionCode('deposit:approve')).toBeNull();
});
it('does not map attendance:export to generic read', () => {
expect(mapPermissionCode('attendance:export')).toBeNull();
});
it('does not map archive to generic delete', () => {
expect(mapPermissionCode('student:archive')).toBeNull();
});
it('returns null for unknown action on known resource (student:nuke)', () => {
expect(mapPermissionCode('student:nuke')).toBeNull();
});
});
describe('isKnownPermissionCode', () => {
it('recognizes known codes', () => {
expect(isKnownPermissionCode('student:view')).toBe(true);
});
it('rejects unknown resources', () => {
expect(isKnownPermissionCode('ghost:action')).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
import { Injectable } from '@nestjs/common';
import { AbilityBuilder, createMongoAbility, detectSubjectType, MongoAbility } from '@casl/ability';
import { CaslAction, mapPermissionCode, permissionCodeSubject } from './casl.constants';
import { AppAbility, AppSubject, AuthPrincipal } from './interfaces';
/**
* Builds a CASL {@link AppAbility} instance for a given user.
*
* This factory is deliberately free of any NestJS execution-context
* dependency so it can be reused outside of HTTP (e.g. Agent Tool
* execution, background jobs, etc.).
*
* ## Two-layer ability model
*
* | Layer | Condition | Grant |
* |---|---|---|
* | Exact code | Every code in `user.permissions` | `Access PermissionCode:<code>` |
* | Domain | Strict CRUD-equivalent codes only | `(create|read|update|delete) Subject` |
* | Super admin | `isSuperAdmin === true` | `manage('all')` |
*
* Unknown/custom codes get only exact-code ability — no domain ability
* is inferred.
*/
@Injectable()
export class CaslAbilityFactory {
/**
* Build ability for a user loaded from the database (or JWT-refreshed).
*/
createForUser(user: AuthPrincipal): AppAbility {
const { can, build } = new AbilityBuilder<MongoAbility<[CaslAction, AppSubject]>>(
createMongoAbility,
);
// 1. Super admin → manage everything
if (user.isSuperAdmin) {
can(CaslAction.Manage, 'all');
return build({ detectSubjectType });
}
// 2. For every permission code the user holds:
// a) Always add exact-code ability (layer 1)
// b) If code is known, also add domain-level ability (layer 2)
for (const code of user.permissions ?? []) {
// Layer 1 — exact code (always)
can(CaslAction.Access, permissionCodeSubject(code));
// Layer 2 — domain-level (known codes only)
const rule = mapPermissionCode(code);
if (rule) can(rule.action, rule.subject);
}
return build({ detectSubjectType });
}
/**
* Create an ability from raw permission codes — useful in tests.
*/
createFromPermissions(permissions: string[], isSuperAdmin = false): AppAbility {
return this.createForUser({ permissions, isSuperAdmin });
}
}

View File

@@ -0,0 +1,180 @@
/**
* CASL authorization constants.
*
* Maps our existing `{resource}:{action}` permission codes into CASL
* `Action` + `Subject` pairs.
*
* ## Two-layer permission model
*
* 1. **Exact code** — `Access PermissionCode:<code>` grants the specific
* `resource:action` code. Every code a user holds (preset or custom)
* gets an exact-code ability. PermissionGuard checks exact codes.
*
* 2. **Domain level** — only strictly equivalent CRUD codes
* (`view|read|create|edit|update|delete`) create broad Subject abilities.
* Workflow-specific operations remain exact-code-only.
*
* Unknown/custom codes (e.g. "student:nuke") get only layer 1, never
* layer 2 — no domain ability is inferred.
*/
/** CASL action strings. */
export const CaslAction = {
Manage: 'manage',
Create: 'create',
Read: 'read',
Update: 'update',
Delete: 'delete',
/** Check exact permission code (e.g. "bill:export-excel").
* Used by PermissionGuard so workflow-specific operations remain distinct. */
Access: 'access',
} as const;
export type CaslAction = (typeof CaslAction)[keyof typeof CaslAction];
/** Subject names for every entity we protect. */
export const SubjectName = {
all: 'all',
Student: 'Student',
Room: 'Room',
Occupancy: 'Occupancy',
Expense: 'Expense',
Bill: 'Bill',
Deposit: 'Deposit',
Classroom: 'Classroom',
Organization: 'Organization',
ClassRental: 'ClassRental',
Class: 'Class',
Schedule: 'Schedule',
Attendance: 'Attendance',
Dashboard: 'Dashboard',
Profile: 'Profile',
Notification: 'Notification',
OperationLog: 'OperationLog',
User: 'User',
Role: 'Role',
Learning: 'Learning',
Exam: 'Exam',
Sync: 'Sync',
Integration: 'Integration',
Department: 'Department',
AiConfig: 'AiConfig',
} as const;
export type SubjectName = (typeof SubjectName)[keyof typeof SubjectName];
/** Build the exact-code CASL subject string for a permission code. */
export function permissionCodeSubject(code: string): string {
return `PermissionCode:${code}`;
}
// ---------------------------------------------------------------------------
// Domain-level action mapping: permission code → CASL action
// Used ONLY for the domain layer — not for exact-code access checks.
// ---------------------------------------------------------------------------
function permissionToAction(permission: string): CaslAction | null {
const actionSegment = permission.split(':')[1] ?? permission;
// Only strictly equivalent CRUD/read permission codes create broad domain
// abilities. Workflow-specific operations remain exact-code-only so that,
// for example, export cannot satisfy read and approve cannot satisfy update.
switch (actionSegment) {
case 'create':
return CaslAction.Create;
case 'view':
case 'read':
return CaslAction.Read;
case 'edit':
case 'update':
return CaslAction.Update;
case 'delete':
return CaslAction.Delete;
default:
return null;
}
}
function permissionToSubject(resource: string): SubjectName | null {
switch (resource) {
case 'dashboard':
return SubjectName.Dashboard;
case 'profile':
return SubjectName.Profile;
case 'notification':
return SubjectName.Notification;
case 'student':
return SubjectName.Student;
case 'room':
return SubjectName.Room;
case 'occupancy':
return SubjectName.Occupancy;
case 'expense':
return SubjectName.Expense;
case 'bill':
return SubjectName.Bill;
case 'deposit':
return SubjectName.Deposit;
case 'classroom':
return SubjectName.Classroom;
case 'organization':
return SubjectName.Organization;
case 'rental':
return SubjectName.ClassRental;
case 'log':
return SubjectName.OperationLog;
case 'user':
return SubjectName.User;
case 'role':
return SubjectName.Role;
case 'class':
return SubjectName.Class;
case 'schedule':
return SubjectName.Schedule;
case 'attendance':
return SubjectName.Attendance;
case 'learning':
return SubjectName.Learning;
case 'exam':
return SubjectName.Exam;
case 'sync':
return SubjectName.Sync;
case 'integration':
return SubjectName.Integration;
case 'department':
return SubjectName.Department;
case 'ai':
return SubjectName.AiConfig;
default:
return null;
}
}
export interface AbilityPermissionRule {
action: CaslAction;
subject: SubjectName;
}
/**
* Map a known `resource:action` permission code to a domain-level
* CASL rule, or `null` if the resource segment is unrecognised.
*
* Domain-level rules are used by services for data-scoping checks.
* They are NOT used for exact-code access control — use
* {@link permissionCodeSubject} for that.
*/
export function mapPermissionCode(code: string): AbilityPermissionRule | null {
const [resource] = code.split(':');
const subject = permissionToSubject(resource ?? '');
if (!subject) return null;
const action = permissionToAction(code);
if (!action) return null;
return { action, subject };
}
/**
* Whether the permission code is "known" — i.e. the resource maps to a
* recognised subject.
*/
export function isKnownPermissionCode(code: string): boolean {
const [resource] = code.split(':');
return permissionToSubject(resource ?? '') !== null;
}

View File

@@ -0,0 +1,31 @@
import { SetMetadata } from '@nestjs/common';
import { PolicyHandler } from '../interfaces';
export const CHECK_POLICIES_KEY = 'check_policies';
/**
* Declare CASL-based policy requirements on a route handler or controller.
*
* Handlers are evaluated with AND semantics — every handler must pass
* for the request to be allowed.
*
* ### Usage — callback handler
* ```ts
* @CheckPolicies((ability) => ability.can('read', 'Student'))
* ```
*
* ### Usage — class-based handler (prefer this for testability)
* ```ts
* import { IPolicyHandler } from '../interfaces';
*
* class ReadStudentPolicyHandler implements IPolicyHandler {
* handle(ability: AppAbility) {
* return ability.can('read', 'Student');
* }
* }
*
* @CheckPolicies(new ReadStudentPolicyHandler())
* ```
*/
export const CheckPolicies = (...handlers: PolicyHandler[]) =>
SetMetadata(CHECK_POLICIES_KEY, handlers);

View File

@@ -0,0 +1,201 @@
import { PoliciesGuard } from './policies.guard';
import { CaslAbilityFactory } from '../casl-ability.factory';
import { AppAbility, IPolicyHandler } from '../interfaces';
describe('PoliciesGuard', () => {
const factory = new CaslAbilityFactory();
/** Build a mock NestJS ExecutionContext for PoliciesGuard */
function createContext(
user: unknown,
opts: {
policyHandlers?: Array<((ability: AppAbility) => boolean) | IPolicyHandler> | null;
controllerPolicyHandlers?: Array<((ability: AppAbility) => boolean) | IPolicyHandler>;
isPublic?: boolean;
} = {},
) {
const meta = new Map<string, unknown>();
if (opts.policyHandlers !== undefined) meta.set('check_policies', opts.policyHandlers);
if (opts.isPublic !== undefined) meta.set('isPublic', opts.isPublic);
const reflector = {
getAllAndOverride: jest.fn((key: string) => meta.get(key) ?? undefined),
getAllAndMerge: jest.fn((key: string) => {
if (key !== 'check_policies') return [];
return [...(opts.policyHandlers ?? []), ...(opts.controllerPolicyHandlers ?? [])];
}),
};
const guard = new PoliciesGuard(reflector as never, factory);
return guard.canActivate({
getHandler: () => function handler() {},
getClass: () => class Controller {},
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as never);
}
// -----------------------------------------------------------------------
// No @CheckPolicies → pass-through
// -----------------------------------------------------------------------
it('passes through when no @CheckPolicies is declared', () => {
expect(createContext(undefined)).toBe(true);
expect(createContext(null)).toBe(true);
});
// -----------------------------------------------------------------------
// @Public interaction
// -----------------------------------------------------------------------
it('skips when @Public is declared, even with @CheckPolicies', () => {
expect(
createContext(undefined, {
policyHandlers: [(ability) => ability.can('read', 'Student')],
isPublic: true,
}),
).toBe(true);
});
// -----------------------------------------------------------------------
// User absent
// -----------------------------------------------------------------------
it('denies when @CheckPolicies is declared but no user present', () => {
expect(
createContext(undefined, {
policyHandlers: [(ability) => ability.can('read', 'Student')],
}),
).toBe(false);
});
// -----------------------------------------------------------------------
// Callback handlers
// -----------------------------------------------------------------------
it('grants when all policies pass for super admin', () => {
expect(
createContext(
{ permissions: [], isSuperAdmin: true },
{
policyHandlers: [
(ability) => ability.can('read', 'Student'),
(ability) => ability.can('delete', 'Class'),
],
},
),
).toBe(true);
});
it('grants when all policies pass for user with correct permissions', () => {
expect(
createContext(
{ permissions: ['student:view', 'class:view'], isSuperAdmin: false },
{
policyHandlers: [
(ability) => ability.can('read', 'Student'),
(ability) => ability.can('read', 'Class'),
],
},
),
).toBe(true);
});
it('denies when any policy fails (AND semantics)', () => {
expect(
createContext(
{ permissions: ['student:view'], isSuperAdmin: false },
{
policyHandlers: [
(ability) => ability.can('read', 'Student'), // passes
(ability) => ability.can('delete', 'Student'), // fails
],
},
),
).toBe(false);
});
it('empty handlers array passes (no policies to check)', () => {
expect(
createContext({ permissions: ['student:view'], isSuperAdmin: false }, { policyHandlers: [] }),
).toBe(true);
});
it('merges controller and handler policies with AND semantics', () => {
expect(
createContext(
{ permissions: ['student:view'], isSuperAdmin: false },
{
policyHandlers: [(ability) => ability.can('read', 'Student')],
controllerPolicyHandlers: [(ability) => ability.can('read', 'Class')],
},
),
).toBe(false);
});
// -----------------------------------------------------------------------
// Class-based handlers
// -----------------------------------------------------------------------
it('supports class-based policy handlers', () => {
class ReadStudentPolicy implements IPolicyHandler {
handle(ability: AppAbility): boolean {
return ability.can('read', 'Student');
}
}
expect(
createContext(
{ permissions: ['student:view'], isSuperAdmin: false },
{ policyHandlers: [new ReadStudentPolicy()] },
),
).toBe(true);
});
it('denies when class-based handler fails', () => {
class DeleteStudentPolicy implements IPolicyHandler {
handle(ability: AppAbility): boolean {
return ability.can('delete', 'Student');
}
}
expect(
createContext(
{ permissions: ['student:view'], isSuperAdmin: false },
{ policyHandlers: [new DeleteStudentPolicy()] },
),
).toBe(false);
});
it('mixes callback and class-based handlers', () => {
class ReadStudentPolicy implements IPolicyHandler {
handle(ability: AppAbility): boolean {
return ability.can('read', 'Student');
}
}
expect(
createContext(
{ permissions: ['student:view', 'class:view'], isSuperAdmin: false },
{
policyHandlers: [new ReadStudentPolicy(), (ability) => ability.can('read', 'Class')],
},
),
).toBe(true);
});
// -----------------------------------------------------------------------
// Instance-level policy
// -----------------------------------------------------------------------
it('custom instance-level policy: checks specific resource conditions', () => {
const ability = factory.createForUser({
permissions: ['student:edit'],
isSuperAdmin: false,
});
const ownsResource = (ab: typeof ability) => ab.can('update', 'Student');
expect(ownsResource(ability)).toBe(true);
});
});

View File

@@ -0,0 +1,79 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CHECK_POLICIES_KEY } from '../decorators/check-policies.decorator';
import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator';
import { CaslAbilityFactory } from '../casl-ability.factory';
import { AppAbility, AuthorizationRequest, PolicyHandler } from '../interfaces';
/**
* Evaluates CASL policies declared via @CheckPolicies().
*
* Registered as a global APP_GUARD — runs after JwtAuthGuard and
* PermissionGuard in the guard chain. Only activates when a route
* carries @CheckPolicies metadata.
*
* ## Interaction with other guards
*
* - @Public() → PoliciesGuard skips (same as other guards).
* - @CheckPolicies alone (no @RequirePermission) → PermissionGuard
* passes through if user is authenticated; PoliciesGuard evaluates.
* - @CheckPolicies + @RequirePermission → both guards run independently;
* both must pass.
*
* ## Handler types
*
* Two forms are supported:
*
* ```ts
* // Callback form
* @CheckPolicies((ability) => ability.can('read', 'Student'))
*
* // Class-based form (testable, NestJS official pattern)
* class ReadStudentPolicyHandler implements IPolicyHandler {
* handle(ability: AppAbility) { return ability.can('read', 'Student'); }
* }
* @CheckPolicies(new ReadStudentPolicyHandler())
* ```
*/
@Injectable()
export class PoliciesGuard implements CanActivate {
constructor(
private reflector: Reflector,
private abilityFactory: CaslAbilityFactory,
) {}
canActivate(context: ExecutionContext): boolean {
// @Public() routes skip all authorization
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const handlers = this.reflector.getAllAndMerge<PolicyHandler[]>(CHECK_POLICIES_KEY, [
context.getHandler(),
context.getClass(),
]);
// No @CheckPolicies → let other guards decide
if (!handlers || handlers.length === 0) return true;
const request = context.switchToHttp().getRequest<AuthorizationRequest>();
const user = request.user;
if (!user) return false;
const ability = this.abilityFactory.createForUser(user);
return handlers.every((handler) => this.execHandler(handler, ability));
}
/**
* Execute a policy handler — supports both callback and class-based forms.
*/
private execHandler(handler: PolicyHandler, ability: AppAbility): boolean {
if (typeof handler === 'function') {
return handler(ability);
}
return handler.handle(ability);
}
}

View File

@@ -0,0 +1,14 @@
export { AuthorizationModule } from './authorization.module';
export { CaslAbilityFactory } from './casl-ability.factory';
export { AuthorizationService } from './authorization.service';
export { PoliciesGuard } from './guards/policies.guard';
export { CheckPolicies } from './decorators/check-policies.decorator';
export { CaslAction, SubjectName, mapPermissionCode } from './casl.constants';
export type {
AppAbility,
AppSubject,
AuthenticatedUser,
AuthPrincipal,
PolicyHandler,
IPolicyHandler,
} from './interfaces';

View File

@@ -0,0 +1,67 @@
import { MongoAbility } from '@casl/ability';
import { CaslAction } from './casl.constants';
// ---------------------------------------------------------------------------
// Subject type union — all entity classes we protect with CASL.
// ---------------------------------------------------------------------------
// CASL expects the subject to be either the class constructor or a string.
// We use string subjects (SubjectName) for simplicity when no instance is
// available, and concrete instance types for per-resource checks.
export type AppSubject = string | Record<string, unknown>;
export type AppAbility = MongoAbility<[CaslAction, AppSubject]>;
// ---------------------------------------------------------------------------
// Authenticated user — what the JWT strategy places on `request.user`.
// ---------------------------------------------------------------------------
export interface AuthenticatedUser {
id: number;
username: string;
/** Flat list of `resource:action` permission codes. */
permissions: string[];
/** Whether the user has a super-admin role. */
isSuperAdmin: boolean;
/** Role names (display/debug only — NEVER used for authorization). */
roles: string[];
}
/**
* Minimum authorization principal — the subset of AuthenticatedUser
* needed by CaslAbilityFactory and AuthorizationService.
*/
export type AuthPrincipal = { readonly permissions: readonly string[]; readonly isSuperAdmin: boolean };
/** Request-like carrier populated only by the trusted authentication layer. */
export interface AuthorizationRequest {
user?: AuthPrincipal;
}
// ---------------------------------------------------------------------------
// Policy handler types for @CheckPolicies()
// ---------------------------------------------------------------------------
/**
* Interface for class-based policy handlers.
*
* Implement this interface in a class to create a testable,
* NestJS-official CASL policy handler:
*
* ```ts
* class ReadStudentPolicyHandler implements IPolicyHandler {
* handle(ability: AppAbility) {
* return ability.can('read', 'Student');
* }
* }
* ```
*/
export interface IPolicyHandler {
handle(ability: AppAbility): boolean;
}
/**
* A policy handler — either a callback or a class implementing
* {@link IPolicyHandler}.
*/
export type PolicyHandler = ((ability: AppAbility) => boolean) | IPolicyHandler;

View File

@@ -30,15 +30,10 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
import * as ExcelJS from 'exceljs';
interface RequestUser {
id: number;
permissions?: string[];
isSuperAdmin?: boolean;
}
import { AuthorizationService, CaslAction, SubjectName, AuthenticatedUser } from '../authorization';
interface AuthenticatedRequest {
user: RequestUser;
user: AuthenticatedUser;
}
@UseGuards(JwtAuthGuard)
@@ -48,11 +43,14 @@ export class ClassesController {
private readonly service: ClassesService,
private readonly logService: OperationLogsService,
private readonly notificationsService: NotificationsService,
private readonly authz: AuthorizationService,
) {}
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
// Legacy: Manage (super_admin) or Update (class:edit) grants broad class access
const canManageAll =
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true;
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
this.authz.can(req, CaslAction.Update, SubjectName.Class);
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
}
@@ -61,7 +59,8 @@ export class ClassesController {
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true,
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
this.authz.can(req, CaslAction.Update, SubjectName.Class),
);
return this.service.findAll(query, classIds);
}

View File

@@ -1,13 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping } from '../entities';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping } from '../entities';
import { ClassesService } from './classes.service';
import { ClassesController } from './classes.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
controllers: [ClassesController],
providers: [ClassesService],
exports: [ClassesService],

View File

@@ -2,6 +2,7 @@ import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@@ -12,6 +13,7 @@ import {
ClassTeacher,
ClassSchedule,
AttendanceRecord,
AttendanceSession,
Classroom,
Student,
StudentDingMapping,
@@ -45,6 +47,8 @@ export class ClassesService {
private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord)
private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(AttendanceSession)
private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping)
@@ -64,7 +68,7 @@ export class ClassesService {
}
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
let where: Record<string, unknown> = {};
const where: Record<string, unknown> = {};
if (query.status) where.status = query.status;
if (query.classType) where.classType = query.classType;
if (query.keyword) where.name = Like(`%${query.keyword}%`);
@@ -288,6 +292,16 @@ export class ClassesService {
const cls = await this.classRepo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('班级不存在');
if (!cls.isArchived) throw new BadRequestException('请先归档再删除');
const sessionCount = await this.attendanceSessionRepo.count({
where: { classId: id },
});
if (sessionCount > 0) {
throw new ConflictException(
`无法删除已产生 ${sessionCount} 个考勤场次的班级。请先取消或停用班级以保护历史考勤数据。`,
);
}
await this.classRepo.remove(cls);
return { success: true };
}

View File

@@ -326,7 +326,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
...existingRental,
startDate: '2026-04-01',
endDate: '2026-04-30',
} as ClassroomRental;
};
const existingSchedule = {
id: 50,
rentalId: 1,
@@ -372,7 +372,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
status: 'active',
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
} as ClassroomRental;
const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental;
const cancelledRental = { ...rental, status: 'cancelled' };
rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental);
@@ -449,7 +449,7 @@ describe('ClassroomRentalsService — organization roles', () => {
lesseeOrganizationId: 2,
startDate: '2026-08-01',
endDate: '2026-08-31',
} as any);
});
expect(rentalRepo.save).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -546,7 +546,6 @@ export class ClassroomRentalsService {
floor: c.floor,
roomType: c.roomType,
capacity: c.capacity,
supervisor: c.supervisor,
})),
organizations: Array.from(organizationMap.values()),
matrix,

View File

@@ -0,0 +1,7 @@
import { CLASSROOM_TEMPLATE_HEADERS } from './classroom-template';
describe('classroom import template', () => {
it('contains only classroom fields used by the product', () => {
expect(CLASSROOM_TEMPLATE_HEADERS).toEqual(['教室名', '楼栋', '楼层', '类型', '容量']);
});
});

View File

@@ -0,0 +1,9 @@
export const CLASSROOM_TEMPLATE_COLUMNS = [
{ header: '教室名', key: 'name', width: 15 },
{ header: '楼栋', key: 'building', width: 12 },
{ header: '楼层', key: 'floor', width: 8 },
{ header: '类型', key: 'roomType', width: 10 },
{ header: '容量', key: 'capacity', width: 10 },
];
export const CLASSROOM_TEMPLATE_HEADERS = CLASSROOM_TEMPLATE_COLUMNS.map(({ header }) => header);

View File

@@ -22,6 +22,7 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import * as ExcelJS from 'exceljs';
import { CLASSROOM_TEMPLATE_COLUMNS } from './classroom-template';
@UseGuards(JwtAuthGuard)
@Controller('classrooms')
@@ -50,15 +51,7 @@ export class ClassroomsController {
async downloadTemplate(@Res() res: Response) {
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('教室导入模板');
ws.columns = [
{ header: '教室名', key: 'name', width: 15 },
{ header: '楼栋', key: 'building', width: 12 },
{ header: '楼层', key: 'floor', width: 8 },
{ header: '类型', key: 'roomType', width: 10 },
{ header: '容量', key: 'capacity', width: 10 },
{ header: '课程类型', key: 'courseType', width: 16 },
{ header: '负责人', key: 'supervisor', width: 12 },
];
ws.columns = CLASSROOM_TEMPLATE_COLUMNS;
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({
@@ -67,8 +60,6 @@ export class ClassroomsController {
floor: 2,
roomType: '大',
capacity: 60,
courseType: '尊享培优班',
supervisor: '张老师',
});
ws.addRow({
name: 'B301',
@@ -76,8 +67,6 @@ export class ClassroomsController {
floor: 3,
roomType: '次大',
capacity: 40,
courseType: '专业课集训班',
supervisor: '李老师',
});
ws.addRow({
name: 'B405',
@@ -85,8 +74,6 @@ export class ClassroomsController {
floor: 4,
roomType: '小',
capacity: 20,
courseType: '',
supervisor: '',
});
// 说明sheet
@@ -97,8 +84,6 @@ export class ClassroomsController {
'1. 教室名必填,建议采用「楼栋+房号」如 A201、B301',
'2. 类型可填 大 / 次大 / 小,为空默认「大」',
'3. 同名教室会自动跳过(不覆盖)',
'4. 课程类型可填尊享培优班、专业课集训班等产品班级',
'5. 负责人为班主任/对接人',
].forEach((note) => ws2.addRow({ note }));
res.setHeader(
@@ -207,8 +192,6 @@ export class ClassroomsController {
floor: Number(row.getCell(3).value) || undefined,
roomType: String(row.getCell(4).value || '') || undefined,
capacity: Number(row.getCell(5).value) || undefined,
courseType: String(row.getCell(6).value || '') || undefined,
supervisor: String(row.getCell(7).value || '') || undefined,
});
});
const result = await this.service.batchImport(rows);

View File

@@ -130,7 +130,6 @@ export class ClassroomsService {
floor?: number;
capacity?: number;
roomType?: string;
courseType?: string;
}[],
) {
let imported = 0;

View File

@@ -21,13 +21,6 @@ export class CreateClassroomDto {
@IsString()
roomType?: string; // 大 / 次大 / 小
@IsOptional()
@IsString()
courseType?: string;
@IsOptional()
@IsString()
supervisor?: string;
@IsOptional()
@IsString()
@@ -56,13 +49,6 @@ export class UpdateClassroomDto {
@IsString()
roomType?: string;
@IsOptional()
@IsString()
courseType?: string;
@IsOptional()
@IsString()
supervisor?: string;
@IsOptional()
@IsString()

View File

@@ -1,36 +1,45 @@
import { Controller, Get, Query, Request, UseGuards } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import {
AuthorizationService,
CaslAction,
SubjectName,
} from '../authorization';
import { RequirePermission } from '../auth/decorators/permission.decorator';
interface RequestUser {
id: number;
username: string;
permissions?: string[];
isSuperAdmin?: boolean;
permissions: string[];
isSuperAdmin: boolean;
}
@UseGuards(JwtAuthGuard)
@RequirePermission('dashboard:view')
@Controller('dashboard')
export class DashboardController {
constructor(private service: DashboardService) {}
constructor(
private service: DashboardService,
private readonly authService: AuthorizationService,
) {}
private canManageAllDashboard(user: RequestUser): boolean {
private canManageAllDashboard(req: { user: RequestUser }): boolean {
const ability = this.authService.abilityForRequest(req);
// Legacy: class:edit grants broad dashboard access
return (
user.isSuperAdmin === true ||
user.permissions?.includes('dashboard:manage') === true ||
user.permissions?.includes('class:edit') === true
ability.can(CaslAction.Manage, SubjectName.Dashboard) ||
ability.can(CaslAction.Update, SubjectName.Class)
);
}
private getAccessibleClassIds(user: RequestUser) {
return this.service.getAccessibleClassIds(user.id, this.canManageAllDashboard(user));
private getAccessibleClassIds(req: { user: RequestUser }) {
return this.service.getAccessibleClassIds(req.user.id, this.canManageAllDashboard(req));
}
@Get('stats')
async getStats(@Request() req: { user: RequestUser }) {
return this.service.getStats(await this.getAccessibleClassIds(req.user));
return this.service.getStats(await this.getAccessibleClassIds(req));
}
@Get('gantt')
@@ -60,7 +69,7 @@ export class DashboardController {
@Get('class-attendance-ranking')
async getClassAttendanceRanking(@Request() req: { user: RequestUser }) {
return this.service.getClassAttendanceRanking(await this.getAccessibleClassIds(req.user));
return this.service.getClassAttendanceRanking(await this.getAccessibleClassIds(req));
}
@Get('classroom-occupancy')

View File

@@ -32,7 +32,7 @@ describe('DashboardService — teacher class scope', () => {
{} as never,
{} as never,
{} as never,
{} as never,
{},
);
await service.getClassAttendanceRanking([8, 9]);

View File

@@ -0,0 +1,380 @@
import Database from 'better-sqlite3';
type SqliteDB = InstanceType<typeof Database>;
/**
* Real SQLite foreign-key constraint tests.
*
* These tests use the `better-sqlite3` driver directly (in-memory) to verify
* that ON DELETE RESTRICT is enforced at the database level, not just in
* application-layer guards.
*/
describe('attendance_sessions FK RESTRICT — real SQLite', () => {
let db: SqliteDB;
function createSchema(): void {
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_archived INTEGER DEFAULT 0
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS class_schedule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER,
week_day INTEGER NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS attendance_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status TEXT DEFAULT 'in_progress',
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
)
`);
}
beforeEach(() => {
db = new Database(':memory:');
createSchema();
});
afterEach(() => {
db.close();
});
it('blocks class deletion when attendance sessions reference it', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
db.exec(
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
);
expect(() => {
db.exec('DELETE FROM classes WHERE id = 1');
}).toThrow();
});
it('allows class deletion when no attendance sessions reference it', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
expect(() => {
db.exec('DELETE FROM classes WHERE id = 1');
}).not.toThrow();
const remaining = db.prepare('SELECT COUNT(*) as cnt FROM classes').get() as {
cnt: number;
};
expect(remaining.cnt).toBe(0);
});
it('blocks schedule deletion when attendance sessions reference it', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
db.exec(
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
);
expect(() => {
db.exec('DELETE FROM class_schedule WHERE id = 1');
}).toThrow();
});
it('PRAGMA foreign_key_list confirms both FKs are present', () => {
// Use raw SQL PRAGMA to avoid better-sqlite3 pragma API quirks
const rows = db.prepare("PRAGMA foreign_key_list('attendance_sessions')").all() as Array<{
id: number;
seq: number;
table: string;
from: string;
to: string;
on_update: string;
on_delete: string;
match: string;
}>;
expect(rows.length).toBe(2);
const scheduleFk = rows.find((fk) => fk.from === 'schedule_id');
expect(scheduleFk).toBeDefined();
expect(scheduleFk!.table).toBe('class_schedule');
expect(scheduleFk!.on_delete).toBe('RESTRICT');
const classFk = rows.find((fk) => fk.from === 'class_id');
expect(classFk).toBeDefined();
expect(classFk!.table).toBe('classes');
expect(classFk!.on_delete).toBe('RESTRICT');
});
it('FK pragma respects ON DELETE RESTRICT for class_id — data survives failed delete', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
db.exec(
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
);
// Verify the session exists
const session = db
.prepare('SELECT * FROM attendance_sessions WHERE class_id = 1')
.get() as Record<string, unknown>;
expect(session).toBeDefined();
// Delete should fail
expect(() => db.exec('DELETE FROM classes WHERE id = 1')).toThrow();
// Session should still exist after failed delete
const after = db
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE class_id = 1')
.get() as { cnt: number };
expect(after.cnt).toBe(1);
});
});
/**
* Integration test: simulate the protectAttendanceHistory SQLite migration.
*
* Creates tables WITHOUT foreign keys (pre-migration state), inserts parent
* session and child attendance_record, runs the table-rebuild migration
* (PRAGMA foreign_keys=OFF, rebuild both tables, PRAGMA foreign_keys=ON,
* foreign_key_check), then verifies:
* - attendance_record.attendance_session_id is preserved
* - RESTRICT still blocks class/schedule deletion
*/
describe('protectAttendanceHistory SQLite migration — integration', () => {
let db: SqliteDB;
function createPreMigrationSchema(): void {
// Schema WITHOUT foreign keys on attendance_sessions (pre-migration)
db.exec('PRAGMA foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS classes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
is_archived INTEGER DEFAULT 0
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS class_schedule (
id INTEGER PRIMARY KEY AUTOINCREMENT,
class_id INTEGER,
week_day INTEGER NOT NULL
)
`);
// attendance_sessions WITHOUT foreign keys
db.exec(`
CREATE TABLE IF NOT EXISTS attendance_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status TEXT DEFAULT 'in_progress',
started_by INTEGER,
started_at DATETIME,
completed_by INTEGER,
completed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`);
// Legacy columns came first; course-attendance columns were appended later.
db.exec(`
CREATE TABLE IF NOT EXISTS attendance_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
class_id INTEGER,
attendance_date DATE NOT NULL,
session VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
remark VARCHAR(200),
source VARCHAR(20) DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
schedule_id INTEGER,
attendance_session_id INTEGER
)
`);
}
function runMigration(): void {
// Step 1: PRAGMA foreign_keys = OFF outside transaction
db.exec('PRAGMA foreign_keys = OFF');
try {
db.exec('BEGIN');
try {
// Rebuild attendance_sessions with FKs
db.exec(`
CREATE TABLE attendance_sessions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
started_by INTEGER,
started_at DATETIME,
completed_by INTEGER,
completed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
)
`);
db.exec(
'INSERT INTO attendance_sessions_new SELECT * FROM attendance_sessions',
);
db.exec('DROP TABLE attendance_sessions');
db.exec(
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
);
db.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
);
// Rebuild attendance_records with FK on attendance_session_id
const recordsFk = db
.prepare("PRAGMA foreign_key_list('attendance_records')")
.all() as Array<{ from: string }>;
const hasSessionFk = recordsFk.some((r) => r.from === 'attendance_session_id');
if (!hasSessionFk) {
db.exec(`
CREATE TABLE attendance_records_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
class_id INTEGER,
schedule_id INTEGER,
attendance_session_id INTEGER,
attendance_date DATE NOT NULL,
session VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
remark VARCHAR(200),
source VARCHAR(20) DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL
)
`);
db.exec(`
INSERT INTO attendance_records_new (
id, student_id, class_id, schedule_id, attendance_session_id,
attendance_date, session, status, remark, source, created_at, updated_at
)
SELECT
id, student_id, class_id, schedule_id, attendance_session_id,
attendance_date, session, status, remark, source, created_at, updated_at
FROM attendance_records
`);
db.exec('DROP TABLE attendance_records');
db.exec(
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
);
db.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
);
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
} finally {
db.exec('PRAGMA foreign_keys = ON');
}
// Run foreign_key_check — should be clean
const checkRows = db.prepare('PRAGMA foreign_key_check').all();
if (checkRows.length > 0) {
throw new Error(
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
);
}
}
beforeEach(() => {
db = new Database(':memory:');
createPreMigrationSchema();
});
afterEach(() => {
db.close();
});
it('preserves attendance_record.session_id after migration', () => {
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
db.exec(
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
);
db.exec(
"INSERT INTO attendance_records (id, student_id, class_id, attendance_session_id, attendance_date, session, status) VALUES (1, 1, 1, 1, '2026-01-01', 'morning', 'present')",
);
// Verify pre-migration state
const preSessionFk = db
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
.all();
expect(preSessionFk.length).toBe(0);
const preRecordsFk = db
.prepare("PRAGMA foreign_key_list('attendance_records')")
.all();
expect(preRecordsFk.length).toBe(0);
// Run migration
runMigration();
// Verify attendance_record still has correct attendance_session_id
const record = db
.prepare('SELECT * FROM attendance_records WHERE id = 1')
.get() as Record<string, unknown>;
expect(record).toBeDefined();
expect(record.attendance_session_id).toBe(1);
expect(record.attendance_date).toBe('2026-01-01');
expect(record.session).toBe('morning');
expect(record.status).toBe('present');
// Verify FKs now exist on both tables
const postSessionFk = db
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
.all();
expect(postSessionFk.length).toBe(2);
const postRecordsFk = db
.prepare("PRAGMA foreign_key_list('attendance_records')")
.all() as Array<{ from: string; table: string; on_delete: string }>;
const sessionFk = postRecordsFk.find((r) => r.from === 'attendance_session_id');
expect(sessionFk).toBeDefined();
expect(sessionFk!.table).toBe('attendance_sessions');
expect(sessionFk!.on_delete).toBe('SET NULL');
// RESTRICT still blocks class/schedule deletion
expect(() => {
db.exec('DELETE FROM classes WHERE id = 1');
}).toThrow();
expect(() => {
db.exec('DELETE FROM class_schedule WHERE id = 1');
}).toThrow();
// Verify data survived the failed deletes
const sessionAfter = db
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE id = 1')
.get() as { cnt: number };
expect(sessionAfter.cnt).toBe(1);
const recordAfter = db
.prepare('SELECT COUNT(*) as cnt FROM attendance_records WHERE id = 1')
.get() as { cnt: number };
expect(recordAfter.cnt).toBe(1);
const classAfter = db
.prepare('SELECT COUNT(*) as cnt FROM classes WHERE id = 1')
.get() as { cnt: number };
expect(classAfter.cnt).toBe(1);
});
});

View File

@@ -1,5 +1,5 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { DataSource, QueryRunner } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
@Injectable()
@@ -9,8 +9,181 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
constructor(private readonly dataSource: DataSource) {}
async onApplicationBootstrap(): Promise<void> {
await this.ensureAiConfigTable();
await this.ensureCourseAttendanceSchema();
await this.backfillOrganizations();
await this.normalizeClassDates();
await this.protectAttendanceHistory();
await this.removeUnusedClassroomColumns();
}
private async removeUnusedClassroomColumns(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['classrooms']);
if (tables.length === 0) return;
const table = await runner.getTable('classrooms');
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
for (const columnName of ['course_type', 'supervisor']) {
if (columnNames.has(columnName)) {
await runner.query(`ALTER TABLE classrooms DROP COLUMN ${columnName}`);
}
}
} finally {
await runner.release();
}
}
private async ensureAiConfigTable(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['ai_config']);
const isMySQL = this.dataSource.options.type === 'mysql';
if (tables.length === 0) {
const pkDef = isMySQL
? 'id INTEGER PRIMARY KEY AUTO_INCREMENT'
: 'id INTEGER PRIMARY KEY AUTOINCREMENT';
const boolType = isMySQL ? 'TINYINT(1)' : 'BOOLEAN';
const datetimeFn = isMySQL ? 'CURRENT_TIMESTAMP' : 'CURRENT_TIMESTAMP';
await runner.query(`
CREATE TABLE ai_config (
${pkDef},
singleton_key VARCHAR(20) NOT NULL DEFAULT 'GLOBAL',
provider VARCHAR(50) NOT NULL DEFAULT 'OPENAI',
base_url VARCHAR(500),
encrypted_api_key TEXT,
api_key_iv VARCHAR(50),
api_key_auth_tag VARCHAR(50),
key_last4 VARCHAR(4),
default_model VARCHAR(100),
enabled ${boolType} DEFAULT 0,
timeout_ms INT DEFAULT 30000,
verified ${boolType} DEFAULT 0,
last_tested_at DATETIME,
last_test_latency_ms INT,
created_at DATETIME NOT NULL DEFAULT ${datetimeFn},
updated_at DATETIME NOT NULL DEFAULT ${datetimeFn}
)
`);
if (isMySQL) {
try {
await runner.query(
'CREATE UNIQUE INDEX uq_ai_config_singleton ON ai_config(singleton_key)',
);
} catch {
// Index may already exist; MySQL has no IF NOT EXISTS for indexes
}
} else {
await runner.query(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton ON ai_config(singleton_key)',
);
}
this.logger.log('已创建 ai_config 表');
} else {
// Check for missing columns
const table = await runner.getTable('ai_config');
const columnNames = new Set(table?.columns.map((c) => c.name) ?? []);
const desiredColumns: Array<{ name: string; def: string }> = [
{ name: 'id', def: '' }, // skip — primary key
{ name: 'singleton_key', def: "VARCHAR(20) NOT NULL DEFAULT 'GLOBAL'" },
{ name: 'provider', def: "VARCHAR(50) NOT NULL DEFAULT 'OPENAI'" },
{ name: 'base_url', def: 'VARCHAR(500)' },
{ name: 'encrypted_api_key', def: 'TEXT' },
{ name: 'api_key_iv', def: 'VARCHAR(50)' },
{ name: 'api_key_auth_tag', def: 'VARCHAR(50)' },
{ name: 'key_last4', def: 'VARCHAR(4)' },
{ name: 'default_model', def: 'VARCHAR(100)' },
{ name: 'enabled', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' },
{ name: 'timeout_ms', def: 'INT DEFAULT 30000' },
{ name: 'verified', def: isMySQL ? 'TINYINT(1) DEFAULT 0' : 'BOOLEAN DEFAULT 0' },
{ name: 'last_tested_at', def: 'DATETIME' },
{ name: 'last_test_latency_ms', def: 'INT' },
{ name: 'created_at', def: 'DATETIME' },
{ name: 'updated_at', def: 'DATETIME' },
];
for (const col of desiredColumns) {
if (col.def && !columnNames.has(col.name)) {
await runner.query(`ALTER TABLE ai_config ADD COLUMN ${col.name} ${col.def}`);
this.logger.log(`已为 ai_config 表添加列: ${col.name}`);
}
}
}
} finally {
await runner.release();
}
}
private async ensureCourseAttendanceSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['attendance_records', 'attendance_sessions']);
const tableNames = new Set(tables.map((table) => table.name));
const isMySQL = this.dataSource.options.type === 'mysql';
if (!tableNames.has('attendance_sessions')) {
const pkDef = isMySQL
? 'id INTEGER PRIMARY KEY AUTO_INCREMENT'
: 'id INTEGER PRIMARY KEY AUTOINCREMENT';
await runner.query(`
CREATE TABLE attendance_sessions (
${pkDef},
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
started_by INTEGER,
started_at DATETIME,
completed_by INTEGER,
completed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
)
`);
}
const attendanceTable = await runner.getTable('attendance_records');
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
if (!columnNames.has('schedule_id')) {
await runner.query('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER');
}
if (!columnNames.has('attendance_session_id')) {
await runner.query(
'ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER',
);
}
const createIndex = async (sql: string) => {
try {
await runner.query(sql);
} catch {
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
}
};
await createIndex(
isMySQL
? 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)'
: 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
);
await createIndex(
isMySQL
? 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)'
: 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
);
} finally {
await runner.release();
}
}
private async backfillOrganizations(): Promise<void> {
@@ -159,4 +332,191 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows;
if (affected) this.logger.log(`已规范化 ${affected} 条班级日期数据`);
}
private async protectAttendanceHistory(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables(['attendance_sessions']);
if (tables.length === 0) return;
const isMySQL = this.dataSource.options.type === 'mysql';
if (isMySQL) {
await this.migrateMySQLAttendanceFKs(runner);
} else {
await this.migrateSQLiteAttendanceFKs(runner);
}
} finally {
await runner.release();
}
}
private async migrateMySQLAttendanceFKs(runner: QueryRunner): Promise<void> {
// Drop any existing FK constraint on schedule_id or class_id
const fkColumns = ['schedule_id', 'class_id'];
for (const col of fkColumns) {
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(`
SELECT CONSTRAINT_NAME
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'attendance_sessions'
AND COLUMN_NAME = ?
AND REFERENCED_TABLE_NAME IS NOT NULL
`, [col]);
for (const row of fkRows) {
try {
await runner.query(
`ALTER TABLE attendance_sessions DROP FOREIGN KEY \`${row.CONSTRAINT_NAME}\``,
);
this.logger.log(`已移除考勤场次 FK 约束: ${row.CONSTRAINT_NAME}`);
} catch {
// constraint may have already been dropped
}
}
}
const constraints: Array<{ name: string; col: string; ref: string }> = [
{ name: 'fk_as_schedule_protect', col: 'schedule_id', ref: 'class_schedule(id)' },
{ name: 'fk_as_class_protect', col: 'class_id', ref: 'classes(id)' },
];
for (const c of constraints) {
// Only skip if RESTRICT constraint is already confirmed via information_schema
const existing: Array<{ DELETE_RULE: string }> = await runner.query(`
SELECT DELETE_RULE
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = 'attendance_sessions'
AND CONSTRAINT_NAME = ?
`, [c.name]);
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
continue;
}
// ADD RESTRICT must throw on failure — no catch
await runner.query(`
ALTER TABLE attendance_sessions
ADD CONSTRAINT ${c.name}
FOREIGN KEY (${c.col}) REFERENCES ${c.ref}
ON DELETE RESTRICT
`);
this.logger.log(`已添加考勤场次删除保护约束: ${c.name}`);
}
}
private async migrateSQLiteAttendanceFKs(runner: QueryRunner): Promise<void> {
// SQLite cannot ALTER TABLE to add foreign keys.
// Rebuild the table inside a transaction: create a new table with FK constraints,
// copy all rows, drop old, rename new, then recreate indexes.
const fkRows: Array<{ id: number }> = await runner.query(
"PRAGMA foreign_key_list('attendance_sessions')",
);
if (fkRows.length > 0) return; // FKs already present
this.logger.log('正在重建 attendance_sessions 表以添加外键保护…');
// PRAGMA foreign_keys=OFF must be issued outside the transaction
await runner.query('PRAGMA foreign_keys = OFF');
try {
await runner.query('BEGIN');
try {
await runner.query(`
CREATE TABLE attendance_sessions_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
class_id INTEGER NOT NULL,
lesson_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
started_by INTEGER,
started_at DATETIME,
completed_by INTEGER,
completed_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
)
`);
await runner.query(`
INSERT INTO attendance_sessions_new (
id, schedule_id, class_id, lesson_date, status,
started_by, started_at, completed_by, completed_at, created_at, updated_at
)
SELECT
id, schedule_id, class_id, lesson_date, status,
started_by, started_at, completed_by, completed_at, created_at, updated_at
FROM attendance_sessions
`);
await runner.query('DROP TABLE attendance_sessions');
await runner.query(
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
);
await runner.query(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
);
// Rebuild attendance_records to add/protect FK on attendance_session_id
const recordsFk = await runner.query(
"PRAGMA foreign_key_list('attendance_records')",
);
const hasSessionFk = recordsFk.some(
(r: { from: string }) => r.from === 'attendance_session_id',
);
if (!hasSessionFk) {
await runner.query(`
CREATE TABLE attendance_records_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
class_id INTEGER,
schedule_id INTEGER,
attendance_session_id INTEGER,
attendance_date DATE NOT NULL,
session VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL,
remark VARCHAR(200),
source VARCHAR(20) DEFAULT 'manual',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL
)
`);
await runner.query(`
INSERT INTO attendance_records_new (
id, student_id, class_id, schedule_id, attendance_session_id,
attendance_date, session, status, remark, source, created_at, updated_at
)
SELECT
id, student_id, class_id, schedule_id, attendance_session_id,
attendance_date, session, status, remark, source, created_at, updated_at
FROM attendance_records
`);
await runner.query('DROP TABLE attendance_records');
await runner.query(
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
);
await runner.query(
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
);
}
// Verify foreign key integrity BEFORE committing the transaction.
// If violations exist, the transaction rolls back and old tables are preserved.
const checkRows = await runner.query('PRAGMA foreign_key_check');
if (checkRows.length > 0) {
throw new Error(
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
);
}
await runner.query('COMMIT');
this.logger.log('attendance_sessions 表外键保护重建完成');
} catch (err) {
await runner.query('ROLLBACK');
throw err;
}
} finally {
await runner.query('PRAGMA foreign_keys = ON');
}
}
}

View File

@@ -0,0 +1,478 @@
import { TestingModule, Test } from '@nestjs/testing';
import { DatabaseMigrationsService } from './database-migrations.service';
import { getDataSourceToken } from '@nestjs/typeorm';
interface MockColumn {
name: string;
}
interface MockTable {
name: string;
columns: MockColumn[];
}
interface MockRunner {
release: jest.Mock;
connect: jest.Mock;
query: jest.Mock;
getTables: jest.Mock;
getTable: jest.Mock;
}
function mockRunner(overrides: {
getTables?: MockTable[];
getTable?: MockTable;
queryError?: Error;
} = {}) {
const release = jest.fn();
const connect = jest.fn();
const query = jest.fn().mockResolvedValue([]);
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
const getTable = jest.fn().mockResolvedValue(
overrides.getTable ?? { name: 'ai_config', columns: [] },
);
if (overrides.queryError) {
query.mockRejectedValue(overrides.queryError);
}
return { release, connect, query, getTables, getTable } satisfies MockRunner;
}
function createDataSource(runner: MockRunner, dbType: string = 'better-sqlite3') {
return {
options: { type: dbType },
createQueryRunner: jest.fn().mockReturnValue(runner),
transaction: jest.fn(),
};
}
// Type to reach private migration methods for testing
interface MigrationsPrivate {
ensureAiConfigTable(): Promise<void>;
backfillOrganizations(): Promise<void>;
normalizeClassDates(): Promise<void>;
ensureCourseAttendanceSchema(): Promise<void>;
protectAttendanceHistory(): Promise<void>;
removeUnusedClassroomColumns(): Promise<void>;
}
describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
let service: MigrationsPrivate & DatabaseMigrationsService;
async function bootstrap(runner: MockRunner) {
const dataSource = createDataSource(runner);
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
service = module.get(
DatabaseMigrationsService,
);
}
it('creates table + index when ai_config does not exist', async () => {
const runner = mockRunner({ getTables: [] });
await bootstrap(runner);
await service.ensureAiConfigTable();
expect(runner.connect).toHaveBeenCalled();
expect(runner.query).toHaveBeenCalledWith(expect.stringContaining('CREATE TABLE ai_config'));
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_config_singleton'),
);
expect(runner.release).toHaveBeenCalled();
});
it('skips ALTER when table exists with all columns', async () => {
const allColumns: MockColumn[] = [
{ name: 'id' },
{ name: 'singleton_key' },
{ name: 'provider' },
{ name: 'base_url' },
{ name: 'encrypted_api_key' },
{ name: 'api_key_iv' },
{ name: 'api_key_auth_tag' },
{ name: 'key_last4' },
{ name: 'default_model' },
{ name: 'enabled' },
{ name: 'timeout_ms' },
{ name: 'verified' },
{ name: 'last_tested_at' },
{ name: 'last_test_latency_ms' },
{ name: 'created_at' },
{ name: 'updated_at' },
];
const runner = mockRunner({
getTables: [{ name: 'ai_config', columns: allColumns }],
getTable: { name: 'ai_config', columns: allColumns },
});
await bootstrap(runner);
await service.ensureAiConfigTable();
expect(runner.connect).toHaveBeenCalled();
// Should NOT issue any ALTER TABLE
const alterCalls = (runner.query as jest.Mock).mock.calls.filter(
(c: unknown[]) => typeof c[0] === 'string' && (c[0]).includes('ALTER TABLE'),
);
expect(alterCalls).toHaveLength(0);
expect(runner.release).toHaveBeenCalled();
});
it('adds missing column via ALTER TABLE', async () => {
// Table has most columns but is missing last_test_latency_ms
const missingOne: MockColumn[] = [
{ name: 'id' },
{ name: 'singleton_key' },
{ name: 'provider' },
{ name: 'base_url' },
{ name: 'encrypted_api_key' },
{ name: 'api_key_iv' },
{ name: 'api_key_auth_tag' },
{ name: 'key_last4' },
{ name: 'default_model' },
{ name: 'enabled' },
{ name: 'timeout_ms' },
{ name: 'verified' },
{ name: 'last_tested_at' },
// last_test_latency_ms missing
{ name: 'created_at' },
{ name: 'updated_at' },
];
const runner = mockRunner({
getTables: [{ name: 'ai_config', columns: missingOne }],
getTable: { name: 'ai_config', columns: missingOne },
});
await bootstrap(runner);
await service.ensureAiConfigTable();
expect(runner.connect).toHaveBeenCalled();
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('ALTER TABLE ai_config ADD COLUMN last_test_latency_ms INT'),
);
expect(runner.release).toHaveBeenCalled();
});
it('releases runner even when query throws', async () => {
const runner = mockRunner({ getTables: [], queryError: new Error('BOOM') });
await bootstrap(runner);
await expect(service.ensureAiConfigTable()).rejects.toThrow('BOOM');
expect(runner.release).toHaveBeenCalled();
});
});
describe('DatabaseMigrationsService — bootstrap failure handling', () => {
it('fails application bootstrap when the required ai_config migration fails', async () => {
const runner = mockRunner();
const dataSource = createDataSource(runner);
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
const service = module.get(DatabaseMigrationsService);
jest.spyOn(service, 'ensureAiConfigTable').mockRejectedValue(new Error('migration failed'));
const backfill = jest.spyOn(service, 'backfillOrganizations').mockResolvedValue();
const normalize = jest.spyOn(service, 'normalizeClassDates').mockResolvedValue();
await expect(service.onApplicationBootstrap()).rejects.toThrow('migration failed');
expect(backfill).not.toHaveBeenCalled();
expect(normalize).not.toHaveBeenCalled();
});
});
describe('DatabaseMigrationsService — course attendance schema', () => {
it('adds schedule linkage columns to an existing attendance_records table', async () => {
const runner = mockRunner({
getTables: [
{ name: 'attendance_records', columns: [{ name: 'id' }] },
{ name: 'attendance_sessions', columns: [{ name: 'id' }] },
],
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
});
await bootstrapCourseAttendance(runner);
await service.ensureCourseAttendanceSchema();
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'),
);
expect(runner.query).toHaveBeenCalledWith(
expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER'),
);
expect(runner.release).toHaveBeenCalled();
});
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
});
await bootstrapCourseAttendance(runner);
await service.ensureCourseAttendanceSchema();
const createSql: string = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''))
.find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? '';
expect(createSql).toContain('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT');
expect(createSql).toContain('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT');
});
});
describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
let service: MigrationsPrivate & DatabaseMigrationsService;
async function bootstrap(runner: MockRunner, dbType: string = 'better-sqlite3') {
const dataSource = createDataSource(runner, dbType);
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
service = module.get(DatabaseMigrationsService);
}
it('skips when attendance_sessions table is absent', async () => {
const runner = mockRunner({ getTables: [] });
await bootstrap(runner);
await service.protectAttendanceHistory();
expect(runner.query).not.toHaveBeenCalled();
expect(runner.release).toHaveBeenCalled();
});
it('SQLite: exits early when FKs already exist', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
runner.query.mockResolvedValueOnce([{ id: 0 }]); // PRAGMA foreign_key_list returns rows
await bootstrap(runner);
await service.protectAttendanceHistory();
// Should not run any TABLE creation (rebuild)
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0);
expect(runner.release).toHaveBeenCalled();
});
it('SQLite: rebuilds table with FK constraints when FKs are absent', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
// PRAGMA foreign_key_list for attendance_sessions → empty
runner.query.mockResolvedValueOnce([]);
// PRAGMA foreign_key_list for attendance_records → also empty (no FK yet)
runner.query.mockResolvedValueOnce([]);
await bootstrap(runner);
await service.protectAttendanceHistory();
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
// PRAGMA foreign_keys = OFF outside the transaction
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = OFF'))).toBe(true);
expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe(true);
expect(queries.some((q: string) =>
q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT')
)).toBe(true);
expect(queries.some((q: string) =>
q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT')
)).toBe(true);
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe(true);
expect(queries.some((q: string) => q.includes('DROP TABLE attendance_sessions'))).toBe(true);
expect(queries.some((q: string) => q.includes('RENAME TO attendance_sessions'))).toBe(true);
expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe(true);
// attendance_records rebuilt with FK
expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe(true);
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe(true);
expect(queries.some((q: string) => q.includes('DROP TABLE attendance_records'))).toBe(true);
expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).toBe(true);
// PRAGMA foreign_keys restored to ON and foreign_key_check runs
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
expect(queries.some((q: string) => q.includes('PRAGMA foreign_key_check'))).toBe(true);
expect(runner.release).toHaveBeenCalled();
});
it('SQLite: rolls back transaction when foreign_key_check finds violations', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
// Use mockImplementation to match by SQL content, not call position
runner.query.mockImplementation((sql: string) => {
if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_list')) {
return Promise.resolve([]); // FKs absent → trigger rebuild
}
if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_check')) {
return Promise.resolve([
{ table: 'attendance_sessions', rowid: 42, parent: 'class_schedule', fkid: 0 },
]);
}
return Promise.resolve([]);
});
await bootstrap(runner);
await expect(service.protectAttendanceHistory()).rejects.toThrow(
/外键一致性检查失败/,
);
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
// The transaction should have been rolled back (ROLLBACK called)
expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true);
// COMMIT should NOT have been called
expect(queries.some((q: string) => q.trim() === 'COMMIT')).toBe(false);
// PRAGMA foreign_keys should still be restored
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
expect(runner.release).toHaveBeenCalled();
});
it('MySQL: drops old FKs and recreates both schedule_id and class_id as RESTRICT', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
// Mock: override SELECT CONSTRAINT_NAME and REFERENTIAL_CONSTRAINTS queries
runner.query.mockImplementation((sql: string, params?: string[]) => {
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
if (params?.[0] === 'schedule_id') {
return Promise.resolve([{ CONSTRAINT_NAME: 'fk_schedule_cascade' }]);
}
if (params?.[0] === 'class_id') {
return Promise.resolve([{ CONSTRAINT_NAME: 'fk_class_cascade' }]);
}
}
// REFERENTIAL_CONSTRAINTS check — constraint does not yet exist
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
return Promise.resolve([]);
}
return Promise.resolve([]);
});
await bootstrap(runner, 'mysql');
await service.protectAttendanceHistory();
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
// Drops old FKs
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_schedule_cascade`'))).toBe(true);
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe(true);
// Checks REFERENTIAL_CONSTRAINTS before ADD
expect(queries.some((q: string) =>
q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')
)).toBe(true);
// Creates new RESTRICT FKs
expect(queries.some((q: string) =>
q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT')
)).toBe(true);
expect(queries.some((q: string) =>
q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT')
)).toBe(true);
expect(runner.release).toHaveBeenCalled();
});
it('MySQL: throws when ADD CONSTRAINT RESTRICT fails', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
const addError = new Error('Cannot add foreign key constraint');
runner.query.mockImplementation((sql: string, params?: string[]) => {
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
return Promise.resolve([]);
}
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
return Promise.resolve([]);
}
if (typeof sql === 'string' && sql.includes('ADD CONSTRAINT')) {
return Promise.reject(addError);
}
return Promise.resolve([]);
});
await bootstrap(runner, 'mysql');
await expect(service.protectAttendanceHistory()).rejects.toThrow('Cannot add foreign key constraint');
expect(runner.release).toHaveBeenCalled();
});
it('MySQL: skips ADD when RESTRICT constraint already confirmed via information_schema', async () => {
const runner = mockRunner({
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
});
runner.query.mockImplementation((sql: string, params?: string[]) => {
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
return Promise.resolve([]);
}
// REFERENTIAL_CONSTRAINTS confirms RESTRICT already present
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
return Promise.resolve([{ DELETE_RULE: 'RESTRICT' }]);
}
return Promise.resolve([]);
});
await bootstrap(runner, 'mysql');
await service.protectAttendanceHistory();
const queries: string[] = (runner.query as jest.Mock).mock.calls
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
// No ADD CONSTRAINT calls
expect(queries.filter((q: string) => q.includes('ADD CONSTRAINT')).length).toBe(0);
expect(runner.release).toHaveBeenCalled();
});
});
describe('DatabaseMigrationsService — classroom cleanup', () => {
let cleanupService: MigrationsPrivate & DatabaseMigrationsService;
async function bootstrapClassroomCleanup(runner: MockRunner) {
const dataSource = createDataSource(runner);
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
cleanupService = module.get(DatabaseMigrationsService);
}
it('drops legacy classroom fields when present', async () => {
const runner = mockRunner({
getTables: [{ name: 'classrooms', columns: [] }],
getTable: {
name: 'classrooms',
columns: [{ name: 'id' }, { name: 'course_type' }, { name: 'supervisor' }],
},
});
await bootstrapClassroomCleanup(runner);
await cleanupService.removeUnusedClassroomColumns();
expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN course_type');
expect(runner.query).toHaveBeenCalledWith('ALTER TABLE classrooms DROP COLUMN supervisor');
expect(runner.release).toHaveBeenCalled();
});
it('does nothing when the classrooms table is absent', async () => {
const runner = mockRunner({ getTables: [] });
await bootstrapClassroomCleanup(runner);
await cleanupService.removeUnusedClassroomColumns();
expect(runner.query).not.toHaveBeenCalled();
expect(runner.release).toHaveBeenCalled();
});
});
async function bootstrapCourseAttendance(runner: MockRunner) {
const dataSource = createDataSource(runner);
const module: TestingModule = await Test.createTestingModule({
providers: [
DatabaseMigrationsService,
{ provide: getDataSourceToken(), useValue: dataSource },
],
}).compile();
service = module.get(DatabaseMigrationsService);
}

View File

@@ -32,6 +32,12 @@ export class DepositsController {
@InjectRepository(Student) private studentRepo: Repository<Student>,
) {}
@Get('student-lookups')
@RequirePermission('deposit:create')
getStudentLookups() {
return this.service.getStudentLookups();
}
@Get()
@RequirePermission('deposit:view')
findAll(@Query('studentId') studentId?: string, @Query('status') status?: string) {
@@ -41,12 +47,6 @@ export class DepositsController {
});
}
@Get('pending-refunds')
@RequirePermission('deposit:edit')
findPendingRefunds() {
return this.service.findPendingRefunds();
}
@Get('stats')
@RequirePermission('deposit:view')
getStats() {
@@ -159,7 +159,7 @@ export class DepositsController {
}
@Put(':id/refund')
@RequirePermission('deposit:edit')
@RequirePermission('deposit:refund')
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.refund(+id, dto, req.user?.id);
@@ -189,67 +189,6 @@ export class DepositsController {
return result;
}
@Post(':id/request-refund')
@RequirePermission('deposit:edit')
async requestRefund(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.requestRefund(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '申请退款',
targetId: +id,
targetType: 'deposit',
detail: '提交退款申请',
ipAddress,
userAgent,
});
return result;
}
@Put(':id/approve-refund')
@RequirePermission('deposit:approve')
async approveRefund(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.approveRefund(+id, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '审批退款',
targetId: +id,
targetType: 'deposit',
detail: `审批通过 → ${result.refundStatus}`,
ipAddress,
userAgent,
});
return result;
}
@Put(':id/reject-refund')
@RequirePermission('deposit:approve')
async rejectRefund(
@Param('id') id: string,
@Body() body: { reason: string },
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.rejectRefund(+id, body.reason, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金管理',
action: '驳回退款',
targetId: +id,
targetType: 'deposit',
detail: `驳回原因:${body.reason}`,
ipAddress,
userAgent,
});
return result;
}
@Delete(':id')
@RequirePermission('deposit:delete')
async remove(@Param('id') id: string, @Request() req: any) {

View File

@@ -0,0 +1,15 @@
import { DepositsService } from './deposits.service';
describe('DepositsService permission-scoped lookups', () => {
it('returns only minimal student fields needed by deposit forms', async () => {
const studentRepo = {
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
};
const service = new DepositsService({} as never, {} as never, studentRepo as never);
await expect(service.getStudentLookups()).resolves.toEqual([
{ id: 2, name: '张三', studentNo: 'S2' },
]);
expect(studentRepo.find).toHaveBeenCalledWith(expect.objectContaining({ select: ['id', 'name', 'studentNo'] }));
});
});

View File

@@ -18,6 +18,14 @@ export class DepositsService {
private studentRepo: Repository<Student>,
) {}
async getStudentLookups() {
return this.studentRepo.find({
select: ['id', 'name', 'studentNo'],
where: { status: 'active' },
order: { name: 'ASC' },
});
}
async findAll(query?: { studentId?: number; status?: string }) {
const qb = this.repo
.createQueryBuilder('d')
@@ -106,79 +114,13 @@ export class DepositsService {
deposit.status =
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
if (dto.notes) deposit.notes = dto.notes;
return this.repo.save(deposit);
}
// ---- Refund approval flow ----
async requestRefund(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理');
if (deposit.refundStatus) throw new BadRequestException('已提交退款申请,请等待审批');
deposit.refundStatus = 'pending';
deposit.refundRequestedAt = new Date();
return this.repo.save(deposit);
}
async approveRefund(id: number, userId: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (!deposit.refundStatus || deposit.refundStatus === 'refunded') {
throw new BadRequestException('未找到待审批的退款申请');
}
const transitions: Record<string, string> = {
pending: 'head_teacher_approved',
head_teacher_approved: 'finance_approved',
finance_approved: 'refunded',
};
const nextStatus = transitions[deposit.refundStatus];
if (!nextStatus) throw new BadRequestException(`无效的退款状态: ${deposit.refundStatus}`);
deposit.refundStatus = nextStatus;
deposit.refundApprovedBy = userId;
deposit.refundStatus = 'refunded';
deposit.refundApprovedBy = userId ?? null as unknown as number;
deposit.refundApprovedAt = new Date();
if (nextStatus === 'refunded') {
deposit.status = 'refunded';
deposit.refundDate = new Date().toISOString().slice(0, 10);
deposit.refundAmount = Number(deposit.amount) - Number(deposit.deductionAmount || 0);
}
return this.repo.save(deposit);
}
async rejectRefund(id: number, reason: string, userId: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');
if (!deposit.refundStatus || deposit.refundStatus === 'refunded') {
throw new BadRequestException('未找到待审批的退款申请');
}
deposit.refundStatus = null as unknown as string;
deposit.refundApprovedBy = userId;
deposit.refundApprovedAt = new Date();
deposit.refundRejectedReason = reason;
return this.repo.save(deposit);
}
async findPendingRefunds() {
return this.repo.find({
where: [
{ refundStatus: 'pending' },
{ refundStatus: 'head_teacher_approved' },
],
relations: ['student', 'installments'],
order: { refundRequestedAt: 'DESC' },
});
}
async remove(id: number) {
const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在');

Some files were not shown because too many files have changed in this diff Show More