fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View File

@@ -17,6 +17,7 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from '../entities/tenant.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { StudentsService } from './students.service';
@@ -30,33 +31,59 @@ import * as ExcelJS from 'exceljs';
@UseGuards(JwtAuthGuard)
@Controller('students')
export class StudentsController {
constructor(
private service: StudentsService,
private logService: OperationLogsService,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
) {}
private canManageAllStudents(user: { isSuperAdmin?: boolean; permissions?: string[] }): boolean {
return (
user.isSuperAdmin === true ||
user.permissions?.includes('student:edit') === true ||
user.permissions?.includes('class:edit') === true
);
}
@Get()
@RequirePermission('student:view')
findAll(
@Query('name') name?: string,
@Query('status') status?: string,
@Query('includeArchived') includeArchived?: string,
@Query('tenantId') tenantId?: string,
async findAll(
@Query('name') name: string | undefined,
@Query('status') status: string | undefined,
@Query('includeArchived') includeArchived: string | undefined,
@Query('tenantId') tenantId: string | undefined,
@Request() req: { user: { id: number; isSuperAdmin?: boolean; permissions?: string[] } },
) {
return this.service.findAll({
name,
status,
includeArchived: includeArchived === 'true',
tenantId: tenantId ? +tenantId : undefined,
});
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllStudents(req.user),
);
return this.service.findAll(
{
name,
status,
includeArchived: includeArchived === 'true',
tenantId: tenantId ? +tenantId : undefined,
},
classIds,
);
}
@Get('export')
@RequirePermission('student:export')
async exportExcel(@Query('includeArchived') includeArchived?: string, @Res() res?: Response, @Request() req?: any) {
const students = await this.service.findAll({ includeArchived: includeArchived === 'true' });
async exportExcel(
@Query('includeArchived') includeArchived?: string,
@Res() res?: Response,
@Request() req?: any,
) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllStudents(req.user),
);
const students = await this.service.findAll(
{ includeArchived: includeArchived === 'true' },
classIds,
);
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('学生名单');
ws.columns = [
@@ -303,6 +330,60 @@ export class StudentsController {
return result;
}
@Post('import-match')
@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);
const ws = workbook.worksheets[0];
const rows: {
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
rows.push({
name: String(row.getCell(1).value || ''),
phone: String(row.getCell(2).value || ''),
idNumber: String(row.getCell(3).value || ''),
gender: String(row.getCell(4).value || '').trim() || undefined,
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
organization: String(row.getCell(8).value || '').trim() || undefined,
supervisor: String(row.getCell(9).value || '').trim() || undefined,
});
});
// Resolve tenant names to IDs
for (const row of rows) {
if (row.organization) {
const tenant = await this.tenantRepo.findOne({ where: { name: row.organization } });
if (tenant) row.tenantId = tenant.id;
}
}
const result = await this.service.matchImport(rows);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生管理',
action: '匹配导入学生',
detail: result.message,
ipAddress,
userAgent,
});
return result;
}
@Get(':id/compare-classes')
@RequirePermission('student:view')
compareClasses(@Param('id') id: string) {

View File

@@ -5,11 +5,21 @@ import { Class } from '../entities/class.entity';
import { Tenant } from '../entities/tenant.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { StudentsService } from './students.service';
import { StudentsController } from './students.controller';
@Module({
imports: [TypeOrmModule.forFeature([Student, Class, ClassStudent, AttendanceRecord, Tenant])],
imports: [
TypeOrmModule.forFeature([
Student,
Class,
ClassStudent,
AttendanceRecord,
Tenant,
ClassTeacher,
]),
],
controllers: [StudentsController],
providers: [StudentsService],
exports: [StudentsService],

View File

@@ -0,0 +1,43 @@
import { StudentsService } from './students.service';
describe('StudentsService — teacher class scope', () => {
it('limits student list to active students in assigned classes', async () => {
const repo = { find: jest.fn().mockResolvedValue([{ id: 11, name: '张三' }]) };
const classStudentRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 11 }, { studentId: 11 }, { studentId: 12 }]),
};
const service = new StudentsService(
repo as never,
classStudentRepo as never,
{} as never,
{} as never,
{} as never,
);
await service.findAll({}, [3, 5]);
expect(classStudentRepo.find).toHaveBeenCalledWith({
where: { classId: expect.any(Object), status: 'active' },
});
expect(repo.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: expect.any(Object) }),
relations: ['tenant'],
}),
);
});
it('returns an empty student list when teacher has no assigned classes', async () => {
const repo = { find: jest.fn() };
const service = new StudentsService(
repo as never,
{} as never,
{} as never,
{} as never,
{} as never,
);
await expect(service.findAll({}, [])).resolves.toEqual([]);
expect(repo.find).not.toHaveBeenCalled();
});
});

View File

@@ -4,20 +4,35 @@ 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 { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
@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>,
) {}
async findAll(query?: { name?: string; status?: string; includeArchived?: boolean; tenantId?: number | string }) {
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 findAll(
query?: {
name?: string;
status?: string;
includeArchived?: boolean;
tenantId?: number | string;
},
accessibleClassIds?: number[],
) {
const where: FindOptionsWhere<Student> = {};
if (query?.name) where.name = Like(`%${query.name}%`);
if (query?.tenantId) where.tenantId = Number(query.tenantId);
@@ -26,6 +41,15 @@ export class StudentsService {
} 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: ['tenant'] });
}
@@ -141,6 +165,74 @@ export class StudentsService {
};
}
async matchImport(
rows: {
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
}[],
) {
let matched = 0;
let skipped = 0;
for (const row of rows) {
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
// 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'
| 'phone'
| 'idNumber'
| 'gender'
| 'ethnicity'
| 'emergencyContact'
| 'emergencyPhone'
| 'organization'
| 'supervisor'
| 'tenantId'
>
> = {};
if (row.name?.trim()) updates.name = row.name.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.organization) updates.organization = row.organization;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.tenantId) updates.tenantId = row.tenantId;
await this.repo.update(student.id, updates as Partial<Student>);
matched++;
}
return {
message: `匹配更新 ${matched} 人,跳过 ${skipped} 条(无匹配)`,
matched,
skipped,
};
}
async compareClasses(studentId: number) {
const student = await this.repo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在');