Compare commits
7 Commits
8f0991a51f
...
1c8bd08be4
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c8bd08be4 | |||
| b9b295c997 | |||
| a266d450e1 | |||
| b5f4b8747c | |||
| cc4f4dae4e | |||
| b6fca99390 | |||
| 1e1c476bc3 |
16
.env.example
16
.env.example
@@ -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 字节
|
||||
# 示例 hex:openssl rand -hex 32
|
||||
# 示例 base64:openssl rand -base64 32
|
||||
AI_CONFIG_ENCRYPTION_KEY=
|
||||
|
||||
# AI API Key 环境变量回退(可选)
|
||||
# 若数据库未保存 Key,将从该环境变量读取
|
||||
# 环境变量 Key 不可从页面覆盖或清除
|
||||
# AI_API_KEY=
|
||||
|
||||
# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用)
|
||||
# AI_ALLOW_PRIVATE_BASE_URL=true
|
||||
|
||||
@@ -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>
|
||||
|
||||
105
apps/admin/src/auth/menu-policy.integration.test.ts
Normal file
105
apps/admin/src/auth/menu-policy.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
186
apps/admin/src/auth/menu-policy.ts
Normal file
186
apps/admin/src/auth/menu-policy.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
53
apps/admin/src/auth/permission-navigation.ts
Normal file
53
apps/admin/src/auth/permission-navigation.ts
Normal 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;
|
||||
}
|
||||
18
apps/admin/src/auth/permission-tabs.integration.test.ts
Normal file
18
apps/admin/src/auth/permission-tabs.integration.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
13
apps/admin/src/auth/permission-tabs.ts
Normal file
13
apps/admin/src/auth/permission-tabs.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
21
apps/admin/src/components/DefaultRoute.tsx
Normal file
21
apps/admin/src/components/DefaultRoute.tsx
Normal 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;
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
115
apps/admin/src/pages/AiConfig/helpers.integration.test.ts
Normal file
115
apps/admin/src/pages/AiConfig/helpers.integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
83
apps/admin/src/pages/AiConfig/helpers.ts
Normal file
83
apps/admin/src/pages/AiConfig/helpers.ts
Normal 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;
|
||||
}
|
||||
73
apps/admin/src/pages/AiConfig/index.module.css
Normal file
73
apps/admin/src/pages/AiConfig/index.module.css
Normal 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;
|
||||
}
|
||||
514
apps/admin/src/pages/AiConfig/index.tsx
Normal file
514
apps/admin/src/pages/AiConfig/index.tsx
Normal 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;
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
84
apps/admin/src/pages/Attendance/attendance-workspace.ts
Normal file
84
apps/admin/src/pages/Attendance/attendance-workspace.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
504
apps/admin/src/pages/Attendance/attendance.css
Normal file
504
apps/admin/src/pages/Attendance/attendance.css
Normal 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
@@ -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]);
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
9
apps/admin/src/pages/Classrooms/classroom-fields.ts
Normal file
9
apps/admin/src/pages/Classrooms/classroom-fields.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const CLASSROOM_VISIBLE_FIELDS = [
|
||||
'name',
|
||||
'building',
|
||||
'floor',
|
||||
'roomType',
|
||||
'capacity',
|
||||
'status',
|
||||
'notes',
|
||||
] as const;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 || '加载失败,请稍后重试');
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>[];
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface ScheduleFormValues {
|
||||
}
|
||||
|
||||
export interface EditableSchedule {
|
||||
id: number;
|
||||
id?: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
19
apps/admin/src/pages/Schedules/schedule-visibility.ts
Normal file
19
apps/admin/src/pages/Schedules/schedule-visibility.ts
Normal 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);
|
||||
};
|
||||
125
apps/admin/src/pages/Schedules/sync-result.integration.test.ts
Normal file
125
apps/admin/src/pages/Schedules/sync-result.integration.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
58
apps/admin/src/pages/Schedules/sync-result.ts
Normal file
58
apps/admin/src/pages/Schedules/sync-result.ts
Normal 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);
|
||||
}
|
||||
@@ -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: '机构负责人',
|
||||
|
||||
@@ -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",
|
||||
|
||||
678
apps/server/src/agent-tools/agent-tool.executor.spec.ts
Normal file
678
apps/server/src/agent-tools/agent-tool.executor.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
251
apps/server/src/agent-tools/agent-tool.executor.ts
Normal file
251
apps/server/src/agent-tools/agent-tool.executor.ts
Normal 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 '执行失败';
|
||||
}
|
||||
}
|
||||
}
|
||||
52
apps/server/src/agent-tools/agent-tool.registry.ts
Normal file
52
apps/server/src/agent-tools/agent-tool.registry.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
180
apps/server/src/agent-tools/agent-tool.types.ts
Normal file
180
apps/server/src/agent-tools/agent-tool.types.ts
Normal 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;
|
||||
}
|
||||
43
apps/server/src/agent-tools/agent-tools.module.ts
Normal file
43
apps/server/src/agent-tools/agent-tools.module.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
4
apps/server/src/agent-tools/index.ts
Normal file
4
apps/server/src/agent-tools/index.ts
Normal 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';
|
||||
180
apps/server/src/agent-tools/tools/get-student-basic.tool.spec.ts
Normal file
180
apps/server/src/agent-tools/tools/get-student-basic.tool.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
82
apps/server/src/agent-tools/tools/get-student-basic.tool.ts
Normal file
82
apps/server/src/agent-tools/tools/get-student-basic.tool.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
181
apps/server/src/agent-tools/tools/search-students.tool.spec.ts
Normal file
181
apps/server/src/agent-tools/tools/search-students.tool.spec.ts
Normal 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' }, {});
|
||||
});
|
||||
});
|
||||
});
|
||||
122
apps/server/src/agent-tools/tools/search-students.tool.ts
Normal file
122
apps/server/src/agent-tools/tools/search-students.tool.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
302
apps/server/src/ai-config/ai-config.controller.spec.ts
Normal file
302
apps/server/src/ai-config/ai-config.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
93
apps/server/src/ai-config/ai-config.controller.ts
Normal file
93
apps/server/src/ai-config/ai-config.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
68
apps/server/src/ai-config/ai-config.entity.ts
Normal file
68
apps/server/src/ai-config/ai-config.entity.ts
Normal 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;
|
||||
}
|
||||
14
apps/server/src/ai-config/ai-config.module.ts
Normal file
14
apps/server/src/ai-config/ai-config.module.ts
Normal 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 {}
|
||||
781
apps/server/src/ai-config/ai-config.service.spec.ts
Normal file
781
apps/server/src/ai-config/ai-config.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
753
apps/server/src/ai-config/ai-config.service.ts
Normal file
753
apps/server/src/ai-config/ai-config.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
129
apps/server/src/ai-config/dto/ai-config.dto.spec.ts
Normal file
129
apps/server/src/ai-config/dto/ai-config.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
116
apps/server/src/ai-config/dto/ai-config.dto.ts
Normal file
116
apps/server/src/ai-config/dto/ai-config.dto.ts
Normal 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 };
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
205
apps/server/src/attendance/attendance-settlement.service.spec.ts
Normal file
205
apps/server/src/attendance/attendance-settlement.service.spec.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
193
apps/server/src/attendance/attendance-settlement.service.ts
Normal file
193
apps/server/src/attendance/attendance-settlement.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
549
apps/server/src/attendance/attendance.lesson-session.spec.ts
Normal file
549
apps/server/src/attendance/attendance.lesson-session.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (Monday–Sunday)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
11
apps/server/src/authorization/authorization.module.ts
Normal file
11
apps/server/src/authorization/authorization.module.ts
Normal 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 {}
|
||||
180
apps/server/src/authorization/authorization.service.spec.ts
Normal file
180
apps/server/src/authorization/authorization.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
95
apps/server/src/authorization/authorization.service.ts
Normal file
95
apps/server/src/authorization/authorization.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
327
apps/server/src/authorization/casl-ability.factory.spec.ts
Normal file
327
apps/server/src/authorization/casl-ability.factory.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
61
apps/server/src/authorization/casl-ability.factory.ts
Normal file
61
apps/server/src/authorization/casl-ability.factory.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
180
apps/server/src/authorization/casl.constants.ts
Normal file
180
apps/server/src/authorization/casl.constants.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
201
apps/server/src/authorization/guards/policies.guard.spec.ts
Normal file
201
apps/server/src/authorization/guards/policies.guard.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
apps/server/src/authorization/guards/policies.guard.ts
Normal file
79
apps/server/src/authorization/guards/policies.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
apps/server/src/authorization/index.ts
Normal file
14
apps/server/src/authorization/index.ts
Normal 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';
|
||||
67
apps/server/src/authorization/interfaces.ts
Normal file
67
apps/server/src/authorization/interfaces.ts
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
7
apps/server/src/classrooms/classroom-template.spec.ts
Normal file
7
apps/server/src/classrooms/classroom-template.spec.ts
Normal 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(['教室名', '楼栋', '楼层', '类型', '容量']);
|
||||
});
|
||||
});
|
||||
9
apps/server/src/classrooms/classroom-template.ts
Normal file
9
apps/server/src/classrooms/classroom-template.ts
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -130,7 +130,6 @@ export class ClassroomsService {
|
||||
floor?: number;
|
||||
capacity?: number;
|
||||
roomType?: string;
|
||||
courseType?: string;
|
||||
}[],
|
||||
) {
|
||||
let imported = 0;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('DashboardService — teacher class scope', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{},
|
||||
);
|
||||
|
||||
await service.getClassAttendanceRanking([8, 9]);
|
||||
|
||||
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal file
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
478
apps/server/src/database/database-migrations.spec.ts
Normal file
478
apps/server/src/database/database-migrations.spec.ts
Normal 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);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
15
apps/server/src/deposits/deposits.lookups.spec.ts
Normal file
15
apps/server/src/deposits/deposits.lookups.spec.ts
Normal 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'] }));
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user