From cc4f4dae4e00cd7a6ab6bf8acd89324190e82e8f Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 12 Jul 2026 22:59:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20audit=20remediation=20=E2=80=94=20SSE=20?= =?UTF-8?q?user=20scoping,=20FK=20transactional=20safety,=20UI=20error=20h?= =?UTF-8?q?andling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review --- apps/admin/src/App.tsx | 4 +- .../src/auth/menu-policy.integration.test.ts | 105 + apps/admin/src/auth/menu-policy.ts | 186 ++ .../permission-navigation.integration.test.ts | 12 + apps/admin/src/auth/permission-navigation.ts | 4 +- .../auth/permission-tabs.integration.test.ts | 23 +- apps/admin/src/components/DefaultRoute.tsx | 11 +- apps/admin/src/components/PermissionRoute.tsx | 10 +- apps/admin/src/layouts/MainLayout.tsx | 152 +- .../AiConfig/helpers.integration.test.ts | 8 +- apps/admin/src/pages/AiConfig/helpers.ts | 20 +- apps/admin/src/pages/AiConfig/index.tsx | 12 +- .../attendance-workspace.integration.test.ts | 53 + .../pages/Attendance/attendance-workspace.ts | 65 + .../admin/src/pages/Attendance/attendance.css | 504 +++++ apps/admin/src/pages/Attendance/index.tsx | 1828 +++++++---------- apps/admin/src/pages/Deposits/index.tsx | 288 +-- .../src/pages/IntegrationConfig/index.tsx | 104 + apps/admin/src/pages/Login/index.tsx | 4 +- apps/admin/src/pages/Occupancies/index.tsx | 2 +- apps/admin/src/pages/Permissions/index.tsx | 4 + apps/admin/src/pages/Schedules/index.tsx | 171 +- .../src/pages/Schedules/schedule-form.ts | 2 +- .../schedule-visibility.integration.test.ts | 39 + .../pages/Schedules/schedule-visibility.ts | 19 + .../Schedules/sync-result.integration.test.ts | 125 ++ apps/admin/src/pages/Schedules/sync-result.ts | 58 + apps/admin/src/pages/Teachers/index.tsx | 8 +- apps/server/src/app.module.ts | 2 + .../attendance-import.service.spec.ts | 97 + .../attendance/attendance-import.service.ts | 10 +- .../attendance/attendance.controller.spec.ts | 218 +- .../src/attendance/attendance.controller.ts | 129 +- .../attendance.lesson-session.spec.ts | 492 +++++ .../src/attendance/attendance.module.ts | 4 +- .../src/attendance/attendance.service.spec.ts | 210 ++ .../src/attendance/attendance.service.ts | 564 ++++- .../dingtalk-attendance.service.spec.ts | 28 + .../src/attendance/dto/attendance.dto.ts | 17 + .../src/attendance/dto/dingtalk-import.dto.ts | 2 + apps/server/src/classes/classes.module.ts | 4 +- apps/server/src/classes/classes.service.ts | 14 + .../database/attendance-fk-restrict.spec.ts | 380 ++++ .../database/database-migrations.service.ts | 255 ++- .../src/database/database-migrations.spec.ts | 258 ++- .../src/deposits/deposits.controller.ts | 69 +- apps/server/src/deposits/deposits.service.ts | 70 +- .../src/entities/attendance-record.entity.ts | 24 +- .../src/entities/attendance-session.entity.ts | 62 + apps/server/src/entities/index.ts | 1 + .../integration/dingtalk.group-delete.spec.ts | 53 + .../src/integration/dingtalk.service.ts | 43 +- apps/server/src/rbac/rbac.controller.ts | 8 +- apps/server/src/rbac/rbac.permissions.spec.ts | 59 +- apps/server/src/rbac/rbac.seed.spec.ts | 97 +- apps/server/src/rbac/rbac.service.ts | 209 +- .../src/rbac/rbac.teacher-workspace.spec.ts | 54 + .../schedules/schedules.controller.spec.ts | 126 ++ .../src/schedules/schedules.controller.ts | 63 +- apps/server/src/schedules/schedules.module.ts | 18 +- .../src/schedules/schedules.scope.spec.ts | 64 + .../src/schedules/schedules.service.spec.ts | 118 +- .../server/src/schedules/schedules.service.ts | 99 +- .../src/students/students.controller.ts | 6 + apps/server/src/students/students.service.ts | 8 + .../src/sync/schedule-sync.service.spec.ts | 348 +++- apps/server/src/sync/schedule-sync.service.ts | 100 +- apps/server/src/sync/sync.controller.ts | 15 + apps/server/src/sync/sync.service.ts | 23 + 69 files changed, 6262 insertions(+), 1980 deletions(-) create mode 100644 apps/admin/src/auth/menu-policy.integration.test.ts create mode 100644 apps/admin/src/auth/menu-policy.ts create mode 100644 apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts create mode 100644 apps/admin/src/pages/Attendance/attendance-workspace.ts create mode 100644 apps/admin/src/pages/Attendance/attendance.css create mode 100644 apps/admin/src/pages/Schedules/schedule-visibility.integration.test.ts create mode 100644 apps/admin/src/pages/Schedules/schedule-visibility.ts create mode 100644 apps/admin/src/pages/Schedules/sync-result.integration.test.ts create mode 100644 apps/admin/src/pages/Schedules/sync-result.ts create mode 100644 apps/server/src/attendance/attendance.lesson-session.spec.ts create mode 100644 apps/server/src/database/attendance-fk-restrict.spec.ts create mode 100644 apps/server/src/entities/attendance-session.entity.ts create mode 100644 apps/server/src/integration/dingtalk.group-delete.spec.ts create mode 100644 apps/server/src/rbac/rbac.teacher-workspace.spec.ts create mode 100644 apps/server/src/schedules/schedules.controller.spec.ts diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 6863893..70a01af 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -193,7 +193,7 @@ const App: React.FC = () => { + } @@ -252,7 +252,7 @@ const App: React.FC = () => { + } diff --git a/apps/admin/src/auth/menu-policy.integration.test.ts b/apps/admin/src/auth/menu-policy.integration.test.ts new file mode 100644 index 0000000..a31724d --- /dev/null +++ b/apps/admin/src/auth/menu-policy.integration.test.ts @@ -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); + }); +}); diff --git a/apps/admin/src/auth/menu-policy.ts b/apps/admin/src/auth/menu-policy.ts new file mode 100644 index 0000000..655aa69 --- /dev/null +++ b/apps/admin/src/auth/menu-policy.ts @@ -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 = { + 老师: '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 { + 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(); + 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; +} diff --git a/apps/admin/src/auth/permission-navigation.integration.test.ts b/apps/admin/src/auth/permission-navigation.integration.test.ts index a730368..facc6a1 100644 --- a/apps/admin/src/auth/permission-navigation.integration.test.ts +++ b/apps/admin/src/auth/permission-navigation.integration.test.ts @@ -11,6 +11,18 @@ describe('permission navigation', () => { 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'); }); diff --git a/apps/admin/src/auth/permission-navigation.ts b/apps/admin/src/auth/permission-navigation.ts index 0c897f4..5d6e2c3 100644 --- a/apps/admin/src/auth/permission-navigation.ts +++ b/apps/admin/src/auth/permission-navigation.ts @@ -13,11 +13,11 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [ { 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: '/teacher-workspace', permission: 'class:view' }, { path: '/classroom-schedule', permission: 'rental:view' }, { path: '/classrooms', permission: 'classroom:view' }, { path: '/classroom-rentals', permission: 'rental:view' }, @@ -32,7 +32,7 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [ { path: '/integration-config', permission: 'integration:read' }, { path: '/ai-config', permission: 'ai:config:read' }, { path: '/users', permission: 'user:view' }, - { path: '/teachers', permission: 'user:view' }, + { path: '/teachers', permission: 'teacher:view' }, ] as const; function matchesPage(page: PermissionPage, pathname: string): boolean { diff --git a/apps/admin/src/auth/permission-tabs.integration.test.ts b/apps/admin/src/auth/permission-tabs.integration.test.ts index 14f1065..6a26b7b 100644 --- a/apps/admin/src/auth/permission-tabs.integration.test.ts +++ b/apps/admin/src/auth/permission-tabs.integration.test.ts @@ -1,19 +1,18 @@ import { describe, expect, it } from 'vitest'; import { filterTabsByPermission } from './permission-tabs'; -describe('permission-aware tabs', () => { - const tabs = [ - { key: 'all', requiredPermission: 'deposit:view' }, - { key: 'pending', requiredPermission: 'deposit:approve' }, - ]; +const tabs = [ + { key: 'records', requiredPermission: 'deposit:view' }, + { key: 'refund', requiredPermission: 'deposit:refund' }, +]; - it('hides tabs whose backing API permission is missing', () => { - expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual(['all']); - }); - - it('shows a privileged tab only when its permission is present', () => { +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:approve']).map((tab) => tab.key), - ).toEqual(['all', 'pending']); + filterTabsByPermission(tabs, ['deposit:view', 'deposit:refund']).map((tab) => tab.key), + ).toEqual(['records', 'refund']); }); }); diff --git a/apps/admin/src/components/DefaultRoute.tsx b/apps/admin/src/components/DefaultRoute.tsx index df1aacf..201f91c 100644 --- a/apps/admin/src/components/DefaultRoute.tsx +++ b/apps/admin/src/components/DefaultRoute.tsx @@ -2,11 +2,18 @@ import React from 'react'; import { Navigate } from 'react-router-dom'; import { Result } from 'antd'; import { usePermission } from '../hooks/usePermission'; -import { findFirstAccessiblePath } from '../auth/permission-navigation'; +import { findRoleAwareLandingPath } from '../auth/menu-policy'; const DefaultRoute: React.FC = () => { const { permissions } = usePermission(); - const firstPath = findFirstAccessiblePath(permissions); + const roles = (() => { + try { + return JSON.parse(localStorage.getItem('user') || '{}').roles || []; + } catch { + return []; + } + })(); + const firstPath = findRoleAwareLandingPath(roles, permissions); if (firstPath) return ; return ; }; diff --git a/apps/admin/src/components/PermissionRoute.tsx b/apps/admin/src/components/PermissionRoute.tsx index 8219272..9a3b880 100644 --- a/apps/admin/src/components/PermissionRoute.tsx +++ b/apps/admin/src/components/PermissionRoute.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Result, Button } from 'antd'; import { useNavigate } from 'react-router-dom'; -import { findFirstAccessiblePath } from '../auth/permission-navigation'; +import { findRoleAwareLandingPath } from '../auth/menu-policy'; import { usePermission } from '../hooks/usePermission'; interface PermissionRouteProps { @@ -13,7 +13,13 @@ const PermissionRoute: React.FC = ({ permission, children const { permissions, hasPermission } = usePermission(); const navigate = useNavigate(); if (!hasPermission(permission)) { - const firstPath = findFirstAccessiblePath(permissions); + let roles: string[] = []; + try { + roles = JSON.parse(localStorage.getItem('user') || '{}').roles || []; + } catch { + roles = []; + } + const firstPath = findRoleAwareLandingPath(roles, permissions); return ( , - label: '数据面板', - permission: 'dashboard:view', - }, - { - key: 'dorm-group', - icon: , - label: '宿舍运营', - permission: 'room:view', - children: [ - { key: '/room-visual', icon: , label: '宿舍总览', permission: 'room:view' }, - { key: '/rooms', icon: , label: '宿舍管理', permission: 'room:view' }, - { key: '/occupancies', icon: , label: '入住管理', permission: 'occupancy:view' }, - ], - }, - { - key: 'student-group', - icon: , - label: '学员管理', - permission: 'student:view', - children: [ - { key: '/students', icon: , label: '学生管理', permission: 'student:view' }, - { key: '/classes', icon: , label: '班级管理', permission: 'class:view' }, - { key: '/attendance', icon: , label: '考勤管理', permission: 'attendance:view' }, - ], - }, - { - key: 'academic-group', - icon: , - label: '教务管理', - permission: 'schedule:view', - children: [ - { key: '/schedules', icon: , label: '排课管理', permission: 'schedule:view' }, - { key: '/teacher-workspace', icon: , label: '教师工作台', permission: 'class:view' }, - ], - }, - { - key: 'classroom-group', - icon: , - label: '教室管理', - permission: 'classroom:view', - children: [ - { key: '/classroom-schedule', icon: , label: '排期总览', permission: 'rental:view' }, - { key: '/classrooms', icon: , label: '教室列表', permission: 'classroom:view' }, - { key: '/classroom-rentals', icon: , label: '租赁订单', permission: 'rental:view' }, - { key: '/organizations', icon: , label: '机构管理', permission: 'organization:view' }, - ], - }, - { - key: 'finance-group', - icon: , - label: '财务管理', - permission: 'expense:view', - children: [ - { key: '/expenses', icon: , label: '费用录入', permission: 'expense:view' }, - { key: '/deposits', icon: , label: '押金管理', permission: 'deposit:view' }, - { key: '/bills', icon: , label: '账单管理', permission: 'bill:view' }, - ], - }, - { - key: 'system-group', - icon: , - label: '系统管理', - permission: 'log:view', - children: [ - { key: '/notifications', icon: , label: '通知中心', permission: 'notification:view' }, - { key: '/operation-logs', icon: , label: '操作日志', permission: 'log:view' }, - { key: '/roles', icon: , label: '角色管理', permission: 'role:view' }, - { key: '/permissions', icon: , label: '权限一览', permission: 'role:view' }, - { key: '/integration-config', icon: , label: '钉钉集成配置', permission: 'integration:read' }, - { key: '/ai-config', icon: , label: 'AI 模型配置', permission: 'ai:config:read' }, - { key: '/users', icon: , label: '账号管理', permission: 'user:view' }, - ], - }, -]; +const iconMap: Record = { + dashboard: , + calendar: , + workspace: , + attendance: , + academic: , + students: , + classes: , + teachers: , + home: , + overview: , + occupancy: , + expense: , + bill: , + deposit: , + classroom: , + rental: , + organization: , + settings: , + users: , + role: , + permission: , + log: , + integration: , + ai: , + notification: , +}; const MainLayout: React.FC = () => { const [collapsed, setCollapsed] = useState(false); @@ -129,8 +71,10 @@ const MainLayout: React.FC = () => { const prevPathname = useRef(''); const navigate = useNavigate(); const location = useLocation(); - const user = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []); - const { hasPermission } = usePermission(); + const [user, setUser] = useState<{ name?: string; username?: string; roles?: string[] }>(() => + JSON.parse(localStorage.getItem('user') || '{}'), + ); + const { permissions, hasPermission } = usePermission(); useEffect(() => { let cancelled = false; @@ -139,7 +83,9 @@ const MainLayout: React.FC = () => { if (cancelled) return; writePermissions(profile.permissions || []); const cachedUser = JSON.parse(localStorage.getItem('user') || '{}'); - localStorage.setItem('user', JSON.stringify({ ...cachedUser, ...profile })); + const nextUser = { ...cachedUser, ...profile }; + localStorage.setItem('user', JSON.stringify(nextUser)); + setUser(nextUser); }) .catch(() => { // The API interceptor handles expired/invalid sessions. @@ -152,22 +98,10 @@ const MainLayout: React.FC = () => { const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px const isDesktop = !!screens.lg; // >= 992px - // 按 permission 过滤菜单 - const filterByPermission = (items: MenuItemType[]): MenuItemType[] => { - return items - .map((item) => { - if (item.children) { - const kids = filterByPermission(item.children); - if (kids.length === 0) return null; - return { ...item, children: kids }; - } - if (!item.permission) return item; - return hasPermission(item.permission) ? item : null; - }) - .filter(Boolean) as MenuItemType[]; - }; - - const menuItems = useMemo(() => filterByPermission(allMenuItems), [hasPermission]); + const menuItems = useMemo( + () => buildMenu(user.roles ?? [], permissions), + [user.roles, permissions], + ); const handleLogout = useCallback(() => { localStorage.removeItem('token'); @@ -181,7 +115,7 @@ const MainLayout: React.FC = () => { if (isMobile) setDrawerOpen(false); }, [navigate, isMobile]); - const findSelectedKeys = (items: MenuItemType[], pathname: string): string[] => { + const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => { for (const item of items) { if (item.key === pathname) return [item.key]; if (item.children) { @@ -192,7 +126,7 @@ const MainLayout: React.FC = () => { return [pathname]; }; - const findOpenKeys = (items: MenuItemType[], pathname: string): string[] => { + const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => { for (const item of items) { if (item.children) { if (item.children.some((c) => c.key === pathname || (c.children && c.children.some((gc) => gc.key === pathname)))) { @@ -220,10 +154,10 @@ const MainLayout: React.FC = () => { - const transformToMenuItems = (items: MenuItemType[]): any[] => { + const transformToMenuItems = (items: AppMenuItem[]): any[] => { return items.map((item) => ({ key: item.key, - icon: item.icon, + icon: item.icon ? iconMap[item.icon] : undefined, label: item.label, children: item.children ? transformToMenuItems(item.children) : undefined, })); diff --git a/apps/admin/src/pages/AiConfig/helpers.integration.test.ts b/apps/admin/src/pages/AiConfig/helpers.integration.test.ts index 1509288..636ae50 100644 --- a/apps/admin/src/pages/AiConfig/helpers.integration.test.ts +++ b/apps/admin/src/pages/AiConfig/helpers.integration.test.ts @@ -69,10 +69,10 @@ describe('AiConfig helpers', () => { }); describe('extractErrorMessage', () => { - it('extracts axios-style response error message', () => { - const err = { - response: { data: { message: 'API出错' } }, - }; + 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出错'); }); diff --git a/apps/admin/src/pages/AiConfig/helpers.ts b/apps/admin/src/pages/AiConfig/helpers.ts index 2cda601..9aafb4b 100644 --- a/apps/admin/src/pages/AiConfig/helpers.ts +++ b/apps/admin/src/pages/AiConfig/helpers.ts @@ -60,23 +60,17 @@ export function shouldAutoSwapBaseUrl( return { baseUrl: currentBaseUrl, shouldSwap: false }; } -/** Extract a safe user-facing error message from any caught value */ +/** 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 = ''; - // axios-style error: { response: { data: { message: string } } } - if (err && typeof err === 'object' && 'response' in err) { - const resp: unknown = err.response; - if (resp && typeof resp === 'object' && 'data' in resp) { - const data: unknown = resp.data; - if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') { - msg = data.message; - } - } - } - // standard Error or any object with a string message property - if (!msg && err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { + if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') { msg = err.message; } diff --git a/apps/admin/src/pages/AiConfig/index.tsx b/apps/admin/src/pages/AiConfig/index.tsx index a1e03b4..87b54f9 100644 --- a/apps/admin/src/pages/AiConfig/index.tsx +++ b/apps/admin/src/pages/AiConfig/index.tsx @@ -173,9 +173,17 @@ const AiConfigPage: React.FC = () => { await api.put('/ai/config', body); message.success('配置已保存'); form.setFieldValue('apiKey', ''); - await loadConfig(); } 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); } @@ -186,7 +194,7 @@ const AiConfigPage: React.FC = () => { const handleTest = useCallback(async () => { try { // Validated fields: compatible requires baseUrl - const fieldsToValidate = ['timeoutMs'] as string[]; + const fieldsToValidate = ['provider', 'timeoutMs'] as string[]; if (currentProvider === 'OPENAI_COMPATIBLE') { fieldsToValidate.push('baseUrl'); } diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts new file mode 100644 index 0000000..f1d8262 --- /dev/null +++ b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + canPullAttendance, + getAttendanceExperience, + getSchedulePhase, + summarizeAttendance, +} 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 }); + }); +}); diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.ts b/apps/admin/src/pages/Attendance/attendance-workspace.ts new file mode 100644 index 0000000..6b4f4a9 --- /dev/null +++ b/apps/admin/src/pages/Attendance/attendance-workspace.ts @@ -0,0 +1,65 @@ +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] += 1; + } + } + return summary; +} diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css new file mode 100644 index 0000000..77714dd --- /dev/null +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -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; +} diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 13dafde..e017b2e 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -1,38 +1,55 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { - Table, + Alert, + Avatar, Button, - Modal, - Form, + Card, + Col, DatePicker, + Drawer, + Empty, + Form, + Input, + Modal, + Progress, + Row, Select, Space, + Spin, + Table, Tag, - Card, - Input, Tooltip, - Row, - Col, - Tabs, - Alert, } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; import { - PlusOutlined, - CalendarOutlined, - UnorderedListOutlined, + ArrowRightOutlined, + CheckCircleFilled, + ClockCircleOutlined, + EditOutlined, ExportOutlined, - CloudDownloadOutlined, + FileSearchOutlined, + ReloadOutlined, + ScheduleOutlined, + TeamOutlined, + WarningFilled, } from '@ant-design/icons'; -import dayjs, { Dayjs } from 'dayjs'; +import dayjs, { type Dayjs } from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; +import { + canPullAttendance, + getAttendanceExperience, + getSchedulePhase, + summarizeAttendance, + type AttendanceSummary, + type SchedulePhase, +} from './attendance-workspace'; +import './attendance.css'; const { RangePicker } = DatePicker; -// ── Constants ── - const SESSION_OPTIONS = [ { value: 'morning_reading', label: '早自习' }, { value: 'morning', label: '上午' }, @@ -41,47 +58,24 @@ const SESSION_OPTIONS = [ { value: 'night_check', label: '晚寝' }, ]; -const SESSION_MAP: Record = { - morning_reading: '早自习', - morning: '上午', - afternoon: '下午', - evening_study: '晚自习', - night_check: '晚寝', +const SESSION_MAP: Record = Object.fromEntries( + SESSION_OPTIONS.map((item) => [item.value, item.label]), +); + +const STATUS_META: Record< + string, + { label: string; color: string; className: string; short: string } +> = { + present: { label: '出勤', color: 'success', className: 'is-present', short: '勤' }, + late: { label: '迟到', color: 'warning', className: 'is-late', short: '迟' }, + absent: { label: '缺勤', color: 'error', className: 'is-absent', short: '缺' }, + leave: { label: '请假', color: 'processing', className: 'is-leave', short: '假' }, + pending: { label: '待确认', color: 'default', className: 'is-pending', short: '待' }, }; -const STATUS_MAP: Record = { - present: { text: '出勤', color: 'green' }, - late: { text: '迟到', color: 'gold' }, - absent: { text: '缺勤', color: 'red' }, - leave: { text: '请假', color: 'blue' }, -}; - -const STATUS_OPTIONS = Object.entries(STATUS_MAP).map(([value, { text }]) => ({ - value, - label: text, -})); - -const SOURCE_OPTIONS = [ - { value: 'manual', label: '手动录入' }, - { value: 'dingtalk', label: '钉钉导入' }, -]; - -const MATCH_STATUS_MAP: Record = { - unmatched: { text: '未处理', color: 'default' }, - pending: { text: '待匹配', color: 'orange' }, - matched: { text: '已匹配', color: 'green' }, -}; - -const MATCH_STATUS_OPTIONS = Object.entries(MATCH_STATUS_MAP).map(([value, { text }]) => ({ - value, - label: text, -})); -const SOURCE_MAP: Record = { - manual: '手动录入', - dingtalk: '钉钉导入', -}; - -// ── Types ── +const STATUS_OPTIONS = Object.entries(STATUS_META) + .filter(([value]) => value !== 'pending') + .map(([value, item]) => ({ value, label: item.label })); interface ClassOption { classId: number; @@ -97,28 +91,49 @@ interface AttendanceRecordItem { status: string; source?: string; remark: string | null; - checkTime?: string; createdAt: string; - updatedAt: string; student: { id: number; name: string }; class: { id: number; name: string } | null; + scheduleId?: number | null; + attendanceSessionId?: number | null; } -interface BatchRecordInput { - studentId: number; - studentName: string; +interface AssignedClass { + classId: number; + className: string; + classCode: string; + roleType: string; + subject: string; } -interface DingRecord { +interface TodaySchedule { id: number; - dingUserId: string; - attendanceDate: string; - checkInTime?: string; - checkOutTime?: string; - timeResult: string; - matchStatus: string; - studentId?: number; + classId: number; + classroomId: number; + startTime: string; + endTime: string; + subject: string; } + +interface LessonAttendanceSession { + id: number; + scheduleId: number; + classId: number; + lessonDate: string; + status: 'in_progress' | 'completed'; +} + +interface LessonAttendanceResponse { + schedule: TodaySchedule; + session: LessonAttendanceSession | null; + records: AttendanceRecordItem[]; +} + +interface TeacherWorkspaceData { + assignedClasses: AssignedClass[]; + todaySchedules: TodaySchedule[]; +} + interface AlertItem { studentId: number; studentName: string; @@ -128,1115 +143,666 @@ interface AlertItem { lastDate: string; } -// ── Component ── +const EMPTY_SUMMARY: AttendanceSummary = { + total: 0, + present: 0, + late: 0, + absent: 0, + leave: 0, + pending: 0, +}; + +function readCurrentRoles(): string[] { + try { + const user = JSON.parse(localStorage.getItem('user') || '{}') as { roles?: string[] }; + return Array.isArray(user.roles) ? user.roles : []; + } catch { + return []; + } +} + +function AttendanceStatusTag({ status }: { status: string }) { + const meta = STATUS_META[status] ?? { + label: status, + color: 'default', + className: 'is-pending', + short: '?', + }; + return ( + + + {meta.label} + + ); +} + +function SummaryStrip({ summary }: { summary: AttendanceSummary }) { + const rate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0; + return ( +
+
+ +
+ 出勤率 + {summary.total} 条记录 +
+
+ {[ + ['present', summary.present], + ['late', summary.late], + ['absent', summary.absent], + ['leave', summary.leave], + ].map(([status, value]) => { + const meta = STATUS_META[String(status)]; + return ( +
+ {meta.short} +
+ {value} + {meta.label} +
+
+ ); + })} +
+ ); +} const AttendancePage: React.FC = () => { - const { hasAnyPermission } = usePermission(); - const canManageAllAttendance = hasAnyPermission('class:edit', 'attendance:edit'); + const { permissions, hasPermission } = usePermission(); + const roles = useMemo(readCurrentRoles, []); + const experience = getAttendanceExperience(permissions, roles); - // ── State ── - const [records, setRecords] = useState([]); - const [loading, setLoading] = useState(false); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); - const [classOptions, setClassOptions] = useState([]); - const [alerts, setAlerts] = useState([]); + if (experience === 'teacher') { + return ; + } - // Filters - const [filterClassId, setFilterClassId] = useState(undefined); - const [filterDateRange, setFilterDateRange] = useState<[Dayjs, Dayjs] | null>(null); - const [filterSession, setFilterSession] = useState(undefined); - const [filterStatus, setFilterStatus] = useState(undefined); - const [filterSource, setFilterSource] = useState(undefined); + return ; +}; - // View toggle - const [calendarView, setCalendarView] = useState(false); - const [calendarData, setCalendarData] = useState< - { - studentId: number; - studentName: string; - days: { date: string; session: string; status: string }[]; - }[] - >([]); - const [calendarLoading, setCalendarLoading] = useState(false); +const TeacherAttendanceWorkspace: React.FC = () => { + const [loading, setLoading] = useState(true); + const [workspace, setWorkspace] = useState(null); + const [selectedSchedule, setSelectedSchedule] = useState(null); + const [lessonSession, setLessonSession] = useState(null); + const [lessonRecords, setLessonRecords] = useState([]); + const [recordLoading, setRecordLoading] = useState(false); + const [drawerOpen, setDrawerOpen] = useState(false); + const [finishing, setFinishing] = useState(false); - // Tab - const [activeTab, setActiveTab] = useState('records'); - - // DingTalk raw data - const [dingRecords, setDingRecords] = useState([]); - const [dingLoading, setDingLoading] = useState(false); - const [dingPage, setDingPage] = useState(1); - const [dingPageSize, setDingPageSize] = useState(20); - const [dingTotal, setDingTotal] = useState(0); - const [dingMatchStatus, setDingMatchStatus] = useState(undefined); - // DingTalk import - const [importModalOpen, setImportModalOpen] = useState(false); - const [importClassId, setImportClassId] = useState(undefined); - const [importClassOptions, setImportClassOptions] = useState([]); - const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]); - const [importing, setImporting] = useState(false); - const [importProgressMsg, setImportProgressMsg] = useState(''); - - // Match modal - const [matchModalOpen, setMatchModalOpen] = useState(false); - const [matchRecordId, setMatchRecordId] = useState(null); - const [matchStudentSearch, setMatchStudentSearch] = useState(''); - const [matchStudentResults, setMatchStudentResults] = useState<{ id: number; name: string }[]>( - [], - ); - const [matchStudentLoading, setMatchStudentLoading] = useState(false); - const [matchSubmitting, setMatchSubmitting] = useState(false); - - // Batch modal - const [batchModalOpen, setBatchModalOpen] = useState(false); - const [batchStudents, setBatchStudents] = useState([]); - const [batchDate, setBatchDate] = useState(dayjs()); - const [batchSession, setBatchSession] = useState('morning'); - const [batchStatus, setBatchStatus] = useState('present'); - const [batchRemark, setBatchRemark] = useState(''); - const [batchSubmitting, setBatchSubmitting] = useState(false); - const [studentSearch, setStudentSearch] = useState(''); - const [studentSearchResults, setStudentSearchResults] = useState<{ id: number; name: string }[]>( - [], - ); - const [studentSearchLoading, setStudentSearchLoading] = useState(false); - - // Edit modal - const [editModalOpen, setEditModalOpen] = useState(false); - const [editRecord, setEditRecord] = useState(null); - const [editForm] = Form.useForm(); - - // ── Edit record ── - const handleEdit = (record: AttendanceRecordItem) => { - setEditRecord(record); - editForm.setFieldsValue({ - session: record.session, - status: record.status, - remark: record.remark, - }); - setEditModalOpen(true); - }; - - const handleEditSubmit = async () => { - try { - const values = await editForm.validateFields(); - await api.put(`/attendance-records/${editRecord!.id}`, values); - message.success('更新成功'); - setEditModalOpen(false); - fetchRecords(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '更新失败'); - } - }; - - // ── Fetch classes ── - const fetchClasses = useCallback(async () => { - try { - const res = await api.get('/attendance-records/classes'); - setClassOptions(res); - } catch { - // Silently fail — class filter just stays empty - } - }, []); - - // ── Fetch records ── - const fetchRecords = useCallback(async () => { + const loadWorkspace = useCallback(async () => { setLoading(true); try { - const params: Record = { page, pageSize }; - if (filterClassId) params.classId = filterClassId; - if (filterDateRange?.[0]) params.dateFrom = filterDateRange[0].format('YYYY-MM-DD'); - if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD'); - if (filterSession) params.session = filterSession; - if (filterStatus) params.status = filterStatus; - if (filterSource) params.source = filterSource; - - const data = await api.get<{ list: AttendanceRecordItem[]; total: number }>( - '/attendance-records', - { params }, - ); - setRecords(data.list); - setTotal(data.total); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '获取考勤记录失败'); + setWorkspace(await api.get('/rbac/teacher-workspace')); + } catch (error: unknown) { + message.error((error as { message?: string })?.message || '加载今日课程失败'); } finally { setLoading(false); } - }, [page, pageSize, filterClassId, filterDateRange, filterSession, filterStatus, filterSource]); + }, []); - // ── Fetch calendar ── - const fetchCalendar = useCallback(async () => { - if (!filterClassId) { - message.warning('请先选择班级'); - return; - } - setCalendarLoading(true); + useEffect(() => { + void loadWorkspace(); + }, [loadWorkspace]); + + const classNameById = useMemo( + () => new Map(workspace?.assignedClasses.map((item) => [item.classId, item.className]) ?? []), + [workspace], + ); + + const loadLessonAttendance = useCallback(async (schedule: TodaySchedule) => { + const today = dayjs().format('YYYY-MM-DD'); + const data = await api.get( + `/attendance-lessons/schedules/${schedule.id}`, + { params: { date: today } }, + ); + setLessonSession(data.session); + setLessonRecords(data.records); + }, []); + + const openAttendance = useCallback(async (schedule: TodaySchedule) => { + setSelectedSchedule(schedule); + setDrawerOpen(true); + setRecordLoading(true); try { - const res = await api.get< - { - studentId: number; - studentName: string; - days: { date: string; session: string; status: string }[]; - }[] - >('/attendance-records/calendar', { - params: { classId: filterClassId }, - }); - setCalendarData(res); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '获取日历数据失败'); - } finally { - setCalendarLoading(false); - } - }, [filterClassId]); - - // ── Fetch DingTalk raw data ── - const fetchDingRecords = useCallback(async () => { - setDingLoading(true); - try { - const params: Record = { page: dingPage, pageSize: dingPageSize }; - if (filterClassId) params.classId = filterClassId; - if (filterDateRange?.[0]) params.dateFrom = filterDateRange[0].format('YYYY-MM-DD'); - if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD'); - if (dingMatchStatus) params.matchStatus = dingMatchStatus; - - const data = await api.get<{ list: DingRecord[]; total: number }>('/ding-attendance-raw', { - params, - }); - setDingRecords(data.list); - setDingTotal(data.total); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '获取钉钉原始数据失败'); - } finally { - setDingLoading(false); - } - }, [dingPage, dingPageSize, filterClassId, filterDateRange, dingMatchStatus]); - - // ── Classes the current teacher may import from DingTalk ── - const fetchImportClasses = useCallback(async () => { - try { - const options = await api.get('/attendance-records/import/dingtalk/classes'); - setImportClassOptions(options); - setImportClassId( - (current) => current ?? (options.length === 1 ? options[0].classId : undefined), + const today = dayjs().format('YYYY-MM-DD'); + const data = await api.post( + `/attendance-lessons/schedules/${schedule.id}/pull`, + { date: today }, ); - } catch (e: unknown) { - const err = e as { message?: string }; - setImportClassOptions([]); - message.error(err?.message || '加载可拉取班级失败,请刷新页面后重试'); - } - }, []); - - const openDingTalkImportModal = () => { - const today = dayjs(); - setImportDateRange([today, today]); - setImportProgressMsg(''); - setImportModalOpen(true); - void fetchImportClasses(); - }; - - const closeDingTalkImportModal = () => { - setImportModalOpen(false); - setImportClassId(undefined); - setImportDateRange([dayjs(), dayjs()]); - setImportProgressMsg(''); - }; - - // ── DingTalk import handler ── - const handleImportDingTalk = useCallback(async () => { - if (!importClassId) { - message.warning('请选择要拉取考勤的班级'); - return; - } - if (!importDateRange?.[0] || !importDateRange?.[1]) { - message.warning('请选择导入日期范围'); - return; - } - setImporting(true); - setImportProgressMsg('正在从钉钉拉取考勤数据...'); - - try { - const result = await api.post<{ - success: boolean; - imported: number; - skipped: number; - matched: number; - errors: string[]; - duration: number; - }>('/attendance-records/import/dingtalk', { - classId: importClassId, - start: importDateRange[0].format('YYYY-MM-DD'), - end: importDateRange[1].format('YYYY-MM-DD'), - }); - - setImportProgressMsg(''); - if (result.success) { - message.success( - `导入完成:${result.imported} 条新增, ${result.skipped} 条跳过, ${result.matched} 条匹配 (${result.duration}ms)`, - ); - if (result.errors?.length) { - message.warning(`警告:${result.errors.join('; ')}`); - } - setImportModalOpen(false); - fetchDingRecords(); - } else { - message.error(`导入失败:${result.errors.join('; ')}`); - } - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '导入失败'); + setLessonSession(data.session); + setLessonRecords(data.records); + message.success('钉钉考勤已更新,请核对待确认和异常记录'); + } catch (error: unknown) { + setLessonSession(null); + setLessonRecords([]); + message.error(error instanceof Error ? error.message : '加载本节课考勤失败'); } finally { - setImporting(false); - } - }, [canManageAllAttendance, importClassId, importDateRange, fetchDingRecords]); - - // ── Effects ── - useEffect(() => { - fetchClasses(); - }, [fetchClasses]); - useEffect(() => { - let cancelled = false; - api - .get('/attendance-records/alerts') - .then((data) => { - if (!cancelled) setAlerts(data); - }) - .catch(() => {}); - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - if (calendarView) { - fetchCalendar(); - } else { - fetchRecords(); - } - }, [calendarView, fetchRecords, fetchCalendar]); - - useEffect(() => { - if (activeTab === 'dingtalk') { - fetchDingRecords(); - } - }, [activeTab, fetchDingRecords]); - - // ── Student search ── - const handleStudentSearch = useCallback(async (value: string) => { - setStudentSearch(value); - if (!value || value.length < 1) { - setStudentSearchResults([]); - return; - } - setStudentSearchLoading(true); - try { - const data = await api.get< - { list?: { id: number; name: string }[] } | { id: number; name: string }[] - >('/students', { params: { search: value, pageSize: 10 } }); - const list = Array.isArray(data) ? data : (data.list ?? []); - setStudentSearchResults(list); - } catch { - setStudentSearchResults([]); - } finally { - setStudentSearchLoading(false); + setRecordLoading(false); } }, []); - const addStudentToBatch = useCallback( - (studentId: number, studentName: string) => { - if (batchStudents.some((s) => s.studentId === studentId)) { - message.warning('该学生已在列表中'); - return; - } - setBatchStudents((prev) => [...prev, { studentId, studentName }]); - setStudentSearch(''); - setStudentSearchResults([]); - }, - [batchStudents], - ); - - const removeBatchStudent = useCallback((studentId: number) => { - setBatchStudents((prev) => prev.filter((s) => s.studentId !== studentId)); - }, []); - - // ── Batch submit ── - const handleBatchSubmit = useCallback(async () => { - if (batchStudents.length === 0) { - message.warning('请添加至少一名学生'); - return; - } - setBatchSubmitting(true); - try { - const records = batchStudents.map((s) => ({ - studentId: s.studentId, - classId: filterClassId, - attendanceDate: batchDate.format('YYYY-MM-DD'), - session: batchSession, - status: batchStatus, - remark: batchRemark || undefined, - })); - await api.post('/attendance-records/batch', { records }); - message.success(`成功录入 ${batchStudents.length} 条考勤记录`); - setBatchModalOpen(false); - setBatchStudents([]); - setBatchRemark(''); - fetchRecords(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '批量录入失败'); - } finally { - setBatchSubmitting(false); - } - }, [ - batchStudents, - batchDate, - batchSession, - batchStatus, - batchRemark, - filterClassId, - fetchRecords, - ]); - - // ── Reset filters ── - const handleReset = useCallback(() => { - setFilterClassId(undefined); - setFilterDateRange(null); - setFilterSession(undefined); - setFilterStatus(undefined); - setFilterSource(undefined); - setPage(1); - }, []); - - // ── Match modal ── - const openMatchModal = useCallback((recordId: number) => { - setMatchRecordId(recordId); - setMatchStudentSearch(''); - setMatchStudentResults([]); - setMatchModalOpen(true); - }, []); - - const handleMatchStudentSearch = useCallback(async (value: string) => { - setMatchStudentSearch(value); - if (!value || value.length < 1) { - setMatchStudentResults([]); - return; - } - setMatchStudentLoading(true); - try { - const data = await api.get< - { list?: { id: number; name: string }[] } | { id: number; name: string }[] - >('/students', { params: { search: value, pageSize: 10 } }); - const list = Array.isArray(data) ? data : (data.list ?? []); - setMatchStudentResults(list); - } catch { - setMatchStudentResults([]); - } finally { - setMatchStudentLoading(false); - } - }, []); - - const handleMatchSubmit = useCallback( - async (studentId: number) => { - if (matchRecordId === null) return; - setMatchSubmitting(true); + const updateLessonRecord = useCallback( + async (record: AttendanceRecordItem, status: string) => { + const previous = record.status; + setLessonRecords((items) => + items.map((item) => (item.id === record.id ? { ...item, status } : item)), + ); try { - await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId }); - message.success('匹配成功'); - setMatchModalOpen(false); - setMatchRecordId(null); - fetchDingRecords(); - } catch (e: unknown) { - const err = e as { message?: string }; - message.error(err?.message || '匹配失败'); - } finally { - setMatchSubmitting(false); + await api.put(`/attendance-records/${record.id}`, { status }); + } catch (error: unknown) { + setLessonRecords((items) => + items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)), + ); + message.error((error as { message?: string })?.message || '更新考勤失败'); } }, - [matchRecordId, fetchDingRecords], - ); - - // ── Report download ── - const handleExportReport = useCallback(() => { - const baseURL = '/api'; - const token = localStorage.getItem('token'); - const params = new URLSearchParams(); - if (filterClassId) params.set('classId', String(filterClassId)); - if (filterDateRange?.[0]) params.set('dateFrom', filterDateRange[0].format('YYYY-MM-DD')); - if (filterDateRange?.[1]) params.set('dateTo', filterDateRange[1].format('YYYY-MM-DD')); - - fetch(`${baseURL}/attendance-records/report?${params.toString()}`, { - headers: { Authorization: `Bearer ${token}` }, - }) - .then((res) => { - if (!res.ok) throw new Error('下载失败'); - return res.blob(); - }) - .then((blob) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = '考勤报表.xlsx'; - a.click(); - URL.revokeObjectURL(url); - message.success('报表下载成功'); - }) - .catch(() => message.error('报表下载失败')); - }, [filterClassId, filterDateRange]); - - // ── DingTalk table columns ── - const dingColumns = useMemo( - () => [ - { - title: '钉钉用户ID', - dataIndex: 'dingUserId', - key: 'dingUserId', - width: 160, - }, - { - title: '打卡时间', - key: 'checkTime', - width: 180, - render: (_: unknown, record: DingRecord) => { - const value = record.checkInTime || record.checkOutTime; - return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : record.attendanceDate || '-'; - }, - }, - { - title: '打卡状态', - dataIndex: 'timeResult', - key: 'timeResult', - width: 120, - }, - { - title: '匹配状态', - dataIndex: 'matchStatus', - key: 'matchStatus', - width: 100, - render: (v: string) => { - const item = MATCH_STATUS_MAP[v]; - return item ? {item.text} : v; - }, - }, - { - title: '操作', - key: 'actions', - width: 80, - render: (_: unknown, record: DingRecord) => { - if (record.matchStatus === 'matched') return -; - return ( - openMatchModal(record.id)} - > - 匹配 - - ); - }, - }, - ], - [openMatchModal], - ); - - // ── Table columns ── - const columns = useMemo( - () => [ - { - title: '学生姓名', - dataIndex: ['student', 'name'], - key: 'studentName', - width: 100, - fixed: 'left' as const, - }, - { - title: '班级', - dataIndex: ['class', 'name'], - key: 'className', - width: 120, - render: (v: string | undefined) => v || '-', - }, - { - title: '日期', - dataIndex: 'attendanceDate', - key: 'attendanceDate', - width: 110, - }, - { - title: '时段', - dataIndex: 'session', - key: 'session', - width: 90, - render: (v: string) => SESSION_MAP[v] || v, - }, - { - title: '状态', - dataIndex: 'status', - key: 'status', - width: 80, - render: (v: string) => { - const item = STATUS_MAP[v]; - return item ? {item.text} : v; - }, - }, - { - title: '来源', - dataIndex: 'source', - key: 'source', - width: 90, - render: (v: string | undefined) => (v ? SOURCE_MAP[v] || v : '-'), - }, - { - title: '备注', - dataIndex: 'remark', - key: 'remark', - width: 150, - render: (v: string | null) => v || '-', - }, - { - title: '录入时间', - dataIndex: 'createdAt', - key: 'createdAt', - width: 160, - render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'), - }, - { - title: '操作', - key: 'actions', - width: 80, - fixed: 'right' as const, - render: (_: unknown, record: AttendanceRecordItem) => ( - handleEdit(record)} - > - 编辑 - - ), - }, - ], [], ); - // ── Calendar columns ── - const calendarDates = useMemo(() => { - const dates = new Set(); - for (const student of calendarData) { - for (const day of student.days) { - dates.add(day.date); - } + const completeAttendance = useCallback(async () => { + if (!lessonSession || !selectedSchedule) return; + setFinishing(true); + try { + const result = await api.post( + `/attendance-lessons/${lessonSession.id}/complete`, + ); + setLessonSession(result.session); + setLessonRecords(result.records); + message.success('考勤核对已完成'); + await loadLessonAttendance(selectedSchedule); + } catch (error: unknown) { + message.error((error as { message?: string })?.message || '完成点名失败'); + } finally { + setFinishing(false); } - return Array.from(dates).sort(); - }, [calendarData]); + }, [lessonSession, selectedSchedule, loadLessonAttendance]); - const calendarColumns = useMemo( - () => [ - { - title: '学生', - dataIndex: 'studentName', - key: 'studentName', - width: 100, - fixed: 'left' as const, - }, - ...calendarDates.map((date) => ({ - title: ( -
-
{dayjs(date).format('MM/DD')}
-
{dayjs(date).format('ddd')}
+ const now = new Date(); + const schedules = workspace?.todaySchedules ?? []; + const startedCount = schedules.filter( + (item) => canPullAttendance(getSchedulePhase(item.startTime, item.endTime, now)), + ).length; + const nextSchedule = schedules.find( + (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended', + ); + const drawerSummary = summarizeAttendance(lessonRecords); + const isAttendanceCompleted = lessonSession?.status === 'completed'; + + return ( +
+
+
+ TEACHING DAY · {dayjs().format('MM月DD日 dddd')} +

今天,从课程开始

+

课表先同步到钉钉考勤;课程开始后,老师可随时拉取该节课的最新打卡结果并核对异常。

+
+ +
+ + + +
今日课程{schedules.length}
+ + +
已开始{startedCount}节,可查看考勤
+ + +
下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
+ +
+ +
+
今日教学节奏

我的课程

+ 课程开始后可拉取钉钉考勤记录 +
+ + + {schedules.length === 0 ? ( + + 今天还没有课程

请联系教务管理员安排课程。

} + /> + + ) : ( +
+ {schedules.map((schedule, index) => { + const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now); + return ( + void openAttendance(schedule)} + /> + ); + })}
- ), - key: date, - width: 80, - render: ( - _: unknown, - record: { - studentId: number; - studentName: string; - days: { date: string; session: string; status: string }[]; - }, - ) => { - const dayRecord = record.days.find((d) => d.date === date); - if (!dayRecord) return -; - const statusInfo = STATUS_MAP[dayRecord.status]; - return ( - - - {statusInfo?.text || dayRecord.status} - - - ); - }, - })), - ], - [calendarDates], + )} + + + setDrawerOpen(false)} width={820} title={null} className="attendance-drawer"> +
+ LESSON ATTENDANCE +

{selectedSchedule?.subject || '课程考勤'}

+

{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}

+
+ {lessonSession && ( + void completeAttendance()}> + 完成核对 + + ) : undefined + } + /> + )} + + + rowKey="id" + loading={recordLoading} + dataSource={lessonRecords} + pagination={false} + locale={{ emptyText: }} + columns={[ + { + title: '学生', dataIndex: ['student', 'name'], + render: (name: string) =>
{name?.slice(0, 1)}{name || '-'}
, + }, + { + title: '考勤结果', dataIndex: 'status', width: 310, + render: (value: string, record: AttendanceRecordItem) => ( +
+ {STATUS_OPTIONS.map((option) => ( + + ))} +
+ ), + }, + { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => }, + { title: '备注', dataIndex: 'remark', render: (value: string | null) => value || }, + ]} + /> +
+
+ ); +}; + +const LessonCard: React.FC<{ + schedule: TodaySchedule; + phase: SchedulePhase; + className: string; + index: number; + onOpen: () => void; +}> = ({ schedule, phase, className, index, onOpen }) => { + const phaseMeta = { + upcoming: { label: '待上课', icon: , tone: 'upcoming' }, + ongoing: { label: '进行中', icon: , tone: 'ongoing' }, + ended: { label: '已结束', icon: , tone: 'ended' }, + }[phase]; + + return ( +
+
{String(index).padStart(2, '0')}
+
{schedule.startTime}{schedule.endTime}
+
+

{schedule.subject}

{phaseMeta.label}
+

{className}教室 {schedule.classroomId}

+
+
+ {phase === 'upcoming' ? ( + + ) : ( + + )} +
+
+ ); +}; + + +const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { + const [records, setRecords] = useState([]); + const [summary, setSummary] = useState(EMPTY_SUMMARY); + const [alerts, setAlerts] = useState([]); + const [classOptions, setClassOptions] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [total, setTotal] = useState(0); + const [classId, setClassId] = useState(); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([ + dayjs().subtract(30, 'day'), + dayjs(), + ]); + const [status, setStatus] = useState(); + const [session, setSession] = useState(); + const [editRecord, setEditRecord] = useState(null); + const [editForm] = Form.useForm(); + + const buildParams = useCallback( + (withPagination = true) => { + const params: Record = {}; + if (withPagination) Object.assign(params, { page, pageSize }); + if (classId) params.classId = classId; + if (dateRange?.[0]) params.dateFrom = dateRange[0].format('YYYY-MM-DD'); + if (dateRange?.[1]) params.dateTo = dateRange[1].format('YYYY-MM-DD'); + if (status) params.status = status; + if (session) params.session = session; + return params; + }, + [page, pageSize, classId, dateRange, status, session], ); - // ── Render ── + const loadRecords = useCallback(async () => { + setLoading(true); + try { + const [recordData, summaryData] = await Promise.all([ + api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { + params: buildParams(true), + }), + api.get('/attendance-records/summary', { + params: buildParams(false), + }), + ]); + setRecords(recordData.list); + setTotal(recordData.total); + setSummary({ ...EMPTY_SUMMARY, ...summaryData }); + } catch (error: unknown) { + message.error((error as { message?: string })?.message || '加载历史考勤失败'); + } finally { + setLoading(false); + } + }, [buildParams]); + + useEffect(() => { + void Promise.all([ + api.get('/attendance-records/classes').then(setClassOptions), + api.get('/attendance-records/alerts').then(setAlerts), + ]).catch(() => undefined); + }, []); + + useEffect(() => { + void loadRecords(); + }, [loadRecords]); + + const resetFilters = () => { + setClassId(undefined); + setDateRange([dayjs().subtract(30, 'day'), dayjs()]); + setStatus(undefined); + setSession(undefined); + setPage(1); + }; + + const handleExport = useCallback(() => { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(buildParams(false))) params.set(key, String(value)); + const token = localStorage.getItem('token'); + fetch(`/api/attendance-records/export?${params.toString()}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((response) => { + if (!response.ok) throw new Error('导出失败'); + return response.blob(); + }) + .then((blob) => { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = `历史考勤-${dayjs().format('YYYYMMDD')}.xlsx`; + anchor.click(); + URL.revokeObjectURL(url); + }) + .catch(() => message.error('导出失败')); + }, [buildParams]); + + const submitEdit = async () => { + if (!editRecord) return; + try { + const values = await editForm.validateFields(); + await api.put(`/attendance-records/${editRecord.id}`, values); + message.success('考勤记录已更新'); + setEditRecord(null); + void loadRecords(); + } catch (error: unknown) { + if ((error as { errorFields?: unknown })?.errorFields) return; + message.error((error as { message?: string })?.message || '更新失败'); + } + }; + + const columns: ColumnsType = [ + { + title: '学生', + dataIndex: ['student', 'name'], + fixed: 'left', + width: 150, + render: (name: string, record) => ( +
+ {name?.slice(0, 1)} +
+ {name || '-'} + {record.class?.name || '未关联班级'} +
+
+ ), + }, + { title: '日期', dataIndex: 'attendanceDate', width: 120 }, + { + title: '时段', + dataIndex: 'session', + width: 110, + render: (value: string) => SESSION_MAP[value] || value, + }, + { + title: '结果', + dataIndex: 'status', + width: 110, + render: (value: string) => , + }, + { + title: '记录来源', + dataIndex: 'source', + width: 110, + render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'), + }, + { + title: '备注', + dataIndex: 'remark', + ellipsis: true, + render: (value: string | null) => value || , + }, + { + title: '归档时间', + dataIndex: 'createdAt', + width: 165, + render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'), + }, + ...(canEdit + ? [ + { + title: '操作', + key: 'action', + fixed: 'right' as const, + width: 80, + render: (_: unknown, record: AttendanceRecordItem) => ( + + ), + }, + ] + : []), + ]; + return ( -
+
+
+
+ ATTENDANCE ARCHIVE +

历史考勤档案

+

面向管理人员的历史检索、异常追踪与数据归档,不承载实时上课操作。

+
+ } + size="large" + onClick={handleExport} + > + 导出当前结果 + +
+ + + {alerts.length > 0 && ( - - `${a.studentName}(${a.className || '-'}):${a.type} ${a.count}次,最近${a.lastDate}`, - ) - .join(';')} - style={{ marginBottom: 16 }} - /> +
+ +
+ {alerts.length} 名学生存在连续异常 + 建议优先核查最近 14 天的缺勤与迟到记录 +
+ `${item.studentName}:${item.type}${item.count}次`).join(';')}> + + +
)} - - {/* ── Filter bar ── */} - - - - { - setFilterSession(v); - setPage(1); - }} - options={SESSION_OPTIONS} - /> - - - { - setFilterSource(v); - setPage(1); - }} - options={SOURCE_OPTIONS} - /> - - - - - - - - - {/* ── Action bar ── */} -
- - } - onClick={() => setBatchModalOpen(true)} - > - 批量录入 - - } - onClick={handleExportReport} - > - 导出考勤报表 - - - -
- - {/* ── Table view ── */} - {!calendarView && ( - `共 ${t} 条`, - onChange: (p, ps) => { - setPage(p); - setPageSize(ps); - }, - }} - /> - )} - - {/* ── Calendar view ── */} - {calendarView && ( - - {calendarData.length === 0 ? ( -
- 暂无数据,请选择班级后查看 -
- ) : ( -
- )} - - )} - - ), - }, - { - key: 'dingtalk', - label: '钉钉原始数据', - children: ( - <> - {/* ── DingTalk filter ── */} - - - - { - setDingMatchStatus(v); - setDingPage(1); - }} - options={MATCH_STATUS_OPTIONS} - /> - - - } - loading={importing} - onClick={openDingTalkImportModal} - > - 从钉钉拉取考勤 - - - - {importing && ( -
- {importProgressMsg} -
- )} - - - {/* ── DingTalk table ── */} -
`共 ${t} 条`, - onChange: (p, ps) => { - setDingPage(p); - setDingPageSize(ps); - }, - }} - /> - - ), - }, - ]} - /> - - {/* ── Batch add modal ── */} - { - setBatchModalOpen(false); - setBatchStudents([]); - setBatchRemark(''); - }} - confirmLoading={batchSubmitting} - width={640} - okText="提交" - cancelText="取消" - > -
- - setBatchDate(d || dayjs())} - style={{ width: '100%' }} - /> - - - -
- - - - - - - + +
+
+ +
+ 档案检索 + 默认查看最近 30 天 +
+
+ ({ - value: item.classId, - label: item.className, - }))} + allowClear + placeholder="考勤结果" + value={status} + onChange={(value) => { + setStatus(value); + setPage(1); + }} + options={STATUS_OPTIONS} + style={{ width: 130 }} /> - - {canManageAllAttendance ? ( - - setImportDateRange(dates as [Dayjs, Dayjs] | null)} - style={{ width: '100%' }} - placeholder={['开始日期', '结束日期']} - /> - - ) : ( - { + setSession(value); + setPage(1); + }} + options={SESSION_OPTIONS} + style={{ width: 130 }} /> - )} - - + + +
+ + + rowKey="id" + columns={columns} + dataSource={records} + loading={loading} + scroll={{ x: 1050 }} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (value) => `共 ${value} 条历史记录`, + onChange: (nextPage, nextPageSize) => { + setPage(nextPage); + setPageSize(nextPageSize); + }, + }} + locale={{ + emptyText: ( + + ), + }} + /> +
- {/* ── Edit modal ── */} setEditModalOpen(false)} + open={Boolean(editRecord)} + title="考勤记录纠错" + okText="保存修正" + onOk={submitEdit} + onCancel={() => setEditRecord(null)} > +
- - - - - - -
- - {/* ── Match student modal ── */} - { - const selected = matchStudentResults.find((s) => s.name === matchStudentSearch); - if (selected) handleMatchSubmit(selected.id); - }} - onCancel={() => { - setMatchModalOpen(false); - setMatchRecordId(null); - setMatchStudentSearch(''); - setMatchStudentResults([]); - }} - confirmLoading={matchSubmitting} - okText="确定" - cancelText="取消" - > -
- - setFilterStatus(v)} - options={[ - { value: 'paid', label: '已缴' }, - { value: 'refunded', label: '已全退' }, - { value: 'partial_refund', label: '部分退还' }, - { value: 'deducted', label: '已全扣' }, - ]} - /> - - } - onClick={() => { - createForm.resetFields(); - createForm.setFieldsValue({ amount: 500, paidDate: dayjs() }); - setCreateModal(true); - }} - > - 收取押金 - - -
`共 ${total} 条` }} - locale={{ emptyText: }} - /> - - ), - }, - ...(hasPermission('deposit:approve') - ? [{ - key: 'pending', - label: '待审批退款', - children: ( -
`共 ${total} 条` }} - /> - ), - }] - : []), + /> +
`共 ${total} 条` }} + locale={{ emptyText: }} + /> {/* Create Modal */} {

{detailModal.refundStatus && (

- 退款审批:{' '} + 退款状态:{' '} {refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus} @@ -644,23 +500,7 @@ const DepositsPage: React.FC = () => { - {/* Reject Refund Modal */} - { setRejectModal(null); setRejectReason(''); }} - okText="确认驳回" - okButtonProps={{ danger: true }} - confirmLoading={saving} - > - setRejectReason(e.target.value)} - rows={3} - /> - + ); }; diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index ae0e7d2..e553f6f 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -7,6 +7,7 @@ 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'; @@ -64,6 +65,28 @@ 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); @@ -85,6 +108,10 @@ const IntegrationConfigPage: React.FC = () => { const [classes, setClasses] = useState([]); const [classForm] = Form.useForm(); const [classModalOpen, setClassModalOpen] = useState(false); + const [attendanceGroups, setAttendanceGroups] = useState([]); + const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false); + const [loadingGroups, setLoadingGroups] = useState(false); + const [deletingGroups, setDeletingGroups] = useState(false); const fetchConfig = async () => { @@ -194,6 +221,7 @@ const IntegrationConfigPage: React.FC = () => { } }; + const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => { return nodes.map((node) => { const users = node.users ?? []; @@ -282,6 +310,41 @@ const IntegrationConfigPage: React.FC = () => { } }; + const openDeleteAllGroups = async () => { + setLoadingGroups(true); + try { + const response = await api.get('/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( + '/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') ? [ { @@ -314,6 +377,15 @@ const IntegrationConfigPage: React.FC = () => { > 获取组织架构 + } + loading={loadingGroups} + onClick={openDeleteAllGroups} + > + 删除钉钉全部考勤组 + {drawerOpen && ( @@ -414,6 +486,38 @@ const IntegrationConfigPage: React.FC = () => { )} + setDeleteGroupsOpen(false)} + > + + ( + + + + )} + /> + ), }, diff --git a/apps/admin/src/pages/Login/index.tsx b/apps/admin/src/pages/Login/index.tsx index aea98a5..bb0d788 100644 --- a/apps/admin/src/pages/Login/index.tsx +++ b/apps/admin/src/pages/Login/index.tsx @@ -5,7 +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 { findFirstAccessiblePath } from '../../auth/permission-navigation'; +import { findRoleAwareLandingPath } from '../../auth/menu-policy'; const { Title } = Typography; @@ -22,7 +22,7 @@ const LoginPage: React.FC = () => { const permissions = res.user.permissions || []; writePermissions(permissions); message.success('登录成功'); - navigate(findFirstAccessiblePath(permissions) || '/', { replace: true }); + navigate(findRoleAwareLandingPath(res.user.roles || [], permissions) || '/', { replace: true }); } catch (err: any) { message.error(err?.message || '登录失败'); } finally { diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index f11dfef..5344a0e 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -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[]; diff --git a/apps/admin/src/pages/Permissions/index.tsx b/apps/admin/src/pages/Permissions/index.tsx index 15f0f89..91607dc 100644 --- a/apps/admin/src/pages/Permissions/index.tsx +++ b/apps/admin/src/pages/Permissions/index.tsx @@ -19,6 +19,10 @@ const PermissionsPage: React.FC = () => { const groupNames: Record = { dashboard: '数据面板', student: '学生管理', + 'student-scope': '学生数据范围', + teacher: '教师管理', + 'teacher-workspace': '教师工作台', + 'attendance-scope': '考勤数据范围', room: '宿舍管理', occupancy: '入住管理', expense: '费用管理', diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index 76ff1e3..42547cf 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -41,12 +41,14 @@ import { 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; @@ -60,6 +62,7 @@ interface ClassScheduleItem { notes: string | null; createdAt: string; updatedAt: string; + canViewDetails?: boolean; } interface ClassroomItem { @@ -91,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 }>; } @@ -144,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); @@ -180,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 || '同步失败'); @@ -238,7 +254,10 @@ const SchedulesPage: React.FC = () => { setLoading(true); try { const [lookups, schedulesRes] = await Promise.all([ - api.get('/class-schedules/lookups') as Promise<{ classrooms: ClassroomItem[]; classes: ClassItem[] }>, + api.get('/class-schedules/lookups') as Promise<{ + classrooms: ClassroomItem[]; + classes: ClassItem[]; + }>, api.get('/class-schedules/weekly', { params: { startDate: startDateStr, @@ -289,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; } @@ -422,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('排课已删除'); @@ -681,20 +703,31 @@ const SchedulesPage: React.FC = () => {

{schedules.map((s) => (
-
+
{s.subject}
@@ -973,7 +1006,7 @@ const SchedulesPage: React.FC = () => { ) : ( selectedSchedules.map((s) => ( { >
- 科目: - {s.subject} + {isMaskedSchedule(s) ? '状态:' : '科目:'} + {s.subject}
-
- 班级: - {classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`} -
- {s.teacherId != null && ( + {!isMaskedSchedule(s) && ( +
+ 班级: + {classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`} +
+ )} + {!isMaskedSchedule(s) && s.teacherId != null && (
教师: {classTeachers.find((u) => u.userId === s.teacherId)?.name || @@ -1010,46 +1045,50 @@ const SchedulesPage: React.FC = () => { 日期: {s.startDate} ~ {s.endDate}
- {s.notes && ( + {!isMaskedSchedule(s) && s.notes && (
备注: {s.notes}
)} -
- - {s.scheduleType === 'RENTAL' ? '租赁' : '内部'} - - {s.status} -
-
- - {s.scheduleType !== 'RENTAL' && ( - } - onClick={() => openEditSchedule(s)} - > - 编辑 - + {!isMaskedSchedule(s) && ( +
+ + {s.scheduleType === 'RENTAL' ? '租赁' : '内部'} + + {s.status} +
)} - handleDelete(s.id)} - okText="删除" - cancelText="取消" - > - } +
+ {!isMaskedSchedule(s) && ( + + {s.scheduleType !== 'RENTAL' && ( + } + onClick={() => openEditSchedule(s)} + > + 编辑 + + )} + handleDelete(s.id)} + okText="删除" + cancelText="取消" > - 删除 - - - + } + > + 删除 + + + + )}
)) @@ -1133,6 +1172,30 @@ const SchedulesPage: React.FC = () => { showIcon /> )} + {syncResult.failedBatchCount > 0 && ( + <> + 0 + ? syncResult.errors.slice(0, 5).map((err, i) => ( +
+ {err} +
+ )) + : undefined + } + style={{ marginBottom: 16 }} + showIcon + /> + {syncResult.errors.length > 5 && ( +
+ ...以及其他 {syncResult.errors.length - 5} 条错误 +
+ )} + + )} {syncResult.groups.length > 0 && (
按班级分组:
diff --git a/apps/admin/src/pages/Schedules/schedule-form.ts b/apps/admin/src/pages/Schedules/schedule-form.ts index d72cb41..42d1d7f 100644 --- a/apps/admin/src/pages/Schedules/schedule-form.ts +++ b/apps/admin/src/pages/Schedules/schedule-form.ts @@ -11,7 +11,7 @@ export interface ScheduleFormValues { } export interface EditableSchedule { - id: number; + id?: number; classId: number; classroomId: number; weekDay: number; diff --git a/apps/admin/src/pages/Schedules/schedule-visibility.integration.test.ts b/apps/admin/src/pages/Schedules/schedule-visibility.integration.test.ts new file mode 100644 index 0000000..4b2723f --- /dev/null +++ b/apps/admin/src/pages/Schedules/schedule-visibility.integration.test.ts @@ -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); + }); +}); diff --git a/apps/admin/src/pages/Schedules/schedule-visibility.ts b/apps/admin/src/pages/Schedules/schedule-visibility.ts new file mode 100644 index 0000000..118db39 --- /dev/null +++ b/apps/admin/src/pages/Schedules/schedule-visibility.ts @@ -0,0 +1,19 @@ +export interface VisibleSchedule { + id: number | null; + classId: number | null; + canViewDetails?: boolean; +} + +export const isMaskedSchedule = ( + schedule: Pick, +) => + schedule.canViewDetails === false || + (schedule.canViewDetails === undefined && (schedule.id === null || schedule.classId === null)); + +export const filterSchedulesForClass = ( + schedules: T[], + classId?: number, +): T[] => { + if (classId == null) return schedules; + return schedules.filter((schedule) => isMaskedSchedule(schedule) || schedule.classId === classId); +}; diff --git a/apps/admin/src/pages/Schedules/sync-result.integration.test.ts b/apps/admin/src/pages/Schedules/sync-result.integration.test.ts new file mode 100644 index 0000000..7e38ae5 --- /dev/null +++ b/apps/admin/src/pages/Schedules/sync-result.integration.test.ts @@ -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([]); + }); +}); diff --git a/apps/admin/src/pages/Schedules/sync-result.ts b/apps/admin/src/pages/Schedules/sync-result.ts new file mode 100644 index 0000000..bc3180c --- /dev/null +++ b/apps/admin/src/pages/Schedules/sync-result.ts @@ -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); +} diff --git a/apps/admin/src/pages/Teachers/index.tsx b/apps/admin/src/pages/Teachers/index.tsx index f709a5f..ef0d77a 100644 --- a/apps/admin/src/pages/Teachers/index.tsx +++ b/apps/admin/src/pages/Teachers/index.tsx @@ -28,8 +28,12 @@ interface ProfileFormValues { } const ROLE_LABELS: Record = { - super_admin: '超管', - teacher: '老师', + super_admin: '超级管理员', + teacher: '任课老师', + academic: '教务管理员', + accommodation_operations: '住宿运营管理员', + classroom_operations: '教室运营管理员', + system_admin: '系统管理员', class_teacher: '班主任', dormitory_supervisor: '宿管', institution_head: '机构负责人', diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index c3f2e8a..da113af 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -28,6 +28,7 @@ import { ClassTeacher, ClassSchedule, AttendanceRecord, + AttendanceSession, DingAttendanceRaw, SyncLog, SyncState, @@ -116,6 +117,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; Role, ClassSchedule, AttendanceRecord, + AttendanceSession, DingAttendanceRaw, Notification, StudentProfile, diff --git a/apps/server/src/attendance/attendance-import.service.spec.ts b/apps/server/src/attendance/attendance-import.service.spec.ts index e4d4689..1ac9065 100644 --- a/apps/server/src/attendance/attendance-import.service.spec.ts +++ b/apps/server/src/attendance/attendance-import.service.spec.ts @@ -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(); + } + }); }); diff --git a/apps/server/src/attendance/attendance-import.service.ts b/apps/server/src/attendance/attendance-import.service.ts index b3f3968..ecd4957 100644 --- a/apps/server/src/attendance/attendance-import.service.ts +++ b/apps/server/src/attendance/attendance-import.service.ts @@ -28,6 +28,8 @@ export class AttendanceImportService { /** RxJS Subject emitting live progress during import */ private progressSubject = new Subject(); 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, @@ -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 { 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 }); } } diff --git a/apps/server/src/attendance/attendance.controller.spec.ts b/apps/server/src/attendance/attendance.controller.spec.ts index ccb4e0b..8b71b5a 100644 --- a/apps/server/src/attendance/attendance.controller.spec.ts +++ b/apps/server/src/attendance/attendance.controller.spec.ts @@ -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,6 +12,7 @@ describe('AttendanceController — DingTalk import scope', () => { }; const importService = { importFromDingTalk: jest.fn(), + progress$: undefined as unknown, }; const logService = { log: jest.fn(), @@ -52,6 +54,7 @@ describe('AttendanceController — DingTalk import scope', () => { endDate: '2026-07-10', userIds: ['ding-today'], autoMatch: true, + userId: 21, }); jest.useRealTimers(); }); @@ -64,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, }); }); @@ -144,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 as never); + + 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 as never)).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 as never); + await controller.completeLessonAttendance('90', req as never); + + 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 as never); + + 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 as never); + await controller.remove('4', req as never); + + 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: [] }, + } as never).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: [] }, + } as never).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: [] }, + } as never).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); + }); +}); diff --git a/apps/server/src/attendance/attendance.controller.ts b/apps/server/src/attendance/attendance.controller.ts index 68dc53d..9f4d07c 100644 --- a/apps/server/src/attendance/attendance.controller.ts +++ b/apps/server/src/attendance/attendance.controller.ts @@ -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,6 +29,8 @@ 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'; @@ -88,11 +90,94 @@ export class AttendanceController { 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 ── @Post('attendance-records/batch') @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, @@ -111,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, @@ -190,13 +276,18 @@ export class AttendanceController { // ── 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, @@ -214,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, @@ -421,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({ @@ -452,17 +548,22 @@ export class AttendanceController { */ @Sse('attendance-records/import/dingtalk/stream') @RequirePermission('attendance:view') - importProgressStream(): Observable { + importProgressStream(@Request() req: { user: RequestUser }): Observable { + const userId = req.user.id; return new Observable((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(); }); } diff --git a/apps/server/src/attendance/attendance.lesson-session.spec.ts b/apps/server/src/attendance/attendance.lesson-session.spec.ts new file mode 100644 index 0000000..e2d5421 --- /dev/null +++ b/apps/server/src/attendance/attendance.lesson-session.spec.ts @@ -0,0 +1,492 @@ +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) => 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) => ({ id: 90, ...value })), + save: jest.fn(async (value: unknown) => value), + }; + const dataSource = { + transaction: jest.fn( + async (cb: (manager: { getRepository: jest.Mock }) => Promise) => { + 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' }, + { matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' }, + { 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: 'late', source: 'dingtalk' }), + expect.objectContaining({ studentId: 3, status: 'absent', source: 'dingtalk' }), + expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }), + ]); + expect(result.records).toHaveLength(4); + }); + + 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' }, + { matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' }, + ]); + + 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: 'late' }), + ]), + ); + 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' }, + { matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' }, + ]); + + 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: 'late' }), + ]), + ); + expect(result.records).toHaveLength(2); + }); + + 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' }, + { matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal' }, + ]); + + // 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'); + }); +}); diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts index e13ee66..8328daf 100644 --- a/apps/server/src/attendance/attendance.module.ts +++ b/apps/server/src/attendance/attendance.module.ts @@ -1,6 +1,6 @@ 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 { AttendanceController } from './attendance.controller'; @@ -9,7 +9,7 @@ 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, ], diff --git a/apps/server/src/attendance/attendance.service.spec.ts b/apps/server/src/attendance/attendance.service.spec.ts index c1dc869..3e28182 100644 --- a/apps/server/src/attendance/attendance.service.spec.ts +++ b/apps/server/src/attendance/attendance.service.spec.ts @@ -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(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((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 = { + id: recordId, + attendanceSessionId: sessionId, + studentId: 1, + status: 'present', + source: 'dingtalk', + attendanceDate: '2026-07-01', + session: 'morning', + }; + + function makeService( + dataSourceMock: { transaction: jest.Mock }, + attendanceRepoOverrides?: Record, + ) { + 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(); + + 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(); + + // 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(); + }); +}); diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index 7f21ae0..a831687 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -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>(); + + async runExclusive(sessionId: number, fn: () => Promise): Promise { + const tail = this.queueTails.get(sessionId) ?? Promise.resolve(); + let release!: () => void; + const newTail = new Promise((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, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + @InjectRepository(AttendanceSession) + private attendanceSessionRepo: Repository, + private dataSource: DataSource, ) {} + private sessionMutex = new SessionMutex(); + async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); @@ -112,13 +144,315 @@ 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[]): string { + const results = new Set(records.map((record) => record.timeResult?.toLowerCase())); + if (results.has('late') || results.has('seriouslate')) return 'late'; + if ( + results.has('notsigned') || + results.has('absenteeism') || + results.has('absent') + ) { + return 'absent'; + } + if (results.has('leave') || results.has('vacation')) return 'leave'; + if (results.has('normal')) return 'present'; + return 'pending'; + } + async createLessonAttendanceFromDingTalk( + scheduleId: number, + lessonDate: string, + userId: number, + ) { + 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 }; + } + + // Refresh in_progress session from latest DingTalk data + return this.dataSource.transaction(async (manager) => { + 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 manually corrected records. + if (record.source !== 'dingtalk') return record; + + const raw = this.selectDingTalkRecordsForLesson( + rawByStudent.get(record.studentId) ?? [], + lessonDate, + schedule.startTime, + schedule.endTime, + ); + record.status = this.mapDingTalkStatus(raw); + record.remark = raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : null; + 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), + source: 'dingtalk', + remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined, + }), + ); + } + + const saved = await recordRepo.save(updatedRecords); + 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).code; + const errno = (err as Record).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), + source: 'dingtalk', + remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined, + }); + }); + const saved = await recordRepo.save(records); + return { schedule, session, records: saved }; + }); + } + + private async fetchDingTalkRawByStudent( + classId: number, + lessonDate: string, + ): Promise> { + 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(); + 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 +543,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 +569,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 +578,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 +673,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 +732,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 +772,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 +830,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 +871,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 +963,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 +978,17 @@ export class AttendanceService { const rawRows = await qb.getRawMany(); // Aggregate by class - const classMap = new Map(); + 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 +1033,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 +1047,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 +1065,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; } - } +} diff --git a/apps/server/src/attendance/dingtalk-attendance.service.spec.ts b/apps/server/src/attendance/dingtalk-attendance.service.spec.ts index 2168e08..a204938 100644 --- a/apps/server/src/attendance/dingtalk-attendance.service.spec.ts +++ b/apps/server/src/attendance/dingtalk-attendance.service.spec.ts @@ -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; + + const [record] = await service.fetchAttendanceResults({ + startDate: '2026-07-12', + endDate: '2026-07-12', + userIds: ['ding-1'], + }); + + expect(record.workDate).toBe('2026-07-12'); + }); }); diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index 8d5d675..201855e 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -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; +} diff --git a/apps/server/src/attendance/dto/dingtalk-import.dto.ts b/apps/server/src/attendance/dto/dingtalk-import.dto.ts index add70ff..adaadc4 100644 --- a/apps/server/src/attendance/dto/dingtalk-import.dto.ts +++ b/apps/server/src/attendance/dto/dingtalk-import.dto.ts @@ -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; } /** diff --git a/apps/server/src/classes/classes.module.ts b/apps/server/src/classes/classes.module.ts index a4d0ee6..5a6f951 100644 --- a/apps/server/src/classes/classes.module.ts +++ b/apps/server/src/classes/classes.module.ts @@ -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], diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index b75605b..eefd945 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -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, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, + @InjectRepository(AttendanceSession) + private attendanceSessionRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(StudentDingMapping) @@ -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 }; } diff --git a/apps/server/src/database/attendance-fk-restrict.spec.ts b/apps/server/src/database/attendance-fk-restrict.spec.ts new file mode 100644 index 0000000..81f915d --- /dev/null +++ b/apps/server/src/database/attendance-fk-restrict.spec.ts @@ -0,0 +1,380 @@ +import Database from 'better-sqlite3'; +type SqliteDB = InstanceType; + +/** + * 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; + 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; + 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); + }); +}); diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index e6f1922..5d4b691 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -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() @@ -10,8 +10,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { async onApplicationBootstrap(): Promise { await this.ensureAiConfigTable(); + await this.ensureCourseAttendanceSchema(); await this.backfillOrganizations(); await this.normalizeClassDates(); + await this.protectAttendanceHistory(); } private async ensureAiConfigTable(): Promise { @@ -100,6 +102,70 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { } } + private async ensureCourseAttendanceSchema(): Promise { + 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 { const runner = this.dataSource.createQueryRunner(); await runner.connect(); @@ -246,4 +312,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 { + 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 { + // 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 { + // 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'); + } + } } diff --git a/apps/server/src/database/database-migrations.spec.ts b/apps/server/src/database/database-migrations.spec.ts index b398c8a..2011ec1 100644 --- a/apps/server/src/database/database-migrations.spec.ts +++ b/apps/server/src/database/database-migrations.spec.ts @@ -18,7 +18,7 @@ function mockRunner(overrides: { } = {}) { const release = jest.fn(); const connect = jest.fn(); - const query = 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: [] }, @@ -31,19 +31,21 @@ function mockRunner(overrides: { return { release, connect, query, getTables, getTable }; } -function createDataSource(runner: ReturnType) { +function createDataSource(runner: ReturnType, dbType: string = 'better-sqlite3') { return { - options: { type: 'better-sqlite3' }, + options: { type: dbType }, createQueryRunner: jest.fn().mockReturnValue(runner), transaction: jest.fn(), }; } -// Type to reach the private ensureAiConfigTable for testing +// Type to reach private migration methods for testing interface MigrationsPrivate { ensureAiConfigTable(): Promise; backfillOrganizations(): Promise; normalizeClassDates(): Promise; + ensureCourseAttendanceSchema(): Promise; + protectAttendanceHistory(): Promise; } describe('DatabaseMigrationsService — ensureAiConfigTable', () => { @@ -176,3 +178,251 @@ describe('DatabaseMigrationsService — bootstrap failure handling', () => { 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: ReturnType, 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) as DatabaseMigrationsService & MigrationsPrivate; + } + + 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(); + }); +}); + +async function bootstrapCourseAttendance(runner: ReturnType) { + const dataSource = createDataSource(runner); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseMigrationsService, + { provide: getDataSourceToken(), useValue: dataSource }, + ], + }).compile(); + service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate; +} diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index b31e808..6e0a588 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -47,12 +47,6 @@ export class DepositsController { }); } - @Get('pending-refunds') - @RequirePermission('deposit:edit') - findPendingRefunds() { - return this.service.findPendingRefunds(); - } - @Get('stats') @RequirePermission('deposit:view') getStats() { @@ -165,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); @@ -195,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) { diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index b2a096f..0f93d3a 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -114,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 = { - 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('押金记录不存在'); diff --git a/apps/server/src/entities/attendance-record.entity.ts b/apps/server/src/entities/attendance-record.entity.ts index d9ddea5..bbe5aa5 100644 --- a/apps/server/src/entities/attendance-record.entity.ts +++ b/apps/server/src/entities/attendance-record.entity.ts @@ -10,10 +10,13 @@ import { } from 'typeorm'; import { Student } from './student.entity'; import { Class } from './class.entity'; +import { ClassSchedule } from './class-schedule.entity'; +import { AttendanceSession } from './attendance-session.entity'; @Entity('attendance_records') @Index(['classId', 'attendanceDate']) @Index(['studentId', 'attendanceDate']) +@Index(['attendanceSessionId', 'studentId'], { unique: true }) export class AttendanceRecord { @PrimaryGeneratedColumn() id: number; @@ -32,6 +35,23 @@ export class AttendanceRecord { @JoinColumn({ name: 'class_id' }) class: Class; + @Column({ name: 'schedule_id', type: 'integer', nullable: true }) + scheduleId: number | null; + + @ManyToOne(() => ClassSchedule, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'schedule_id' }) + schedule: ClassSchedule | null; + + @Column({ name: 'attendance_session_id', type: 'integer', nullable: true }) + attendanceSessionId: number | null; + + @ManyToOne(() => AttendanceSession, (session) => session.records, { + onDelete: 'SET NULL', + nullable: true, + }) + @JoinColumn({ name: 'attendance_session_id' }) + attendanceSession: AttendanceSession | null; + @Column({ name: 'attendance_date', type: 'date' }) attendanceDate: string; @@ -41,8 +61,8 @@ export class AttendanceRecord { @Column({ length: 20 }) status: string; - @Column({ length: 200, nullable: true }) - remark: string; + @Column({ type: 'varchar', length: 200, nullable: true }) + remark: string | null; @Column({ name: 'source', length: 20, default: 'manual' }) source: string; diff --git a/apps/server/src/entities/attendance-session.entity.ts b/apps/server/src/entities/attendance-session.entity.ts new file mode 100644 index 0000000..0fd63c7 --- /dev/null +++ b/apps/server/src/entities/attendance-session.entity.ts @@ -0,0 +1,62 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ClassSchedule } from './class-schedule.entity'; +import { Class } from './class.entity'; +import { AttendanceRecord } from './attendance-record.entity'; + +@Entity('attendance_sessions') +@Index(['scheduleId', 'lessonDate'], { unique: true }) +export class AttendanceSession { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'schedule_id', type: 'integer' }) + scheduleId: number; + + @ManyToOne(() => ClassSchedule, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'schedule_id' }) + schedule: ClassSchedule; + + @Column({ name: 'class_id', type: 'integer' }) + classId: number; + + @ManyToOne(() => Class, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'class_id' }) + class: Class; + + @Column({ name: 'lesson_date', type: 'date' }) + lessonDate: string; + + @Column({ length: 20, default: 'in_progress' }) + status: string; + + @Column({ name: 'started_by', type: 'integer', nullable: true }) + startedBy: number | null; + + @Column({ name: 'started_at', type: 'datetime', nullable: true }) + startedAt: Date | null; + + @Column({ name: 'completed_by', type: 'integer', nullable: true }) + completedBy: number | null; + + @Column({ name: 'completed_at', type: 'datetime', nullable: true }) + completedAt: Date | null; + + @OneToMany(() => AttendanceRecord, (record) => record.attendanceSession) + records: AttendanceRecord[]; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index edb2806..361e55e 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -21,6 +21,7 @@ export { ClassStudent } from './class-student.entity'; export { ClassTeacher, TeacherRoleType } from './class-teacher.entity'; export { ClassSchedule, ScheduleType } from './class-schedule.entity'; export { AttendanceRecord } from './attendance-record.entity'; +export { AttendanceSession } from './attendance-session.entity'; export { DingAttendanceRaw } from './ding-attendance-raw.entity'; export { SyncLog } from './sync-log.entity'; export { SyncState } from './sync-state.entity'; diff --git a/apps/server/src/integration/dingtalk.group-delete.spec.ts b/apps/server/src/integration/dingtalk.group-delete.spec.ts new file mode 100644 index 0000000..00159b0 --- /dev/null +++ b/apps/server/src/integration/dingtalk.group-delete.spec.ts @@ -0,0 +1,53 @@ +import { DingTalkService } from './dingtalk.service'; + +describe('DingTalkService attendance group deletion', () => { + const originalAppKey = process.env.DINGTALK_APP_KEY; + const originalAppSecret = process.env.DINGTALK_APP_SECRET; + let service: DingTalkService; + + beforeEach(() => { + process.env.DINGTALK_APP_KEY = 'test-app-key'; + process.env.DINGTALK_APP_SECRET = 'test-app-secret'; + service = new DingTalkService({} as never, {} as never); + Object.assign(service, { + accessToken: 'test-token', + tokenExpiresAt: Date.now() + 3_600_000, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + global.fetch = undefined as unknown as typeof fetch; + }); + + afterAll(() => { + if (originalAppKey === undefined) delete process.env.DINGTALK_APP_KEY; + else process.env.DINGTALK_APP_KEY = originalAppKey; + if (originalAppSecret === undefined) delete process.env.DINGTALK_APP_SECRET; + else process.env.DINGTALK_APP_SECRET = originalAppSecret; + }); + + it('converts groupId to groupKey before deleting the group', async () => { + global.fetch = jest + .fn() + .mockResolvedValueOnce({ + json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', result: 'group-key-1' }), + }) + .mockResolvedValueOnce({ + json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', success: true }), + }) as jest.MockedFunction; + + await service.deleteAttendanceGroup(123, 'manager'); + + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('/topapi/attendance/groups/idtokey'), + expect.objectContaining({ body: JSON.stringify({ op_user_id: 'manager', group_id: 123 }) }), + ); + expect(global.fetch).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('/topapi/attendance/group/delete'), + expect.objectContaining({ body: JSON.stringify({ op_userid: 'manager', group_key: 'group-key-1' }) }), + ); + }); +}); diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index f686e92..3e01989 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -480,7 +480,7 @@ export class DingTalkService { return records.map((r) => ({ userId: r.userId, userName: '', - workDate: new Date(r.workDate).toISOString().slice(0, 10), + workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10), timeResult: r.timeResult ?? r.sourceType ?? '', locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '', planCheckTime: '', @@ -728,6 +728,47 @@ export class DingTalkService { return all; } + async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise { + if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + const token = await this.getAccessToken(); + + await this.rateLimit(); + const keyResponse = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }), + }, + ); + const keyData = await keyResponse.json() as { + errcode: number; + errmsg: string; + result?: string; + }; + if (keyData.errcode !== 0 || !keyData.result) { + throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`); + } + + await this.rateLimit(); + const deleteResponse = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }), + }, + ); + const deleteData = await deleteResponse.json() as { + errcode: number; + errmsg: string; + success?: boolean; + }; + if (deleteData.errcode !== 0 || deleteData.success !== true) { + throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`); + } + } + // ═══════════════════════════════════════════ // 考勤排班 — 排班分配 diff --git a/apps/server/src/rbac/rbac.controller.ts b/apps/server/src/rbac/rbac.controller.ts index 06d1e96..95b022d 100644 --- a/apps/server/src/rbac/rbac.controller.ts +++ b/apps/server/src/rbac/rbac.controller.ts @@ -126,7 +126,7 @@ export class RbacController { // ==================== 用户管理 ==================== @Get('users') - @RequirePermission('user:view') + @RequirePermission('user:view', 'teacher:view') getUsers(@Query('isArchived') isArchived?: string) { const archived = isArchived === 'true'; return this.rbacService.findAllUsers(archived); @@ -301,7 +301,7 @@ export class RbacController { // ---- 教师工作台 ---- @Get('teacher-workspace') - @RequirePermission('class:view') + @RequirePermission('teacher-workspace:view') async getTeacherWorkspace(@Request() req: any) { return this.rbacService.getTeacherWorkspace(req.user?.id); } @@ -309,7 +309,7 @@ export class RbacController { // ---- 教师管理 ---- @Get('teachers') - @RequirePermission('user:view') + @RequirePermission('teacher:view') async getTeachers( @Query('search') search?: string, @Query('page') page?: string, @@ -323,7 +323,7 @@ export class RbacController { } @Put('teachers/:id/profile') - @RequirePermission('user:edit') + @RequirePermission('teacher:edit') async updateTeacherProfile( @Param('id') id: string, @Body() profile: UpdateProfileDto, diff --git a/apps/server/src/rbac/rbac.permissions.spec.ts b/apps/server/src/rbac/rbac.permissions.spec.ts index a2f5543..56fdd15 100644 --- a/apps/server/src/rbac/rbac.permissions.spec.ts +++ b/apps/server/src/rbac/rbac.permissions.spec.ts @@ -7,32 +7,59 @@ function permissionsFor(roleCode: string): { groups: string[]; extras: string[] } describe('preset role permissions', () => { - it('gives teachers explicit workspace permissions without class/schedule delete privileges', () => { + it('keeps teachers read-only in scheduling while preserving class attendance access', () => { const teacher = permissionsFor('teacher'); expect(teacher.groups).toEqual(['notification', 'profile']); expect(teacher.extras).toEqual( expect.arrayContaining([ - 'student:view', - 'class:view', + 'teacher-workspace:view', 'schedule:view', 'attendance:view', 'attendance:create', - 'attendance:export', + 'attendance:self-edit', ]), ); - expect(teacher.extras).not.toEqual( - expect.arrayContaining(['class:delete', 'schedule:delete']), + expect(teacher.extras).not.toContain('schedule:create'); + expect(teacher.extras).not.toContain('schedule:edit'); + expect(teacher.extras).not.toContain('schedule:delete'); + expect(teacher.extras).not.toContain('student:view'); + expect(teacher.extras).not.toContain('class:view'); + expect(teacher.extras).not.toContain('attendance:export'); + }); + + + it('gives academic administrators the complete teaching administration workflow', () => { + const academic = permissionsFor('academic'); + expect(academic.groups).toEqual( + expect.arrayContaining(['student', 'class', 'schedule', 'attendance', 'classroom']), + ); + expect(academic.extras).toEqual(expect.arrayContaining(['sync:read', 'sync:trigger'])); + }); + + it('combines accommodation, expenses, bills and deposits in one operations role', () => { + const accommodation = permissionsFor('accommodation_operations'); + expect(accommodation.groups).toEqual( + expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']), + ); + expect(accommodation.extras).toContain('student:basic-view'); + }); + + it('keeps classroom rental operations separate from accommodation operations', () => { + const classroomOperations = permissionsFor('classroom_operations'); + expect(classroomOperations.groups).toEqual( + expect.arrayContaining(['classroom', 'rental', 'organization']), + ); + expect(classroomOperations.groups).not.toEqual(expect.arrayContaining(['room', 'deposit'])); + }); + + it('limits system administrators to accounts, permissions, logs and integrations', () => { + const systemAdmin = permissionsFor('system_admin'); + expect(systemAdmin.groups).toEqual( + expect.arrayContaining(['user', 'role', 'log', 'integration', 'sync', 'ai']), + ); + expect(systemAdmin.groups).not.toEqual( + expect.arrayContaining(['student', 'schedule', 'attendance', 'expense']), ); }); - - it('gives institution heads every read permission required by the classroom rental pages', () => { - const role = permissionsFor('institution_head'); - expect(role.groups).toEqual(expect.arrayContaining(['classroom', 'rental', 'organization'])); - }); - - it('keeps roles without dashboard access off the dashboard', () => { - expect(permissionsFor('teacher').groups).not.toContain('dashboard'); - expect(permissionsFor('institution_head').groups).not.toContain('dashboard'); - }); }); diff --git a/apps/server/src/rbac/rbac.seed.spec.ts b/apps/server/src/rbac/rbac.seed.spec.ts index 30ec479..286a020 100644 --- a/apps/server/src/rbac/rbac.seed.spec.ts +++ b/apps/server/src/rbac/rbac.seed.spec.ts @@ -1,7 +1,7 @@ import { RbacService } from './rbac.service'; describe('RbacService seedData', () => { - it('adds preset permissions to system roles without removing manually granted permissions', async () => { + it('migrates the legacy teacher role and replaces broad permissions with the teaching matrix', async () => { const permissions = [ { id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' }, { id: 2, code: 'notification:view', name: '查看通知', group: 'notification' }, @@ -10,27 +10,32 @@ describe('RbacService seedData', () => { { id: 5, code: 'schedule:view', name: '查看排课', group: 'schedule' }, { id: 6, code: 'attendance:view', name: '查看考勤', group: 'attendance' }, { id: 7, code: 'attendance:create', name: '新增考勤', group: 'attendance' }, - { id: 8, code: 'attendance:export', name: '导出考勤', group: 'attendance' }, + { id: 8, code: 'teacher-workspace:view', name: '教师工作台', group: 'teacher-workspace' }, { id: 9, code: 'room:view', name: '查看宿舍', group: 'room' }, + { id: 10, code: 'schedule:create', name: '新增排课', group: 'schedule' }, ]; const teacherRole = { id: 1, name: '老师', - description: '查看和管理本班学生', + code: 'teacher', + description: '旧角色', isSystem: true, - permissions: [permissions[8]], + status: 1, + permissions: [permissions[2], permissions[3], permissions[8]], }; const permRepo = { findOne: jest.fn( - async ({ where }: any) => permissions.find((p) => p.code === where.code) ?? null, + async ({ where }: any) => permissions.find((permission) => permission.code === where.code) ?? null, ), create: jest.fn((value) => value), save: jest.fn(async (value) => value), find: jest.fn(async () => permissions), }; const roleRepo = { - findOne: jest.fn(async ({ where }: any) => (where.name === '老师' ? teacherRole : null)), + findOne: jest.fn(async ({ where }: any) => + where.code === 'teacher' || where.name === '老师' ? teacherRole : null, + ), create: jest.fn((value) => ({ ...value, permissions: [] })), save: jest.fn(async (value) => value), find: jest.fn(async () => [teacherRole]), @@ -50,8 +55,86 @@ describe('RbacService seedData', () => { await service.seedData(); + expect(teacherRole.name).toBe('任课老师'); expect(teacherRole.permissions.map((permission) => permission.code)).toEqual( - expect.arrayContaining(['room:view', 'profile:view', 'student:view', 'attendance:create']), + expect.arrayContaining([ + 'profile:view', + 'teacher-workspace:view', + 'schedule:view', + 'attendance:create', + ]), ); + expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain( + 'schedule:create', + ); + expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('student:view'); + expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('class:view'); + expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('room:view'); + }); +}); + +describe('RbacService legacy role consolidation', () => { + it('moves users from duplicate accommodation roles before deleting the duplicates', async () => { + const permissions = [ + { id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' }, + { id: 2, code: 'room:view', name: '查看宿舍', group: 'room' }, + { id: 3, code: 'expense:view', name: '查看费用', group: 'expense' }, + { id: 4, code: 'student:basic-view', name: '学生基础信息', group: 'student-scope' }, + ]; + const targetRole: any = { + id: 10, + name: '住宿运营管理员', + code: 'accommodation_operations', + description: '', + isSystem: true, + status: 1, + permissions: [], + users: [], + }; + const legacyRole: any = { + id: 11, + name: '财务', + code: 'finance', + description: '', + isSystem: true, + status: 1, + permissions: [], + users: [{ id: 21 }], + }; + const user: any = { id: 21, roles: [legacyRole] }; + const permRepo = { + findOne: jest.fn(async ({ where }: any) => permissions.find((item) => item.code === where.code) ?? null), + create: jest.fn((value) => value), + save: jest.fn(async (value) => value), + find: jest.fn(async () => permissions), + }; + const roleRepo = { + findOne: jest.fn(async () => targetRole), + create: jest.fn((value) => ({ ...value, permissions: [] })), + save: jest.fn(async (value) => value), + find: jest.fn(async () => [targetRole, legacyRole]), + remove: jest.fn(async (value) => value), + }; + const userRepo = { + count: jest.fn(async () => 1), + findOne: jest.fn(async () => user), + save: jest.fn(async (value) => value), + }; + const service = new RbacService( + permRepo as never, + roleRepo as never, + userRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + ); + + await service.seedData(); + + expect(user.roles).toEqual([targetRole]); + expect(userRepo.save).toHaveBeenCalledWith(user); + expect(roleRepo.remove).toHaveBeenCalledWith(legacyRole); }); }); diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 46cd56f..36b8d7c 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -17,7 +17,11 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = { code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' }, { code: 'profile:view', name: '查看个人资料', group: 'profile' }, { code: 'notification:view', name: '查看通知', group: 'notification' }, - { code: 'student:view', name: '查看学生', group: 'student' }, + { code: 'student:view', name: '查看学生管理', group: 'student' }, + { code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' }, + { code: 'teacher-workspace:view', name: '查看教师工作台', group: 'teacher-workspace' }, + { code: 'teacher:view', name: '查看教师', group: 'teacher' }, + { code: 'teacher:edit', name: '编辑教师', group: 'teacher' }, { code: 'student:create', name: '新增学生', group: 'student' }, { code: 'student:edit', name: '编辑学生', group: 'student' }, { code: 'student:delete', name: '删除学生', group: 'student' }, @@ -46,7 +50,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = { code: 'deposit:create', name: '新增押金', group: 'deposit' }, { code: 'deposit:edit', name: '编辑押金', group: 'deposit' }, { code: 'deposit:delete', name: '删除押金', group: 'deposit' }, - { code: 'deposit:approve', name: '审批退款', group: 'deposit' }, + { code: 'deposit:refund', name: '直接退还押金', group: 'deposit' }, { code: 'classroom:view', name: '查看教室', group: 'classroom' }, { code: 'classroom:create', name: '新增教室', group: 'classroom' }, { code: 'classroom:edit', name: '编辑教室', group: 'classroom' }, @@ -80,7 +84,8 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = { code: 'schedule:delete', name: '删除排课', group: 'schedule' }, { code: 'attendance:view', name: '查看考勤', group: 'attendance' }, { code: 'attendance:create', name: '新增考勤', group: 'attendance' }, - { code: 'attendance:edit', name: '编辑考勤', group: 'attendance' }, + { code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' }, + { code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' }, { code: 'attendance:export', name: '导出考勤', group: 'attendance' }, { code: 'attendance:generate', name: '按课表生成考勤', group: 'attendance' }, { code: 'learning:create', name: '创建学习任务', group: 'learning' }, @@ -108,85 +113,39 @@ export const PRESET_ROLES: Array<{ isSystem: boolean; permissionGroups: string[]; extraPermissions?: string[]; + legacyNames?: string[]; + legacyCodes?: string[]; }> = [ { - name: '超管', + name: '超级管理员', code: 'super_admin', - description: '系统超级管理员,拥有全部权限', + description: '系统初始化、应急维护和全局权限处理', isSystem: true, permissionGroups: [], + legacyNames: ['超管', 'super_admin'], }, { - name: '宿管老师', - code: 'dormitory_supervisor', - description: '管理宿舍相关业务', - isSystem: true, - permissionGroups: [ - 'student', - 'room', - 'occupancy', - 'expense', - 'bill', - 'deposit', - 'log', - 'dashboard', - 'class', - 'schedule', - 'attendance', - 'notification', - 'profile', - ], - }, - { - name: '老师', + name: '任课老师', code: 'teacher', - description: '查看和管理本班学生', + description: '查看自己的排课、今日课程和任教班级考勤', isSystem: true, permissionGroups: ['notification', 'profile'], extraPermissions: [ - 'student:view', - 'class:view', + 'teacher-workspace:view', 'schedule:view', 'attendance:view', 'attendance:create', - 'attendance:export', + 'attendance:self-edit', ], + legacyNames: ['老师'], }, { - name: '机构负责人', - code: 'institution_head', - description: '管理机构教室和课程', - isSystem: true, - permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'], - }, - { - name: '财务', - code: 'finance', - description: '管理费用、账单与押金', - isSystem: true, - permissionGroups: ['expense', 'bill', 'deposit', 'dashboard', 'notification', 'profile'], - }, - { - name: '宿管', - code: 'dorm_manager', - description: '管理宿舍入住与宿舍信息', + name: '教务管理员', + code: 'academic', + description: '管理学生、班级、教师、全局排课和历史考勤', isSystem: true, permissionGroups: [ 'student', - 'room', - 'occupancy', - 'deposit', - 'dashboard', - 'notification', - 'profile', - ], - }, - { - name: '教务', - code: 'academic', - description: '管理班级、排课、考勤、学习与考试', - isSystem: true, - permissionGroups: [ 'class', 'schedule', 'attendance', @@ -197,6 +156,59 @@ export const PRESET_ROLES: Array<{ 'notification', 'profile', ], + extraPermissions: [ + 'teacher-workspace:view', + 'teacher:view', + 'teacher:edit', + 'sync:read', + 'sync:trigger', + ], + legacyNames: ['教务'], + }, + { + name: '住宿运营管理员', + code: 'accommodation_operations', + description: '管理宿舍、入住、住宿费用、账单、押金和退宿结算', + isSystem: true, + permissionGroups: [ + 'room', + 'occupancy', + 'expense', + 'bill', + 'deposit', + 'dashboard', + 'notification', + 'profile', + ], + extraPermissions: ['student:basic-view'], + legacyNames: ['宿管老师', '宿管', '财务'], + legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'], + }, + { + name: '教室运营管理员', + code: 'classroom_operations', + description: '管理教室、教室排期、外部机构和租赁订单', + isSystem: true, + permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'], + legacyNames: ['机构负责人'], + legacyCodes: ['institution_head'], + }, + { + name: '系统管理员', + code: 'system_admin', + description: '管理账号、角色、日志、同步和系统配置', + isSystem: true, + permissionGroups: [ + 'user', + 'role', + 'log', + 'integration', + 'sync', + 'ai', + 'department', + 'notification', + 'profile', + ], }, ]; @@ -215,6 +227,18 @@ export class RbacService { @InjectRepository(Student) private studentRepo: Repository, ) {} + private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise { + for (const code of preset.legacyCodes ?? []) { + const role = await this.roleRepo.findOne({ where: { code } }); + if (role) return role; + } + for (const name of preset.legacyNames ?? []) { + const role = await this.roleRepo.findOne({ where: { name } }); + if (role) return role; + } + return null; + } + async seedData(): Promise { // Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL) for (const p of PRESET_PERMISSIONS) { @@ -227,7 +251,10 @@ export class RbacService { // Step 2: 幂等插入预置角色 for (const r of PRESET_ROLES) { - const exists = await this.roleRepo.findOne({ where: { name: r.name } }); + const exists = + (await this.roleRepo.findOne({ where: { code: r.code } })) || + (await this.roleRepo.findOne({ where: { name: r.name } })) || + (await this.findLegacyPresetRole(r)); if (!exists) { await this.roleRepo.save( this.roleRepo.create({ @@ -239,14 +266,44 @@ export class RbacService { ); } } - const allRoles = await this.roleRepo.find({ relations: ['permissions'] }); + const allRoles = await this.roleRepo.find({ relations: ['permissions', 'users'] }); - // Step 3: 构建角色-权限关联 + // Step 3: 合并旧角色并构建新的职责权限矩阵 for (const preset of PRESET_ROLES) { - const role = allRoles.find((r) => r.name === preset.name || r.code === preset.code); + const matchesPreset = (role: Role) => + role.name === preset.name || + role.code === preset.code || + preset.legacyNames?.includes(role.name) || + preset.legacyCodes?.includes(role.code); + const candidates = allRoles.filter(matchesPreset); + const role = candidates.find((candidate) => candidate.code === preset.code) ?? candidates[0]; if (!role) continue; - if (role.code !== preset.code) { + + const duplicateRoles = candidates.filter((candidate) => candidate.id !== role.id); + if (duplicateRoles.length > 0) { + for (const duplicate of duplicateRoles) { + for (const relatedUser of duplicate.users ?? []) { + const user = await this.userRepo.findOne({ + where: { id: relatedUser.id }, + relations: ['roles'], + }); + if (!user) continue; + const remainingRoles = (user.roles ?? []).filter( + (assignedRole) => assignedRole.id !== duplicate.id && assignedRole.id !== role.id, + ); + user.roles = [...remainingRoles, role]; + await this.userRepo.save(user); + } + await this.roleRepo.remove(duplicate); + } + } + + if (role.code !== preset.code || role.name !== preset.name || role.description !== preset.description) { role.code = preset.code; + role.name = preset.name; + role.description = preset.description; + role.isSystem = preset.isSystem; + role.status = 1; await this.roleRepo.save(role); } @@ -265,12 +322,11 @@ export class RbacService { ); } - // 系统角色只补齐预置权限,不移除管理员手动授予的额外权限。 - // 这样新增权限(例如 profile:view)会自动补上,同时避免重启后覆盖人工配置。 - const currentIds = new Set(role.permissions.map((permission) => permission.id)); - const missingPerms = perms.filter((permission) => !currentIds.has(permission.id)); - if (missingPerms.length > 0) { - role.permissions = [...role.permissions, ...missingPerms]; + // 系统预置角色必须严格遵循职责矩阵;额外授权请创建自定义角色叠加。 + const currentIds = role.permissions.map((permission) => permission.id).sort((a, b) => a - b); + const targetIds = perms.map((permission) => permission.id).sort((a, b) => a - b); + if (currentIds.join(',') !== targetIds.join(',')) { + role.permissions = perms; await this.roleRepo.save(role); } } @@ -285,7 +341,7 @@ export class RbacService { passwordHash: hash, name: '管理员', }); - const superAdminRole = allRoles.find((r) => r.name === '超管'); + const superAdminRole = allRoles.find((r) => r.code === 'super_admin'); if (superAdminRole) { adminUser.roles = [superAdminRole]; } @@ -555,7 +611,6 @@ export class RbacService { .andWhere('cs.startDate <= :today', { today: todayStr }) .andWhere('cs.endDate >= :today', { today: todayStr }) .andWhere('cs.status = :status', { status: 'active' }) - .andWhere('cs.teacherId = :userId', { userId }) .orderBy('cs.startTime', 'ASC') .getMany(); @@ -594,8 +649,8 @@ export class RbacService { async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) { const page = query?.page || 1; const pageSize = query?.pageSize || 20; - const teacherRoleCodes = ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin']; - const teacherRoleNames = ['老师', '班主任', '宿管老师', '超管']; + const teacherRoleCodes = ['teacher', 'super_admin']; + const teacherRoleNames = ['任课老师', '老师', '超级管理员', '超管']; const qb = this.userRepo .createQueryBuilder('u') diff --git a/apps/server/src/rbac/rbac.teacher-workspace.spec.ts b/apps/server/src/rbac/rbac.teacher-workspace.spec.ts new file mode 100644 index 0000000..1aed803 --- /dev/null +++ b/apps/server/src/rbac/rbac.teacher-workspace.spec.ts @@ -0,0 +1,54 @@ +import { RbacService } from './rbac.service'; + +describe('RbacService getTeacherWorkspace', () => { + it('loads today schedules for every assigned class without requiring schedule.teacherId', async () => { + const queryBuilder = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([ + { + id: 12, + classId: 8, + classroomId: 3, + teacherId: null, + weekDay: new Date().getDay() || 7, + startTime: '09:00', + endTime: '10:00', + subject: '数学', + scheduleType: 'INTERNAL', + }, + ]), + }; + const classTeacherRepo = { + find: jest.fn().mockResolvedValue([ + { + classId: 8, + userId: 21, + roleType: 'subject_teacher', + subject: '数学', + class: { id: 8, name: '一班', code: 'C001' }, + }, + ]), + }; + const classStudentRepo = { find: jest.fn().mockResolvedValue([]) }; + const classScheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(queryBuilder) }; + const service = new RbacService( + {} as never, + {} as never, + {} as never, + {} as never, + classStudentRepo as never, + classTeacherRepo as never, + classScheduleRepo as never, + {} as never, + ); + + const result = await service.getTeacherWorkspace(21); + + expect(result.todaySchedules).toHaveLength(1); + expect(queryBuilder.andWhere).not.toHaveBeenCalledWith('cs.teacherId = :userId', { + userId: 21, + }); + }); +}); diff --git a/apps/server/src/schedules/schedules.controller.spec.ts b/apps/server/src/schedules/schedules.controller.spec.ts new file mode 100644 index 0000000..ded3d43 --- /dev/null +++ b/apps/server/src/schedules/schedules.controller.spec.ts @@ -0,0 +1,126 @@ +import { ForbiddenException } from '@nestjs/common'; +import { SchedulesController } from './schedules.controller'; +import { SchedulesService } from './schedules.service'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { NotificationsService } from '../notifications/notifications.service'; + +const teacherRequest = { + user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false }, + headers: {}, +}; + +const scheduleDto = { + classId: 8, + classroomId: 3, + weekDay: 1, + startTime: '09:00', + endTime: '10:00', + startDate: '2026-07-01', + endDate: '2026-07-31', + subject: '数学', +}; + +describe('SchedulesController — class data scope', () => { + const service = { + getAccessibleClassIds: jest.fn(), + assertClassAccess: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + getClassroomOccupancy: jest.fn(), + maskScheduleOccupancy: jest.fn((schedule) => ({ + ...schedule, + id: null, + classId: null, + subject: '已占用', + teacherId: null, + notes: null, + canViewDetails: false, + })), + checkConflict: jest.fn(), + }; + const logService = { log: jest.fn().mockResolvedValue(undefined) }; + const notificationsService = { create: jest.fn() }; + const ability = { can: jest.fn().mockReturnValue(false) }; + const authzService = { abilityForRequest: jest.fn().mockReturnValue(ability) }; + let controller: SchedulesController; + + beforeEach(() => { + jest.clearAllMocks(); + ability.can.mockReturnValue(false); + service.getAccessibleClassIds.mockResolvedValue([8]); + controller = new SchedulesController( + service as unknown as SchedulesService, + logService as unknown as OperationLogsService, + notificationsService as unknown as NotificationsService, + authzService as never, + ); + }); + + it('checks the requested class before creating a schedule', async () => { + service.create.mockResolvedValue({ id: 1, ...scheduleDto }); + + await controller.create(scheduleDto, teacherRequest as never); + + expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false); + expect(service.create).toHaveBeenCalledWith(scheduleDto); + }); + + it('checks both the current and destination class before moving a schedule', async () => { + service.findOne.mockResolvedValue({ id: 4, ...scheduleDto }); + service.update.mockResolvedValue({ id: 4, ...scheduleDto, classId: 9 }); + + await controller.update('4', { classId: 9 }, teacherRequest as never); + + expect(service.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false); + expect(service.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 9, false); + }); + + it('checks the owning class before returning full schedule details', async () => { + service.findOne.mockResolvedValue({ id: 4, ...scheduleDto }); + + await controller.findOne('4', teacherRequest as never); + + expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false); + }); + + it('checks the owning class before deleting a schedule', async () => { + service.findOne.mockResolvedValue({ id: 4, ...scheduleDto }); + service.remove.mockResolvedValue({ success: true }); + + await controller.remove('4', teacherRequest as never); + + expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false); + expect(service.remove).toHaveBeenCalledWith(4); + }); + + it('returns only masked occupancy blocks from the classroom occupancy endpoint', async () => { + service.getClassroomOccupancy.mockResolvedValue([ + { id: 2, classId: 99, subject: '英语', teacherId: 7, notes: '隐私', classroomId: 3 }, + ]); + + const result = await controller.getClassroomOccupancy('3', undefined, teacherRequest as never); + + expect(result).toEqual([ + expect.objectContaining({ + id: null, + classId: null, + subject: '已占用', + teacherId: null, + notes: null, + canViewDetails: false, + }), + ]); + expect(JSON.stringify(result)).not.toContain('英语'); + expect(JSON.stringify(result)).not.toContain('隐私'); + }); + + it('rejects records without a class instead of exposing full details to a scoped teacher', async () => { + service.findOne.mockResolvedValue({ id: 4, ...scheduleDto, classId: null }); + + await expect(controller.findOne('4', teacherRequest as never)).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); +}); diff --git a/apps/server/src/schedules/schedules.controller.ts b/apps/server/src/schedules/schedules.controller.ts index 616f8f5..75cde05 100644 --- a/apps/server/src/schedules/schedules.controller.ts +++ b/apps/server/src/schedules/schedules.controller.ts @@ -9,12 +9,10 @@ import { Query, UseGuards, Request, + ForbiddenException, + ConflictException, } from '@nestjs/common'; -import { - AuthorizationService, - CaslAction, - SubjectName, -} from '../authorization'; +import { AuthorizationService, CaslAction, SubjectName } from '../authorization'; import { SchedulesService } from './schedules.service'; import { CreateScheduleDto, @@ -25,7 +23,6 @@ import { import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; -import { ConflictException } from '@nestjs/common'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; import { RequirePermission } from '../auth/decorators/permission.decorator'; @@ -56,6 +53,22 @@ export class SchedulesController { ); } + private assertClassAccess(req: { user: RequestUser }, classId: number) { + return this.service.assertClassAccess(req.user.id, classId, this.canManageAllSchedules(req)); + } + + private async getAuthorizedSchedule(id: number, req: { user: RequestUser }) { + const schedule = await this.service.findOne(id); + if (schedule.classId == null) { + if (!this.canManageAllSchedules(req)) { + throw new ForbiddenException('无权访问该排课详情'); + } + return schedule; + } + await this.assertClassAccess(req, schedule.classId); + return schedule; + } + @Get('lookups') @RequirePermission('schedule:view') async getLookups(@Request() req: { user: RequestUser }) { @@ -99,14 +112,29 @@ export class SchedulesController { @Get('classroom/:id/occupancy') @RequirePermission('schedule:view') - getClassroomOccupancy(@Param('id') id: string, @Query('date') date?: string) { - return this.service.getClassroomOccupancy(+id, date); + async getClassroomOccupancy( + @Param('id') id: string, + @Query('date') date: string | undefined, + @Request() req: { user: RequestUser }, + ) { + const schedules = await this.service.getClassroomOccupancy(+id, date); + const classIds = await this.service.getAccessibleClassIds( + req.user.id, + this.canManageAllSchedules(req), + ); + if (!classIds) return schedules.map((schedule) => ({ ...schedule, canViewDetails: true })); + const allowed = new Set(classIds); + return schedules.map((schedule) => + schedule.classId !== null && allowed.has(schedule.classId) + ? { ...schedule, canViewDetails: true } + : this.service.maskScheduleOccupancy(schedule), + ); } @Get(':id') @RequirePermission('schedule:view') - findOne(@Param('id') id: string) { - return this.service.findOne(+id); + async findOne(@Param('id') id: string, @Request() req: { user: RequestUser }) { + return this.getAuthorizedSchedule(+id, req); } @Post() @@ -116,6 +144,7 @@ export class SchedulesController { @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { const { ipAddress, userAgent } = extractRequestInfo(req); + await this.assertClassAccess(req as { user: RequestUser }, dto.classId); try { const result = await this.service.create(dto); await this.logService.log({ @@ -152,7 +181,9 @@ export class SchedulesController { content: `教室${dto.classroomId} 周${dto.weekDay} ${dto.startTime}-${dto.endTime} 与已有排课冲突`, }); } - } catch {} + } catch { + // Best-effort conflict notification must not hide the original conflict. + } } throw error; } @@ -166,7 +197,10 @@ export class SchedulesController { @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { const { ipAddress, userAgent } = extractRequestInfo(req); - const existing = await this.service.findOne(+id); + const existing = await this.getAuthorizedSchedule(+id, req as { user: RequestUser }); + if (dto.classId !== undefined && dto.classId !== existing.classId) { + await this.assertClassAccess(req as { user: RequestUser }, dto.classId); + } try { const result = await this.service.update(+id, dto); await this.logService.log({ @@ -203,7 +237,9 @@ export class SchedulesController { content: `教室${existing.classroomId} 周${existing.weekDay} ${existing.startTime}-${existing.endTime} (更新) 与已有排课冲突`, }); } - } catch {} + } catch { + // Best-effort conflict notification must not hide the original conflict. + } } throw error; } @@ -216,6 +252,7 @@ export class SchedulesController { @Request() req: { user?: { id: number; username: string }; headers?: Record }, ) { const { ipAddress, userAgent } = extractRequestInfo(req); + await this.getAuthorizedSchedule(+id, req as { user: RequestUser }); const result = await this.service.remove(+id); await this.logService.log({ userId: req.user?.id, diff --git a/apps/server/src/schedules/schedules.module.ts b/apps/server/src/schedules/schedules.module.ts index 094e5fc..bf4041e 100644 --- a/apps/server/src/schedules/schedules.module.ts +++ b/apps/server/src/schedules/schedules.module.ts @@ -1,6 +1,13 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities'; +import { + ClassSchedule, + Class, + Classroom, + ClassroomRental, + ClassTeacher, + AttendanceSession, +} from '../entities'; import { SchedulesService } from './schedules.service'; import { SchedulesController } from './schedules.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; @@ -8,7 +15,14 @@ import { NotificationsModule } from '../notifications/notifications.module'; @Module({ imports: [ - TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental, ClassTeacher]), + TypeOrmModule.forFeature([ + ClassSchedule, + Class, + Classroom, + ClassroomRental, + ClassTeacher, + AttendanceSession, + ]), OperationLogsModule, NotificationsModule, ], diff --git a/apps/server/src/schedules/schedules.scope.spec.ts b/apps/server/src/schedules/schedules.scope.spec.ts index c57a97d..31d9e6e 100644 --- a/apps/server/src/schedules/schedules.scope.spec.ts +++ b/apps/server/src/schedules/schedules.scope.spec.ts @@ -39,3 +39,67 @@ describe('SchedulesService — teacher class scope', () => { expect(qb.getMany).not.toHaveBeenCalled(); }); }); + +describe('SchedulesService — shared classroom occupancy visibility', () => { + it('shows other classes as masked busy blocks while preserving assigned-class details', async () => { + const qb = createQb(); + qb.getMany.mockResolvedValue([ + { + id: 1, + classId: 3, + classroomId: 10, + weekDay: 1, + startTime: '09:00', + endTime: '10:00', + startDate: '2026-07-01', + endDate: '2026-07-31', + subject: '数学', + teacherId: 8, + scheduleType: 'INTERNAL', + status: 'active', + notes: '本班备注', + }, + { + id: 2, + classId: 99, + classroomId: 10, + weekDay: 1, + startTime: '10:00', + endTime: '11:00', + startDate: '2026-07-01', + endDate: '2026-07-31', + subject: '其他班隐私科目', + teacherId: 9, + scheduleType: 'INTERNAL', + status: 'active', + notes: '其他班备注', + }, + ]); + const service = new SchedulesService( + { createQueryBuilder: jest.fn().mockReturnValue(qb) } as never, + {} as never, + {} as never, + {} as never, + ); + + const result = await service.getWeeklyView({}, [3]); + const blocks = result[10][1]; + + expect(blocks[0]).toEqual(expect.objectContaining({ subject: '数学', canViewDetails: true })); + expect(blocks[1]).toEqual( + expect.objectContaining({ + subject: '已占用', + classId: null, + teacherId: null, + notes: null, + canViewDetails: false, + }), + ); + expect(JSON.stringify(blocks[1])).not.toContain('其他班隐私科目'); + expect(JSON.stringify(blocks[1])).not.toContain('其他班备注'); + expect(qb.andWhere).not.toHaveBeenCalledWith( + 'cs.classId IN (:...accessibleClassIds)', + expect.anything(), + ); + }); +}); diff --git a/apps/server/src/schedules/schedules.service.spec.ts b/apps/server/src/schedules/schedules.service.spec.ts index fdb8133..cbdc64d 100644 --- a/apps/server/src/schedules/schedules.service.spec.ts +++ b/apps/server/src/schedules/schedules.service.spec.ts @@ -7,6 +7,8 @@ import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Class } from '../entities/class.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; +import { AttendanceSession } from '../entities/attendance-session.entity'; +import { Classroom } from '../entities/classroom.entity'; /** Build a mock query-builder where each chain method returns `this`. */ function mockQueryBuilder(results: T[] = []) { @@ -20,6 +22,45 @@ function mockQueryBuilder(results: T[] = []) { return qb; } +describe('SchedulesService — getLookups', () => { + it('includes active classrooms that have never been scheduled', async () => { + const classroom = { id: 7, name: '新教室', building: 'A座' } as Classroom; + const classroomRepo = { find: jest.fn().mockResolvedValue([classroom]) }; + const scheduleQb = { + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + innerJoin: jest.fn().mockReturnThis(), + distinct: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + const module = await Test.createTestingModule({ + providers: [ + SchedulesService, + { + provide: getRepositoryToken(ClassSchedule), + useValue: { createQueryBuilder: jest.fn().mockReturnValue(scheduleQb) }, + }, + { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, + { provide: getRepositoryToken(Classroom), useValue: classroomRepo }, + { provide: getRepositoryToken(ClassroomRental), useValue: {} }, + { provide: getRepositoryToken(ClassTeacher), useValue: {} }, + { provide: getRepositoryToken(AttendanceSession), useValue: {} }, + ], + }).compile(); + + const service = module.get(SchedulesService); + + await expect(service.getLookups([1])).resolves.toMatchObject({ classrooms: [classroom] }); + expect(classroomRepo.find).toHaveBeenCalledWith({ + where: expect.any(Object), + select: ['id', 'name', 'building', 'floor', 'roomType'], + order: { building: 'ASC', name: 'ASC' }, + }); + }); +}); + describe('SchedulesService — checkConflict', () => { let service: SchedulesService; let scheduleRepo: jest.Mocked, 'createQueryBuilder'>>; @@ -34,6 +75,7 @@ describe('SchedulesService — checkConflict', () => { providers: [ SchedulesService, { provide: getRepositoryToken(ClassSchedule), useValue: mockRepo }, + { provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(ClassroomRental), @@ -43,6 +85,10 @@ describe('SchedulesService — checkConflict', () => { provide: getRepositoryToken(ClassTeacher), useValue: { find: jest.fn().mockResolvedValue([]) }, }, + { + provide: getRepositoryToken(AttendanceSession), + useValue: { count: jest.fn().mockResolvedValue(0) }, + }, ], }).compile(); @@ -141,13 +187,13 @@ describe('SchedulesService — checkConflict', () => { describe('SchedulesService — getClassroomOccupancy', () => { let service: SchedulesService; let scheduleRepo: jest.Mocked, 'createQueryBuilder'>>; - beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ SchedulesService, { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, + { provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() }, @@ -156,6 +202,10 @@ describe('SchedulesService — getClassroomOccupancy', () => { provide: getRepositoryToken(ClassTeacher), useValue: { find: jest.fn().mockResolvedValue([]) }, }, + { + provide: getRepositoryToken(AttendanceSession), + useValue: { count: jest.fn().mockResolvedValue(0) }, + }, ], }).compile(); @@ -191,3 +241,69 @@ describe('SchedulesService — getClassroomOccupancy', () => { expect(qb.andWhere).toHaveBeenCalledWith('cs.endDate >= :date', { date: '2026-03-15' }); }); }); + +describe('SchedulesService — remove', () => { + let service: SchedulesService; + let scheduleRepo: jest.Mocked, 'findOne' | 'remove'>>; + let attendanceSessionRepo: jest.Mocked, 'count'>>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SchedulesService, + { + provide: getRepositoryToken(ClassSchedule), + useValue: { findOne: jest.fn(), remove: jest.fn() }, + }, + { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, + { provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } }, + { + provide: getRepositoryToken(ClassroomRental), + useValue: { createQueryBuilder: jest.fn() }, + }, + { + provide: getRepositoryToken(ClassTeacher), + useValue: { find: jest.fn().mockResolvedValue([]) }, + }, + { + provide: getRepositoryToken(AttendanceSession), + useValue: { count: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(SchedulesService); + scheduleRepo = module.get(getRepositoryToken(ClassSchedule)); + attendanceSessionRepo = module.get(getRepositoryToken(AttendanceSession)); + }); + + it('deletes a schedule with no attendance sessions', async () => { + const schedule = { id: 1, subject: '数学' } as ClassSchedule; + (scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule); + (scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule); + (attendanceSessionRepo.count as jest.Mock).mockResolvedValue(0); + + const result = await service.remove(1); + + expect(result).toEqual({ success: true }); + expect(scheduleRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(scheduleRepo.remove).toHaveBeenCalledWith(schedule); + }); + + it('rejects deletion when attendance sessions exist', async () => { + const schedule = { id: 2, subject: '英语' } as ClassSchedule; + (scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule); + (scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule); + (attendanceSessionRepo.count as jest.Mock).mockResolvedValue(3); + + await expect(service.remove(2)).rejects.toThrow(ConflictException); + expect(scheduleRepo.remove).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException for non-existent schedule', async () => { + (scheduleRepo.findOne as jest.Mock).mockResolvedValue(null); + + await expect(service.remove(999)).rejects.toThrow('排课记录不存在'); + expect(scheduleRepo.remove).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index ab2e2ca..8490cb3 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -2,12 +2,19 @@ import { Injectable, NotFoundException, ConflictException, + ForbiddenException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; -import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities'; - +import { In, Not, Repository } from 'typeorm'; +import { + ClassSchedule, + Class, + Classroom, + ClassroomRental, + ClassTeacher, + AttendanceSession, +} from '../entities'; import { CreateScheduleDto, UpdateScheduleDto, @@ -21,10 +28,13 @@ export class SchedulesService { @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, @InjectRepository(Class) private readonly classRepo: Repository, + @InjectRepository(Classroom) private readonly classroomRepo: Repository, @InjectRepository(ClassroomRental) private readonly rentalRepo: Repository, @InjectRepository(ClassTeacher) private readonly classTeacherRepo: Repository, + @InjectRepository(AttendanceSession) + private readonly attendanceSessionRepo: Repository, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -33,6 +43,31 @@ export class SchedulesService { return [...new Set(assignments.map((assignment) => assignment.classId))]; } + async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise { + if (canManageAll) return; + const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } }); + if (!assignment) throw new ForbiddenException('只能管理自己被分配班级的排课'); + } + + maskScheduleOccupancy(schedule: ClassSchedule) { + return { + id: null, + classId: null, + classroomId: schedule.classroomId, + weekDay: schedule.weekDay, + startTime: schedule.startTime, + endTime: schedule.endTime, + startDate: schedule.startDate, + endDate: schedule.endDate, + subject: '已占用', + teacherId: null, + scheduleType: schedule.scheduleType, + status: schedule.status, + notes: null, + canViewDetails: false, + }; + } + async getLookups(accessibleClassIds?: number[]) { const classes = accessibleClassIds ? accessibleClassIds.length > 0 @@ -47,24 +82,15 @@ export class SchedulesService { order: { name: 'ASC' }, }); - const classroomRows = await this.scheduleRepo - .createQueryBuilder('schedule') - .select('classroom.id', 'classroomId') - .addSelect('classroom.name', 'classroomName') - .addSelect('classroom.building', 'classroomBuilding') - .innerJoin('schedule.classroom', 'classroom') - .distinct(true) - .orderBy('classroom.building', 'ASC') - .addOrderBy('classroom.name', 'ASC') - .getRawMany(); + const classrooms = await this.classroomRepo.find({ + where: { status: Not('archived') }, + select: ['id', 'name', 'building', 'floor', 'roomType'], + order: { building: 'ASC', name: 'ASC' }, + }); return { classes, - classrooms: classroomRows.map((row) => ({ - id: Number(row.classroomId), - name: String(row.classroomName ?? ''), - building: String(row.classroomBuilding ?? ''), - })), + classrooms, }; } @@ -181,6 +207,16 @@ export class SchedulesService { async remove(id: number) { const schedule = await this.scheduleRepo.findOne({ where: { id } }); if (!schedule) throw new NotFoundException('排课记录不存在'); + + const sessionCount = await this.attendanceSessionRepo.count({ + where: { scheduleId: id }, + }); + if (sessionCount > 0) { + throw new ConflictException( + `无法删除已产生 ${sessionCount} 个考勤场次的排课。请先取消或停用排课以保护历史考勤数据。`, + ); + } + await this.scheduleRepo.remove(schedule); return { success: true }; } @@ -236,10 +272,6 @@ export class SchedulesService { if (query.classroomId) { qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId }); } - if (accessibleClassIds) { - if (accessibleClassIds.length === 0) return {}; - qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); - } if (query.startDate) { qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); } @@ -253,12 +285,25 @@ export class SchedulesService { .addOrderBy('cs.startTime', 'ASC') .getMany(); + const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null; + const visibleSchedules = schedules.map((schedule) => { + const canViewDetails = + allowedClassIds === null || + (schedule.classId !== null && allowedClassIds.has(schedule.classId)); + if (canViewDetails) return { ...schedule, canViewDetails: true }; + + // Other classes remain visible only as a room/time occupancy block. + // Do not expose class, subject, teacher, notes, or internal record IDs. + return this.maskScheduleOccupancy(schedule); + }); + // Group by classroomId → weekDay - const matrix: Record> = {}; - for (const s of schedules) { - if (!matrix[s.classroomId]) matrix[s.classroomId] = {}; - if (!matrix[s.classroomId][s.weekDay]) matrix[s.classroomId][s.weekDay] = []; - matrix[s.classroomId][s.weekDay].push(s); + const matrix: Record> = {}; + for (const schedule of visibleSchedules) { + if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {}; + if (!matrix[schedule.classroomId][schedule.weekDay]) + matrix[schedule.classroomId][schedule.weekDay] = []; + matrix[schedule.classroomId][schedule.weekDay].push(schedule); } return matrix; diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index c5a7fd8..3cb20fd 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -52,6 +52,12 @@ export class StudentsController { ); } + @Get('basic-lookups') + @RequirePermission('student:basic-view', 'student:view') + getBasicLookups() { + return this.service.getBasicLookups(); + } + @Get() @RequirePermission('student:view') async findAll( diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index f023f7b..4a3aac6 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -27,6 +27,14 @@ export class StudentsService { return [...new Set(assignments.map((assignment) => assignment.classId))]; } + async getBasicLookups() { + return this.repo.find({ + select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'], + where: { status: 'active' }, + order: { name: 'ASC' }, + }); + } + async findAll( query?: { name?: string; diff --git a/apps/server/src/sync/schedule-sync.service.spec.ts b/apps/server/src/sync/schedule-sync.service.spec.ts index 21bc9fb..557f6e4 100644 --- a/apps/server/src/sync/schedule-sync.service.spec.ts +++ b/apps/server/src/sync/schedule-sync.service.spec.ts @@ -28,7 +28,7 @@ describe('ScheduleSyncService — absence threshold', () => { find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]), }; const dingTalkService = { - queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_16:00-17:00' }]), + queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '冲刺班_16:00-17:00' }]), upsertShift: jest.fn().mockResolvedValue(456), queryAttendanceGroups: jest.fn().mockResolvedValue([ { group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 }, @@ -50,7 +50,7 @@ describe('ScheduleSyncService — absence threshold', () => { expect(dingTalkService.upsertShift).toHaveBeenCalledWith(expect.objectContaining({ id: 456, - name: '排课_16:00-17:00', + name: '冲刺班_16:00-17:00', setting: expect.objectContaining({ absenteeism_late_minutes: 60 }), })); }); @@ -118,3 +118,347 @@ describe('ScheduleSyncService — attendance machine only', () => { expect(dingTalkService.createAttendanceGroup).not.toHaveBeenCalled(); }); }); + + +describe('ScheduleSyncService — partial batch failure', () => { + it('reports failedBatchCount > 0 when a scheduleUsers batch fails, not full success', async () => { + const scheduleRepo = { + find: jest.fn().mockResolvedValue([ + { + id: 1, + classId: 10, + classroomId: 1, + weekDay: 1, + startTime: '09:00', + endTime: '11:00', + startDate: '2026-07-06', + endDate: '2026-07-06', + status: 'active', + } as ClassSchedule, + { + id: 2, + classId: 20, + classroomId: 2, + weekDay: 2, + startTime: '14:00', + endTime: '16:00', + startDate: '2026-07-07', + endDate: '2026-07-07', + status: 'active', + } as ClassSchedule, + ]), + }; + const classStudentRepo = { + find: jest.fn().mockResolvedValue([ + { classId: 10, studentId: 20, status: 'active' }, + { classId: 20, studentId: 30, status: 'active' }, + ]), + }; + const mappingRepo = { + find: jest.fn().mockResolvedValue([ + { studentId: 20, dingUserId: 'student-1' }, + { studentId: 30, dingUserId: 'student-2' }, + ]), + }; + const classRepo = { + find: jest.fn().mockResolvedValue([ + { id: 10, name: '冲刺班' }, + { id: 20, name: '强化班' }, + ]), + }; + + const scheduleUsers = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('钉钉排班失败: rate limited (code=33018)')); + + const dingTalkService = { + queryShifts: jest.fn().mockResolvedValue([ + { id: 900, name: '排课_09:00-11:00' }, + { id: 901, name: '排课_14:00-16:00' }, + ]), + upsertShift: jest.fn().mockResolvedValue(900).mockResolvedValueOnce(900).mockResolvedValueOnce(901), + queryAttendanceGroups: jest.fn().mockResolvedValue([ + { group_id: 777, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 }, + ]), + updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), + createAttendanceGroup: jest.fn().mockResolvedValue(888), + scheduleUsers, + }; + + const service = new ScheduleSyncService( + scheduleRepo as never, + classStudentRepo as never, + mappingRepo as never, + classRepo as never, + dingTalkService as never, + ); + + const result = await service.syncAll('2026-07-06', 2); + + expect(scheduleUsers).toHaveBeenCalledTimes(2); + expect(result.failedBatchCount).toBeGreaterThan(0); + expect(result.errors).toBeDefined(); + expect(result.errors!.length).toBeGreaterThan(0); + // syncedItems should only count the successful batch + expect(result.syncedItems).toBeGreaterThan(0); + }); +}); + +describe('ScheduleSyncService — attendance group failure', () => { + it('counts createAttendanceGroup failure as real failure, not skippedNoMapping', async () => { + const scheduleRepo = { + find: jest.fn().mockResolvedValue([ + { + id: 1, + classId: 10, + classroomId: 1, + weekDay: 1, + startTime: '09:00', + endTime: '11:00', + startDate: '2026-07-06', + endDate: '2026-07-06', + status: 'active', + } as ClassSchedule, + { + id: 2, + classId: 20, + classroomId: 2, + weekDay: 2, + startTime: '14:00', + endTime: '16:00', + startDate: '2026-07-07', + endDate: '2026-07-07', + status: 'active', + } as ClassSchedule, + ]), + }; + const classStudentRepo = { + find: jest.fn().mockResolvedValue([ + { classId: 10, studentId: 20, status: 'active' }, + { classId: 20, studentId: 30, status: 'active' }, + ]), + }; + const mappingRepo = { + find: jest.fn().mockResolvedValue([ + { studentId: 20, dingUserId: 'student-1' }, + { studentId: 30, dingUserId: 'student-2' }, + ]), + }; + const classRepo = { + find: jest.fn().mockResolvedValue([ + { id: 10, name: '冲刺班' }, + { id: 20, name: '强化班' }, + ]), + }; + + const createAttendanceGroup = jest + .fn() + .mockRejectedValue(new Error('钉钉考勤组创建失败: insuffient permission (code=403)')); + + const dingTalkService = { + queryShifts: jest.fn().mockResolvedValue([ + { id: 900, name: '排课_09:00-11:00' }, + { id: 901, name: '排课_14:00-16:00' }, + ]), + upsertShift: jest.fn().mockResolvedValue(900), + queryAttendanceGroups: jest.fn().mockResolvedValue([ + { group_id: 888, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 }, + ]), + updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), + createAttendanceGroup, + scheduleUsers: jest.fn().mockResolvedValue(undefined), + }; + + const service = new ScheduleSyncService( + scheduleRepo as never, + classStudentRepo as never, + mappingRepo as never, + classRepo as never, + dingTalkService as never, + ); + + const result = await service.syncAll('2026-07-06', 2); + + // group failure must NOT be counted as skippedNoMapping + expect(result.skippedNoMapping).toBe(0); + // group failure must increment failure counters + expect(result.failedBatchCount).toBeGreaterThan(0); + expect(result.failedItems).toBeGreaterThan(0); + // error message must contain the group failure detail + expect(result.errors).toBeDefined(); + expect(result.errors!.some((e) => e.includes('考勤组'))).toBe(true); + expect(result.errors!.some((e) => e.includes('强化班'))).toBe(true); + // the successful class should still sync + expect(result.syncedItems).toBeGreaterThan(0); + expect(result.groupCount).toBe(1); + }); +}); + +describe('ScheduleSyncService — dedup', () => { + it('does not write duplicate schedule items for same user/date/shift', async () => { + const scheduleRepo = { + find: jest.fn().mockResolvedValue([ + { + id: 1, + classId: 10, + classroomId: 1, + weekDay: 3, + startTime: '10:00', + endTime: '12:00', + startDate: '2026-07-01', + endDate: '2026-07-31', + status: 'active', + } as ClassSchedule, + { + id: 2, + classId: 10, + classroomId: 1, + weekDay: 3, + startTime: '10:00', + endTime: '12:00', + startDate: '2026-07-08', + endDate: '2026-07-08', + status: 'active', + } as ClassSchedule, + ]), + }; + const classStudentRepo = { + find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]), + }; + const mappingRepo = { + find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]), + }; + const classRepo = { + find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]), + }; + + const scheduleUsers = jest.fn().mockResolvedValue(undefined); + const dingTalkService = { + queryShifts: jest + .fn() + .mockResolvedValue([{ id: 456, name: '排课_10:00-12:00' }]), + upsertShift: jest.fn().mockResolvedValue(456), + queryAttendanceGroups: jest.fn().mockResolvedValue([ + { group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 }, + ]), + updateAttendanceGroup: jest.fn().mockResolvedValue(undefined), + createAttendanceGroup: jest.fn(), + scheduleUsers, + }; + + const service = new ScheduleSyncService( + scheduleRepo as never, + classStudentRepo as never, + mappingRepo as never, + classRepo as never, + dingTalkService as never, + ); + + await service.syncAll('2026-07-01', 31); + + const batchItems = scheduleUsers.mock.calls[0][1] as Array<{ userid: string; work_date: number; shift_id: number }>; + + // 2026-07-08 is a Wednesday (weekDay 3), so both schedules hit that date. + // The dedup should collapse the two identical {userid, work_date, shift_id} items into one. + const key = (item: { userid: string; work_date: number; shift_id: number }) => + `${item.userid}-${item.work_date}-${item.shift_id}`; + + const seen = new Set(); + for (const item of batchItems) { + const k = key(item); + expect(seen.has(k)).toBe(false); + seen.add(k); + } + + // At least one item exists for 07-08 (proving overlap was handled) + // work_date is epoch ms at 00:00:00+08:00; convert back to date string + const fmtDate = (epochMs: number) => { + const d = new Date(epochMs); + return new Date(d.getTime() - d.getTimezoneOffset() * 60000) + .toISOString().slice(0, 10); + }; + const july8Items = batchItems.filter((i) => fmtDate(i.work_date) === '2026-07-08'); + expect(july8Items.length).toBe(1); + }); +}); + +describe('ScheduleSyncService — all shifts fail', () => { + it('counts failedBatchCount and failedItems when every shift creation fails, never skippedNoMapping', async () => { + const scheduleRepo = { + find: jest.fn().mockResolvedValue([ + { + id: 1, + classId: 10, + classroomId: 1, + weekDay: 1, + startTime: '09:00', + endTime: '11:00', + startDate: '2026-07-06', + endDate: '2026-07-06', + status: 'active', + } as ClassSchedule, + { + id: 2, + classId: 10, + classroomId: 1, + weekDay: 2, + startTime: '14:00', + endTime: '16:00', + startDate: '2026-07-07', + endDate: '2026-07-07', + status: 'active', + } as ClassSchedule, + ]), + }; + const classStudentRepo = { + find: jest.fn().mockResolvedValue([ + { classId: 10, studentId: 20, status: 'active' }, + ]), + }; + const mappingRepo = { + find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]), + }; + const classRepo = { + find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]), + }; + + const dingTalkService = { + queryShifts: jest.fn().mockResolvedValue([]), + upsertShift: jest.fn().mockRejectedValue(new Error('钉钉班次创建失败: permission denied')), + queryAttendanceGroups: jest.fn().mockResolvedValue([]), + updateAttendanceGroup: jest.fn(), + createAttendanceGroup: jest.fn(), + scheduleUsers: jest.fn(), + }; + + const service = new ScheduleSyncService( + scheduleRepo as never, + classStudentRepo as never, + mappingRepo as never, + classRepo as never, + dingTalkService as never, + ); + + const result = await service.syncAll('2026-07-06', 2); + + // All shifts failed → no shifts created + expect(result.shiftCount).toBe(0); + // No attendance groups created (no usable shifts) + expect(result.groupCount).toBe(0); + // Nothing synced + expect(result.syncedItems).toBe(0); + // Must NOT count as skippedNoMapping + expect(result.skippedNoMapping).toBe(0); + // Failure counters must reflect the failed shifts + expect(result.failedBatchCount).toBeGreaterThan(0); + expect(result.failedItems).toBeGreaterThan(0); + // Errors must contain shift failure messages + expect(result.errors).toBeDefined(); + expect(result.errors!.length).toBeGreaterThan(0); + expect(result.errors!.some((e) => e.includes('班次'))).toBe(true); + // No scheduleUsers calls (no group created) + expect(dingTalkService.scheduleUsers).not.toHaveBeenCalled(); + }); +}); + diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts index d6a1708..339b4f0 100644 --- a/apps/server/src/sync/schedule-sync.service.ts +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -21,6 +21,12 @@ export interface ScheduleSyncResult { syncedItems: number; /** 因无学生或无钉钉映射而跳过的排课数 */ skippedNoMapping: number; + /** 写入失败的排班批次数 */ + failedBatchCount: number; + /** 写入失败的排班条数 */ + failedItems: number; + /** 失败批次错误详情 */ + errors: string[]; /** 按班级分组的详情 */ groups: Array<{ className: string; @@ -39,6 +45,13 @@ export interface ScheduleSyncResult { * 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次) * 5. 将排课展开为每个学生的每日排班,批量写入钉钉 * + * ## 残余风险:同步窗口内已不存在的旧排班无法清理 + * 钉钉开放平台未暴露排班删除接口(仅提供 `schedule/listbyusers` 查询和 + * `group/schedule/async` 写入)。`queryScheduleByUsers` 受限于 7 天窗口 + * 和每次 50 个用户,且无配套删除能力,无法在同步前清理旧排班。 + * 当前产品流程为"排课后手动同步钉钉",依赖运营人员知晓同步时机; + * 若后续需要自动清理,需等钉钉开放排班删除 API 或改用考勤组覆盖策略。 + * * ## API 调用优化 * - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。 * - 排班写入按考勤组分批(钉钉单次最多 200 条)。 @@ -77,7 +90,9 @@ export class ScheduleSyncService { const empty: ScheduleSyncResult = { scheduleCount: 0, shiftCount: 0, groupCount: 0, - syncedItems: 0, skippedNoMapping: 0, groups: [], + syncedItems: 0, skippedNoMapping: 0, + failedBatchCount: 0, failedItems: 0, errors: [], + groups: [], }; // ── Step 1: 查询活跃排课(必须关联到班级才能取学生) ── @@ -93,23 +108,37 @@ export class ScheduleSyncService { // ── Step 2: 班级 → 学生钉钉ID 映射 ── const classIds = [...new Set(schedules.map((s) => s.classId as number))]; const classDingUsers = await this.buildClassDingUserMap(classIds); + const classNameMap = await this.loadClassNames(classIds); // ── Step 3: 班次(按时间段去重,班次列表只查一次) ── - const shiftKey = (start: string, end: string) => `${start}-${end}`; - const uniqueShifts = new Map(); - for (const s of schedules) { - const key = shiftKey(s.startTime, s.endTime); + const shiftKey = (classId: number, start: string, end: string) => + `${classId}|${start}-${end}`; + const uniqueShifts = new Map< + string, + { className: string; startTime: string; endTime: string } + >(); + const shiftScheduleCount = new Map(); + for (const schedule of schedules) { + const classId = schedule.classId as number; + const key = shiftKey(classId, schedule.startTime, schedule.endTime); if (!uniqueShifts.has(key)) { - uniqueShifts.set(key, { startTime: s.startTime, endTime: s.endTime }); + uniqueShifts.set(key, { + className: classNameMap.get(classId) || `班级${classId}`, + startTime: schedule.startTime, + endTime: schedule.endTime, + }); } + shiftScheduleCount.set(key, (shiftScheduleCount.get(key) || 0) + 1); } - const existingShifts = await this.dingTalkService.queryShifts(opUserId); const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id])); const timeToShiftId = new Map(); + const errors: string[] = []; + let failedBatchCount = 0; + let failedItems = 0; let shiftCount = 0; - for (const [key, { startTime, endTime }] of uniqueShifts) { - const shiftName = `排课_${startTime}-${endTime}`; + for (const [key, { className, startTime, endTime }] of uniqueShifts) { + const shiftName = `${className}_${startTime}-${endTime}`; try { let shiftId = shiftByName.get(shiftName); const shiftParams = { @@ -133,7 +162,11 @@ export class ScheduleSyncService { timeToShiftId.set(key, shiftId); shiftCount++; } catch (e) { - this.logger.error(`创建班次 ${shiftName} 失败: ${(e as Error).message}`); + const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`; + this.logger.error(msg); + errors.push(msg); + failedBatchCount++; + failedItems += shiftScheduleCount.get(key) || 0; } } @@ -142,7 +175,6 @@ export class ScheduleSyncService { const groupByName = new Map(existingGroups.map((g) => [g.group_name, g.group_id])); // ── Step 5: 按班级同步 ── - const classNameMap = await this.loadClassNames(classIds); const schedulesByClass = new Map(); for (const s of schedules) { const cid = s.classId as number; @@ -154,7 +186,6 @@ export class ScheduleSyncService { let skippedNoMapping = 0; let groupCount = 0; const groupDetails: ScheduleSyncResult['groups'] = []; - for (const [classId, classSchedules] of schedulesByClass) { const className = classNameMap.get(classId) || `班级${classId}`; const dingUserIds = classDingUsers.get(classId) ?? []; @@ -168,11 +199,21 @@ export class ScheduleSyncService { // 该班级用到的班次 const classShiftIds = new Set(); for (const s of classSchedules) { - const sid = timeToShiftId.get(shiftKey(s.startTime, s.endTime)); + const sid = timeToShiftId.get(shiftKey(classId, s.startTime, s.endTime)); if (sid) classShiftIds.add(sid); } if (classShiftIds.size === 0) { - this.logger.warn(`班级 ${className} 无可用班次,跳过`); + this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure)`); + continue; + } + + // 先展开排班以计算受影响条数 + const items = this.expandSchedules( + classSchedules, dingUserIds, timeToShiftId, startDate, endDate, + ); + + if (items.length === 0) { + this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`); skippedNoMapping += classSchedules.length; continue; } @@ -205,16 +246,14 @@ export class ScheduleSyncService { } groupCount++; } catch (e) { - this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`); - skippedNoMapping += classSchedules.length; + const msg = `考勤组 ${groupName} 创建/更新失败: ${(e as Error).message}`; + this.logger.error(msg); + errors.push(msg); + failedBatchCount++; + failedItems += items.length; continue; } - // 展开为每个学生的每日排班 - const items = this.expandSchedules( - classSchedules, dingUserIds, timeToShiftId, startDate, endDate, - ); - // 批量写入(单次≤200) let classItems = 0; for (let i = 0; i < items.length; i += 200) { @@ -224,7 +263,11 @@ export class ScheduleSyncService { syncedItems += batch.length; classItems += batch.length; } catch (e) { - this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`); + const msg = `排班写入失败 (groupId=${attendanceGroupId}, batch=${Math.floor(i / 200) + 1}): ${(e as Error).message}`; + this.logger.error(msg); + failedBatchCount++; + failedItems += batch.length; + errors.push(msg); } } @@ -233,7 +276,8 @@ export class ScheduleSyncService { this.logger.log( `排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` + - `${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`, + `${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` + + (failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''), ); return { @@ -242,6 +286,9 @@ export class ScheduleSyncService { groupCount, syncedItems, skippedNoMapping, + failedBatchCount, + failedItems, + errors, groups: groupDetails, }; } @@ -297,6 +344,7 @@ export class ScheduleSyncService { syncFrom: string, syncTo: string, ): DingTalkScheduleItem[] { + const seen = new Set(); const items: DingTalkScheduleItem[] = []; const fromDate = new Date(syncFrom); const toDate = new Date(syncTo); @@ -309,7 +357,7 @@ export class ScheduleSyncService { } for (const s of schedules) { - const shiftId = timeToShiftId.get(`${s.startTime}-${s.endTime}`); + const shiftId = timeToShiftId.get(`${s.classId}|${s.startTime}-${s.endTime}`); if (!shiftId) continue; const scheduleStart = s.startDate > syncFrom ? s.startDate : syncFrom; @@ -321,6 +369,9 @@ export class ScheduleSyncService { const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime(); for (const userid of dingUserIds) { + const dedupKey = `${userid}|${workDate}|${shiftId}`; + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false }); } } @@ -329,6 +380,7 @@ export class ScheduleSyncService { return items; } + private minutesBetween(startTime: string, endTime: string): number { const [startHour, startMinute] = startTime.split(':').map(Number); const [endHour, endMinute] = endTime.split(':').map(Number); diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index ef99733..8f5cd87 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -50,6 +50,21 @@ export class SyncController { return { success: true, data: tree }; } + @Get('dingtalk/attendance-groups') + @RequirePermission('sync:read') + async getDingTalkAttendanceGroups() { + return { success: true, data: await this.syncService.getDingTalkAttendanceGroups() }; + } + + @Post('dingtalk/attendance-groups/delete-all') + @RequirePermission('sync:trigger') + async deleteAllDingTalkAttendanceGroups() { + return { + success: true, + data: await this.syncService.deleteAllDingTalkAttendanceGroups(), + }; + } + @Get('logs') @RequirePermission('sync:read') async getLogs( diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index d0bdeb8..d52c72d 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -98,6 +98,29 @@ export class SyncService { return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId); } + async getDingTalkAttendanceGroups() { + return this.dingTalkService.queryAttendanceGroups(); + } + + async deleteAllDingTalkAttendanceGroups() { + const groups = await this.dingTalkService.queryAttendanceGroups(); + const deleted: Array<{ groupId: number; groupName: string }> = []; + const failed: Array<{ groupId: number; groupName: string; error: string }> = []; + for (const group of groups) { + try { + await this.dingTalkService.deleteAttendanceGroup(group.group_id); + deleted.push({ groupId: group.group_id, groupName: group.group_name }); + } catch (error: unknown) { + failed.push({ + groupId: group.group_id, + groupName: group.group_name, + error: error instanceof Error ? error.message : String(error), + }); + } + } + return { total: groups.length, deleted, failed }; + } + // ── 排班同步 ── /** 将本地排课同步到钉钉考勤排班 */