540 lines
18 KiB
TypeScript
540 lines
18 KiB
TypeScript
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository, Like, Not, In, FindOptionsWhere } 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 { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||
import type { StudentAccessScope } from './student-access-scope';
|
||
|
||
@Injectable()
|
||
export class StudentsService {
|
||
constructor(
|
||
@InjectRepository(Student) private repo: Repository<Student>,
|
||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
|
||
) {}
|
||
|
||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||
if (canManageAll) return undefined;
|
||
const assignments = await this.classTeacherRepo.find({ where: { userId } });
|
||
return [...new Set(assignments.map((assignment) => assignment.classId))];
|
||
}
|
||
|
||
async getBasicLookups() {
|
||
return this.repo.find({
|
||
select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],
|
||
where: { status: 'active' },
|
||
order: { name: 'ASC' },
|
||
});
|
||
}
|
||
|
||
async findAll(
|
||
query?: {
|
||
name?: string;
|
||
status?: string;
|
||
includeArchived?: boolean;
|
||
organizationId?: number | string;
|
||
},
|
||
accessibleClassIds?: number[],
|
||
) {
|
||
const where: FindOptionsWhere<Student> = {};
|
||
if (query?.name) where.name = Like(`%${query.name}%`);
|
||
if (query?.organizationId) where.organizationId = Number(query.organizationId);
|
||
if (query?.status) {
|
||
where.status = query.status;
|
||
} else if (!query?.includeArchived) {
|
||
where.status = Not(In(['archived', 'staff']));
|
||
}
|
||
if (accessibleClassIds) {
|
||
if (accessibleClassIds.length === 0) return [];
|
||
const classStudents = await this.classStudentRepo.find({
|
||
where: { classId: In(accessibleClassIds), status: 'active' },
|
||
});
|
||
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||
if (studentIds.length === 0) return [];
|
||
where.id = In(studentIds);
|
||
}
|
||
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['organization'] });
|
||
}
|
||
|
||
async findOne(id: number) {
|
||
const student = await this.repo.findOne({
|
||
where: { id },
|
||
relations: ['occupancies', 'occupancies.room'],
|
||
});
|
||
if (!student) throw new NotFoundException('学生不存在');
|
||
return student;
|
||
}
|
||
|
||
async create(dto: CreateStudentDto) {
|
||
await this.assertActiveOrganization(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);
|
||
await this.repo.update(id, dto);
|
||
return this.repo.findOne({ where: { id } });
|
||
}
|
||
|
||
async remove(id: number) {
|
||
const student = await this.findOne(id);
|
||
if (student.status === 'archived') {
|
||
throw new BadRequestException('该学生已归档');
|
||
}
|
||
await this.repo.update(id, { status: 'archived' });
|
||
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 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 batchImport(
|
||
rows: {
|
||
name: string;
|
||
studentNo?: string;
|
||
phone?: string;
|
||
idNumber?: string;
|
||
gender?: string;
|
||
ethnicity?: string;
|
||
emergencyContact?: string;
|
||
emergencyPhone?: string;
|
||
organization?: string;
|
||
supervisor?: string;
|
||
organizationId?: number;
|
||
}[],
|
||
) {
|
||
let imported = 0;
|
||
let skipped = 0;
|
||
for (const row of rows) {
|
||
if (!row.name || !row.name.trim()) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
||
if (exists) {
|
||
skipped++;
|
||
continue;
|
||
}
|
||
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()),
|
||
}),
|
||
);
|
||
imported++;
|
||
}
|
||
return {
|
||
message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`,
|
||
imported,
|
||
skipped,
|
||
};
|
||
}
|
||
|
||
async matchImport(
|
||
rows: {
|
||
name: string;
|
||
studentNo?: string;
|
||
phone?: string;
|
||
idNumber?: string;
|
||
gender?: string;
|
||
ethnicity?: string;
|
||
emergencyContact?: string;
|
||
emergencyPhone?: string;
|
||
organization?: string;
|
||
supervisor?: string;
|
||
organizationId?: number;
|
||
}[],
|
||
) {
|
||
let matched = 0;
|
||
let skipped = 0;
|
||
for (const row of rows) {
|
||
// 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);
|
||
matched++;
|
||
}
|
||
return {
|
||
message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
|
||
matched,
|
||
skipped,
|
||
};
|
||
}
|
||
|
||
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 compareClasses(studentId: number) {
|
||
const student = await this.repo.findOne({ where: { id: studentId } });
|
||
if (!student) throw new NotFoundException('学生不存在');
|
||
|
||
const enrollments = await this.classStudentRepo.find({
|
||
where: { studentId },
|
||
relations: ['class'],
|
||
});
|
||
|
||
if (enrollments.length === 0) {
|
||
return { student, enrollments: [] };
|
||
}
|
||
|
||
const classIds = enrollments.map((e) => e.classId);
|
||
const attendanceRecords = await this.attendanceRepo.find({
|
||
where: { studentId, classId: In(classIds) },
|
||
});
|
||
|
||
const attendanceByClass = new Map<number, AttendanceRecord[]>();
|
||
for (const r of attendanceRecords) {
|
||
const list = attendanceByClass.get(r.classId) || [];
|
||
list.push(r);
|
||
attendanceByClass.set(r.classId, list);
|
||
}
|
||
|
||
const comparison = enrollments.map((e) => {
|
||
const records = attendanceByClass.get(e.classId) || [];
|
||
const total = records.length;
|
||
const present = records.filter((r) => r.status === 'present' || r.status === '正常').length;
|
||
const absent = records.filter((r) => r.status === 'absent' || r.status === '缺勤').length;
|
||
const late = records.filter((r) => r.status === 'late' || r.status === '迟到').length;
|
||
const leave = records.filter((r) => r.status === 'leave' || r.status === '请假').length;
|
||
|
||
return {
|
||
classId: e.classId,
|
||
className: e.class?.name || '',
|
||
classType: e.class?.classType || '',
|
||
startDate: e.class?.startDate || '',
|
||
endDate: e.class?.endDate || '',
|
||
joinDate: e.joinDate,
|
||
leaveDate: e.leaveDate,
|
||
status: e.status,
|
||
attendanceStats: {
|
||
total,
|
||
present,
|
||
absent,
|
||
late,
|
||
leave,
|
||
rate: total > 0 ? Math.round((present / total) * 100) : 0,
|
||
},
|
||
};
|
||
});
|
||
|
||
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',
|
||
'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.studentNo LIKE :keyword)',
|
||
{ keyword: `%${query.keyword}%` },
|
||
);
|
||
}
|
||
if (query?.organizationId) {
|
||
qb.andWhere('student.organizationId = :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.studentId IN (:...ids)', { ids: studentIds })
|
||
.andWhere('cs.status = :status', { status: 'active' });
|
||
|
||
if (scope.type === 'teacher') {
|
||
csQb.andWhere(
|
||
'cs.classId 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.studentId = :studentId', { studentId })
|
||
.andWhere('cs.status = :status', { status: 'active' });
|
||
|
||
if (scope.type === 'teacher') {
|
||
csQb.andWhere(
|
||
'cs.classId 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 });
|
||
}
|
||
}
|
||
}
|