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:
2026-08-11 11:52:09 +08:00
parent 386c8dd5b3
commit 545d087b3d
4 changed files with 647 additions and 26 deletions

View File

@@ -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) {

View 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(['数学', '英语']);
});
});

View File

@@ -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}`;
},
},
});
};
}

View File

@@ -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' })