diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index e6b5bd26..fb37686a 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -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(); + 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(); + 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) { diff --git a/apps/server/src/classes/classes.teacher-subjects.spec.ts b/apps/server/src/classes/classes.teacher-subjects.spec.ts new file mode 100644 index 00000000..532c16fb --- /dev/null +++ b/apps/server/src/classes/classes.teacher-subjects.spec.ts @@ -0,0 +1,325 @@ +import { BadRequestException } from '@nestjs/common'; +import { ClassesService, normalizeTeacherSubjects } from './classes.service'; +import { ClassTeacher, TeacherRoleType } from '../entities'; + +function createService( + classRepo: Record, + classTeacherRepo: Record, + classStudentRepo: Record = { find: jest.fn().mockResolvedValue([]) }, + dataSource: Record = {}, +) { + const ds = { + // 默认把事务回调的 manager 代理到 classTeacherRepo 的 save/create,保持既有断言语义 + transaction: async (fn: (manager: Record) => 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) => ({ 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) => ({ 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) => ({ 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) => ({ 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) => ({ 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) => ({ 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) => ({ 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) => ({ 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(['数学', '英语']); + }); +}); diff --git a/apps/server/src/classes/dto/class.dto.ts b/apps/server/src/classes/dto/class.dto.ts index df09081f..301d8d63 100644 --- a/apps/server/src/classes/dto/class.dto.ts +++ b/apps/server/src/classes/dto/class.dto.ts @@ -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} 行`; + }, + }, + }); + }; +} diff --git a/apps/server/src/entities/class-teacher.entity.ts b/apps/server/src/entities/class-teacher.entity.ts index b6ad057f..8ef0a57b 100644 --- a/apps/server/src/entities/class-teacher.entity.ts +++ b/apps/server/src/entities/class-teacher.entity.ts @@ -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' })