Refactor: extract helpers to satisfy detekt (throws, returns, loops)

This commit is contained in:
2026-09-27 22:00:15 +03:00
parent a821d97ef7
commit 577a2c8f41
7 changed files with 168 additions and 92 deletions

View File

@@ -29,18 +29,8 @@ class AssetStorage(
): StoredAsset { ): StoredAsset {
val ext = originalName.substringAfterLast('.', "").lowercase() val ext = originalName.substringAfterLast('.', "").lowercase()
val isVideo = ext in VIDEO_EXTENSIONS val isVideo = ext in VIDEO_EXTENSIONS
val isImage = ext in IMAGE_EXTENSIONS requireAllowedType(originalName, isVideo, isImage = ext in IMAGE_EXTENSIONS)
if (!isVideo && !isImage) { requireSizeWithinLimit(originalName, bytes.size, isVideo)
throw BadRequestException(
"Недопустимый тип файла: $originalName " +
"(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " +
"видео: ${VIDEO_EXTENSIONS.joinToString(", ")})",
)
}
val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES
if (bytes.size > limit) {
throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)")
}
val publicName = "${UUID.randomUUID()}.$ext" val publicName = "${UUID.randomUUID()}.$ext"
val target = rootDir.resolve(publicName).normalize() val target = rootDir.resolve(publicName).normalize()
if (!target.startsWith(rootDir)) { if (!target.startsWith(rootDir)) {
@@ -50,6 +40,31 @@ class AssetStorage(
return StoredAsset(publicName, target, mimeFor(ext)) return StoredAsset(publicName, target, mimeFor(ext))
} }
private fun requireAllowedType(
originalName: String,
isVideo: Boolean,
isImage: Boolean,
) {
if (!isVideo && !isImage) {
throw BadRequestException(
"Недопустимый тип файла: $originalName " +
"(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " +
"видео: ${VIDEO_EXTENSIONS.joinToString(", ")})",
)
}
}
private fun requireSizeWithinLimit(
originalName: String,
size: Int,
isVideo: Boolean,
) {
val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES
if (size > limit) {
throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)")
}
}
fun read(publicName: String): Pair<Path, String>? { fun read(publicName: String): Pair<Path, String>? {
val clean = publicName.substringAfterLast('/') val clean = publicName.substringAfterLast('/')
val target = rootDir.resolve(clean).normalize() val target = rootDir.resolve(clean).normalize()

View File

@@ -168,12 +168,12 @@ class ContentService(
articleSlug: String, articleSlug: String,
includeHidden: Boolean = false, includeHidden: Boolean = false,
): ContentArticleView { ): ContentArticleView {
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена") val ch = requireFound(chapters.findBySlug(chapterSlug), "Глава не найдена")
if (!includeHidden && !ch.published) throw NotFoundException("Глава скрыта") requireVisible(ch.published, includeHidden, "Глава скрыта")
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден") val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
if (!includeHidden && !sec.published) throw NotFoundException("Раздел скрыт") requireVisible(sec.published, includeHidden, "Раздел скрыт")
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена") val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
if (!includeHidden && !a.published) throw NotFoundException("Статья скрыта") requireVisible(a.published, includeHidden, "Статья скрыта")
return ContentArticleView( return ContentArticleView(
id = a.id.toString(), id = a.id.toString(),
chapterSlug = ch.slug, chapterSlug = ch.slug,
@@ -258,6 +258,19 @@ class ContentService(
} }
} }
private fun <T> requireFound(
value: T?,
message: String,
): T = value ?: throw NotFoundException(message)
private fun requireVisible(
published: Boolean,
includeHidden: Boolean,
message: String,
) {
if (!includeHidden && !published) throw NotFoundException(message)
}
private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull() private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull()
@Transactional @Transactional

View File

@@ -81,20 +81,7 @@ class LabService(
val levelByCode = levelEntities.associateBy { it.code } val levelByCode = levelEntities.associateBy { it.code }
bundle.variants.forEach { vmeta -> saveVariants(lab, levelByCode, bundle.variants)
val level =
levelByCode[vmeta.level]
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
variantsRepo.save(
Variant(
lab = lab,
labLevel = level,
number = vmeta.number,
title = vmeta.title,
statementMd = vmeta.statement,
),
)
}
return LabImportResult( return LabImportResult(
labId = lab.id.toString(), labId = lab.id.toString(),
@@ -248,10 +235,54 @@ class LabService(
return detail(labId) return detail(labId)
} }
@Transactional private fun validateCreate(request: LabCreateRequest) {
fun create(request: LabCreateRequest): LabDetail {
if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым") if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым")
if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным") if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным")
}
private fun saveLevels(
lab: Lab,
levels: List<LevelSaveRequest>,
) {
val levelMetas = levels.ifEmpty { listOf(LevelSaveRequest(code = "basic", title = "Основная группа")) }
levelMetas.forEachIndexed { index, meta ->
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
labLevels.save(
LabLevel(
lab = lab,
code = meta.code.trim(),
title = meta.title.orEmpty().ifBlank { meta.code },
description = meta.description.orEmpty(),
position = meta.position?.takeIf { it > 0 } ?: index + 1,
),
)
}
}
private fun saveVariants(
lab: Lab,
levelByCode: Map<String, LabLevel>,
variantMetas: List<VariantMeta>,
) {
variantMetas.forEach { vmeta ->
val level =
levelByCode[vmeta.level]
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
variantsRepo.save(
Variant(
lab = lab,
labLevel = level,
number = vmeta.number,
title = vmeta.title,
statementMd = vmeta.statement,
),
)
}
}
@Transactional
fun create(request: LabCreateRequest): LabDetail {
validateCreate(request)
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0) val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) { if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре") throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
@@ -269,22 +300,7 @@ class LabService(
status = request.status ?: "draft", status = request.status ?: "draft",
), ),
) )
val levelMetas = saveLevels(lab, request.levels)
request.levels.ifEmpty {
listOf(LevelSaveRequest(code = "basic", title = "Основная группа"))
}
levelMetas.forEachIndexed { index, meta ->
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
labLevels.save(
LabLevel(
lab = lab,
code = meta.code.trim(),
title = meta.title.orEmpty().ifBlank { meta.code },
description = meta.description.orEmpty(),
position = meta.position?.takeIf { it > 0 } ?: index + 1,
),
)
}
return detail(lab.id) return detail(lab.id)
} }

View File

@@ -89,12 +89,13 @@ class GroupService(
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") } groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet() val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet()
val currentByStudent = HashMap<UUID, Pair<UUID, String>>() val currentByStudent = HashMap<UUID, Pair<UUID, String>>()
for (e in enrollments.findAll()) { enrollments
val sid = e.student?.accountId ?: continue .findAll()
val gid = e.group?.id ?: continue .mapNotNull { enrollment ->
val name = e.group?.name ?: continue val sid = enrollment.student?.accountId ?: return@mapNotNull null
currentByStudent[sid] = gid to name val group = enrollment.group ?: return@mapNotNull null
} sid to (group.id to group.name)
}.forEach { (sid, group) -> currentByStudent[sid] = group }
return students return students
.findAll() .findAll()
.filter { it.accountId !in memberIds } .filter { it.accountId !in memberIds }

View File

@@ -92,26 +92,43 @@ class StudentImportParser {
filename: String, filename: String,
rows: List<List<String>>, rows: List<List<String>>,
): ParsedGroup { ): ParsedGroup {
val headerIndex = requireHeader(rows)
val columns = requireColumns(rows[headerIndex])
val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) }
if (students.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента")
}
return ParsedGroup(groupNameFrom(filename, rows), students)
}
private fun requireHeader(rows: List<List<String>>): Int {
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } } val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
if (headerIndex < 0) { if (headerIndex < 0) {
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.") throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
} }
val columns = mapColumns(rows[headerIndex]) return headerIndex
}
private fun requireColumns(header: List<String>): Map<String, Int> {
val columns = mapColumns(header)
if (columns["studentNo"] == null) { if (columns["studentNo"] == null) {
throw BadRequestException("Не найден столбец «Студенческий номер»") throw BadRequestException("Не найден столбец «Студенческий номер»")
} }
val students = return columns
rows.drop(headerIndex + 1).mapNotNull { row -> }
private fun parseStudent(
row: List<String>,
columns: Map<String, Int>,
): ParsedStudent? {
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim() fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
val studentNo = normalizeNumber(cell("studentNo")) val studentNo = normalizeNumber(cell("studentNo"))
val lastName = cell("lastName") val lastName = cell("lastName")
if (studentNo.isBlank() && lastName.isBlank()) { if (studentNo.isBlank() && lastName.isBlank()) return null
return@mapNotNull null
}
if (studentNo.isBlank()) { if (studentNo.isBlank()) {
throw BadRequestException("В одной из строк отсутствует студенческий номер") throw BadRequestException("В одной из строк отсутствует студенческий номер")
} }
ParsedStudent( return ParsedStudent(
studentNo = studentNo, studentNo = studentNo,
lastName = lastName, lastName = lastName,
firstName = cell("firstName"), firstName = cell("firstName"),
@@ -120,11 +137,6 @@ class StudentImportParser {
note = cell("note"), note = cell("note"),
) )
} }
if (students.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента")
}
return ParsedGroup(groupNameFrom(filename, rows), students)
}
private fun isStudentHeader(value: String): Boolean { private fun isStudentHeader(value: String): Boolean {
val normalized = value.lowercase().replace("\u00a0", " ").trim() val normalized = value.lowercase().replace("\u00a0", " ").trim()

View File

@@ -114,16 +114,22 @@ class DefenseService(
code: String, code: String,
): DefenseDto { ): DefenseDto {
syncTimeouts() syncTimeouts()
val s = val s = requireWaitingSession(studentId, code)
sessions.findByCode(code.trim())
?: throw BadRequestException("Код не найден")
if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата")
s.status = DefenseStatus.started.name s.status = DefenseStatus.started.name
s.startedAt = Instant.now() s.startedAt = Instant.now()
return toDto(sessions.save(s)) return toDto(sessions.save(s))
} }
private fun requireWaitingSession(
studentId: UUID,
code: String,
): DefenseSession {
val s = sessions.findByCode(code.trim()) ?: throw BadRequestException("Код не найден")
if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата")
return s
}
@Transactional @Transactional
fun finish( fun finish(
studentId: UUID, studentId: UUID,

View File

@@ -32,6 +32,21 @@ class LearnController(
private fun isAdmin(session: HttpSession): Boolean = (session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true private fun isAdmin(session: HttpSession): Boolean = (session.getAttribute(SessionKeys.USER) as? SessionUser)?.isAdmin == true
private fun treeContainsArticle(
tree: ContentTreeNodeDto,
chapter: String,
section: String,
article: String,
): Boolean {
val sec =
tree.chapters
.firstOrNull { it.slug == chapter }
?.sections
?.firstOrNull { it.slug == section }
?: return false
return sec.articles.any { it.slug == article }
}
private fun sectionChoices(tree: ContentTreeNodeDto): List<Map<String, String>> = private fun sectionChoices(tree: ContentTreeNodeDto): List<Map<String, String>> =
tree.chapters.flatMap { chapter -> tree.chapters.flatMap { chapter ->
chapter.sections.map { section -> chapter.sections.map { section ->
@@ -103,9 +118,7 @@ class LearnController(
): String { ): String {
val admin = isAdmin(session) val admin = isAdmin(session)
val tree: ContentTreeNodeDto = api.contentTree(token(session), all = admin) val tree: ContentTreeNodeDto = api.contentTree(token(session), all = admin)
val ch = tree.chapters.firstOrNull { it.slug == chapter } ?: return "redirect:/learn" if (!treeContainsArticle(tree, chapter, section, article)) return "redirect:/learn"
val sec = ch.sections.firstOrNull { it.slug == section } ?: return "redirect:/learn"
if (sec.articles.none { it.slug == article }) return "redirect:/learn"
val a: ContentArticleDto = val a: ContentArticleDto =
try { try {