From a7a7af166765ab44e5e79fbdd2a6aaa95931b61b Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 23 Jul 2026 12:23:33 +0800 Subject: [PATCH] fix: close permission review gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: harden permission-gated UI — minimum-org endpoint, modal/Popconfirm fail-closed on revocation --- .../permission-state.integration.test.tsx | 6 +- .../src/components/JinshujuMatchModal.tsx | 27 ++- .../StudentProfileContent/index.tsx | 23 ++- apps/admin/src/hooks/useViewSensitive.ts | 28 ++- apps/admin/src/layouts/MainLayout.tsx | 66 +++++-- apps/admin/src/pages/Attendance/index.tsx | 4 +- apps/admin/src/pages/Exams/detail.tsx | 2 +- apps/admin/src/pages/Occupancies/index.tsx | 79 +++++---- apps/admin/src/pages/Rooms/index.tsx | 99 ++++++----- apps/admin/src/pages/Students/index.tsx | 161 +++++++++++------- .../organizations.controller.spec.ts | 23 +++ .../organizations/organizations.controller.ts | 6 + .../organizations.service.spec.ts | 17 ++ .../organizations/organizations.service.ts | 8 + apps/server/src/students/dto/student.dto.ts | 3 +- .../src/students/students.lifecycle.spec.ts | 27 +-- apps/server/src/students/students.service.ts | 39 ++--- 17 files changed, 391 insertions(+), 227 deletions(-) create mode 100644 apps/server/src/organizations/organizations.controller.spec.ts diff --git a/apps/admin/src/auth/permission-state.integration.test.tsx b/apps/admin/src/auth/permission-state.integration.test.tsx index bd93b98..04d7198 100644 --- a/apps/admin/src/auth/permission-state.integration.test.tsx +++ b/apps/admin/src/auth/permission-state.integration.test.tsx @@ -54,14 +54,12 @@ describe('permission state', () => { expect(container?.textContent).toContain('编辑学生'); }); - it('fails closed after profile refresh failure', async () => { + it('stays fail-closed while profile verification is retried after a failure', async () => { writePermissions(['student:edit']); beginPermissionVerification(); - clearPermissions('ready'); - expect(readPermissionState()).toEqual({ permissions: [], status: 'ready' }); + expect(readPermissionState()).toEqual({ permissions: [], status: 'loading' }); await renderPermissionButton(); expect(container?.textContent).not.toContain('编辑学生'); - expect(localStorage.getItem('permissions')).toBeNull(); }); }); diff --git a/apps/admin/src/components/JinshujuMatchModal.tsx b/apps/admin/src/components/JinshujuMatchModal.tsx index a65312d..108479f 100644 --- a/apps/admin/src/components/JinshujuMatchModal.tsx +++ b/apps/admin/src/components/JinshujuMatchModal.tsx @@ -312,8 +312,11 @@ interface MatchModalProps { } const JinshujuMatchModal: React.FC = ({ open, onClose, onApplied }) => { - const { hasPermission } = usePermission(); + const { hasPermission, hasAllPermissions, permissionsReady } = usePermission(); + const canReadSync = hasPermission('sync:read'); const canTriggerSync = hasPermission('sync:trigger'); + const canEnterModal = permissionsReady && hasAllPermissions('sync:read', 'sync:trigger'); + const canWriteRules = permissionsReady && canTriggerSync; const [step, setStep] = useState<'connection' | 'rule' | 'match' | 'applying'>('connection'); const [loading, setLoading] = useState(false); const [selectedRuleId, setSelectedRuleId] = useState(); @@ -334,8 +337,22 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie // Load rules on open useEffect(() => { - if (open) loadRules(); - }, [open]); + if (open && canEnterModal) loadRules(); + }, [open, canEnterModal]); + + // Close and reset when permission is lost + const enteredRef = useRef(false); + useEffect(() => { + if (canEnterModal) { + enteredRef.current = true; + return; + } + if (enteredRef.current) { + enteredRef.current = false; + reset(); + onClose(); + } + }, [canEnterModal, onClose]); const loadRules = async () => { try { @@ -347,6 +364,7 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie }; const handleConnectionNext = async () => { + if (!canTriggerSync) return; try { const values = await credForm.validateFields(); setLoading(true); @@ -366,6 +384,7 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie }; const handlePreview = async () => { + if (!canTriggerSync) return; try { const values = await credForm.validateFields(); setLoading(true); @@ -689,7 +708,7 @@ const JinshujuMatchModal: React.FC = ({ open, onClose, onApplie return ( = ({ onClose, }) => { const { hasPermission, hasAnyPermission } = usePermission(); - const canViewOrganizations = hasPermission('organization:view'); - const canChooseOrganization = - canViewOrganizations && hasAnyPermission('student:create', 'student:edit'); + const canLoadOrganizations = hasAnyPermission( + 'organization:view', + 'student:create', + 'student:edit', + ); + const canChooseOrganization = hasAnyPermission('student:create', 'student:edit'); const [aggregateData, setAggregateData] = useState(null); const [organizations, setOrganizations] = useState>([]); const [loading, setLoading] = useState(false); @@ -1426,17 +1429,17 @@ const StudentProfileContent: React.FC = ({ }, [fetchData]); useEffect(() => { - if (!canViewOrganizations) { + if (!canLoadOrganizations) { setOrganizations([]); return; } api - .get('/organizations', { params: { includeArchived: 'false' } }) + .get('/organizations/options') .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string }>); + setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); }) .catch(() => {}); - }, [canViewOrganizations]); + }, [canLoadOrganizations]); const handlePreviewReport = useCallback(async () => { try { @@ -1451,7 +1454,11 @@ const StudentProfileContent: React.FC = ({ } }, [studentId]); - const handleViewSensitive = useViewSensitive(studentId, '学生档案'); + const handleViewSensitive = useViewSensitive( + studentId, + '学生档案', + hasPermission('log:create'), + ); const tabItems = useMemo(() => { if (!aggregateData) return []; diff --git a/apps/admin/src/hooks/useViewSensitive.ts b/apps/admin/src/hooks/useViewSensitive.ts index 48cc70e..6a99021 100644 --- a/apps/admin/src/hooks/useViewSensitive.ts +++ b/apps/admin/src/hooks/useViewSensitive.ts @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { Modal } from 'antd'; import api from '../api'; import { message } from '../ui/app-message'; @@ -9,16 +9,35 @@ import { message } from '../ui/app-message'; * * @param studentId - The student whose data is being viewed * @param module - Audit module label (e.g. '学生管理', '学生档案') + * @param canLog - Whether the current user has log:create; when false any + * already-open confirm modal is destroyed. */ -export function useViewSensitive(studentId: number, module: string) { +export function useViewSensitive(studentId: number, module: string, canLog: boolean) { + const canLogRef = useRef(canLog); + const modalRef = useRef | null>(null); + canLogRef.current = canLog; + + useEffect(() => { + if (!canLogRef.current && modalRef.current) { + modalRef.current.destroy(); + modalRef.current = null; + } + return () => { + modalRef.current?.destroy(); + modalRef.current = null; + }; + }, []); + return useCallback( (field: string, value: string) => { - Modal.confirm({ + if (!canLogRef.current) return; + modalRef.current = Modal.confirm({ title: '查看敏感信息', content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`, okText: '确认查看', cancelText: '取消', onOk: async () => { + if (!canLogRef.current) return; try { await api.post('/operation-logs/audit', { module, @@ -37,6 +56,9 @@ export function useViewSensitive(studentId: number, module: string) { okText: '关闭', }); }, + afterClose: () => { + modalRef.current = null; + }, }); }, [studentId, module], diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 3e37c69..15eadd3 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -86,25 +86,57 @@ const MainLayout: React.FC = () => { useEffect(() => { let cancelled = false; - beginPermissionVerification(); - api - .get<{ id: number; username: string; permissions: string[]; roles?: string[] }>( - '/auth/profile', - ) - .then((profile) => { - if (cancelled) return; - writePermissions(profile.permissions || []); - const cachedUser = JSON.parse(localStorage.getItem('user') || '{}'); - const nextUser = { ...cachedUser, ...profile }; - localStorage.setItem('user', JSON.stringify(nextUser)); - setUser(nextUser); - }) - .catch(() => { - if (!cancelled) clearPermissions('ready'); - // The API interceptor handles expired/invalid sessions. - }); + let retryTimer: number | undefined; + let verificationInFlight = false; + + const verifyPermissions = () => { + if (cancelled || verificationInFlight || !localStorage.getItem('token')) return; + if (retryTimer !== undefined) { + window.clearTimeout(retryTimer); + retryTimer = undefined; + } + verificationInFlight = true; + beginPermissionVerification(); + api + .get<{ id: number; username: string; permissions: string[]; roles?: string[] }>( + '/auth/profile', + ) + .then((profile) => { + if (cancelled) return; + verificationInFlight = false; + writePermissions(profile.permissions || []); + const cachedUser = JSON.parse(localStorage.getItem('user') || '{}'); + const nextUser = { ...cachedUser, ...profile }; + localStorage.setItem('user', JSON.stringify(nextUser)); + setUser(nextUser); + }) + .catch(() => { + verificationInFlight = false; + if (cancelled || !localStorage.getItem('token')) return; + retryTimer = window.setTimeout(verifyPermissions, 5_000); + }); + }; + + const handleStorage = (event: StorageEvent) => { + if (event.key !== 'token' && event.key !== 'permissions') return; + beginPermissionVerification(); + window.location.reload(); + }; + const handleOnline = () => verifyPermissions(); + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') verifyPermissions(); + }; + + verifyPermissions(); + window.addEventListener('storage', handleStorage); + window.addEventListener('online', handleOnline); + document.addEventListener('visibilitychange', handleVisibilityChange); return () => { cancelled = true; + if (retryTimer !== undefined) window.clearTimeout(retryTimer); + window.removeEventListener('storage', handleStorage); + window.removeEventListener('online', handleOnline); + document.removeEventListener('visibilitychange', handleVisibilityChange); }; }, []); diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 3935bdd..98b4c83 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -1146,9 +1146,9 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => value={studentSearch} onChange={(event) => setStudentSearch(event.target.value)} /> - +
diff --git a/apps/admin/src/pages/Exams/detail.tsx b/apps/admin/src/pages/Exams/detail.tsx index fea4658..94ad8db 100644 --- a/apps/admin/src/pages/Exams/detail.tsx +++ b/apps/admin/src/pages/Exams/detail.tsx @@ -27,7 +27,7 @@ interface ExamDetail extends ExamItem { } const PhoneCell: React.FC<{ row: ScoreRow }> = ({ row }) => { - const reveal = useViewSensitive(row.studentId, '考试管理'); + const reveal = useViewSensitive(row.studentId, '考试管理', hasPermission('log:create')); const { hasPermission } = usePermission(); if (!row.phone) return <>-; return ( diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index ab85b1f..fc9bd7f 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { Table, Button, @@ -38,8 +38,11 @@ import { usePermission } from '../../hooks/usePermission'; const { RangePicker } = DatePicker; const OccupanciesPage: React.FC = () => { - const { hasPermission } = usePermission(); - const canCheckIn = hasPermission('occupancy:checkin'); + const { hasPermission, permissionsReady } = usePermission(); + const canCheckIn = permissionsReady && hasPermission('occupancy:checkin'); + const canCheckOut = permissionsReady && hasPermission('occupancy:checkout'); + const canTransfer = permissionsReady && hasPermission('occupancy:transfer'); + const canDelete = permissionsReady && hasPermission('occupancy:delete'); const [data, setData] = useState([]); const [students, setStudents] = useState([]); const [rooms, setRooms] = useState([]); @@ -69,6 +72,12 @@ const OccupanciesPage: React.FC = () => { const selectedCheckInRoomId = Form.useWatch('roomId', checkInForm); const selectedTransferRoomId = Form.useWatch('newRoomId', transferForm); + // Close modals when the user loses the required permission + useEffect(() => { if (!canCheckIn) { setCheckInModal(false); checkInForm.resetFields(); } }, [canCheckIn, checkInForm]); + useEffect(() => { if (!canCheckOut && checkOutModal) { setCheckOutModal(null); checkOutForm.resetFields(); } }, [canCheckOut, checkOutModal, checkOutForm]); + useEffect(() => { if (!canCheckOut) { setBatchCheckOutModal(false); batchCheckOutForm.resetFields(); } }, [canCheckOut, batchCheckOutForm]); + useEffect(() => { if (!canTransfer && transferModal) { setTransferModal(null); transferForm.resetFields(); } }, [canTransfer, transferModal, transferForm]); + const activeOccupancyByStudentId = useMemo(() => { const map = new Map(); data.forEach((item) => { @@ -372,27 +381,28 @@ const OccupanciesPage: React.FC = () => { ) : ( 已退宿 - { - try { - await api.delete(`/occupancies/${record.id}`); - message.success('归档成功'); - fetchData(); - } catch (e: any) { - message.error(e?.message || '归档失败'); - } - }} - > - } + {canDelete ? ( + { + try { + await api.delete(`/occupancies/${record.id}`); + message.success('归档成功'); + fetchData(); + } catch (e: any) { + message.error(e?.message || '归档失败'); + } + }} > - 归档 - - + + + ) : null} ), }, @@ -584,15 +594,15 @@ const OccupanciesPage: React.FC = () => { > 批量退宿 - ) : ( + )} + {canDelete ? ( - } @@ -600,8 +610,9 @@ const OccupanciesPage: React.FC = () => { loading={batchLoading} > 批量归档 - + + ) : null} )} + + ) : null ) : ( <> { > 编辑 - handleArchive(r.id)}> - } - > - 归档 - - + {canDeleteRooms ? ( + handleArchive(r.id)}> + + + ) : null} )} @@ -638,23 +646,24 @@ const RoomsPage: React.FC = () => { - - } + {canDeleteRooms ? ( + - 批量归档 - - + + + ) : null} { { setModalOpen(false); setEditing(null); @@ -994,20 +1003,19 @@ const RoomsPage: React.FC = () => { > 编辑 - {r.status !== 'occupied' && ( + {r.status !== 'occupied' && canEditRooms && ( handleDeleteBed(r.id)} > - 归档 - + )} @@ -1147,20 +1155,19 @@ const RoomsPage: React.FC = () => { > 编辑 - {r.status !== 'occupied' && ( + {r.status !== 'occupied' && canEditRooms && ( handleDeleteLocker(r.id)} > - 归档 - + )} diff --git a/apps/admin/src/pages/Students/index.tsx b/apps/admin/src/pages/Students/index.tsx index d984946..3951a75 100644 --- a/apps/admin/src/pages/Students/index.tsx +++ b/apps/admin/src/pages/Students/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Alert, App, @@ -85,15 +85,24 @@ interface StudentFilterLookups { const StudentsPage: React.FC = () => { const { modal } = App.useApp(); - const { hasPermission, hasAnyPermission } = usePermission(); + const { hasPermission, hasAnyPermission, hasAllPermissions } = usePermission(); const canViewOrganizations = hasPermission('organization:view'); - const canChooseOrganization = - canViewOrganizations && hasAnyPermission('student:create', 'student:edit'); + const canLoadOrganizations = hasAnyPermission( + 'organization:view', + 'student:create', + 'student:edit', + ); + const canChooseOrganization = hasAnyPermission('student:create', 'student:edit'); + const canCreateStudent = hasPermission('student:create'); + const canEditStudent = hasPermission('student:edit'); + const canDeleteStudent = hasPermission('student:delete'); + const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger'); const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [organizations, setOrganizations] = useState([]); const [editing, setEditing] = useState(null); + const canSaveStudent = editing ? canEditStudent : canCreateStudent; const [searchName, setSearchName] = useState(''); const [filterStatus, setFilterStatus] = useState(undefined); const [filterOrganizationId, setFilterOrganizationId] = useState(undefined); @@ -118,13 +127,40 @@ const StudentsPage: React.FC = () => { const [jinshujuOpen, setJinshujuOpen] = useState(false); + // Sensitive info modal — command-style; destroy when log:create is lost or comp unmounts. + // Close the student form modal when the user loses the required permission. + useEffect(() => { + if (!canSaveStudent && modalOpen) { + setModalOpen(false); + setEditing(null); + form.resetFields(); + } + }, [canSaveStudent, modalOpen, form]); + + // Close sensitive modal when log:create is lost (imperative ref already set above). + const logCreateRef = React.useRef(hasPermission('log:create')); + const sensitiveModalRef = React.useRef | null>(null); + logCreateRef.current = hasPermission('log:create'); + useEffect(() => { + if (!logCreateRef.current && sensitiveModalRef.current) { + sensitiveModalRef.current.destroy(); + sensitiveModalRef.current = null; + } + return () => { + sensitiveModalRef.current?.destroy(); + sensitiveModalRef.current = null; + }; + }, []); + const handleViewSensitive = (studentId: number, field: string, value: string) => { - modal.confirm({ + if (!logCreateRef.current) return; + sensitiveModalRef.current = modal.confirm({ title: '查看敏感信息', content: `您即将查看 "${field}" 的完整信息。此操作将被记录。`, okText: '确认查看', cancelText: '取消', onOk: async () => { + if (!logCreateRef.current) return; try { await api.post('/operation-logs/audit', { module: '学生管理', @@ -142,6 +178,9 @@ const StudentsPage: React.FC = () => { message.error('审计日志记录失败,请稍后重试'); } }, + afterClose: () => { + sensitiveModalRef.current = null; + }, }); }; @@ -194,16 +233,25 @@ const StudentsPage: React.FC = () => { }, [fetchData]); useEffect(() => { + if (!canLoadOrganizations) { + setOrganizations([]); + setFilterOrganizationId(undefined); + return; + } if (canViewOrganizations) { api .get('/organizations', { params: { includeArchived: 'false' } }) .then((res: unknown) => { - setOrganizations(res as Array<{ id: number; name: string }>); + setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); }) .catch(() => {}); } else { - setOrganizations([]); - setFilterOrganizationId(undefined); + api + .get('/organizations/options') + .then((res: unknown) => { + setOrganizations(res as Array<{ id: number; name: string; isHost?: boolean }>); + }) + .catch(() => {}); } api .get('/students/filter-lookups') @@ -212,7 +260,7 @@ const StudentsPage: React.FC = () => { setTeacherOptions(res.teachers || []); }) .catch(() => {}); - }, [canViewOrganizations]); + }, [canLoadOrganizations]); const handleSave = async () => { const values = await form.validateFields(); setSaving(true); @@ -614,21 +662,18 @@ const StudentsPage: React.FC = () => { render: (_: any, record: any) => ( {record.status === 'archived' ? ( - handleRestore(record.id)} - okText="恢复" - cancelText="取消" - > - } - type="link" + canEditStudent ? ( + handleRestore(record.id)} + okText="恢复" + cancelText="取消" > - 恢复 - - + + + ) : null ) : ( <> { > 编辑 - handleArchive(record.id)} - okText="归档" - cancelText="取消" - > - handleArchive(record.id)} + okText="归档" + cancelText="取消" + > + + ) : null} )} @@ -765,23 +811,24 @@ const StudentsPage: React.FC = () => { - - } + {canDeleteStudent ? ( + - 批量归档 - - + + + ) : null} { ) : null} - } - onClick={() => setJinshujuOpen(true)} - > - 同步金数据 - + {canSyncJinshuju ? ( + + ) : null} } @@ -929,8 +974,8 @@ const StudentsPage: React.FC = () => { title={editing ? '编辑学生' : '添加学生'} className="student-form-modal" width={720} - open={modalOpen} - onOk={handleSave} + open={modalOpen && canSaveStudent} + onOk={canSaveStudent ? handleSave : undefined} onCancel={() => { setModalOpen(false); setEditing(null); @@ -1007,7 +1052,7 @@ const StudentsPage: React.FC = () => { - {hasPermission('sync:read') ? ( + {canSyncJinshuju ? ( setJinshujuOpen(false)} diff --git a/apps/server/src/organizations/organizations.controller.spec.ts b/apps/server/src/organizations/organizations.controller.spec.ts new file mode 100644 index 0000000..feb7131 --- /dev/null +++ b/apps/server/src/organizations/organizations.controller.spec.ts @@ -0,0 +1,23 @@ +import 'reflect-metadata'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; +import { OrganizationsController } from './organizations.controller'; + +describe('OrganizationsController permissions', () => { + it('allows student editors to use the options endpoint without full entity exposure', () => { + expect( + Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findOptions), + ).toEqual(['organization:view', 'student:create', 'student:edit']); + }); + + it('keeps the full entity list restricted to organization viewers only', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findAll)).toEqual([ + 'organization:view', + ]); + }); + + it('keeps organization detail restricted to organization viewers', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OrganizationsController.prototype.findOne)).toEqual([ + 'organization:view', + ]); + }); +}); diff --git a/apps/server/src/organizations/organizations.controller.ts b/apps/server/src/organizations/organizations.controller.ts index 8ab4ac5..6650420 100644 --- a/apps/server/src/organizations/organizations.controller.ts +++ b/apps/server/src/organizations/organizations.controller.ts @@ -25,6 +25,12 @@ export class OrganizationsController { private logService: OperationLogsService, ) {} + @Get('options') + @RequirePermission('organization:view', 'student:create', 'student:edit') + findOptions() { + return this.service.findOptions(); + } + @Get() @RequirePermission('organization:view') findAll( diff --git a/apps/server/src/organizations/organizations.service.spec.ts b/apps/server/src/organizations/organizations.service.spec.ts index 5588e8e..13f8040 100644 --- a/apps/server/src/organizations/organizations.service.spec.ts +++ b/apps/server/src/organizations/organizations.service.spec.ts @@ -27,4 +27,21 @@ describe('OrganizationsService — host organization rules', () => { await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException); expect(repo.update).not.toHaveBeenCalled(); }); + + it('findOptions returns only id, name, isHost for active organizations', async () => { + const orgs = [ + { id: 1, name: '本机构', isHost: true }, + { id: 2, name: '分校', isHost: false }, + ]; + repo.find.mockResolvedValue(orgs as Organization[]); + + const result = await service.findOptions(); + + expect(repo.find).toHaveBeenCalledWith({ + select: ['id', 'name', 'isHost'], + where: { status: 'active' }, + order: { isHost: 'DESC', name: 'ASC' }, + }); + expect(result).toEqual(orgs); + }); }); diff --git a/apps/server/src/organizations/organizations.service.ts b/apps/server/src/organizations/organizations.service.ts index 655de27..f4128cb 100644 --- a/apps/server/src/organizations/organizations.service.ts +++ b/apps/server/src/organizations/organizations.service.ts @@ -30,6 +30,14 @@ export class OrganizationsService { return this.repo.find({ where, order: { isHost: 'DESC', name: 'ASC' } }); } + async findOptions() { + return this.repo.find({ + select: ['id', 'name', 'isHost'] as const, + where: { status: 'active' }, + order: { isHost: 'DESC' as const, name: 'ASC' as const }, + }); + } + async findOne(id: number) { const organization = await this.repo.findOne({ where: { id } }); if (!organization) throw new NotFoundException('机构不存在'); diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index 18d53c2..276e1c8 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -33,9 +33,8 @@ export class CreateStudentDto { @IsString() emergencyPhone?: string; - @IsOptional() @IsInt() - organizationId?: number; + organizationId: number; @IsOptional() @IsString() diff --git a/apps/server/src/students/students.lifecycle.spec.ts b/apps/server/src/students/students.lifecycle.spec.ts index 3a85d87..fbe7b11 100644 --- a/apps/server/src/students/students.lifecycle.spec.ts +++ b/apps/server/src/students/students.lifecycle.spec.ts @@ -43,21 +43,6 @@ describe('StudentsService — archive lifecycle boundaries', () => { expect(repo.save).not.toHaveBeenCalled(); }); - it('defaults a new student to the active host organization when none is supplied', async () => { - const repo = { - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...value, id: 1 })), - }; - const organizationRepo = { - findOne: jest.fn().mockResolvedValue({ id: 7, isHost: true, status: 'active' }), - }; - - await expect(createService(repo, organizationRepo).create({ name: '张三' })).resolves.toEqual( - expect.objectContaining({ organizationId: 7 }), - ); - expect(repo.create).toHaveBeenCalledWith(expect.objectContaining({ organizationId: 7 })); - }); - it('returns not found for a missing student', async () => { const repo = { findOne: jest.fn().mockResolvedValue(null) }; await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException); @@ -65,13 +50,11 @@ describe('StudentsService — archive lifecycle boundaries', () => { it('builds export archive maps from profile and result rows', async () => { const profileRepo = { - find: jest.fn().mockResolvedValue([ - { - studentId: 1, - targetCollege: '北京大学', - collegeSchool: '北京职业技术学院', - }, - ]), + find: jest.fn().mockResolvedValue([{ + studentId: 1, + targetCollege: '北京大学', + collegeSchool: '北京职业技术学院', + }]), }; const resultRepo = { find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]), diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 4deafab..26a2279 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -164,9 +164,8 @@ export class StudentsService { } async create(dto: CreateStudentDto) { - const organizationId = dto.organizationId || (await this.getHostOrganizationId()); - await this.assertActiveOrganization(organizationId); - return this.repo.save(this.repo.create({ ...dto, organizationId })); + await this.assertActiveOrganization(dto.organizationId); + return this.repo.save(this.repo.create(dto)); } async update(id: number, dto: UpdateStudentDto) { @@ -315,9 +314,7 @@ export class StudentsService { }; } - private normalizeImportData( - importData: StudentWorkbookImport | StudentImportRow[], - ): StudentWorkbookImport { + private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport { if (Array.isArray(importData)) { return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; } @@ -373,24 +370,18 @@ export class StudentsService { if (!phone) return imported; const enrollmentByClassName = new Map(); - for (const enrollmentRow of data.enrollments.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { + for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) { const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); if (!enrollment) continue; if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); imported++; } - for (const examRow of data.examScores.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { + for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) { if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { imported++; } } - for (const learningRow of data.learningRecords.filter( - (item) => this.normalizePhone(item.phone) === phone, - )) { + for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) { if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { imported++; } @@ -399,9 +390,7 @@ export class StudentsService { } private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { - const entity = - (await this.profileRepo.findOne({ where: { studentId } })) || - this.profileRepo.create({ studentId }); + const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); @@ -414,12 +403,9 @@ export class StudentsService { } private async upsertResultFromImport(studentId: number, row: StudentImportRow) { - const entity = - (await this.resultRepo.findOne({ where: { studentId } })) || - this.resultRepo.create({ studentId }); + const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId }); if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; - if (row.professionalFinalScore !== undefined) - entity.professionalFinalScore = row.professionalFinalScore; + if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); @@ -637,9 +623,10 @@ export class StudentsService { // ---- Filters ---- if (query?.keyword) { - qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', { - keyword: `%${query.keyword}%`, - }); + qb.andWhere( + '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', + { keyword: `%${query.keyword}%` }, + ); } if (query?.organizationId) { qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });