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,23 +159,34 @@ 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)
}
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) { if (existing != null) {
existing.lastName = parsedStudent.lastName existing.lastName = parsed.lastName
existing.firstName = parsedStudent.firstName existing.firstName = parsed.firstName
existing.middleName = parsedStudent.middleName existing.middleName = parsed.middleName
existing.basis = parsedStudent.basis existing.basis = parsed.basis
existing.note = parsedStudent.note existing.note = parsed.note
students.save(existing) students.save(existing)
updated.add(parsedStudent.studentNo) updated.add(parsed.studentNo)
existing return existing
} else { }
val password = Passwords.generate() val password = Passwords.generate()
val account = val account =
accounts.save( accounts.save(
Account( Account(
login = parsedStudent.studentNo, login = parsed.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "", passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT, role = LmsRole.STUDENT,
mustChangePassword = true, mustChangePassword = true,
@@ -184,26 +195,31 @@ class GroupService(
) )
val created = val created =
Student( Student(
studentNo = parsedStudent.studentNo, studentNo = parsed.studentNo,
lastName = parsedStudent.lastName, lastName = parsed.lastName,
firstName = parsedStudent.firstName, firstName = parsed.firstName,
middleName = parsedStudent.middleName, middleName = parsed.middleName,
basis = parsedStudent.basis, basis = parsed.basis,
note = parsedStudent.note, note = parsed.note,
) )
created.account = account created.account = account
students.save(created) students.save(created)
added.add( added.add(
Credential( Credential(
id = account.id.toString(), id = account.id.toString(),
studentNo = parsedStudent.studentNo, studentNo = parsed.studentNo,
fullName = parsedStudent.fullName, fullName = parsed.fullName,
password = password, password = password,
), ),
) )
created return created
} }
/** Зачисляет студента в группу (или переводит из другой). */
private fun enrollIntoGroup(
group: GroupEntity,
student: Student,
) {
val accountId = student.accountId!! val accountId = student.accountId!!
val enrollment = enrollments.findByStudentAccountId(accountId) val enrollment = enrollments.findByStudentAccountId(accountId)
if (enrollment == null) { if (enrollment == null) {
@@ -213,8 +229,6 @@ class GroupService(
enrollments.save(enrollment) enrollments.save(enrollment)
} }
} }
return ImportResult(group.id.toString(), group.name, added, updated)
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? { fun groupOfStudent(accountId: UUID): GroupSummary? {

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,93 +146,74 @@ 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))
} }
return studentIds.distinct()
} }
val allStudents = studentIds.distinct()
var assignedTotal = 0 private fun clearAutoAssignments(
var totalStudents = 0 labId: UUID,
var lastLabId: String? = null allStudents: List<UUID>,
var lastLabTitle: String? = null ) {
for (labIdStr in labIds) {
val labId = UUID.fromString(labIdStr)
val lab = academic.getLab(labIdStr, token)
// force сбрасывает авто-назначения только у выбранных студентов;
// закреплённые вручную варианты остаются как есть.
if (force) {
assignments assignments
.findByLabId(labId) .findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual } .filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) } .forEach { assignments.delete(it) }
} }
private fun keepExisting(
labId: UUID,
allStudents: List<UUID>,
): Map<UUID, VariantAssignment> {
val kept = mutableMapOf<UUID, VariantAssignment>() val kept = mutableMapOf<UUID, VariantAssignment>()
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
assignments assignments
.findByLabId(labId) .findByLabId(labId)
.filter { it.studentId in allStudents } .filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it } .forEach { kept[it.studentId] = it }
return kept
}
val toAssign = allStudents.filterNot { kept.containsKey(it) } /** Выдаёт варианты студентам без назначения; очередь общая, повторы — только когда пул исчерпан. */
private fun assignNew(
// Уровень ограничивает пул: студент получает вариант только СВОЕГО уровня. lab: LabInfo,
// При этом очередь одна общая для всех выбранных групп: круг не labId: UUID,
// «перезапускается» на каждой группе, а продолжается по наименее toAssign: List<UUID>,
// занятым вариантам уровня (пока хоть один не выдан — повторов нет). ): Int {
val poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } } val poolsByLevel = lab.levels.associate { level -> level.id to level.variants.map { it.id } }
val defaultLevel = lab.levels.firstOrNull()?.id val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isEmpty() || poolsByLevel.isEmpty()) return 0
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
val usage = val usage =
assignments assignments
.findByLabId(labId) .findByLabId(labId)
.groupingBy { it.variantId.toString() } .groupingBy { it.variantId.toString() }
.eachCount() .eachCount()
.toMutableMap() .toMutableMap()
var assigned = 0
for (sid in toAssign.shuffled()) { for (sid in toAssign.shuffled()) {
val levelId = val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString() ?: defaultLevel
levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString() val pool = pickPool(levelId, poolsByLevel, defaultLevel)
?: defaultLevel if (pool.isEmpty()) continue
val pool = 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 assigned
}
private fun pickPool(
levelId: String?,
poolsByLevel: Map<String, List<String>>,
defaultLevel: String?,
): List<String> =
levelId levelId
?.let { poolsByLevel[it] } ?.let { poolsByLevel[it] }
.orEmpty() .orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() } .ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.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 DistributeResult(
labs = labIds.size,
assigned = assignedTotal,
total = totalStudents,
labId = if (labIds.size == 1) lastLabId else null,
labTitle = if (labIds.size == 1) lastLabTitle else null,
)
}
@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,27 +570,20 @@ 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 { inQuotes && c == '"' -> inQuotes = false
sb.append(c) inQuotes -> sb.append(c)
} c == '"' -> inQuotes = true
} else { c == delimiter -> {
when (c) {
'"' -> inQuotes = true
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++
} }
out.add(sb.toString()) out.add(sb.toString())
@@ -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)
} }
zos.write("Архив сдач группы ${group.name} (последние попытки)\n".toByteArray(Charsets.UTF_8))
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() 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)
} }
}
}
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) zin.copyTo(zos)
zos.closeEntry() zos.closeEntry()
} }
e = zin.nextEntry entry = zin.nextEntry
} }
} }
} }
}
} private fun zipAttachment(
} name: String,
val name = "solutions-${group.name}.zip" bytes: ByteArray,
val encoded = java.net.URLEncoder.encode(name, java.nio.charset.StandardCharsets.UTF_8) ): 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")