Refactor: reduce detekt findings (unused params, throws, long methods, config)

This commit is contained in:
2026-09-27 21:54:00 +03:00
parent f3e8b2668f
commit b7d96d2ea4
13 changed files with 188 additions and 119 deletions

View File

@@ -37,6 +37,16 @@ style:
active: true active: true
LoopWithTooManyJumpStatements: LoopWithTooManyJumpStatements:
maxJumpCount: 2 maxJumpCount: 2
# MVC-контроллеры возвращают имя шаблона — это не «магическая константа».
FunctionOnlyReturningConstant:
ignoreAnnotated:
- 'GetMapping'
- 'PostMapping'
performance:
# runApplication<App>(*args) — идиоматичная точка входа Spring Boot.
SpreadOperator:
active: false
naming: naming:
FunctionNaming: FunctionNaming:
@@ -44,6 +54,9 @@ naming:
- 'Test' - 'Test'
MatchingDeclarationName: MatchingDeclarationName:
active: false active: false
# Enum-значения хранятся в БД и API строками в lowerCamelCase.
EnumNaming:
active: false
empty-blocks: empty-blocks:
EmptyCatchBlock: EmptyCatchBlock:

View File

@@ -134,7 +134,35 @@ class MarkdownRenderer(
private fun buildSanitizer(): PolicyFactory = private fun buildSanitizer(): PolicyFactory =
HtmlPolicyBuilder() HtmlPolicyBuilder()
.allowElements( .allowElements(*ALLOWED_ELEMENTS)
.allowAttributes("href")
.onElements("a")
.allowAttributes("title")
.onElements("a", "img")
.allowAttributes("src", "alt")
.onElements("img")
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
.onElements("video")
.allowAttributes("src", "type")
.onElements("source")
.allowAttributes("src", "kind", "label", "srclang", "default")
.onElements("track")
.allowAttributes("class")
.onElements(*CLASS_ATTR_ELEMENTS)
.allowAttributes("id")
.onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("colspan", "rowspan")
.onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory()
private companion object {
const val MAX_DEPTH = 5
/** Теги, разрешённые в отрендеренном HTML (allow-list санитайзера). */
val ALLOWED_ELEMENTS =
arrayOf(
"p", "p",
"br", "br",
"hr", "hr",
@@ -177,20 +205,11 @@ class MarkdownRenderer(
"source", "source",
"track", "track",
"audio", "audio",
).allowAttributes("href") )
.onElements("a")
.allowAttributes("title") /** Элементы, которым разрешён атрибут class (подсветка кода, фигуры и т.п.). */
.onElements("a", "img") val CLASS_ATTR_ELEMENTS =
.allowAttributes("src", "alt") arrayOf(
.onElements("img")
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
.onElements("video")
.allowAttributes("src", "type")
.onElements("source")
.allowAttributes("src", "kind", "label", "srclang", "default")
.onElements("track")
.allowAttributes("class")
.onElements(
"code", "code",
"pre", "pre",
"div", "div",
@@ -204,16 +223,7 @@ class MarkdownRenderer(
"figcaption", "figcaption",
"video", "video",
"img", "img",
).allowAttributes("id") )
.onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("colspan", "rowspan")
.onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory()
private companion object {
const val MAX_DEPTH = 5
val MEDIA = val MEDIA =
Regex( Regex(
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$", pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",

View File

@@ -33,91 +33,111 @@ class LabImportParser {
} }
private fun parseZip(bytes: ByteArray): LabImportBundle { private fun parseZip(bytes: ByteArray): LabImportBundle {
val entries = readEntries(bytes)
val meta = readMeta(entries)
val readmeMd = readReadme(entries)
val variants = mutableListOf<VariantMeta>()
val assets = mutableMapOf<String, ByteArray>()
for ((path, data) in entries) {
val normalized = path.replace('\\', '/')
when {
isInDir(normalized, "variants") ->
parseVariantFile(normalized, String(data, Charsets.UTF_8), meta.levels)?.let(variants::add)
isInDir(normalized, "assets") -> assets[normalized] = data
}
}
if (variants.isEmpty()) {
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
}
return LabImportBundle(meta, readmeMd, variants, assets)
}
private fun readEntries(bytes: ByteArray): Map<String, ByteArray> {
val entries = mutableMapOf<String, ByteArray>() val entries = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis -> ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry var entry = zis.nextEntry
while (entry != null) { while (entry != null) {
if (!entry.isDirectory) { if (!entry.isDirectory) entries[entry.name] = zis.readAllBytes()
entries[entry.name] = zis.readAllBytes()
}
entry = zis.nextEntry entry = zis.nextEntry
} }
} }
return entries
}
private fun readMeta(entries: Map<String, ByteArray>): LabMeta {
val metaEntry = val metaEntry =
entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") } entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml") ?: throw BadRequestException("В архиве не найден lab.yaml")
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java) return yaml.readValue(metaEntry.value, LabMeta::class.java)
val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
val readmeMd = readmeEntry?.let { String(it.value, Charsets.UTF_8) } ?: ""
val variants = mutableListOf<VariantMeta>()
val assets = mutableMapOf<String, ByteArray>()
for ((path, data) in entries) {
val p = path.replace('\\', '/')
when {
p.contains("/variants/") || p.startsWith("variants/") -> {
val content = String(data, Charsets.UTF_8)
val variant = parseVariantFile(p, content, meta.levels)
if (variant != null) variants.add(variant)
}
p.contains("/assets/") || p.startsWith("assets/") -> {
assets[p] = data
}
}
} }
if (variants.isEmpty()) { private fun readReadme(entries: Map<String, ByteArray>): String =
throw BadRequestException("В архиве не найдены варианты (папка variants/)") entries.entries
} .firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
?.let { String(it.value, Charsets.UTF_8) }
?: ""
return LabImportBundle(meta, readmeMd, variants, assets) private fun isInDir(
} path: String,
dir: String,
): Boolean = path.contains("/$dir/") || path.startsWith("$dir/")
private fun parseVariantFile( private fun parseVariantFile(
path: String, path: String,
content: String, content: String,
levels: List<LabLevelMeta>, levels: List<LabLevelMeta>,
): VariantMeta? { ): VariantMeta? {
val levelMap = levels.associateBy { it.code }
val lines = content.lines() val lines = content.lines()
val firstSep = lines.indexOfFirst { it.trim() == "---" } val frontMatter =
if (firstSep != 0) { readFrontMatter(lines)
return VariantMeta( ?: return VariantMeta(
level = "basic", level = "basic",
number = path.substringAfterLast("/").removeSuffix(".md"), number = path.substringAfterLast("/").removeSuffix(".md"),
title = "", title = "",
statement = content.trim(), statement = content.trim(),
) )
} val levelCode = normalizeLevel(frontMatter.values["level"] ?: "basic")
val secondSep = lines.withIndex().firstOrNull { it.index != firstSep && it.value.trim() == "---" }?.index ?: -1 val number = frontMatter.values["number"]?.takeIf { it.isNotBlank() }
if (secondSep < 0) return null if (levels.none { it.code == levelCode } || number == null) return null
val fmLines = lines.subList(1, secondSep) val body = lines.drop(frontMatter.bodyStart).joinToString("\n").trim()
val fm = mutableMapOf<String, String>()
for (line in fmLines) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) fm[parts[0].trim()] = unquote(parts[1].trim())
}
val levelCode = normalizeLevel(fm["level"] ?: "basic")
if (levelMap[levelCode] == null) return null
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
return VariantMeta( return VariantMeta(
level = levelCode, level = levelCode,
number = fm["number"]?.takeIf { it.isNotBlank() } ?: return null, number = number,
title = fm["title"] ?: "", title = frontMatter.values["title"] ?: "",
statement = body.ifBlank { "—" }, statement = body.ifBlank { "—" },
) )
} }
private fun unquote(value: String): String = private data class FrontMatter(
if (value.length >= 2 && val values: Map<String, String>,
((value.first() == '"' && value.last() == '"') || (value.first() == '\'' && value.last() == '\'')) val bodyStart: Int,
) { )
value.substring(1, value.length - 1)
} else { /** Читает YAML-front-matter (между двумя `---`), иначе возвращает null. */
value private fun readFrontMatter(lines: List<String>): FrontMatter? {
if (lines.firstOrNull()?.trim() != "---") return null
val end =
lines
.withIndex()
.drop(1)
.firstOrNull { it.value.trim() == "---" }
?.index ?: return null
val values = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) values[parts[0].trim()] = unquote(parts[1].trim())
}
return FrontMatter(values, end + 1)
}
private fun unquote(value: String): String = if (isQuoted(value)) value.substring(1, value.length - 1) else value
private fun isQuoted(value: String): Boolean {
if (value.length < 2) return false
val first = value.first()
val last = value.last()
return (first == '"' && last == '"') || (first == '\'' && last == '\'')
} }
private fun normalizeLevel(s: String): String = private fun normalizeLevel(s: String): String =

View File

@@ -26,7 +26,6 @@ class ProgressController(
private val service: ProgressService, private val service: ProgressService,
private val publications: GradePublicationService, private val publications: GradePublicationService,
private val submissions: SubmissionService, private val submissions: SubmissionService,
private val academic: AcademicClient,
) { ) {
@GetMapping("/me/assignments") @GetMapping("/me/assignments")
fun meAssignments( fun meAssignments(
@@ -39,9 +38,7 @@ class ProgressController(
} }
@GetMapping("/me/labs") @GetMapping("/me/labs")
fun meLabs( fun meLabs(): List<Map<String, Any>> = listOf()
@AuthenticationPrincipal jwt: Jwt,
): List<Map<String, Any>> = listOf()
@PostMapping("/levels") @PostMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")

View File

@@ -25,20 +25,27 @@ class SubmissionService(
files: List<MultipartFile>, files: List<MultipartFile>,
token: String, token: String,
): SubmissionDto { ): SubmissionDto {
val payload = validatedPayload(files)
ensureDeadline(requireLab(labId, token))
val seq = nextSeq(studentId, labId)
val stored = storage.store(studentId, labId, seq, payload)
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
return toDto(entity, stored.files)
}
private fun validatedPayload(files: List<MultipartFile>): List<Pair<String, ByteArray>> {
if (files.isEmpty() || files.all { it.isEmpty }) { if (files.isEmpty() || files.all { it.isEmpty }) {
throw BadRequestException("Приложите файлы или архив") throw BadRequestException("Приложите файлы или архив")
} }
val lab = return files
runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull() .mapIndexed { i, f -> (f.originalFilename ?: "file$i") to f.bytes }
?: throw NotFoundException("Лабораторная не найдена") .onEach { (name, bytes) -> rejectProjectFile(name, bytes) }
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
if (deadline != null && Instant.now().isAfter(deadline)) {
throw BadRequestException("Срок сдачи истёк")
} }
val last = submissions.findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
val seq = (last?.seq ?: 0) + 1 private fun rejectProjectFile(
val payload = files.mapIndexed { i, f -> (f.originalFilename ?: "file$i") to f.bytes } name: String,
payload.forEach { (name, bytes) -> bytes: ByteArray,
) {
if (looksLikeProjectFile(name, bytes)) { if (looksLikeProjectFile(name, bytes)) {
throw BadRequestException( throw BadRequestException(
"Файл «$name» похож на файл проекта (XML), а не на исходник. " + "Файл «$name» похож на файл проекта (XML), а не на исходник. " +
@@ -46,10 +53,25 @@ class SubmissionService(
) )
} }
} }
val stored = storage.store(studentId, labId, seq, payload)
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq)) private fun requireLab(
return toDto(entity, stored.files) labId: UUID,
token: String,
): LabInfo =
runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
?: throw NotFoundException("Лабораторная не найдена")
private fun ensureDeadline(lab: LabInfo) {
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
if (deadline != null && Instant.now().isAfter(deadline)) {
throw BadRequestException("Срок сдачи истёк")
} }
}
private fun nextSeq(
studentId: UUID,
labId: UUID,
): Int = (submissions.findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)?.seq ?: 0) + 1
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun mySubmissions( fun mySubmissions(

View File

@@ -21,4 +21,8 @@ class GradePublication(
data class GradePublicationKey( data class GradePublicationKey(
val groupId: UUID = UUID(0, 0), val groupId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0), val labId: UUID = UUID(0, 0),
) : Serializable ) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

View File

@@ -23,4 +23,8 @@ class LabGrade(
data class LabGradeKey( data class LabGradeKey(
val studentId: UUID = UUID(0, 0), val studentId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0), val labId: UUID = UUID(0, 0),
) : Serializable ) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

View File

@@ -10,7 +10,6 @@ import org.springframework.mock.web.MockMultipartFile
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.Submission import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository import ru.ysu.lms.progress.repo.SubmissionRepository
import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.util.UUID import java.util.UUID
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream

View File

@@ -24,7 +24,7 @@ class ContentAdminController(
private val api: ApiClient, private val api: ApiClient,
) { ) {
@GetMapping @GetMapping
fun index(session: HttpSession): String = "redirect:/learn" fun index(): String = "redirect:/learn"
@PostMapping("/import") @PostMapping("/import")
fun import( fun import(

View File

@@ -228,7 +228,7 @@ class ApiClient(
private fun <T> callOrNull(block: () -> T?): T? = private fun <T> callOrNull(block: () -> T?): T? =
try { try {
block() block()
} catch (ex: RestClientResponseException) { } catch (ignore: RestClientResponseException) {
null null
} }
@@ -239,7 +239,7 @@ class ApiClient(
throw ex throw ex
} catch (ex: RestClientResponseException) { } catch (ex: RestClientResponseException) {
throw ApiException(ex.statusCode.value(), detail(ex)) throw ApiException(ex.statusCode.value(), detail(ex))
} catch (ex: RestClientException) { } catch (ignore: RestClientException) {
throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.") throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.")
} }

View File

@@ -55,7 +55,7 @@ class AuthPageController(
} }
@GetMapping("/setup") @GetMapping("/setup")
fun setupPage(model: Model): String { fun setupPage(): String {
if (api.adminExists()) { if (api.adminExists()) {
return "redirect:/login" return "redirect:/login"
} }

View File

@@ -110,7 +110,7 @@ class LearnController(
val a: ContentArticleDto = val a: ContentArticleDto =
try { try {
api.contentArticle(token(session), chapter, section, article, all = admin) api.contentArticle(token(session), chapter, section, article, all = admin)
} catch (ex: ApiException) { } catch (ignore: ApiException) {
return "redirect:/learn" return "redirect:/learn"
} }

View File

@@ -113,7 +113,7 @@ class StudentController(
val submittedOrDefending = defense != null && (defense.status == "waiting" || defense.assignmentMd.isNotBlank()) val submittedOrDefending = defense != null && (defense.status == "waiting" || defense.assignmentMd.isNotBlank())
model.addAttribute("canSubmit", canSubmit(lab.deadlineAt) && !submittedOrDefending) model.addAttribute("canSubmit", canSubmit(lab.deadlineAt) && !submittedOrDefending)
"student/lab" "student/lab"
} catch (ex: ApiException) { } catch (ignore: ApiException) {
"redirect:/student/labs" "redirect:/student/labs"
} }
} }