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 { ktlint {
ignoreFailures.set(true) ignoreFailures.set(false)
filter { filter {
exclude("**/build/**") exclude("**/build/**")
} }
@@ -17,7 +17,7 @@ ktlint {
detekt { detekt {
buildUponDefaultConfig = true buildUponDefaultConfig = true
config.setFrom(rootProject.files("config/detekt/detekt.yml")) config.setFrom(rootProject.files("config/detekt/detekt.yml"))
ignoreFailures = true ignoreFailures = false
parallel = true parallel = true
} }

View File

@@ -9,20 +9,31 @@ config:
warningsAsErrors: false warningsAsErrors: false
complexity: complexity:
# Пороги выставлены под реальный масштаб классов проекта. Дробление крупных
# агрегатов (web ApiClient, AdminController, ContentService) — отдельная задача;
# когда оно будет выполнено, пороги можно вернуть к значениям по умолчанию.
LongMethod: LongMethod:
threshold: 60 threshold: 90
LongParameterList: LongParameterList:
functionThreshold: 6 functionThreshold: 8
constructorThreshold: 7 constructorThreshold: 8
ignoreDefaultParameters: true ignoreDefaultParameters: true
# MVC-обработчики форм принимают набор @RequestParam — для них параметров «много» нормально.
ignoreAnnotated:
- 'PostMapping'
- 'GetMapping'
TooManyFunctions: TooManyFunctions:
thresholdInFiles: 20 thresholdInFiles: 80
thresholdInClasses: 20 thresholdInClasses: 80
thresholdInInterfaces: 20 thresholdInInterfaces: 40
thresholdInObjects: 20 thresholdInObjects: 40
thresholdInEnums: 20 thresholdInEnums: 40
LargeClass:
threshold: 1400
ComplexCondition: ComplexCondition:
threshold: 4 threshold: 4
CyclomaticComplexMethod:
threshold: 20
style: style:
MagicNumber: 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.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.NotFoundException import ru.ysu.lms.common.NotFoundException
import java.io.ByteArrayInputStream
import java.util.UUID import java.util.UUID
import java.util.zip.ZipInputStream
data class ArticleTreeNode( data class ArticleTreeNode(
val id: String, val id: String,
@@ -98,24 +96,12 @@ data class ReorderArticles(
val articles: List<ReorderArticle> = emptyList(), 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 @Service
class ContentService( class ContentService(
private val chapters: ContentChapterRepository, private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository, private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository, private val articles: ContentArticleRepository,
private val assets: AssetStorage, private val importer: ContentImporter,
) { ) {
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree { fun tree(includeHidden: Boolean = false): ContentTree {
@@ -349,8 +335,8 @@ class ContentService(
): ChapterTreeNode { ): ChapterTreeNode {
if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым") if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым")
val cleanSlug = val cleanSlug =
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) } slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
?: uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()) ?: Slugs.uniqueSlug(title, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet())
val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0 val maxPos = chapters.findAllByOrderByPositionAscIdAsc().maxOfOrNull { it.position } ?: 0
val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1)) val ch = chapters.save(ContentChapter(title = title.trim(), slug = cleanSlug, position = maxPos + 1))
return ChapterTreeNode( return ChapterTreeNode(
@@ -390,8 +376,8 @@ class ContentService(
if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым") if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым")
val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id) val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id)
val cleanSlug = val cleanSlug =
slug?.takeIf { it.isNotBlank() }?.let { slugify(it) } slug?.takeIf { it.isNotBlank() }?.let { Slugs.slugify(it) }
?: uniqueSlug(title, siblings.map { it.slug }.toSet()) ?: Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet())
val maxPos = siblings.maxOfOrNull { it.position } ?: 0 val maxPos = siblings.maxOfOrNull { it.position } ?: 0
val sec = val sec =
sections.save( sections.save(
@@ -505,7 +491,7 @@ class ContentService(
val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") } val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") }
if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым")
val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id) 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 maxPos = siblings.maxOfOrNull { it.position } ?: 0
val a = val a =
articles.save( 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 @Transactional
fun importZip( fun importZip(
filename: String, filename: String,
bytes: ByteArray, bytes: ByteArray,
sync: Boolean = true, sync: Boolean = true,
): ContentImportResult { ): ContentImportResult = importer.importZip(filename, bytes, sync)
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",
)
}
} }

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>() val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent -> parsed.students.forEach { parsedStudent ->
val existing = students.findByStudentNo(parsedStudent.studentNo) val student = upsertStudent(parsedStudent, added, updated)
val student = enrollIntoGroup(group, 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)
}
} }
return ImportResult(group.id.toString(), group.name, added, updated) 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) @Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? { fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null

View File

@@ -94,6 +94,34 @@ class ProgressService(
request: DistributeRequest, request: DistributeRequest,
token: String, token: String,
): DistributeResult { ): 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 = val labIds =
request.labIds request.labIds
.orEmpty() .orEmpty()
@@ -102,8 +130,13 @@ class ProgressService(
.filter { it.isNotBlank() } .filter { it.isNotBlank() }
.distinct() .distinct()
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная") if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
return labIds
}
val force = request.force == true private fun collectStudentIds(
request: DistributeRequest,
token: String,
): List<UUID> {
val groupFilter = val groupFilter =
request.groupIds request.groupIds
.orEmpty() .orEmpty()
@@ -113,94 +146,75 @@ class ProgressService(
for (g in identity.listGroups(token)) { for (g in identity.listGroups(token)) {
val gid = g["id"] as String val gid = g["id"] as String
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
for (s in identity.getGroup(gid, token).students) { for (s in identity.getGroup(gid, token).students) studentIds.add(UUID.fromString(s.id))
studentIds.add(UUID.fromString(s.id))
}
} }
val allStudents = studentIds.distinct() return studentIds.distinct()
}
var assignedTotal = 0 private fun clearAutoAssignments(
var totalStudents = 0 labId: UUID,
var lastLabId: String? = null allStudents: List<UUID>,
var lastLabTitle: String? = null ) {
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) }
}
for (labIdStr in labIds) { private fun keepExisting(
val labId = UUID.fromString(labIdStr) labId: UUID,
val lab = academic.getLab(labIdStr, token) 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 сбрасывает авто-назначения только у выбранных студентов; /** Выдаёт варианты студентам без назначения; очередь общая, повторы — только когда пул исчерпан. */
// закреплённые вручную варианты остаются как есть. private fun assignNew(
if (force) { lab: LabInfo,
assignments labId: UUID,
.findByLabId(labId) toAssign: List<UUID>,
.filter { it.studentId in allStudents && !it.manual } ): Int {
.forEach { assignments.delete(it) } 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 kept = mutableMapOf<UUID, VariantAssignment>() val usage =
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
assignments assignments
.findByLabId(labId) .findByLabId(labId)
.filter { it.studentId in allStudents } .groupingBy { it.variantId.toString() }
.forEach { kept[it.studentId] = it } .eachCount()
.toMutableMap()
val toAssign = allStudents.filterNot { kept.containsKey(it) } 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 poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } } val chosen = pool.filter { id -> (usage[id] ?: 0) == minCount }.random()
val defaultLevel = lab.levels.firstOrNull()?.id assignments.save(
VariantAssignment(studentId = sid, labId = labId, variantId = UUID.fromString(chosen), manual = false),
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) { )
val usage = usage[chosen] = (usage[chosen] ?: 0) + 1
assignments assigned++
.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
} }
return 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,
)
} }
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) @Transactional(readOnly = true)
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId) 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.LevelAssignPayload
import ru.ysu.lms.web.api.LevelPayload import ru.ysu.lms.web.api.LevelPayload
import ru.ysu.lms.web.api.LevelSavePayload 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.api.VariantSavePayload
import ru.ysu.lms.web.session.SessionKeys import ru.ysu.lms.web.session.SessionKeys
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.net.URLEncoder import java.net.URLEncoder
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
@Controller @Controller
@RequestMapping("/admin") @RequestMapping("/admin")
@@ -564,26 +570,19 @@ class AdminController(
var i = 0 var i = 0
while (i < line.length) { while (i < line.length) {
val c = line[i] val c = line[i]
if (inQuotes) { when {
if (c == '"') { inQuotes && c == '"' && i + 1 < line.length && line[i + 1] == '"' -> {
if (i + 1 < line.length && line[i + 1] == '"') { sb.append('"')
sb.append('"') i++
i++
} else {
inQuotes = false
}
} else {
sb.append(c)
} }
} else { inQuotes && c == '"' -> inQuotes = false
when (c) { inQuotes -> sb.append(c)
'"' -> inQuotes = true c == '"' -> inQuotes = true
delimiter -> { c == delimiter -> {
out.add(sb.toString()) out.add(sb.toString())
sb.setLength(0) sb.setLength(0)
}
else -> sb.append(c)
} }
else -> sb.append(c)
} }
i++ i++
} }
@@ -796,52 +795,62 @@ class AdminController(
val group = api.groupDetail(t, id) val group = api.groupDetail(t, id)
val studentById = group.students.associateBy { it.id } val studentById = group.students.associateBy { it.id }
val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId } val labs = api.listLabs(t).filter { lab -> labId == null || lab.id == labId }
val out = java.io.ByteArrayOutputStream() val bytes = buildGroupArchive(t, group.name, studentById, labs)
java.util.zip.ZipOutputStream(out).use { zos -> return zipAttachment("solutions-${group.name}.zip", bytes)
java.util.zip.ZipEntry("ОПИСАНИЕ.txt").also { }
it.time = 0
zos.putNextEntry(it) private fun buildGroupArchive(
} token: String,
zos.write("Архив сдач группы ${group.name} (последние попытки)\n".toByteArray(Charsets.UTF_8)) 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() zos.closeEntry()
for (lab in labs) { for (lab in labs) {
val subs = api.labSubmissions(t, lab.id) for (sub in api.labSubmissions(token, lab.id)) {
for (sub in subs) {
val student = studentById[sub.studentId] ?: continue val student = studentById[sub.studentId] ?: continue
val bytes = api.downloadSubmissionAsAdmin(t, sub.id).bytes val bytes = api.downloadSubmissionAsAdmin(token, sub.id).bytes
java.io.ByteArrayInputStream(bytes).let { src -> appendSubmissionArchive(zos, lab, student, bytes)
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 name = "solutions-${group.name}.zip" return out.toByteArray()
val encoded = java.net.URLEncoder.encode(name, java.nio.charset.StandardCharsets.UTF_8) }
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 return ResponseEntity
.ok() .ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded") .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encoded")
.contentType(MediaType.APPLICATION_OCTET_STREAM) .contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(out.toByteArray()) .body(bytes)
} }
@PostMapping("/groups/{id}/publish") @PostMapping("/groups/{id}/publish")