feat: 重构各业务模块管理页面与服务
This commit is contained in:
233
apps/server/src/students/students.agent.service.ts
Normal file
233
apps/server/src/students/students.agent.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
314
apps/server/src/students/students.import.service.ts
Normal file
314
apps/server/src/students/students.import.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
235
apps/server/src/students/students.lifecycle.service.ts
Normal file
235
apps/server/src/students/students.lifecycle.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
21
apps/server/src/students/students.organization.ts
Normal file
21
apps/server/src/students/students.organization.ts
Normal 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;
|
||||
}
|
||||
31
apps/server/src/students/students.purge.controller.spec.ts
Normal file
31
apps/server/src/students/students.purge.controller.spec.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
77
apps/server/src/students/students.purge.spec.ts
Normal file
77
apps/server/src/students/students.purge.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user