From 1238265c8e064753f037ab2b6124926d1dbe67bf Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Wed, 16 Sep 2026 22:00:22 +0300 Subject: [PATCH] Content import: sync sections and articles (archive is the source of truth) - importZip now updates titles/positions of existing chapters and sections and removes sections/articles of archived chapters that are missing in the archive (so restructured material no longer leaves stale sections behind); removed count is reported in ContentImportResult and in the admin flash message - docs: note about import semantics in the class diagram and sequences --- docs/03-domain-model.md | 2 + docs/04-sequences.md | 5 ++ .../lms/academic/content/ContentService.kt | 46 +++++++++++++++++-- .../lms/web/admin/ContentAdminController.kt | 4 +- .../main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt | 1 + 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/docs/03-domain-model.md b/docs/03-domain-model.md index 3ef00af..c435d31 100644 --- a/docs/03-domain-model.md +++ b/docs/03-domain-model.md @@ -229,6 +229,8 @@ classDiagram class ContentSectionRepository class ContentArticleRepository + note for ContentService "Импорт ZIP — источник истины для глав из архива: устаревшие разделы и статьи удаляются (removed)" + LabController --> LabService ContentPublicController --> ContentService ContentAdminController --> ContentService diff --git a/docs/04-sequences.md b/docs/04-sequences.md index cd6bec5..f616d2e 100644 --- a/docs/04-sequences.md +++ b/docs/04-sequences.md @@ -292,6 +292,11 @@ sequenceDiagram Note over A,AC: админ открывает скрытую статью через /api/v1/content/all/{c}/{s}/{a} ``` +> **Импорт ZIP — источник истины для глав, которые есть в архиве.** Разделы и статьи, +> которых нет в архиве, удаляются из этих глав (счётчик `removed` в результате импорта); +> главы, отсутствующие в архиве целиком, не затрагиваются. Поэтому после крупной +> перестройки структуры старую главу, которой больше нет в архиве, нужно удалить в интерфейсе. + ## 10. Страница группы у администратора (агрегация данных) ```mermaid diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt index 334d1d9..4f0fa0d 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentService.kt @@ -59,6 +59,8 @@ data class ContentImportResult( val sections: Int, val articles: Int, val assets: Int, + /** Сколько устаревших разделов и статей удалено при синхронизации. */ + val removed: Int = 0, ) /** @@ -453,24 +455,38 @@ class ContentService( var chCount = 0 var secCount = 0 var artCount = 0 + var removedCount = 0 var currentChapter: ContentChapter? = null var currentSection: ContentSection? = null + val touchedChapters = mutableSetOf() + val seenSections = mutableSetOf() + val seenArticles = mutableSetOf() for (p in parsed) { if (currentChapter?.title != p.chapterDir) { val slug = chapterSlug(p) ?: "chapter-${chCount + 1}" - currentChapter = chapters.findBySlug(slug) ?: chapters.save( + currentChapter = chapters.findBySlug(slug)?.apply { + title = p.chapterDir + position = p.chapterPos + } ?: chapters.save( ContentChapter(title = p.chapterDir, slug = slug, 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.findByChapterIdAndSlug(chapter.id, slug) ?: sections.save( + currentSection = sections.findByChapterIdAndSlug(chapter.id, slug)?.apply { + title = p.sectionDir + position = p.sectionPos + } ?: sections.save( ContentSection(chapterId = chapter.id, title = p.sectionDir, slug = slug, position = p.sectionPos), ) + currentSection = sections.save(currentSection!!) + seenSections.add(currentSection!!.id) secCount += 1 } val section = currentSection ?: throw BadRequestException("Не удалось определить раздел") @@ -489,10 +505,34 @@ class ContentService( position = p.articlePos } articles.save(article) + seenArticles.add(article.id) artCount += 1 } - return ContentImportResult(chapters = chCount, sections = secCount, articles = artCount, assets = linkRewrite.size) + // Синхронизация: в главах из архива удаляем разделы и статьи, которых в архиве нет. + for (chapterId in touchedChapters) { + 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, + ) } private fun chapterSlug(p: ParsedArticle): String? = diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt index 159f369..df13f54 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt @@ -38,9 +38,11 @@ class ContentAdminController( } return try { val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes) + val removedNote = if (result.removed > 0) ", удалено устаревших: ${result.removed}" else "" redirect.addFlashAttribute( "notice", - "Импортировано: глав ${result.chapters}, разделов ${result.sections}, статей ${result.articles}, картинок ${result.assets}", + "Импортировано: глав ${result.chapters}, разделов ${result.sections}, " + + "статей ${result.articles}, картинок ${result.assets}$removedNote", ) "redirect:/admin/content" } catch (ex: ApiException) { diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt index 3d4b472..2d98a7a 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiDtos.kt @@ -365,6 +365,7 @@ data class ContentImportResultDto( val sections: Int = 0, val articles: Int = 0, val assets: Int = 0, + val removed: Int = 0, ) class DownloadDto(