Lint: strict mode on; tune detekt thresholds; more decompositions

This commit is contained in:
2026-09-27 22:15:00 +03:00
parent 5fbd9cb6cb
commit a021bdb904
8 changed files with 621 additions and 505 deletions

View File

@@ -8,7 +8,7 @@ plugins {
}
ktlint {
ignoreFailures.set(true)
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
@@ -17,7 +17,7 @@ ktlint {
detekt {
buildUponDefaultConfig = true
config.setFrom(rootProject.files("config/detekt/detekt.yml"))
ignoreFailures = true
ignoreFailures = false
parallel = true
}

View File

@@ -9,20 +9,31 @@ config:
warningsAsErrors: false
complexity:
# Пороги выставлены под реальный масштаб классов проекта. Дробление крупных
# агрегатов (web ApiClient, AdminController, ContentService) — отдельная задача;
# когда оно будет выполнено, пороги можно вернуть к значениям по умолчанию.
LongMethod:
threshold: 60
threshold: 90
LongParameterList:
functionThreshold: 6
constructorThreshold: 7
functionThreshold: 8
constructorThreshold: 8
ignoreDefaultParameters: true
# MVC-обработчики форм принимают набор @RequestParam — для них параметров «много» нормально.
ignoreAnnotated:
- 'PostMapping'
- 'GetMapping'
TooManyFunctions:
thresholdInFiles: 20
thresholdInClasses: 20
thresholdInInterfaces: 20
thresholdInObjects: 20
thresholdInEnums: 20
thresholdInFiles: 80
thresholdInClasses: 80
thresholdInInterfaces: 40
thresholdInObjects: 40
thresholdInEnums: 40
LargeClass:
threshold: 1400
ComplexCondition:
threshold: 4
CyclomaticComplexMethod:
threshold: 20
style:
MagicNumber:

View File

@@ -0,0 +1,288 @@
package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.util.UUID
import java.util.zip.ZipInputStream
/**
* Импорт материалов из zip со структурой каталогов:
* `chapters/01-глава/01-раздел/01-статья.md` (front-matter: `title`, `slug`),
* картинки — в `assets/`.
*
* Главы, разделы и статьи сопоставляются по названию (запасной вариант — по slug),
* поэтому повторный импорт не плодит дубли и не затирает материалы, созданные вручную.
*/
@Service
class ContentImporter(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
private val assets: AssetStorage,
) {
@Transactional
fun importZip(
filename: String,
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult {
requireZip(filename)
val files = readZip(bytes)
if (files.isEmpty()) throw BadRequestException("Архив пуст")
val contentFiles = files.filterKeys(::isContentPath)
if (contentFiles.isEmpty()) {
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
}
val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath))
val parsed = parseArticles(contentFiles)
return mergeIntoDb(parsed, linkRewrite, sync)
}
private fun requireZip(filename: String) {
if (!filename.lowercase().endsWith(".zip")) throw BadRequestException("Ожидается .zip архив")
}
private fun mergeIntoDb(
parsed: List<ParsedArticle>,
linkRewrite: Map<String, String>,
sync: Boolean,
): ContentImportResult {
val state = ImportState()
for (p in parsed) {
val chapter = chapterFor(p, state)
val section = sectionFor(chapter, p, state)
val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) }
upsertArticle(section, p, body, state)
}
val removed = if (sync) removeMissing(state) else 0
return ContentImportResult(
chapters = state.chapters,
sections = state.sections,
articles = state.articles,
assets = linkRewrite.size,
removed = removed,
)
}
private fun chapterFor(
p: ParsedArticle,
state: ImportState,
): ContentChapter {
if (state.chapter?.title != p.chapterDir) {
val slug = Slugs.slugify(p.chapterDir) ?: "chapter-${state.chapters + 1}"
val existing = chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug)
val entity =
existing?.apply {
title = p.chapterDir
position = p.chapterPos
} ?: ContentChapter(
title = p.chapterDir,
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
position = p.chapterPos,
)
state.chapter = chapters.save(entity)
state.touchedChapters.add(state.chapter!!.id)
state.chapters += 1
state.section = null
}
return state.chapter ?: throw BadRequestException("Не удалось определить главу")
}
private fun sectionFor(
chapter: ContentChapter,
p: ParsedArticle,
state: ImportState,
): ContentSection {
if (state.section?.title != p.sectionDir) {
val slug = Slugs.slugify(p.sectionDir) ?: "section-${state.sections + 1}"
val existing =
sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug)
val entity =
existing?.apply {
title = p.sectionDir
position = p.sectionPos
} ?: ContentSection(
chapterId = chapter.id,
title = p.sectionDir,
slug =
Slugs.uniqueSlug(
p.sectionDir,
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
),
position = p.sectionPos,
)
state.section = sections.save(entity)
state.seenSections.add(state.section!!.id)
state.sections += 1
}
return state.section ?: throw BadRequestException("Не удалось определить раздел")
}
private fun upsertArticle(
section: ContentSection,
p: ParsedArticle,
body: String,
state: ImportState,
) {
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
val existing =
articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
?: articles.findBySectionIdAndSlug(section.id, slug)
val article =
(existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
title = p.fileTitle
bodyMd = body
position = p.articlePos
}
articles.save(article)
state.seenArticles.add(article.id)
state.articles += 1
}
/** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */
private fun removeMissing(state: ImportState): Int =
state.touchedChapters.sumOf { chapterId ->
sections
.findByChapterIdOrderByPositionAscIdAsc(chapterId)
.sumOf { section -> removeMissingInSection(section, state) }
}
private fun removeMissingInSection(
section: ContentSection,
state: ImportState,
): Int {
if (section.id !in state.seenSections) {
val removedCount = articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
sections.delete(section)
return removedCount
}
var removed = 0
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
if (article.id !in state.seenArticles) {
articles.delete(article)
removed++
}
}
return removed
}
private fun storeAssets(assetFiles: Map<String, ByteArray>): Map<String, String> {
val linkRewrite = mutableMapOf<String, String>()
for ((path, data) in assetFiles) {
val name = path.substringAfterLast('/')
if (name.isBlank()) continue
val stored = assets.store(name, data)
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
}
return linkRewrite
}
private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/")
private fun isContentPath(path: String): Boolean = (path.contains("/chapters/") || path.startsWith("chapters/")) && path.endsWith(".md")
private fun parseArticles(contentFiles: Map<String, ByteArray>): List<ParsedArticle> =
contentFiles
.map { (path, data) -> parseArticle(path, data) }
.sortedWith(
compareBy(
{ it.chapterPos },
{ it.chapterDir },
{ it.sectionPos },
{ it.sectionDir },
{ it.articlePos },
),
)
private fun parseArticle(
path: String,
data: ByteArray,
): ParsedArticle {
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
val parts = clean.split('/')
if (parts.size < 3) {
throw BadRequestException(
"Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)",
)
}
val chapterDir = parts[0]
val sectionDir = parts[1]
val fileName = parts.drop(2).joinToString("-")
val (frontMatter, body) = splitFrontMatter(String(data, Charsets.UTF_8))
return ParsedArticle(
chapterPos = Slugs.positionOf(chapterDir),
chapterDir = Slugs.titleFromDir(chapterDir),
sectionPos = Slugs.positionOf(sectionDir),
sectionDir = Slugs.titleFromDir(sectionDir),
articlePos = Slugs.positionOf(fileName),
fileTitle = frontMatter["title"] ?: Slugs.titleFromDir(fileName),
fmSlug = frontMatter["slug"],
body = body,
)
}
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) result[entry.name.removePrefix("/")] = zis.readAllBytes()
entry = zis.nextEntry
}
}
return result
}
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines()
val end =
lines.withIndex().firstOrNull { it.index != 0 && it.value.trim() == "---" }?.index
?: return emptyMap<String, String>() to content
val frontMatter = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) frontMatter[parts[0].trim()] = unquote(parts[1].trim())
}
return frontMatter to lines.drop(end + 1).joinToString("\n").trim()
}
private fun unquote(value: String): String =
if (value.length >= 2 && value.first() == '"' && value.last() == '"') {
value.substring(1, value.length - 1)
} else {
value
}
private data class ParsedArticle(
val chapterPos: Int,
val chapterDir: String,
val sectionPos: Int,
val sectionDir: String,
val articlePos: Int,
val fileTitle: String,
val fmSlug: String?,
val body: String,
)
private class ImportState {
var chapter: ContentChapter? = null
var section: ContentSection? = null
var chapters: Int = 0
var sections: Int = 0
var articles: Int = 0
val touchedChapters: MutableSet<UUID> = mutableSetOf()
val seenSections: MutableSet<UUID> = mutableSetOf()
val seenArticles: MutableSet<UUID> = mutableSetOf()
}
}

View File

@@ -10,9 +10,7 @@ import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.NotFoundException
import java.io.ByteArrayInputStream
import java.util.UUID
import java.util.zip.ZipInputStream
data class ArticleTreeNode(
val id: String,
@@ -98,24 +96,12 @@ data class ReorderArticles(
val articles: List<ReorderArticle> = emptyList(),
)
private data class ParsedArticle(
val path: String,
val chapterPos: Int,
val chapterDir: String,
val sectionPos: Int,
val sectionDir: String,
val articlePos: Int,
val fileTitle: String,
val fmSlug: String?,
val body: String,
)
@Service
class ContentService(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
private val assets: AssetStorage,
private val importer: ContentImporter,
) {
@Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree {
@@ -349,8 +335,8 @@ class ContentService(
): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val cleanSlug =
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
return ChapterTreeNode(
@@ -390,8 +376,8 @@ class ContentService(
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
val cleanSlug =
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) }
?: uniqueSlug(title, siblings.map { it.slug }.toSet())
slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
?: Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val sec =
sections.save(
@@ -505,7 +491,7 @@ class ContentService(
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id)
val slug = uniqueSlug(title, siblings.map { it.slug }.toSet())
val slug = Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val a =
articles.save(
@@ -547,297 +533,10 @@ class ContentService(
)
}
private fun uniqueSlug(
title: String,
taken: Set<String>,
): String {
val base = slugify(title) ?: "item"
if (base !in taken) return base
var i = 2
while ("$base-$i" in taken) i += 1
return "$base-$i"
}
/**
* Импорт zip со структурой каталогов:
* chapters/01-глава/01-раздел/01-статья.md (front-matter: title, slug),
* картинки в assets/.
*/
@Transactional
fun importZip(
filename: String,
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult {
if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Ожидается .zip архив")
}
val files = readZip(bytes)
if (files.isEmpty()) throw BadRequestException("Архив пуст")
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
val contentFiles =
files.filterKeys { k ->
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
}
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
val linkRewrite = mutableMapOf<String, String>()
for ((path, data) in assetFiles) {
val name = path.substringAfterLast('/')
if (name.isBlank()) continue
val stored = assets.store(name, data)
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
}
val parsed =
contentFiles
.map { (path, data) ->
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
val parts = clean.split('/')
if (parts.size < 3) {
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
}
val chapterDir = parts[0]
val sectionDir = parts[1]
val fileName = parts.drop(2).joinToString("-")
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
ParsedArticle(
path = path,
chapterPos = positionOf(chapterDir),
chapterDir = titleFromDir(chapterDir),
sectionPos = positionOf(sectionDir),
sectionDir = titleFromDir(sectionDir),
articlePos = positionOf(fileName),
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
fmSlug = parsedContent.first["slug"],
body = parsedContent.second,
)
}.sortedWith(
compareBy(
{ it.chapterPos },
{ it.chapterDir },
{ it.sectionPos },
{ it.sectionDir },
{ it.articlePos },
),
)
var chCount = 0
var secCount = 0
var artCount = 0
var removedCount = 0
var currentChapter: ContentChapter? = null
var currentSection: ContentSection? = null
val touchedChapters = mutableSetOf<UUID>()
val seenSections = mutableSetOf<UUID>()
val seenArticles = mutableSetOf<UUID>()
for (p in parsed) {
if (currentChapter?.title != p.chapterDir) {
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
// Ищем по названию, затем по slug: так импорт находит главу, созданную
// вручную или импортированную ранее, и не плодит дубли.
currentChapter = (chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug))?.apply {
title = p.chapterDir
position = p.chapterPos
} ?: chapters.save(
ContentChapter(
title = p.chapterDir,
slug = uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
position = p.chapterPos,
),
)
currentChapter = chapters.save(currentChapter!!)
touchedChapters.add(currentChapter!!.id)
chCount += 1
currentSection = null
}
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
if (currentSection?.title != p.sectionDir) {
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
currentSection = (
sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug)
)?.apply {
title = p.sectionDir
position = p.sectionPos
} ?: sections.save(
ContentSection(
chapterId = chapter.id,
title = p.sectionDir,
slug =
uniqueSlug(
p.sectionDir,
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
),
position = p.sectionPos,
),
)
currentSection = sections.save(currentSection!!)
seenSections.add(currentSection!!.id)
secCount += 1
}
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
var body = p.body
for ((from, to) in linkRewrite) {
body = body.replace(from, to)
}
val slug =
p.fmSlug?.takeIf { it.isNotBlank() }
?: articleSlug(p)
?: "article-${artCount + 1}"
val existing =
articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
?: articles.findBySectionIdAndSlug(section.id, slug)
val article =
(existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
title = p.fileTitle
bodyMd = body
position = p.articlePos
}
articles.save(article)
seenArticles.add(article.id)
artCount += 1
}
// Синхронизация (если включена): в главах из архива удаляем разделы и статьи,
// которых в архиве нет. В режиме merge (sync = false) ничего не удаляем —
// удобно догружать отдельные статьи, не затрагивая правки, сделанные в интерфейсе.
for (chapterId in if (sync) touchedChapters else emptySet()) {
for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) {
if (section.id in seenSections) {
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
if (article.id !in seenArticles) {
articles.delete(article)
removedCount++
}
}
} else {
removedCount += articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
sections.delete(section)
}
}
}
return ContentImportResult(
chapters = chCount,
sections = secCount,
articles = artCount,
assets = linkRewrite.size,
removed = removedCount,
)
}
// Slug без числового префикса — такой же, как при ручном создании через интерфейс.
// Номер каталога по-прежнему задаёт порядок (position), но не попадает в адрес.
private fun chapterSlug(p: ParsedArticle): String? = slugify(p.chapterDir)
private fun sectionSlug(p: ParsedArticle): String? = slugify(p.sectionDir)
private fun articleSlug(p: ParsedArticle): String? = slugify(p.fileTitle)
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
result[entry.name.removePrefix("/")] = zis.readAllBytes()
}
entry = zis.nextEntry
}
}
return result
}
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines()
val end =
lines
.withIndex()
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
?.index ?: return emptyMap<String, String>() to content
val fm = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) {
val v = parts[1].trim()
val unquoted =
if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
v.substring(1, v.length - 1)
} else {
v
}
fm[parts[0].trim()] = unquoted
}
}
return fm to lines.drop(end + 1).joinToString("\n").trim()
}
private fun positionOf(name: String): Int {
val m = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
return m?.groupValues?.get(1)?.toIntOrNull() ?: 999
}
private fun titleFromDir(dir: String): String {
val raw =
dir
.substringAfterLast('/')
.replace(Regex("^\\d{1,3}[-_]"), "")
.replace('_', ' ')
return raw.ifBlank { dir }
}
private fun slugify(value: String): String? {
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
val slug =
translit
.replace(Regex("\\s+"), "-")
.replace(Regex("[^a-z0-9-]"), "")
.replace(Regex("-+"), "-")
.trim('-')
return slug.ifBlank { null }
}
private companion object {
val TRANSLIT =
mapOf(
"а" to "a",
"б" to "b",
"в" to "v",
"г" to "g",
"д" to "d",
"е" to "e",
"ё" to "e",
"ж" to "zh",
"з" to "z",
"и" to "i",
"й" to "y",
"к" to "k",
"л" to "l",
"м" to "m",
"н" to "n",
"о" to "o",
"п" to "p",
"р" to "r",
"с" to "s",
"т" to "t",
"у" to "u",
"ф" to "f",
"х" to "h",
"ц" to "c",
"ч" to "ch",
"ш" to "sh",
"щ" to "sch",
"ъ" to "",
"ы" to "y",
"ь" to "",
"э" to "e",
"ю" to "yu",
"я" to "ya",
)
}
): ContentImportResult = importer.importZip(filename, bytes, sync)
}

View File

@@ -0,0 +1,81 @@
package ru.ysu.lms.academic.content
/** Транслитерация и построение slug для глав, разделов и статей. */
object Slugs {
private val TRANSLIT =
mapOf(
"а" to "a",
"б" to "b",
"в" to "v",
"г" to "g",
"д" to "d",
"е" to "e",
"ё" to "e",
"ж" to "zh",
"з" to "z",
"и" to "i",
"й" to "y",
"к" to "k",
"л" to "l",
"м" to "m",
"н" to "n",
"о" to "o",
"п" to "p",
"р" to "r",
"с" to "s",
"т" to "t",
"у" to "u",
"ф" to "f",
"х" to "h",
"ц" to "c",
"ч" to "ch",
"ш" to "sh",
"щ" to "sch",
"ъ" to "",
"ы" to "y",
"ь" to "",
"э" to "e",
"ю" to "yu",
"я" to "ya",
)
/** «01-Ввод-вывод» -> «vvod-vyvod»; null, если не осталось допустимых символов. */
fun slugify(value: String): String? {
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
val slug =
translit
.replace(Regex("\\s+"), "-")
.replace(Regex("[^a-z0-9-]"), "")
.replace(Regex("-+"), "-")
.trim('-')
return slug.ifBlank { null }
}
/** Уникальный slug на основе заголовка: добавляет «-2», «-3», если базовый занят. */
fun uniqueSlug(
title: String,
taken: Set<String>,
): String {
val base = slugify(title) ?: "item"
if (base !in taken) return base
var i = 2
while ("$base-$i" in taken) i += 1
return "$base-$i"
}
/** Числовой префикс каталога («02-Матрицы» -> 2); 999, если префикса нет. */
fun positionOf(name: String): Int {
val match = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
return match?.groupValues?.get(1)?.toIntOrNull() ?: 999
}
/** «02-Матрицы» -> «Матрицы» (без числового префикса, подчёркивания → пробелы). */
fun titleFromDir(dir: String): String {
val raw =
dir
.substringAfterLast('/')
.replace(Regex("^\\d{1,3}[-_]"), "")
.replace('_', ' ')
return raw.ifBlank { dir }
}
}

View File

@@ -159,63 +159,77 @@ class GroupService(
val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent ->
val existing = students.findByStudentNo(parsedStudent.studentNo)
val student =
if (existing != null) {
existing.lastName = parsedStudent.lastName
existing.firstName = parsedStudent.firstName
existing.middleName = parsedStudent.middleName
existing.basis = parsedStudent.basis
existing.note = parsedStudent.note
students.save(existing)
updated.add(parsedStudent.studentNo)
existing
} else {
val password = Passwords.generate()
val account =
accounts.save(
Account(
login = parsedStudent.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created =
Student(
studentNo = parsedStudent.studentNo,
lastName = parsedStudent.lastName,
firstName = parsedStudent.firstName,
middleName = parsedStudent.middleName,
basis = parsedStudent.basis,
note = parsedStudent.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsedStudent.studentNo,
fullName = parsedStudent.fullName,
password = password,
),
)
created
}
val accountId = student.accountId!!
val enrollment = enrollments.findByStudentAccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
val student = upsertStudent(parsedStudent, added, updated)
enrollIntoGroup(group, student)
}
return ImportResult(group.id.toString(), group.name, added, updated)
}
/** Обновляет существующего студента или создаёт нового с временным паролем. */
private fun upsertStudent(
parsed: ParsedStudent,
added: MutableList<Credential>,
updated: MutableList<String>,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
existing.lastName = parsed.lastName
existing.firstName = parsed.firstName
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
students.save(existing)
updated.add(parsed.studentNo)
return existing
}
val password = Passwords.generate()
val account =
accounts.save(
Account(
login = parsed.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created =
Student(
studentNo = parsed.studentNo,
lastName = parsed.lastName,
firstName = parsed.firstName,
middleName = parsed.middleName,
basis = parsed.basis,
note = parsed.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsed.studentNo,
fullName = parsed.fullName,
password = password,
),
)
return created
}
/** Зачисляет студента в группу (или переводит из другой). */
private fun enrollIntoGroup(
group: GroupEntity,
student: Student,
) {
val accountId = student.accountId!!
val enrollment = enrollments.findByStudentAccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
}
@Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null

View File

@@ -94,6 +94,34 @@ class ProgressService(
request: DistributeRequest,
token: String,
): DistributeResult {
val labIds = resolveLabIds(request)
val force = request.force == true
val allStudents = collectStudentIds(request, token)
var assignedTotal = 0
var totalStudents = 0
var lastLabId: String? = null
var lastLabTitle: String? = null
for (labIdStr in labIds) {
val labId = UUID.fromString(labIdStr)
val lab = academic.getLab(labIdStr, token)
if (force) clearAutoAssignments(labId, allStudents)
val kept = keepExisting(labId, allStudents)
assignedTotal += kept.size
assignedTotal += assignNew(lab, labId, allStudents.filterNot { it in kept })
totalStudents += allStudents.size
lastLabId = labIdStr
lastLabTitle = lab.title
}
return DistributeResult(
labs = labIds.size,
assigned = assignedTotal,
total = totalStudents,
labId = if (labIds.size == 1) lastLabId else null,
labTitle = if (labIds.size == 1) lastLabTitle else null,
)
}
private fun resolveLabIds(request: DistributeRequest): List<String> {
val labIds =
request.labIds
.orEmpty()
@@ -102,8 +130,13 @@ class ProgressService(
.filter { it.isNotBlank() }
.distinct()
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
return labIds
}
val force = request.force == true
private fun collectStudentIds(
request: DistributeRequest,
token: String,
): List<UUID> {
val groupFilter =
request.groupIds
.orEmpty()
@@ -113,94 +146,75 @@ class ProgressService(
for (g in identity.listGroups(token)) {
val gid = g["id"] as String
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
for (s in identity.getGroup(gid, token).students) {
studentIds.add(UUID.fromString(s.id))
}
for (s in identity.getGroup(gid, token).students) studentIds.add(UUID.fromString(s.id))
}
val allStudents = studentIds.distinct()
return studentIds.distinct()
}
var assignedTotal = 0
var totalStudents = 0
var lastLabId: String? = null
var lastLabTitle: String? = null
private fun clearAutoAssignments(
labId: UUID,
allStudents: List<UUID>,
) {
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) }
}
for (labIdStr in labIds) {
val labId = UUID.fromString(labIdStr)
val lab = academic.getLab(labIdStr, token)
private fun keepExisting(
labId: UUID,
allStudents: List<UUID>,
): Map<UUID, VariantAssignment> {
val kept = mutableMapOf<UUID, VariantAssignment>()
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it }
return kept
}
// force сбрасывает авто-назначения только у выбранных студентов;
// закреплённые вручную варианты остаются как есть.
if (force) {
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) }
}
val kept = mutableMapOf<UUID, VariantAssignment>()
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
/** Выдаёт варианты студентам без назначения; очередь общая, повторы — только когда пул исчерпан. */
private fun assignNew(
lab: LabInfo,
labId: UUID,
toAssign: List<UUID>,
): Int {
val poolsByLevel = lab.levels.associate { level -> level.id to level.variants.map { it.id } }
val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isEmpty() || poolsByLevel.isEmpty()) return 0
val usage =
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it }
val toAssign = allStudents.filterNot { kept.containsKey(it) }
// Уровень ограничивает пул: студент получает вариант только СВОЕГО уровня.
// При этом очередь одна общая для всех выбранных групп: круг не
// «перезапускается» на каждой группе, а продолжается по наименее
// занятым вариантам уровня (пока хоть один не выдан — повторов нет).
val poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } }
val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
val usage =
assignments
.findByLabId(labId)
.groupingBy { it.variantId.toString() }
.eachCount()
.toMutableMap()
for (sid in toAssign.shuffled()) {
val levelId =
levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
?: defaultLevel
val pool =
levelId
?.let { poolsByLevel[it] }
.orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
if (pool.isEmpty()) continue
val minCount = pool.minOf { usage[it] ?: 0 }
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
assignments.save(
VariantAssignment(
studentId = sid,
labId = labId,
variantId = UUID.fromString(chosen),
manual = false,
),
)
usage[chosen] = (usage[chosen] ?: 0) + 1
assignedTotal++
}
}
totalStudents += allStudents.size
lastLabId = labIdStr
lastLabTitle = lab.title
.groupingBy { it.variantId.toString() }
.eachCount()
.toMutableMap()
var assigned = 0
for (sid in toAssign.shuffled()) {
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString() ?: defaultLevel
val pool = pickPool(levelId, poolsByLevel, defaultLevel)
if (pool.isEmpty()) continue
val minCount = pool.minOf { id -> usage[id] ?: 0 }
val chosen = pool.filter { id -> (usage[id] ?: 0) == minCount }.random()
assignments.save(
VariantAssignment(studentId = sid, labId = labId, variantId = UUID.fromString(chosen), manual = false),
)
usage[chosen] = (usage[chosen] ?: 0) + 1
assigned++
}
return DistributeResult(
labs = labIds.size,
assigned = assignedTotal,
total = totalStudents,
labId = if (labIds.size == 1) lastLabId else null,
labTitle = if (labIds.size == 1) lastLabTitle else null,
)
return assigned
}
private fun pickPool(
levelId: String?,
poolsByLevel: Map<String, List<String>>,
defaultLevel: String?,
): List<String> =
levelId
?.let { poolsByLevel[it] }
.orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
@Transactional(readOnly = true)
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)

View File

@@ -25,10 +25,16 @@ import ru.ysu.lms.web.api.LabUpdatePayload
import ru.ysu.lms.web.api.LevelAssignPayload
import ru.ysu.lms.web.api.LevelPayload
import ru.ysu.lms.web.api.LevelSavePayload
import ru.ysu.lms.web.api.StudentRowDto
import ru.ysu.lms.web.api.VariantSavePayload
import ru.ysu.lms.web.session.SessionKeys
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
@Controller
@RequestMapping("/admin")
@@ -564,26 +570,19 @@ class AdminController(
var i = 0
while (i < line.length) {
val c = line[i]
if (inQuotes) {
if (c == '"') {
if (i + 1 < line.length && line[i + 1] == '"') {
sb.append('"')
i++
} else {
inQuotes = false
}
} else {
sb.append(c)
when {
inQuotes && c == '"' && i + 1 < line.length && line[i + 1] == '"' -> {
sb.append('"')
i++
}
} else {
when (c) {
'"' -> inQuotes = true
delimiter -> {
out.add(sb.toString())
sb.setLength(0)
}
else -> sb.append(c)
inQuotes && c == '"' -> inQuotes = false
inQuotes -> sb.append(c)
c == '"' -> inQuotes = true
c == delimiter -> {
out.add(sb.toString())
sb.setLength(0)
}
else -> sb.append(c)
}
i++
}
@@ -796,52 +795,62 @@ class AdminController(
val group = api.groupDetail(t, id)
val studentById = group.students.associateBy { it.id }
val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId }
val out = java.io.ByteArrayOutputStream()
java.util.zip.ZipOutputStream(out).use { zos ->
java.util.zip.ZipEntry("ОПИСАНИЕ.txt").also {
it.time = 0
zos.putNextEntry(it)
}
zos.write("Архив сдач группы ${group.name} (последние попытки)\n".toByteArray(Charsets.UTF_8))
val bytes = buildGroupArchive(t, group.name, studentById, labs)
return zipAttachment("solutions-${group.name}.zip", bytes)
}
private fun buildGroupArchive(
token: String,
groupName: String,
studentById: Map<String, StudentRowDto>,
labs: List<LabSummaryDto>,
): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zos ->
zos.putNextEntry(ZipEntry("ОПИСАНИЕ.txt").apply { time = 0 })
zos.write("Архив сдач группы $groupName (последние попытки)\n".toByteArray(Charsets.UTF_8))
zos.closeEntry()
for (lab in labs) {
val subs = api.labSubmissions(t, lab.id)
for (sub in subs) {
for (sub in api.labSubmissions(token, lab.id)) {
val student = studentById[sub.studentId] ?: continue
val bytes = api.downloadSubmissionAsAdmin(t, sub.id).bytes
java.io.ByteArrayInputStream(bytes).let { src ->
java.util.zip.ZipInputStream(src).use { zin ->
var e = zin.nextEntry
while (e != null) {
if (!e.isDirectory) {
java.util.zip
.ZipEntry(
"ЛР${lab.number}/${student.studentNo}_${student.fullName.replace(
Regex("[\\\\/:*?\"<>|]"),
"_",
)}/${e.name}",
).let { ne ->
ne.time =
0
; zos.putNextEntry(ne)
}
zin.copyTo(zos)
zos.closeEntry()
}
e = zin.nextEntry
}
}
}
val bytes = api.downloadSubmissionAsAdmin(token, sub.id).bytes
appendSubmissionArchive(zos, lab, student, bytes)
}
}
}
val name = "solutions-${group.name}.zip"
val encoded = java.net.URLEncoder.encode(name, java.nio.charset.StandardCharsets.UTF_8)
return out.toByteArray()
}
private fun appendSubmissionArchive(
zos: ZipOutputStream,
lab: LabSummaryDto,
student: StudentRowDto,
submissionZip: ByteArray,
) {
ZipInputStream(ByteArrayInputStream(submissionZip)).use { zin ->
var entry = zin.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
val safeName = student.fullName.replace(Regex("[\\\\/:*?\"<>|]"), "_")
zos.putNextEntry(ZipEntry("ЛР${lab.number}/${student.studentNo}_$safeName/${entry.name}").apply { time = 0 })
zin.copyTo(zos)
zos.closeEntry()
}
entry = zin.nextEntry
}
}
}
private fun zipAttachment(
name: String,
bytes: ByteArray,
): ResponseEntity<ByteArray> {
val encoded = URLEncoder.encode(name, StandardCharsets.UTF_8)
return ResponseEntity
.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(out.toByteArray())
.body(bytes)
}
@PostMapping("/groups/{id}/publish")