feat(server): 教师分配 DTO 校验与实体对齐,重复分配由唯一索引兜底
- 科目长度上限收敛到 dto MAX_SUBJECT_LENGTH(30),subjects/subject 加 @MaxLength - ValidateSubjectTeacherHasSubject 对非字符串元素做类型守卫,避免 500 - normalizeTeacherSubjects 去重键小写对齐 MySQL 大小写不敏感 collation - 任课老师逐行保存并包事务:并发 1062 跳过、非重复错误整体回滚 - 非科目角色去掉无事务悲观锁,改查重 + save 捕获 1062 转 400 - 实体 subject 列对齐迁移后 NOT NULL DEFAULT '',唯一索引显式命名 - 提交 DTO 跨字段校验:合计行数不超过文件解析上限,id 需为正整数
This commit is contained in:
@@ -12,16 +12,16 @@ Like } from 'typeorm';
|
||||
import { escapeLike } from '../common/like-escape';
|
||||
import {
|
||||
Class,
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
Exam,
|
||||
Student,
|
||||
StudentDingMapping
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
Exam,
|
||||
Student,
|
||||
StudentDingMapping,
|
||||
} from '../entities';
|
||||
import { ClassesQueriesService } from './classes-queries.service';
|
||||
import { ClassesQueriesService, isDuplicateEntryError } from './classes-queries.service';
|
||||
import { normalizeDateOnly } from '../database/date-normalization';
|
||||
import dayjs from '../common/dayjs';
|
||||
import {
|
||||
@@ -31,13 +31,46 @@ import {
|
||||
AddTeacherDto,
|
||||
QueryClassScheduleDto,
|
||||
QueryClassAttendanceSummaryDto,
|
||||
MAX_TEACHER_SUBJECTS,
|
||||
MAX_SUBJECT_LENGTH,
|
||||
} from './dto/class.dto';
|
||||
import type { ClassRosterCreateRow, ClassRosterImportRow } from './class-roster-import';
|
||||
|
||||
interface RawStudentCount {
|
||||
classId: string;
|
||||
count: string;
|
||||
}
|
||||
|
||||
/** 归一化多科目:去空白、去重、超限丢弃(长度上限由 dto MAX_SUBJECT_LENGTH 校验前置拦截)。
|
||||
* 去重键统一小写:class_teacher 唯一索引在 MySQL 默认 collation 下大小写不敏感,
|
||||
* 否则 'Math'+'math' 会通过此处去重却在 save 时撞 1062(保留首个原始大小写展示)。 */
|
||||
export function normalizeTeacherSubjects(subjects?: string[]): string[] {
|
||||
if (!subjects) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const raw of subjects) {
|
||||
const trimmed = raw?.trim() ?? '';
|
||||
if (!trimmed || trimmed.length > MAX_SUBJECT_LENGTH) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(trimmed);
|
||||
if (result.length >= MAX_TEACHER_SUBJECTS) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 解析老师科目:优先 subjects,归一化后为空时回退兼容旧字段 subject(含空白项场景)。 */
|
||||
function resolveTeacherSubjects(
|
||||
subjects: string[] | undefined,
|
||||
subject: string | undefined,
|
||||
): string[] {
|
||||
const normalized = normalizeTeacherSubjects(subjects);
|
||||
if (normalized.length > 0) return normalized;
|
||||
if (subject) return normalizeTeacherSubjects([subject]);
|
||||
return [];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClassesService {
|
||||
constructor(
|
||||
@@ -91,6 +124,17 @@ export class ClassesService {
|
||||
return this.queries.batchImportStudents(classId, users);
|
||||
}
|
||||
|
||||
async previewRosterImport(classId: number, rows: ClassRosterImportRow[]) {
|
||||
return this.queries.previewRosterImport(classId, rows);
|
||||
}
|
||||
|
||||
async commitRosterImport(
|
||||
classId: number,
|
||||
input: { addStudentIds?: number[]; createRows?: ClassRosterCreateRow[] },
|
||||
) {
|
||||
return this.queries.commitRosterImport(classId, input);
|
||||
}
|
||||
|
||||
async getSchedule(classId: number, query: QueryClassScheduleDto) {
|
||||
return this.queries.getSchedule(classId, query);
|
||||
}
|
||||
@@ -145,6 +189,7 @@ export class ClassesService {
|
||||
const teachers = await this.classTeacherRepo.find({
|
||||
where: { classId: id },
|
||||
relations: ['user'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -158,6 +203,7 @@ export class ClassesService {
|
||||
leaveDate: s.leaveDate,
|
||||
status: s.status,
|
||||
})),
|
||||
// 一行一科目:直接返回原始行,同一老师多科时按科目拆成多行
|
||||
teachers: teachers.map((t) => ({
|
||||
id: t.id,
|
||||
userId: t.userId,
|
||||
@@ -165,6 +211,7 @@ export class ClassesService {
|
||||
username: t.user?.username,
|
||||
roleType: t.roleType,
|
||||
subject: t.subject,
|
||||
subjects: t.subject ? [t.subject] : [],
|
||||
})),
|
||||
studentCount: students.filter((s) => s.status === 'active').length,
|
||||
};
|
||||
@@ -173,6 +220,18 @@ export class ClassesService {
|
||||
async create(dto: CreateClassDto) {
|
||||
const { studentIds, teachers, users, ...classData } = dto;
|
||||
|
||||
// 先校验老师科目(任课老师必须至少一个科目),避免 400 时已写入班级留下孤儿数据
|
||||
const teacherPlans = (teachers ?? []).map((t) => ({
|
||||
userId: t.userId,
|
||||
roleType: t.roleType,
|
||||
subjects: resolveTeacherSubjects(t.subjects, t.subject),
|
||||
}));
|
||||
for (const plan of teacherPlans) {
|
||||
if (plan.roleType === 'subject_teacher' && plan.subjects.length === 0) {
|
||||
throw new BadRequestException('任课老师至少需要一个科目');
|
||||
}
|
||||
}
|
||||
|
||||
const cls = this.classRepo.create({
|
||||
...classData,
|
||||
startDate: normalizeDateOnly(classData.startDate) ?? undefined,
|
||||
@@ -192,17 +251,41 @@ export class ClassesService {
|
||||
await this.classStudentRepo.save(entries);
|
||||
}
|
||||
|
||||
// add teachers
|
||||
if (teachers?.length) {
|
||||
const entries = teachers.map((t) =>
|
||||
this.classTeacherRepo.create({
|
||||
classId: saved.id,
|
||||
userId: t.userId,
|
||||
roleType: t.roleType,
|
||||
subject: t.subject,
|
||||
}),
|
||||
);
|
||||
await this.classTeacherRepo.save(entries);
|
||||
// add teachers:任课老师一行一科目;其他角色单行无科目
|
||||
if (teacherPlans.length) {
|
||||
const entries: ClassTeacher[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const plan of teacherPlans) {
|
||||
if (plan.roleType === 'subject_teacher') {
|
||||
for (const subject of plan.subjects) {
|
||||
// 唯一索引 collation 大小写不敏感,去重键统一小写避免 'Math'+'math' 撞 1062
|
||||
const key = `${plan.userId}:${plan.roleType}:${subject.toLowerCase()}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push(
|
||||
this.classTeacherRepo.create({
|
||||
classId: saved.id,
|
||||
userId: plan.userId,
|
||||
roleType: plan.roleType,
|
||||
subject,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const key = `${plan.userId}:${plan.roleType}:`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push(
|
||||
this.classTeacherRepo.create({
|
||||
classId: saved.id,
|
||||
userId: plan.userId,
|
||||
roleType: plan.roleType,
|
||||
subject: '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (entries.length) await this.classTeacherRepo.save(entries);
|
||||
|
||||
// sync head/life/academic teacher IDs
|
||||
await this.syncClassTeacherIds(saved.id);
|
||||
@@ -349,16 +432,80 @@ export class ClassesService {
|
||||
}
|
||||
|
||||
async getTeachers(classId: number) {
|
||||
return this.classTeacherRepo.find({
|
||||
const rows = await this.classTeacherRepo.find({
|
||||
where: { classId },
|
||||
relations: ['user'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
// 一行一科目:直接返回原始行,同一老师多科时按科目拆成多行
|
||||
return rows.map((t) => ({
|
||||
id: t.id,
|
||||
userId: t.userId,
|
||||
username: t.user?.username,
|
||||
name: t.user?.name,
|
||||
roleType: t.roleType,
|
||||
subject: t.subject,
|
||||
subjects: t.subject ? [t.subject] : [],
|
||||
}));
|
||||
}
|
||||
|
||||
async addTeacher(classId: number, dto: AddTeacherDto) {
|
||||
const cls = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
|
||||
// 任课老师:一个科目一行;同科目已存在则跳过,允许追加新科目
|
||||
if (dto.roleType === 'subject_teacher') {
|
||||
const subjects = resolveTeacherSubjects(dto.subjects, dto.subject);
|
||||
if (subjects.length === 0) {
|
||||
throw new BadRequestException('任课老师至少需要一个科目');
|
||||
}
|
||||
const existing = await this.classTeacherRepo.find({
|
||||
where: { classId, userId: dto.userId, roleType: dto.roleType },
|
||||
});
|
||||
const existingSubjects = new Set(
|
||||
existing
|
||||
.map((row) => row.subject?.trim().toLowerCase())
|
||||
.filter((subject): subject is string => !!subject),
|
||||
);
|
||||
const entries: ClassTeacher[] = [];
|
||||
for (const subject of subjects) {
|
||||
// 大小写不敏感比对:与唯一索引 collation 一致
|
||||
if (existingSubjects.has(subject.toLowerCase())) continue;
|
||||
entries.push(
|
||||
this.classTeacherRepo.create({
|
||||
classId,
|
||||
userId: dto.userId,
|
||||
roleType: dto.roleType,
|
||||
subject,
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 逐行保存:MySQL 多行 INSERT 是原子语句,任意一行撞唯一索引整批失败;
|
||||
// 逐行可让并发下已存在的科目跳过(1062 不中止 InnoDB 事务)、其余科目正常落库;
|
||||
// 非重复错误(断连等)整体回滚,避免部分科目已落库而接口报错。
|
||||
// 返回真实持久化的行。
|
||||
const persisted: ClassTeacher[] = [];
|
||||
if (entries.length) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
persisted.push(await manager.save(entry));
|
||||
} catch (error) {
|
||||
// 并发添加同一科目时唯一索引冲突:该行已存在,跳过即可
|
||||
if (!isDuplicateEntryError(error)) throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await this.syncClassTeacherIds(classId);
|
||||
return persisted;
|
||||
}
|
||||
|
||||
// 班主任/生活老师/学服老师:单行无科目(subject 占位空串 '')。
|
||||
// 先查重快速失败;并发下两个请求都可能通过查重,由 4 列唯一索引
|
||||
// (class_id,user_id,role_type,subject) 兜底,save 捕获 1062 转 400
|
||||
// (TypeORM 0.3.x 无事务时不可用悲观锁,故不依赖锁串行化)。
|
||||
const existing = await this.classTeacherRepo.findOne({
|
||||
where: { classId, userId: dto.userId, roleType: dto.roleType },
|
||||
});
|
||||
@@ -368,12 +515,17 @@ export class ClassesService {
|
||||
classId,
|
||||
userId: dto.userId,
|
||||
roleType: dto.roleType,
|
||||
subject: dto.subject,
|
||||
subject: '',
|
||||
});
|
||||
await this.classTeacherRepo.save(entry);
|
||||
try {
|
||||
await this.classTeacherRepo.save(entry);
|
||||
} catch (error) {
|
||||
if (!isDuplicateEntryError(error)) throw error;
|
||||
throw new BadRequestException('该教师已分配此角色');
|
||||
}
|
||||
|
||||
await this.syncClassTeacherIds(classId);
|
||||
return entry;
|
||||
return [entry];
|
||||
}
|
||||
|
||||
async removeTeacher(classId: number, userId: number) {
|
||||
|
||||
325
apps/server/src/classes/classes.teacher-subjects.spec.ts
Normal file
325
apps/server/src/classes/classes.teacher-subjects.spec.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ClassesService, normalizeTeacherSubjects } from './classes.service';
|
||||
import { ClassTeacher, TeacherRoleType } from '../entities';
|
||||
|
||||
function createService(
|
||||
classRepo: Record<string, jest.Mock>,
|
||||
classTeacherRepo: Record<string, jest.Mock>,
|
||||
classStudentRepo: Record<string, jest.Mock> = { find: jest.fn().mockResolvedValue([]) },
|
||||
dataSource: Record<string, unknown> = {},
|
||||
) {
|
||||
const ds = {
|
||||
// 默认把事务回调的 manager 代理到 classTeacherRepo 的 save/create,保持既有断言语义
|
||||
transaction: async (fn: (manager: Record<string, jest.Mock>) => unknown) =>
|
||||
fn({ save: classTeacherRepo.save, create: classTeacherRepo.create }),
|
||||
...dataSource,
|
||||
};
|
||||
return new ClassesService(
|
||||
classRepo as never,
|
||||
classStudentRepo as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
ds as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('normalizeTeacherSubjects', () => {
|
||||
it('trims, deduplicates, and drops empty/oversized subjects', () => {
|
||||
expect(
|
||||
normalizeTeacherSubjects([' 数学 ', '数学', ' ', '英语', 'x'.repeat(31), '语文']),
|
||||
).toEqual(['数学', '英语', '语文']);
|
||||
});
|
||||
|
||||
it('returns an empty array for undefined/null values and caps at 20', () => {
|
||||
expect(normalizeTeacherSubjects(undefined)).toEqual([]);
|
||||
const many = Array.from({ length: 25 }, (_, i) => `科目${i}`);
|
||||
expect(normalizeTeacherSubjects(many)).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassesService — teacher multi-subject (one row per subject)', () => {
|
||||
it('creates one class_teacher row per subject for subject_teacher', async () => {
|
||||
const created: ClassTeacher[] = [];
|
||||
const classTeacherRepo = {
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: created.length + 1, ...value })),
|
||||
save: jest.fn().mockImplementation(async (entries: ClassTeacher | ClassTeacher[]) => {
|
||||
created.push(...(Array.isArray(entries) ? entries : [entries]));
|
||||
return entries;
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const classRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1 }),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const service = createService(classRepo, classTeacherRepo);
|
||||
|
||||
const result = await service.addTeacher(1, {
|
||||
userId: 10,
|
||||
roleType: TeacherRoleType.SUBJECT_TEACHER,
|
||||
subjects: ['数学', '英语', ' 数学 '],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(classTeacherRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ classId: 1, userId: 10, roleType: 'subject_teacher', subject: '数学' }),
|
||||
);
|
||||
expect(classTeacherRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ classId: 1, userId: 10, roleType: 'subject_teacher', subject: '英语' }),
|
||||
);
|
||||
expect(classTeacherRepo.save).toHaveBeenCalledTimes(2);
|
||||
expect(classRepo.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects subject_teacher without any subject', async () => {
|
||||
const classTeacherRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const service = createService(
|
||||
{ findOne: jest.fn().mockResolvedValue({ id: 1 }), update: jest.fn() },
|
||||
classTeacherRepo,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.addTeacher(1, { userId: 10, roleType: TeacherRoleType.SUBJECT_TEACHER }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.addTeacher(1, {
|
||||
userId: 10,
|
||||
roleType: TeacherRoleType.SUBJECT_TEACHER,
|
||||
subjects: [' '],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(classTeacherRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips subjects that already exist and appends new ones', async () => {
|
||||
const created: ClassTeacher[] = [];
|
||||
const classTeacherRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ id: 1, classId: 1, userId: 10, roleType: 'subject_teacher', subject: '数学' },
|
||||
])
|
||||
.mockResolvedValue([]),
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 2, ...value })),
|
||||
save: jest.fn().mockImplementation(async (entries: ClassTeacher | ClassTeacher[]) => {
|
||||
created.push(...(Array.isArray(entries) ? entries : [entries]));
|
||||
return entries;
|
||||
}),
|
||||
};
|
||||
const service = createService(
|
||||
{ findOne: jest.fn().mockResolvedValue({ id: 1 }), update: jest.fn() },
|
||||
classTeacherRepo,
|
||||
);
|
||||
|
||||
const result = await service.addTeacher(1, {
|
||||
userId: 10,
|
||||
roleType: TeacherRoleType.SUBJECT_TEACHER,
|
||||
subjects: ['数学', '物理'],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].subject).toBe('物理');
|
||||
expect(created.map((row) => row.subject)).toEqual(['物理']);
|
||||
});
|
||||
|
||||
it('keeps non-subject roles as a single row with empty-string subject and rejects duplicate roles', async () => {
|
||||
const classTeacherRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 3, ...value })),
|
||||
save: jest.fn().mockResolvedValue(undefined),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const classRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }), update: jest.fn() };
|
||||
const service = createService(classRepo, classTeacherRepo);
|
||||
|
||||
const result = await service.addTeacher(1, {
|
||||
userId: 11,
|
||||
roleType: TeacherRoleType.HEAD_TEACHER,
|
||||
subjects: ['数学'],
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].subject).toBe('');
|
||||
expect(classTeacherRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ classId: 1, userId: 11, roleType: 'head_teacher', subject: '' }),
|
||||
);
|
||||
|
||||
classTeacherRepo.findOne.mockResolvedValue({ id: 3 });
|
||||
await expect(
|
||||
service.addTeacher(1, { userId: 11, roleType: TeacherRoleType.HEAD_TEACHER }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('returns getTeachers rows as one row per subject', async () => {
|
||||
const rows = [
|
||||
{ id: 1, classId: 5, userId: 10, roleType: 'subject_teacher', subject: '数学', user: { id: 10 } },
|
||||
{ id: 2, classId: 5, userId: 10, roleType: 'subject_teacher', subject: '英语', user: { id: 10 } },
|
||||
{ id: 3, classId: 5, userId: 11, roleType: 'head_teacher', subject: null, user: { id: 11 } },
|
||||
];
|
||||
const service = createService({}, { find: jest.fn().mockResolvedValue(rows) });
|
||||
|
||||
const result = await service.getTeachers(5);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
userId: 10,
|
||||
roleType: 'subject_teacher',
|
||||
subject: '数学',
|
||||
subjects: ['数学'],
|
||||
}),
|
||||
);
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
userId: 10,
|
||||
roleType: 'subject_teacher',
|
||||
subject: '英语',
|
||||
subjects: ['英语'],
|
||||
}),
|
||||
);
|
||||
expect(result[2]).toEqual(
|
||||
expect.objectContaining({ userId: 11, roleType: 'head_teacher', subject: null, subjects: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns findOne teachers as one row per subject', async () => {
|
||||
const classRepo = {
|
||||
findOne: jest.fn().mockResolvedValueOnce({ id: 5 }).mockResolvedValue({ id: 5 }),
|
||||
};
|
||||
const classStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const classTeacherRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ id: 1, classId: 5, userId: 10, roleType: 'subject_teacher', subject: '数学', user: { name: '张老师', username: 'zhang' } },
|
||||
{ id: 2, classId: 5, userId: 10, roleType: 'subject_teacher', subject: '英语', user: { name: '张老师', username: 'zhang' } },
|
||||
]),
|
||||
};
|
||||
const service = createService(classRepo, classTeacherRepo, classStudentRepo);
|
||||
|
||||
const detail = await service.findOne(5);
|
||||
|
||||
expect(detail.teachers).toEqual([
|
||||
expect.objectContaining({
|
||||
userId: 10,
|
||||
name: '张老师',
|
||||
username: 'zhang',
|
||||
roleType: 'subject_teacher',
|
||||
subject: '数学',
|
||||
subjects: ['数学'],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
userId: 10,
|
||||
name: '张老师',
|
||||
username: 'zhang',
|
||||
roleType: 'subject_teacher',
|
||||
subject: '英语',
|
||||
subjects: ['英语'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands create teachers into one row per subject', async () => {
|
||||
const created: ClassTeacher[] = [];
|
||||
const classRepo = {
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 9, ...value })),
|
||||
save: jest.fn().mockResolvedValue({ id: 9 }),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 9 }),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const classTeacherRepo = {
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: created.length + 1, ...value })),
|
||||
save: jest.fn().mockImplementation(async (entries: ClassTeacher | ClassTeacher[]) => {
|
||||
created.push(...(Array.isArray(entries) ? entries : [entries]));
|
||||
return entries;
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const classStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const service = createService(classRepo, classTeacherRepo, classStudentRepo);
|
||||
|
||||
await service.create({
|
||||
name: '高三1班',
|
||||
code: 'G3-1',
|
||||
classType: 'culture',
|
||||
teachers: [
|
||||
{ userId: 10, roleType: TeacherRoleType.SUBJECT_TEACHER, subjects: ['数学', '英语'] },
|
||||
{ userId: 11, roleType: TeacherRoleType.HEAD_TEACHER, subjects: ['不应存储'] },
|
||||
],
|
||||
});
|
||||
|
||||
expect(created.map((row) => ({ userId: row.userId, roleType: row.roleType, subject: row.subject }))).toEqual([
|
||||
{ userId: 10, roleType: 'subject_teacher', subject: '数学' },
|
||||
{ userId: 10, roleType: 'subject_teacher', subject: '英语' },
|
||||
{ userId: 11, roleType: 'head_teacher', subject: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects create with subject_teacher missing subjects', async () => {
|
||||
const classRepo = {
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 9, ...value })),
|
||||
save: jest.fn().mockResolvedValue({ id: 9 }),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 9 }),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const classTeacherRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
};
|
||||
const service = createService(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) });
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
name: '高三1班',
|
||||
code: 'G3-1',
|
||||
classType: 'culture',
|
||||
teachers: [{ userId: 10, roleType: TeacherRoleType.SUBJECT_TEACHER }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// 预校验发生在写班级之前,避免 400 留下孤儿班级
|
||||
expect(classRepo.save).not.toHaveBeenCalled();
|
||||
expect(classTeacherRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to subject when subjects is empty or whitespace-only in create', async () => {
|
||||
const created: ClassTeacher[] = [];
|
||||
const classRepo = {
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: 9, ...value })),
|
||||
save: jest.fn().mockResolvedValue({ id: 9 }),
|
||||
findOne: jest.fn().mockResolvedValue({ id: 9 }),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const classTeacherRepo = {
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassTeacher>) => ({ id: created.length + 1, ...value })),
|
||||
save: jest.fn().mockImplementation(async (entries: ClassTeacher | ClassTeacher[]) => {
|
||||
created.push(...(Array.isArray(entries) ? entries : [entries]));
|
||||
return entries;
|
||||
}),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = createService(classRepo, classTeacherRepo, { find: jest.fn().mockResolvedValue([]) });
|
||||
|
||||
await service.create({
|
||||
name: '高三1班',
|
||||
code: 'G3-1',
|
||||
classType: 'culture',
|
||||
teachers: [
|
||||
{ userId: 10, roleType: TeacherRoleType.SUBJECT_TEACHER, subjects: [' '], subject: '数学' },
|
||||
{ userId: 11, roleType: TeacherRoleType.SUBJECT_TEACHER, subjects: [], subject: '英语' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(created.map((row) => row.subject)).toEqual(['数学', '英语']);
|
||||
});
|
||||
});
|
||||
@@ -7,21 +7,80 @@ import {
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
ArrayNotEmpty,
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
Min,
|
||||
IsBoolean,
|
||||
ValidationArguments,
|
||||
ValidationOptions,
|
||||
registerDecorator,
|
||||
} from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
|
||||
import { CLASS_ROSTER_MAX_ROWS } from '../class-roster-import';
|
||||
|
||||
/** 与 classes.service.ts 的 MAX_TEACHER_SUBJECTS 保持一致的服务端单次科目数上限。 */
|
||||
export const MAX_TEACHER_SUBJECTS = 20;
|
||||
|
||||
/** 单个科目名长度上限(与实体列 varchar(50) 及 service 归一化截断一致)。 */
|
||||
export const MAX_SUBJECT_LENGTH = 30;
|
||||
|
||||
/**
|
||||
* 任课老师(subject_teacher)必须提供 subject 或 subjects 至少一个(与 service 层约束一致,
|
||||
* 前置到 DTO 校验直接返回 400)。挂在 roleType 上以确保校验必然执行。
|
||||
*/
|
||||
function ValidateSubjectTeacherHasSubject(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'validateSubjectTeacherHasSubject',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(_value: unknown, args: ValidationArguments) {
|
||||
const dto = args.object as { roleType?: string; subjects?: string[]; subject?: string };
|
||||
if (dto.roleType !== TeacherRoleType.SUBJECT_TEACHER) return true;
|
||||
// subjects/subject 尚未经 @IsString 校验(校验器挂在先声明的 roleType 上),
|
||||
// 非字符串元素(如 [123])需先按类型过滤,避免 trim 抛 TypeError 变 500
|
||||
const subjects = Array.isArray(dto.subjects)
|
||||
? dto.subjects
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return (
|
||||
subjects.length > 0 || (typeof dto.subject === 'string' && Boolean(dto.subject.trim()))
|
||||
);
|
||||
},
|
||||
defaultMessage() {
|
||||
return '任课老师至少需要一个科目';
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class ClassTeacherItemDto {
|
||||
@IsInt()
|
||||
userId: number;
|
||||
|
||||
@IsEnum(TeacherRoleType)
|
||||
@ValidateSubjectTeacherHasSubject()
|
||||
roleType: string;
|
||||
|
||||
/** 多科目(新字段,优先于 subject) */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_TEACHER_SUBJECTS)
|
||||
@MaxLength(MAX_SUBJECT_LENGTH, { each: true })
|
||||
@IsString({ each: true })
|
||||
subjects?: string[];
|
||||
|
||||
/** 兼容旧调用:单个科目 */
|
||||
@IsOptional()
|
||||
@MaxLength(MAX_SUBJECT_LENGTH)
|
||||
@IsString()
|
||||
subject?: string;
|
||||
}
|
||||
@@ -176,9 +235,20 @@ export class AddTeacherDto {
|
||||
userId: number;
|
||||
|
||||
@IsEnum(TeacherRoleType)
|
||||
@ValidateSubjectTeacherHasSubject()
|
||||
roleType: string;
|
||||
|
||||
/** 多科目(新字段,优先于 subject) */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(MAX_TEACHER_SUBJECTS)
|
||||
@MaxLength(MAX_SUBJECT_LENGTH, { each: true })
|
||||
@IsString({ each: true })
|
||||
subjects?: string[];
|
||||
|
||||
/** 兼容旧调用:单个科目 */
|
||||
@IsOptional()
|
||||
@MaxLength(MAX_SUBJECT_LENGTH)
|
||||
@IsString()
|
||||
subject?: string;
|
||||
}
|
||||
@@ -223,3 +293,72 @@ export class ImportUserItem {
|
||||
@IsString()
|
||||
mobile?: string;
|
||||
}
|
||||
|
||||
export class RosterCreateRowDto {
|
||||
// 姓名允许缺省:缺少姓名的行由服务端按冲突跳过,而不是让整批请求 400
|
||||
@IsOptional()
|
||||
@MaxLength(50)
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@MaxLength(20)
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@MaxLength(30)
|
||||
@IsString()
|
||||
idNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@MaxLength(30)
|
||||
@IsString()
|
||||
studentNo?: string;
|
||||
}
|
||||
|
||||
export class CommitRosterImportDto {
|
||||
// 与文件解析上限保持一致,防止绕过上传直接提交超大数组;
|
||||
// 两个字段合计不得超过文件解析行数上限(单个 2000 行文件拆分提交会翻倍)
|
||||
@ValidateRosterCommitLimits()
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(CLASS_ROSTER_MAX_ROWS)
|
||||
@IsInt({ each: true })
|
||||
@Min(1, { each: true })
|
||||
addStudentIds?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(CLASS_ROSTER_MAX_ROWS)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RosterCreateRowDto)
|
||||
createRows?: RosterCreateRowDto[];
|
||||
}
|
||||
|
||||
/** 校验 addStudentIds/createRows 至少一项且合计不超过文件解析上限。 */
|
||||
function ValidateRosterCommitLimits(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'validateRosterCommitLimits',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(_value: unknown, args: ValidationArguments) {
|
||||
const dto = args.object as {
|
||||
addStudentIds?: number[];
|
||||
createRows?: RosterCreateRowDto[];
|
||||
};
|
||||
const total = (dto.addStudentIds?.length ?? 0) + (dto.createRows?.length ?? 0);
|
||||
return total > 0 && total <= CLASS_ROSTER_MAX_ROWS;
|
||||
},
|
||||
defaultMessage() {
|
||||
return `合计提交行数不能超过 ${CLASS_ROSTER_MAX_ROWS} 行`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,12 @@ export enum TeacherRoleType {
|
||||
}
|
||||
|
||||
@Entity('class_teacher')
|
||||
@Unique(['classId', 'userId', 'roleType'])
|
||||
@Unique('IDX_class_teacher_class_user_role_subject', [
|
||||
'classId',
|
||||
'userId',
|
||||
'roleType',
|
||||
'subject',
|
||||
])
|
||||
export class ClassTeacher {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
@@ -40,7 +45,7 @@ export class ClassTeacher {
|
||||
@Column({ name: 'role_type', length: 30 })
|
||||
roleType: string;
|
||||
|
||||
@Column({ name: 'subject', length: 50, nullable: true })
|
||||
@Column({ name: 'subject', type: 'varchar', length: 50, default: '' })
|
||||
subject: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
|
||||
Reference in New Issue
Block a user