feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -0,0 +1,233 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { ClassStudent } from '../entities/class-student.entity';
import type { StudentAccessScope } from './student-access-scope';
@Injectable()
export class StudentsAgentService {
/**
* Whitelisted output type for agent student searches.
* NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone.
*/
private static readonly AGENT_STUDENT_SELECT = [
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
] as const;
constructor(
@InjectRepository(Student) private readonly repo: Repository<Student>,
@InjectRepository(ClassStudent)
private readonly classStudentRepo: Repository<ClassStudent>,
) {}
/**
* Search students with SQL-enforced scope, field whitelist, and limit.
*
* @param scope — data-range discriminator (manageAll or teacher).
* @param query — optional keyword, classId, organizationId, limit.
* @returns formatted whitelist-only results with classIds.
*/
async agentSearchStudents(
scope: StudentAccessScope,
query?: {
keyword?: string;
classId?: number;
organizationId?: number;
limit?: number;
},
): Promise<
{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
}[]
> {
const limit = Math.max(1, Math.min(query?.limit ?? 20, 50));
const qb = this.repo
.createQueryBuilder('student')
.distinct(true)
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'student.createdAt',
'organization.name',
])
.leftJoin('student.organization', 'organization');
this.applyStudentScope(qb, scope, query?.classId);
if (query?.keyword) {
qb.andWhere('(student.name LIKE :keyword OR student.student_no LIKE :keyword)', {
keyword: `%${query.keyword}%`,
});
}
if (query?.organizationId) {
qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId });
}
qb.orderBy('student.createdAt', 'DESC').take(limit);
const rows: Record<string, unknown>[] = await qb.getRawMany();
if (rows.length === 0) return [];
// Second bounded query: classIds only for the returned student ids.
// For teacher scope, the class filter MUST be re-applied so the
// teacher only sees classIds they are assigned to.
const studentIds = rows.map((r) => r.student_id as number);
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.studentId', 'cs.classId'])
.where('cs.student_id IN (:...ids)', { ids: studentIds })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
const classMap = new Map<number, number[]>();
for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) {
const sid = cr.cs_student_id;
if (!classMap.has(sid)) classMap.set(sid, []);
classMap.get(sid)!.push(cr.cs_class_id);
}
return rows.map((r) => ({
id: r.student_id as number,
name: r.student_name as string,
studentNo: (r.student_student_no as string) ?? '',
gender: (r.student_gender as string) ?? '',
status: r.student_status as string,
organizationId: r.student_organization_id as number,
organizationName: (r.organization_name as string) ?? '',
classIds: classMap.get(r.student_id as number) ?? [],
}));
}
/**
* Get single student basic info with SQL-enforced scope + whitelist.
* Returns `null` for students out of scope or non-existent (no leak).
*/
async agentGetStudentBasic(
scope: StudentAccessScope,
studentId: number,
): Promise<{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
} | null> {
const qb = this.repo
.createQueryBuilder('student')
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
])
.leftJoin('student.organization', 'organization')
.where('student.id = :studentId', { studentId });
this.applyStudentScope(qb, scope);
const row = await qb.getRawOne();
if (!row) return null;
// For teacher scope, re-apply class filter so teacher only sees
// classIds they are assigned to (not ALL active classIds of the student).
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.classId'])
.where('cs.student_id = :studentId', { studentId })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
return {
id: row.student_id as number,
name: row.student_name as string,
studentNo: (row.student_student_no as string) ?? '',
gender: (row.student_gender as string) ?? '',
status: row.student_status as string,
organizationId: row.student_organization_id as number,
organizationName: (row.organization_name as string) ?? '',
classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id),
};
}
/**
* Apply data-range scope to a student QueryBuilder.
*
* - `manageAll`: no restriction.
* - `teacher`: INNER JOIN ClassStudent → active students in the
* teacher's assigned classes (via ClassTeacher).
* - When `classId` is provided, it is ANDed with the scope
* (intersection) — the model cannot widen access.
*/
private applyStudentScope(
qb: ReturnType<typeof this.repo.createQueryBuilder>,
scope: StudentAccessScope,
classId?: number,
): void {
if (scope.type === 'manageAll') {
if (classId != null) {
qb.innerJoin(
'class_student',
'cs_scope',
'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus',
{ scopeClassId: classId, scopeCsStatus: 'active' },
);
}
return;
}
// Teacher scope: active students in teacher's assigned classes
const teacherClause =
'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' +
'(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)';
qb.innerJoin('class_student', 'cs_scope', teacherClause, {
scopeTeacherUserId: scope.userId,
scopeCsStatus: 'active',
});
if (classId != null) {
qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId });
}
}
}

View File

@@ -12,7 +12,6 @@ import {
Res,
UseInterceptors,
UploadedFile,
Inject,
ParseIntPipe,
UsePipes,
ValidationPipe,
@@ -20,17 +19,20 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Organization } from '../entities/organization.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { StudentsService } from './students.service';
import { CreateStudentDto, QueryStudentDto, UpdateStudentDto } from './dto/student.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
import type { AuthenticatedUser } from '../authorization';
import {
AuthorizationService,
CaslAction,
SubjectName,
type AuthenticatedUser,
} from '../authorization';
import * as ExcelJS from 'exceljs';
import {
createStudentImportTemplateWorkbook,
@@ -79,27 +81,17 @@ export class StudentsController {
@Get()
@RequirePermission('student:view')
async findAll(
@Query() query: QueryStudentDto,
@Request() req: AuthenticatedRequest,
) {
async findAll(@Query() query: QueryStudentDto, @Request() req: AuthenticatedRequest) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllStudents(req),
);
return this.service.findAll(
query,
classIds,
);
return this.service.findAll(query, classIds);
}
@Get('export')
@RequirePermission('student:export')
async exportExcel(
@Query() query: QueryStudentDto,
@Res() res?: Response,
@Request() req?: any,
) {
async exportExcel(@Query() query: QueryStudentDto, @Res() res?: Response, @Request() req?: any) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllStudents(req),
@@ -142,15 +134,8 @@ export class StudentsController {
admittedMajor: result?.admittedMajor || '',
});
}
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '导出学生',
detail: `导出 ${students.length} 名学生`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`,
});
res!.setHeader(
'Content-Type',
@@ -183,18 +168,9 @@ export class StudentsController {
@Post()
@RequirePermission('student:create')
async create(@Body() dto: CreateStudentDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '新增学生',
targetId: result.id,
targetType: 'student',
detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
});
return result;
}
@@ -203,35 +179,23 @@ export class StudentsController {
@RequirePermission('student:edit')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRestore(dto.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '批量恢复学生',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '批量恢复学生', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@Put(':id')
@RequirePermission('student:edit')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateStudentDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
async update(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateStudentDto,
@Request() req: any,
) {
const result = await this.service.update(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '编辑学生',
targetId: id,
targetType: 'student',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '编辑学生', targetId: id, targetType: 'student', detail: JSON.stringify(dto),
});
return result;
}
@@ -239,17 +203,9 @@ export class StudentsController {
@Delete(':id')
@RequirePermission('student:delete')
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '归档学生',
targetId: id,
targetType: 'student',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '归档学生', targetId: id, targetType: 'student',
});
return result;
}
@@ -257,16 +213,29 @@ export class StudentsController {
@Post('batch-delete')
@RequirePermission('student:delete')
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids || []);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '批量归档学生',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Delete(':id/permanent')
@RequirePermission('student:purge')
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purge(id);
await logAudit(this.logService, req, {
module: '学生管理', action: '永久删除学生', targetId: id, targetType: 'student', detail: '物理删除,不可恢复',
});
return result;
}
@Post('batch-permanent-delete')
@RequirePermission('student:purge')
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurge(body.ids || []);
await logAudit(this.logService, req, {
module: '学生管理', action: '批量永久删除学生', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -274,17 +243,9 @@ export class StudentsController {
@Put(':id/restore')
@RequirePermission('student:edit')
async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '恢复学生',
targetId: id,
targetType: 'student',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student',
});
return result;
}
@@ -293,9 +254,8 @@ export class StudentsController {
@RequirePermission('student:import')
@UseInterceptors(FileInterceptor('file'))
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const importData = parseStudentImportWorkbook(workbook);
// Resolve organization names to IDs
for (const row of importData.students) {
@@ -309,14 +269,8 @@ export class StudentsController {
}
}
const result = await this.service.batchImport(importData);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '导入学生',
detail: result.message,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '导入学生', detail: result.message,
});
return result;
}
@@ -325,9 +279,8 @@ export class StudentsController {
@RequirePermission('student:import')
@UseInterceptors(FileInterceptor('file'))
async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const importData = parseStudentImportWorkbook(workbook);
// Resolve organization names to IDs
for (const row of importData.students) {
@@ -339,14 +292,8 @@ export class StudentsController {
}
}
const result = await this.service.matchImport(importData);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '更新已有学生资料',
detail: result.message,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '学生管理', action: '更新已有学生资料', detail: result.message,
});
return result;
}

View File

@@ -0,0 +1,314 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { StudentProfile } from '../entities/student-profile.entity';
import { StudentEnrollment } from '../entities/student-enrollment.entity';
import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity';
import { Organization } from '../entities/organization.entity';
import type {
ExamScoreImportRow,
LearningRecordImportRow,
StudentEnrollmentImportRow,
StudentImportRow,
StudentWorkbookImport,
} from './student-import';
import { getHostOrganizationId } from './students.organization';
@Injectable()
export class StudentsImportService {
constructor(
@InjectRepository(Student) private readonly repo: Repository<Student>,
@InjectRepository(StudentProfile) private readonly profileRepo: Repository<StudentProfile>,
@InjectRepository(StudentEnrollment)
private readonly enrollmentRepo: Repository<StudentEnrollment>,
@InjectRepository(ExamScore) private readonly examScoreRepo: Repository<ExamScore>,
@InjectRepository(LearningRecord)
private readonly learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private readonly resultRepo: Repository<ResultArchive>,
@InjectRepository(Organization) private readonly organizationRepo: Repository<Organization>,
) {}
async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
const data = this.normalizeImportData(importData);
let imported = 0;
let skipped = 0;
let archiveImported = 0;
for (const row of data.students) {
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) {
skipped++;
continue;
}
const student = await this.repo.save(
this.repo.create({
name: row.name.trim(),
studentNo: row.studentNo?.trim() || undefined,
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender || undefined,
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
supervisor: row.supervisor || undefined,
organizationId: row.organizationId || (await getHostOrganizationId(this.organizationRepo)),
}),
);
archiveImported += await this.importArchiveData(student.id, row, data);
imported++;
}
return {
message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`,
imported,
archiveImported,
skipped,
};
}
async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
const data = this.normalizeImportData(importData);
let matched = 0;
let skipped = 0;
let archiveImported = 0;
for (const row of data.students) {
// Match by phone first, then idNumber
let student = row.phone?.trim()
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
: null;
if (!student && row.idNumber?.trim()) {
student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } });
}
if (!student) {
skipped++;
continue;
}
const updates: Partial<
Pick<
Student,
| 'name'
| 'studentNo'
| 'phone'
| 'idNumber'
| 'gender'
| 'ethnicity'
| 'emergencyContact'
| 'emergencyPhone'
| 'supervisor'
| 'organizationId'
>
> = {};
if (row.name?.trim()) updates.name = row.name.trim();
if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
if (row.phone?.trim()) updates.phone = row.phone.trim();
if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (row.gender) updates.gender = row.gender;
if (row.ethnicity) updates.ethnicity = row.ethnicity;
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.organizationId) updates.organizationId = row.organizationId;
await this.repo.update(student.id, updates);
archiveImported += await this.importArchiveData(student.id, row, data);
matched++;
}
return {
message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`,
matched,
archiveImported,
skipped,
};
}
private normalizeImportData(
importData: StudentWorkbookImport | StudentImportRow[],
): StudentWorkbookImport {
if (Array.isArray(importData)) {
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
}
return importData;
}
private normalizePhone(phone?: string) {
return phone?.trim() || '';
}
private sameValue(left?: string | number | null, right?: string | number | null) {
return String(left ?? '').trim() === String(right ?? '').trim();
}
private hasProfileData(row: StudentImportRow) {
return [
row.targetCollege,
row.targetMajor,
row.collegeSchool,
row.collegeMajor,
row.subjectDirection,
row.grade,
row.profileDate,
row.notes,
].some((value) => value !== undefined && String(value).trim() !== '');
}
private hasResultData(row: StudentImportRow) {
return [
row.cultureFinalScore,
row.professionalFinalScore,
row.admissionStatus,
row.admittedCollege,
row.admittedMajor,
].some((value) => value !== undefined && String(value).trim() !== '');
}
private async importArchiveData(
studentId: number,
row: StudentImportRow,
data: StudentWorkbookImport,
) {
const phone = this.normalizePhone(row.phone);
let imported = 0;
if (this.hasProfileData(row)) {
await this.upsertProfileFromImport(studentId, row);
imported++;
}
if (this.hasResultData(row)) {
await this.upsertResultFromImport(studentId, row);
imported++;
}
if (!phone) return imported;
const enrollmentByClassName = new Map<string, StudentEnrollment>();
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,
)) {
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
imported++;
}
}
for (const learningRow of data.learningRecords.filter(
(item) => this.normalizePhone(item.phone) === phone,
)) {
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
imported++;
}
}
return imported;
}
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
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();
if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim();
if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim();
if (row.grade?.trim()) entity.grade = row.grade.trim();
if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim();
if (row.notes?.trim()) entity.notes = row.notes.trim();
await this.profileRepo.save(entity);
}
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
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.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();
await this.resultRepo.save(entity);
}
private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) {
if (!row.courseCategory?.trim() || !row.classType?.trim()) {
return null;
}
const existing = await this.enrollmentRepo.find({ where: { studentId } });
const entity =
existing.find(
(item) =>
this.sameValue(item.courseCategory, row.courseCategory) &&
this.sameValue(item.classType, row.classType) &&
this.sameValue(item.className, row.className) &&
this.sameValue(item.startDate, row.startDate),
) || this.enrollmentRepo.create({ studentId });
entity.courseCategory = row.courseCategory.trim();
entity.classType = row.classType.trim();
if (row.className?.trim()) entity.className = row.className.trim();
if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim();
if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim();
if (row.startDate?.trim()) entity.startDate = row.startDate.trim();
if (row.endDate?.trim()) entity.endDate = row.endDate.trim();
if (row.status?.trim()) entity.status = row.status.trim();
else if (!entity.status) entity.status = 'active';
return this.enrollmentRepo.save(entity);
}
private async upsertExamScoreFromImport(
studentId: number,
row: ExamScoreImportRow,
enrollmentByClassName: Map<string, StudentEnrollment>,
) {
if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false;
const existing = await this.examScoreRepo.find({ where: { studentId } });
const entity =
existing.find(
(item) =>
this.sameValue(item.examType, row.examType) &&
this.sameValue(item.examName, row.examName) &&
this.sameValue(item.subject, row.subject) &&
this.sameValue(item.examDate, row.examDate),
) || this.examScoreRepo.create({ studentId });
entity.examType = row.examType.trim();
entity.subject = row.subject.trim();
entity.score = row.score;
if (row.examName?.trim()) entity.examName = row.examName.trim();
if (row.classAvg !== undefined) entity.classAvg = row.classAvg;
if (row.rank !== undefined) entity.rank = row.rank;
if (row.examDate?.trim()) entity.examDate = row.examDate.trim();
if (row.enrollmentName?.trim()) {
const enrollment = enrollmentByClassName.get(row.enrollmentName.trim());
if (enrollment) entity.enrollmentId = enrollment.id;
}
if (!entity.status) entity.status = 'active';
await this.examScoreRepo.save(entity);
return true;
}
private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) {
if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false;
const existing = await this.learningRecordRepo.find({ where: { studentId } });
const entity =
existing.find(
(item) =>
this.sameValue(item.recordDate, row.recordDate) &&
this.sameValue(item.recordType, row.recordType) &&
this.sameValue(item.content, row.content),
) || this.learningRecordRepo.create({ studentId });
entity.recordDate = row.recordDate.trim();
entity.recordType = row.recordType.trim();
entity.content = row.content.trim();
if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim();
if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim();
if (!entity.status) entity.status = 'active';
await this.learningRecordRepo.save(entity);
return true;
}
}

View File

@@ -0,0 +1,235 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Bill } from '../entities/bill.entity';
import { Deposit } from '../entities/deposit.entity';
import { StudentProfile } from '../entities/student-profile.entity';
import { StudentEnrollment } from '../entities/student-enrollment.entity';
import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
@Injectable()
export class StudentsLifecycleService {
constructor(
@InjectRepository(Student) private readonly repo: Repository<Student>,
@InjectRepository(ClassStudent) private readonly classStudentRepo: Repository<ClassStudent>,
@InjectRepository(AttendanceRecord)
private readonly attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(StudentProfile) private readonly profileRepo: Repository<StudentProfile>,
@InjectRepository(StudentEnrollment)
private readonly enrollmentRepo: Repository<StudentEnrollment>,
@InjectRepository(ExamScore) private readonly examScoreRepo: Repository<ExamScore>,
@InjectRepository(LearningRecord)
private readonly learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private readonly resultRepo: Repository<ResultArchive>,
@InjectRepository(Occupancy) private readonly occupancyRepo: Repository<Occupancy>,
@InjectRepository(PersonalExpense)
private readonly personalExpenseRepo: Repository<PersonalExpense>,
@InjectRepository(Bill) private readonly billRepo: Repository<Bill>,
@InjectRepository(Deposit) private readonly depositRepo: Repository<Deposit>,
@InjectRepository(ArchiveAttachment)
private readonly attachmentRepo: Repository<ArchiveAttachment>,
@InjectRepository(StudentDingMapping)
private readonly dingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(StudentWallet) private readonly walletRepo: Repository<StudentWallet>,
@InjectRepository(RoomInspectionDetail)
private readonly inspectionDetailRepo: Repository<RoomInspectionDetail>,
) {}
private async findOne(id: number) {
const student = await this.repo.findOne({
where: { id },
relations: ['occupancies', 'occupancies.room'],
});
if (!student) throw new NotFoundException('学生不存在');
return student;
}
async getArchiveExportMaps(studentIds: number[]) {
if (studentIds.length === 0) {
return {
profiles: new Map<number, StudentProfile>(),
results: new Map<number, ResultArchive>(),
};
}
const [profiles, results] = await Promise.all([
this.profileRepo.find({ where: { studentId: In(studentIds) } }),
this.resultRepo.find({ where: { studentId: In(studentIds) } }),
]);
return {
profiles: new Map(profiles.map((profile) => [profile.studentId, profile])),
results: new Map(results.map((result) => [result.studentId, result])),
};
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生');
const students = await this.repo.find({ where: { id: In(ids) } });
const skipped: string[] = [];
const targetIds: number[] = [];
for (const s of students) {
if (s.status === 'archived') skipped.push(s.name);
else targetIds.push(s.id);
}
let affected = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
affected = result.affected || 0;
}
const message =
skipped.length > 0
? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected} 人(数据已保留,可随时恢复)`;
return { message, archived: affected, skipped: skipped.length };
}
async restore(id: number) {
const student = await this.findOne(id);
if (student.status !== 'archived') {
throw new BadRequestException('该学生未被归档');
}
await this.repo.update(id, { status: 'active' });
return { message: '已恢复' };
}
private async assertNoStudentReferences(studentId: number) {
const [
occupancyCount,
personalExpenseCount,
billCount,
depositCount,
classMemberCount,
profileCount,
enrollmentCount,
examScoreCount,
learningRecordCount,
attachmentCount,
resultCount,
attendanceCount,
dingMappingCount,
walletCount,
inspectionDetailCount,
] = await Promise.all([
this.occupancyRepo.count({ where: { studentId } }),
this.personalExpenseRepo.count({ where: { studentId } }),
this.billRepo.count({ where: { studentId } }),
this.depositRepo.count({ where: { studentId } }),
this.classStudentRepo.count({ where: { studentId } }),
this.profileRepo.count({ where: { studentId } }),
this.enrollmentRepo.count({ where: { studentId } }),
this.examScoreRepo.count({ where: { studentId } }),
this.learningRecordRepo.count({ where: { studentId } }),
this.attachmentRepo.count({ where: { studentId } }),
this.resultRepo.count({ where: { studentId } }),
this.attendanceRepo.count({ where: { studentId } }),
this.dingMappingRepo.count({ where: { studentId } }),
this.walletRepo.count({ where: { studentId } }),
this.inspectionDetailRepo.count({ where: { studentId } }),
]);
const refs: Array<[string, number]> = [
['入住记录', occupancyCount],
['个人费用', personalExpenseCount],
['账单', billCount],
['押金', depositCount],
['班级成员', classMemberCount],
['档案信息', profileCount],
['报名记录', enrollmentCount],
['考试成绩', examScoreCount],
['学习记录', learningRecordCount],
['档案附件', attachmentCount],
['录取结果', resultCount],
['考勤记录', attendanceCount],
['钉钉映射', dingMappingCount],
['学生钱包', walletCount],
['查寝明细', inspectionDetailCount],
];
const references = refs.filter(([, count]) => count > 0);
if (references.length > 0) {
const names = references.map(([name]) => name).join('、');
throw new BadRequestException(`该学生存在关联数据(${names}),无法永久删除`);
}
}
async purge(id: number) {
const student = await this.findOne(id);
if (student.status !== 'archived') {
throw new BadRequestException('仅已归档学生可以永久删除,请先归档');
}
await this.assertNoStudentReferences(id);
await this.repo.delete(id);
return { message: '已永久删除学生(不可恢复)' };
}
async batchPurge(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的学生');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('学生 ID 无效');
}
const students = await this.repo.find({ where: { id: In(uniqueIds) } });
if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在');
const deleted: number[] = [];
const skipped: string[] = [];
for (const student of students) {
if (student.status !== 'archived') {
skipped.push(`${student.name}(未归档)`);
continue;
}
try {
await this.assertNoStudentReferences(student.id);
} catch {
skipped.push(`${student.name}(存在关联数据)`);
continue;
}
await this.repo.delete(student.id);
deleted.push(student.id);
}
const message =
skipped.length > 0
? `已永久删除 ${deleted.length} 人;${skipped.length} 人被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已永久删除 ${deleted.length} 名学生(不可恢复)`;
return { message, deleted: deleted.length, skipped: skipped.length };
}
async batchRestore(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('学生 ID 无效');
}
const students = await this.repo.find({ where: { id: In(uniqueIds) } });
if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在');
const targetIds = students
.filter((student) => student.status === 'archived')
.map((student) => student.id);
const skipped = students.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 名学生`, restored, skipped };
}
}

View File

@@ -11,6 +11,14 @@ import { StudentEnrollment } from '../entities/student-enrollment.entity';
import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Bill } from '../entities/bill.entity';
import { Deposit } from '../entities/deposit.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { StudentsService } from './students.service';
import { StudentAccessScopeFactory } from './student-access-scope.factory';
import { StudentsController } from './students.controller';
@@ -29,6 +37,14 @@ import { StudentsController } from './students.controller';
ExamScore,
LearningRecord,
ResultArchive,
Occupancy,
PersonalExpense,
Bill,
Deposit,
ArchiveAttachment,
StudentDingMapping,
StudentWallet,
RoomInspectionDetail,
]),
],
controllers: [StudentsController],

View File

@@ -0,0 +1,21 @@
import { BadRequestException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { Organization } from '../entities/organization.entity';
export async function assertActiveOrganization(
organizationRepo: Repository<Organization>,
id: number,
): Promise<void> {
const organization = await organizationRepo.findOne({ where: { id, status: 'active' } });
if (!organization) throw new BadRequestException('所属机构不存在或已归档');
}
export async function getHostOrganizationId(
organizationRepo: Repository<Organization>,
): Promise<number> {
const organization = await organizationRepo.findOne({
where: { isHost: true, status: 'active' },
});
if (!organization) throw new BadRequestException('尚未配置本机构');
return organization.id;
}

View File

@@ -0,0 +1,31 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { StudentsController } from './students.controller';
describe('StudentsController purge routes', () => {
it('requires student:purge on permanent delete routes', () => {
expect(Reflect.getMetadata(PERMISSION_KEY, StudentsController.prototype.purge)).toEqual([
'student:purge',
]);
expect(
Reflect.getMetadata(PERMISSION_KEY, StudentsController.prototype.batchPurge),
).toEqual(['student:purge']);
});
it('writes permanent delete audit logs', async () => {
const service = { purge: jest.fn().mockResolvedValue({ message: '已永久删除学生(不可恢复)' }) };
const log = jest.fn().mockResolvedValue(undefined);
const controller = new StudentsController(
service as never,
{ log } as never,
{} as never,
{} as never,
);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purge(1, req);
expect(service.purge).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '学生管理', action: '永久删除学生', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,77 @@
import { BadRequestException } from '@nestjs/common';
import { StudentsService } from './students.service';
const student = { id: 1, name: '张三', status: 'archived' };
const createService = (overrides?: {
student?: Record<string, unknown>;
counts?: Record<string, number>;
}) => {
const counts = overrides?.counts ?? {};
const countFor = (key: string) => jest.fn().mockResolvedValue(counts[key] ?? 0);
const repo = {
findOne: jest.fn().mockResolvedValue(overrides?.student ?? student),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
find: jest.fn().mockResolvedValue([overrides?.student ?? student]),
};
const occupancyCount = countFor('occupancy');
const service = new StudentsService(
repo as never,
{ count: countFor('classStudent') } as never,
{} as never,
{ count: countFor('attendance') } as never,
{} as never,
{} as never,
{ count: countFor('profile') } as never,
{ count: countFor('enrollment') } as never,
{ count: countFor('examScore') } as never,
{ count: countFor('learningRecord') } as never,
{ count: countFor('result') } as never,
{ count: occupancyCount } as never,
{ count: countFor('personalExpense') } as never,
{ count: countFor('bill') } as never,
{ count: countFor('deposit') } as never,
{ count: countFor('attachment') } as never,
{ count: countFor('dingMapping') } as never,
{ count: countFor('wallet') } as never,
{ count: countFor('inspectionDetail') } as never,
);
return { service, repo, occupancyCount };
};
describe('StudentsService.purge', () => {
it('rejects students that are not archived', async () => {
const { service, repo } = createService({ student: { id: 1, name: '张三', status: 'active' } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('仅已归档学生可以永久删除,请先归档'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('rejects students with any reference', async () => {
const { service, repo } = createService({ counts: { occupancy: 2 } });
await expect(service.purge(1)).rejects.toThrow(
new BadRequestException('该学生存在关联数据(入住记录),无法永久删除'),
);
expect(repo.delete).not.toHaveBeenCalled();
});
it('deletes an archived student with no references', async () => {
const { service, repo } = createService();
await expect(service.purge(1)).resolves.toEqual({ message: '已永久删除学生(不可恢复)' });
expect(repo.delete).toHaveBeenCalledWith(1);
});
it('batch purge returns deleted and skipped counts', async () => {
const { service, repo, occupancyCount } = createService();
repo.find = jest.fn().mockResolvedValue([
{ id: 1, name: '甲', status: 'archived' },
{ id: 2, name: '乙', status: 'archived' },
{ id: 3, name: '丙', status: 'active' },
]);
occupancyCount.mockResolvedValueOnce(1).mockResolvedValue(0);
const result = await service.batchPurge([1, 2, 3]);
expect(result).toMatchObject({ deleted: 1, skipped: 2 });
expect(repo.delete).toHaveBeenCalledWith(2);
});
});

View File

@@ -1,29 +1,37 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, Not, In, FindOptionsWhere, IsNull } from 'typeorm';
import { Like, Not, In, FindOptionsWhere, IsNull, Repository } from 'typeorm';
import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Organization } from '../entities/organization.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Bill } from '../entities/bill.entity';
import { Deposit } from '../entities/deposit.entity';
import { StudentProfile } from '../entities/student-profile.entity';
import { StudentEnrollment } from '../entities/student-enrollment.entity';
import { ExamScore } from '../entities/exam-score.entity';
import { LearningRecord } from '../entities/learning-record.entity';
import { ResultArchive } from '../entities/result-archive.entity';
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
import type {
ExamScoreImportRow,
LearningRecordImportRow,
StudentEnrollmentImportRow,
StudentImportRow,
StudentWorkbookImport,
} from './student-import';
import type { StudentAccessScope } from './student-access-scope';
import { assertActiveOrganization } from './students.organization';
import { StudentsImportService } from './students.import.service';
import { StudentsLifecycleService } from './students.lifecycle.service';
import { StudentsAgentService } from './students.agent.service';
@Injectable()
export class StudentsService {
private importService?: StudentsImportService;
private lifecycleService?: StudentsLifecycleService;
private agentService?: StudentsAgentService;
constructor(
@InjectRepository(Student) private repo: Repository<Student>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@@ -36,8 +44,63 @@ export class StudentsService {
@InjectRepository(ExamScore) private examScoreRepo: Repository<ExamScore>,
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
@InjectRepository(Occupancy) private occupancyRepo: Repository<Occupancy>,
@InjectRepository(PersonalExpense) private personalExpenseRepo: Repository<PersonalExpense>,
@InjectRepository(Bill) private billRepo: Repository<Bill>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
@InjectRepository(StudentDingMapping) private dingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(StudentWallet) private walletRepo: Repository<StudentWallet>,
@InjectRepository(RoomInspectionDetail)
private inspectionDetailRepo: Repository<RoomInspectionDetail>,
) {}
private get imports(): StudentsImportService {
if (!this.importService) {
this.importService = new StudentsImportService(
this.repo,
this.profileRepo,
this.enrollmentRepo,
this.examScoreRepo,
this.learningRecordRepo,
this.resultRepo,
this.organizationRepo,
);
}
return this.importService;
}
private get lifecycle(): StudentsLifecycleService {
if (!this.lifecycleService) {
this.lifecycleService = new StudentsLifecycleService(
this.repo,
this.classStudentRepo,
this.attendanceRepo,
this.profileRepo,
this.enrollmentRepo,
this.examScoreRepo,
this.learningRecordRepo,
this.resultRepo,
this.occupancyRepo,
this.personalExpenseRepo,
this.billRepo,
this.depositRepo,
this.attachmentRepo,
this.dingMappingRepo,
this.walletRepo,
this.inspectionDetailRepo,
);
}
return this.lifecycleService;
}
private get agents(): StudentsAgentService {
if (!this.agentService) {
this.agentService = new StudentsAgentService(this.repo, this.classStudentRepo);
}
return this.agentService;
}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
if (canManageAll) return undefined;
const assignments = await this.classTeacherRepo.find({ where: { userId } });
@@ -52,21 +115,8 @@ export class StudentsService {
});
}
async getArchiveExportMaps(studentIds: number[]) {
if (studentIds.length === 0) {
return {
profiles: new Map<number, StudentProfile>(),
results: new Map<number, ResultArchive>(),
};
}
const [profiles, results] = await Promise.all([
this.profileRepo.find({ where: { studentId: In(studentIds) } }),
this.resultRepo.find({ where: { studentId: In(studentIds) } }),
]);
return {
profiles: new Map(profiles.map((profile) => [profile.studentId, profile])),
results: new Map(results.map((result) => [result.studentId, result])),
};
async getArchiveExportMaps(...args: Parameters<StudentsLifecycleService['getArchiveExportMaps']>) {
return this.lifecycle.getArchiveExportMaps(...args);
}
async findAll(
@@ -164,13 +214,13 @@ export class StudentsService {
}
async create(dto: CreateStudentDto) {
await this.assertActiveOrganization(dto.organizationId);
await assertActiveOrganization(this.organizationRepo, dto.organizationId);
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateStudentDto) {
await this.findOne(id);
if (dto.organizationId) await this.assertActiveOrganization(dto.organizationId);
if (dto.organizationId) await assertActiveOrganization(this.organizationRepo, dto.organizationId);
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
@@ -184,345 +234,32 @@ export class StudentsService {
return { message: '已归档(数据已保留,可随时恢复)' };
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生');
const students = await this.repo.find({ where: { id: In(ids) } });
const skipped: string[] = [];
const targetIds: number[] = [];
for (const s of students) {
if (s.status === 'archived') skipped.push(s.name);
else targetIds.push(s.id);
}
let affected = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
affected = result.affected || 0;
}
const message =
skipped.length > 0
? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected} 人(数据已保留,可随时恢复)`;
return { message, archived: affected, skipped: skipped.length };
async batchRemove(...args: Parameters<StudentsLifecycleService['batchRemove']>) {
return this.lifecycle.batchRemove(...args);
}
async restore(id: number) {
const student = await this.findOne(id);
if (student.status !== 'archived') {
throw new BadRequestException('该学生未被归档');
}
await this.repo.update(id, { status: 'active' });
return { message: '已恢复' };
async restore(...args: Parameters<StudentsLifecycleService['restore']>) {
return this.lifecycle.restore(...args);
}
async batchRestore(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('学生 ID 无效');
}
const students = await this.repo.find({ where: { id: In(uniqueIds) } });
if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在');
const targetIds = students.filter((student) => student.status === 'archived').map((student) => student.id);
const skipped = students.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 名学生`, restored, skipped };
async purge(...args: Parameters<StudentsLifecycleService['purge']>) {
return this.lifecycle.purge(...args);
}
async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
const data = this.normalizeImportData(importData);
let imported = 0;
let skipped = 0;
let archiveImported = 0;
for (const row of data.students) {
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) {
skipped++;
continue;
}
const student = await this.repo.save(
this.repo.create({
name: row.name.trim(),
studentNo: row.studentNo?.trim() || undefined,
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender || undefined,
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
supervisor: row.supervisor || undefined,
organizationId: row.organizationId || (await this.getHostOrganizationId()),
}),
);
archiveImported += await this.importArchiveData(student.id, row, data);
imported++;
}
return {
message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`,
imported,
archiveImported,
skipped,
};
async batchPurge(...args: Parameters<StudentsLifecycleService['batchPurge']>) {
return this.lifecycle.batchPurge(...args);
}
async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) {
const data = this.normalizeImportData(importData);
let matched = 0;
let skipped = 0;
let archiveImported = 0;
for (const row of data.students) {
// Match by phone first, then idNumber
let student = row.phone?.trim()
? await this.repo.findOne({ where: { phone: row.phone.trim() } })
: null;
if (!student && row.idNumber?.trim()) {
student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } });
}
if (!student) {
skipped++;
continue;
}
// Update matched student with non-empty imported fields
const updates: Partial<
Pick<
Student,
| 'name'
| 'studentNo'
| 'phone'
| 'idNumber'
| 'gender'
| 'ethnicity'
| 'emergencyContact'
| 'emergencyPhone'
| 'supervisor'
| 'organizationId'
>
> = {};
if (row.name?.trim()) updates.name = row.name.trim();
if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim();
if (row.phone?.trim()) updates.phone = row.phone.trim();
if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (row.gender) updates.gender = row.gender;
if (row.ethnicity) updates.ethnicity = row.ethnicity;
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.organizationId) updates.organizationId = row.organizationId;
await this.repo.update(student.id, updates);
archiveImported += await this.importArchiveData(student.id, row, data);
matched++;
}
return {
message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`,
matched,
archiveImported,
skipped,
};
async batchRestore(...args: Parameters<StudentsLifecycleService['batchRestore']>) {
return this.lifecycle.batchRestore(...args);
}
private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport {
if (Array.isArray(importData)) {
return { students: importData, enrollments: [], examScores: [], learningRecords: [] };
}
return importData;
async batchImport(...args: Parameters<StudentsImportService['batchImport']>) {
return this.imports.batchImport(...args);
}
private normalizePhone(phone?: string) {
return phone?.trim() || '';
}
private sameValue(left?: string | number | null, right?: string | number | null) {
return String(left ?? '').trim() === String(right ?? '').trim();
}
private hasProfileData(row: StudentImportRow) {
return [
row.targetCollege,
row.targetMajor,
row.collegeSchool,
row.collegeMajor,
row.subjectDirection,
row.grade,
row.profileDate,
row.notes,
].some((value) => value !== undefined && String(value).trim() !== '');
}
private hasResultData(row: StudentImportRow) {
return [
row.cultureFinalScore,
row.professionalFinalScore,
row.admissionStatus,
row.admittedCollege,
row.admittedMajor,
].some((value) => value !== undefined && String(value).trim() !== '');
}
private async importArchiveData(
studentId: number,
row: StudentImportRow,
data: StudentWorkbookImport,
) {
const phone = this.normalizePhone(row.phone);
let imported = 0;
if (this.hasProfileData(row)) {
await this.upsertProfileFromImport(studentId, row);
imported++;
}
if (this.hasResultData(row)) {
await this.upsertResultFromImport(studentId, row);
imported++;
}
if (!phone) return imported;
const enrollmentByClassName = new Map<string, StudentEnrollment>();
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)) {
if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) {
imported++;
}
}
for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) {
if (await this.upsertLearningRecordFromImport(studentId, learningRow)) {
imported++;
}
}
return imported;
}
private async upsertProfileFromImport(studentId: number, row: StudentImportRow) {
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();
if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim();
if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim();
if (row.grade?.trim()) entity.grade = row.grade.trim();
if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim();
if (row.notes?.trim()) entity.notes = row.notes.trim();
await this.profileRepo.save(entity);
}
private async upsertResultFromImport(studentId: number, row: StudentImportRow) {
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.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();
await this.resultRepo.save(entity);
}
private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) {
if (!row.courseCategory?.trim() || !row.classType?.trim()) {
return null;
}
const existing = await this.enrollmentRepo.find({ where: { studentId } });
const entity =
existing.find(
(item) =>
this.sameValue(item.courseCategory, row.courseCategory) &&
this.sameValue(item.classType, row.classType) &&
this.sameValue(item.className, row.className) &&
this.sameValue(item.startDate, row.startDate),
) || this.enrollmentRepo.create({ studentId });
entity.courseCategory = row.courseCategory.trim();
entity.classType = row.classType.trim();
if (row.className?.trim()) entity.className = row.className.trim();
if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim();
if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim();
if (row.startDate?.trim()) entity.startDate = row.startDate.trim();
if (row.endDate?.trim()) entity.endDate = row.endDate.trim();
if (row.status?.trim()) entity.status = row.status.trim();
else if (!entity.status) entity.status = 'active';
return this.enrollmentRepo.save(entity);
}
private async upsertExamScoreFromImport(
studentId: number,
row: ExamScoreImportRow,
enrollmentByClassName: Map<string, StudentEnrollment>,
) {
if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false;
const existing = await this.examScoreRepo.find({ where: { studentId } });
const entity =
existing.find(
(item) =>
this.sameValue(item.examType, row.examType) &&
this.sameValue(item.examName, row.examName) &&
this.sameValue(item.subject, row.subject) &&
this.sameValue(item.examDate, row.examDate),
) || this.examScoreRepo.create({ studentId });
entity.examType = row.examType.trim();
entity.subject = row.subject.trim();
entity.score = row.score;
if (row.examName?.trim()) entity.examName = row.examName.trim();
if (row.classAvg !== undefined) entity.classAvg = row.classAvg;
if (row.rank !== undefined) entity.rank = row.rank;
if (row.examDate?.trim()) entity.examDate = row.examDate.trim();
if (row.enrollmentName?.trim()) {
const enrollment = enrollmentByClassName.get(row.enrollmentName.trim());
if (enrollment) entity.enrollmentId = enrollment.id;
}
if (!entity.status) entity.status = 'active';
await this.examScoreRepo.save(entity);
return true;
}
private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) {
if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false;
const existing = await this.learningRecordRepo.find({ where: { studentId } });
const entity =
existing.find(
(item) =>
this.sameValue(item.recordDate, row.recordDate) &&
this.sameValue(item.recordType, row.recordType) &&
this.sameValue(item.content, row.content),
) || this.learningRecordRepo.create({ studentId });
entity.recordDate = row.recordDate.trim();
entity.recordType = row.recordType.trim();
entity.content = row.content.trim();
if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim();
if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim();
if (!entity.status) entity.status = 'active';
await this.learningRecordRepo.save(entity);
return true;
}
private async assertActiveOrganization(id: number) {
const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } });
if (!organization) throw new BadRequestException('所属机构不存在或已归档');
}
private async getHostOrganizationId() {
const organization = await this.organizationRepo.findOne({
where: { isHost: true, status: 'active' },
});
if (!organization) throw new BadRequestException('尚未配置本机构');
return organization.id;
async matchImport(...args: Parameters<StudentsImportService['matchImport']>) {
return this.imports.matchImport(...args);
}
async compareClasses(studentId: number) {
@@ -581,228 +318,15 @@ export class StudentsService {
return { student, enrollments: comparison };
}
// -------------------------------------------------------------------------
// Agent-safe query APIs — SQL-level scope + field whitelist
// -------------------------------------------------------------------------
/**
* Whitelisted output type for agent student searches.
* NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone.
*/
private static readonly AGENT_STUDENT_SELECT = [
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
] as const;
/**
* Search students with SQL-enforced scope, field whitelist, and limit.
*
* @param scope — data-range discriminator (manageAll or teacher).
* @param query — optional keyword, classId, organizationId, limit.
* @returns formatted whitelist-only results with classIds.
*/
async agentSearchStudents(
scope: StudentAccessScope,
query?: {
keyword?: string;
classId?: number;
organizationId?: number;
limit?: number;
},
): Promise<
{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
}[]
> {
const limit = Math.max(1, Math.min(query?.limit ?? 20, 50));
const qb = this.repo
.createQueryBuilder('student')
.distinct(true)
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'student.createdAt',
'organization.name',
])
.leftJoin('student.organization', 'organization');
// ---- Scope enforcement ----
this.applyStudentScope(qb, scope, query?.classId);
// ---- Filters ----
if (query?.keyword) {
qb.andWhere(
'(student.name LIKE :keyword OR student.student_no LIKE :keyword)',
{ keyword: `%${query.keyword}%` },
);
}
if (query?.organizationId) {
qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId });
}
qb.orderBy('student.createdAt', 'DESC').take(limit);
const rows: Record<string, unknown>[] = await qb.getRawMany();
if (rows.length === 0) return [];
// Second bounded query: classIds only for the returned student ids.
// For teacher scope, the class filter MUST be re-applied so the
// teacher only sees classIds they are assigned to.
const studentIds = rows.map((r) => r.student_id as number);
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.studentId', 'cs.classId'])
.where('cs.student_id IN (:...ids)', { ids: studentIds })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
const classMap = new Map<number, number[]>();
for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) {
const sid = cr.cs_student_id;
if (!classMap.has(sid)) classMap.set(sid, []);
classMap.get(sid)!.push(cr.cs_class_id);
}
return rows.map((r) => ({
id: r.student_id as number,
name: r.student_name as string,
studentNo: (r.student_student_no as string) ?? '',
gender: (r.student_gender as string) ?? '',
status: r.student_status as string,
organizationId: r.student_organization_id as number,
organizationName: (r.organization_name as string) ?? '',
classIds: classMap.get(r.student_id as number) ?? [],
}));
...args: Parameters<StudentsAgentService['agentSearchStudents']>
) {
return this.agents.agentSearchStudents(...args);
}
/**
* Get single student basic info with SQL-enforced scope + whitelist.
* Returns `null` for students out of scope or non-existent (no leak).
*/
async agentGetStudentBasic(
scope: StudentAccessScope,
studentId: number,
): Promise<{
id: number;
name: string;
studentNo: string;
gender: string;
status: string;
organizationId: number;
organizationName: string;
classIds: number[];
} | null> {
const qb = this.repo
.createQueryBuilder('student')
.select([
'student.id',
'student.name',
'student.studentNo',
'student.gender',
'student.status',
'student.organizationId',
'organization.name',
])
.leftJoin('student.organization', 'organization')
.where('student.id = :studentId', { studentId });
this.applyStudentScope(qb, scope);
const row = await qb.getRawOne();
if (!row) return null;
// For teacher scope, re-apply class filter so teacher only sees
// classIds they are assigned to (not ALL active classIds of the student).
const csQb = this.classStudentRepo
.createQueryBuilder('cs')
.select(['cs.classId'])
.where('cs.student_id = :studentId', { studentId })
.andWhere('cs.status = :status', { status: 'active' });
if (scope.type === 'teacher') {
csQb.andWhere(
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
{ scopeTeacherUserId: scope.userId },
);
}
const classRows = await csQb.getRawMany();
return {
id: row.student_id as number,
name: row.student_name as string,
studentNo: (row.student_student_no as string) ?? '',
gender: (row.student_gender as string) ?? '',
status: row.student_status as string,
organizationId: row.student_organization_id as number,
organizationName: (row.organization_name as string) ?? '',
classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id),
};
}
/**
* Apply data-range scope to a student QueryBuilder.
*
* - `manageAll`: no restriction.
* - `teacher`: INNER JOIN ClassStudent → active students in the
* teacher's assigned classes (via ClassTeacher).
* - When `classId` is provided, it is ANDed with the scope
* (intersection) — the model cannot widen access.
*/
private applyStudentScope(
qb: ReturnType<typeof this.repo.createQueryBuilder>,
scope: StudentAccessScope,
classId?: number,
): void {
if (scope.type === 'manageAll') {
if (classId != null) {
qb.innerJoin(
'class_student',
'cs_scope',
'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus',
{ scopeClassId: classId, scopeCsStatus: 'active' },
);
}
return;
}
// Teacher scope: active students in teacher's assigned classes
const teacherClause =
'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' +
'(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)';
qb.innerJoin('class_student', 'cs_scope', teacherClause, {
scopeTeacherUserId: scope.userId,
scopeCsStatus: 'active',
});
if (classId != null) {
qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId });
}
...args: Parameters<StudentsAgentService['agentGetStudentBasic']>
) {
return this.agents.agentGetStudentBasic(...args);
}
}