From 4035bc5a0f957b17a75871e7eaead24baa07646a Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Sun, 27 Sep 2026 22:48:13 +0300 Subject: [PATCH] Refactor: split ContentService and ProgressService; defaults tightened --- config/detekt/detekt.yml | 4 +- .../content/ContentAuthoringService.kt | 244 ++++++++++++++ .../academic/content/ContentOrderService.kt | 88 +++++ .../lms/academic/content/ContentService.kt | 301 ------------------ .../controller/ContentAdminController.kt | 53 +-- .../controller/ContentOrderController.kt | 44 +++ .../ru/ysu/lms/progress/GradeService.kt | 101 ++++++ .../ru/ysu/lms/progress/ProgressController.kt | 73 +---- .../ru/ysu/lms/progress/ProgressService.kt | 91 ------ .../ysu/lms/progress/SubmissionController.kt | 80 +++++ 10 files changed, 577 insertions(+), 502 deletions(-) create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentOrderService.kt create mode 100644 services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentOrderController.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt create mode 100644 services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionController.kt diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml index b5482b5..f9346ea 100644 --- a/config/detekt/detekt.yml +++ b/config/detekt/detekt.yml @@ -26,8 +26,8 @@ complexity: - 'GetMapping' TooManyFunctions: # ContentService (26) — следующий кандидат на разделение. - thresholdInFiles: 27 - thresholdInClasses: 27 + thresholdInFiles: 20 + thresholdInClasses: 20 thresholdInInterfaces: 20 thresholdInObjects: 20 thresholdInEnums: 20 diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt new file mode 100644 index 0000000..8047ec0 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentAuthoringService.kt @@ -0,0 +1,244 @@ +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 ru.ysu.lms.common.NotFoundException +import java.util.UUID + +@Service +class ContentAuthoringService( + private val chapters: ContentChapterRepository, + private val sections: ContentSectionRepository, + private val articles: ContentArticleRepository, +) { + @Transactional + fun updateArticle( + articleId: UUID, + title: String, + bodyMd: String, + ) { + val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } + if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") + a.title = title.trim() + a.bodyMd = bodyMd + articles.save(a) + } + + @Transactional + fun deleteArticle(articleId: UUID) { + if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена") + articles.deleteById(articleId) + } + + @Transactional + fun createChapter( + title: String, + slug: String?, + ): ChapterTreeNode { + if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым") + val cleanSlug = + 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( + id = ch.id.toString(), + title = ch.title, + slug = ch.slug, + position = ch.position, + published = ch.published, + sections = emptyList(), + ) + } + + @Transactional + fun renameChapter( + chapterId: UUID, + title: String, + ) { + val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } + if (title.isBlank()) throw BadRequestException("Название не может быть пустым") + ch.title = title.trim() + chapters.save(ch) + } + + @Transactional + fun deleteChapter(chapterId: UUID) { + if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена") + chapters.deleteById(chapterId) + } + + @Transactional + fun createSection( + chapterId: UUID, + title: String, + slug: String?, + ): SectionTreeNode { + val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } + if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым") + val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id) + val cleanSlug = + 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( + ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1), + ) + return SectionTreeNode( + id = sec.id.toString(), + title = sec.title, + slug = sec.slug, + position = sec.position, + published = sec.published, + articles = emptyList(), + ) + } + + @Transactional + fun renameSection( + sectionId: UUID, + title: String, + ) { + val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } + if (title.isBlank()) throw BadRequestException("Название не может быть пустым") + sec.title = title.trim() + sections.save(sec) + } + + @Transactional + fun deleteSection(sectionId: UUID) { + if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден") + sections.deleteById(sectionId) + } + + /** Переносит все статьи раздела в другой и удаляет раздел. */ + @Transactional + fun moveArticlesAndDeleteSection( + sectionId: UUID, + targetSectionId: UUID, + ) { + val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } + val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") } + if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным") + val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id) + var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0 + for (a in moving) { + pos += 1 + a.sectionId = dst.id + a.position = pos + articles.save(a) + } + sections.delete(src) + } + + /** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */ + @Transactional + fun createArticlePlaced( + title: String, + bodyMd: String, + chapterId: UUID?, + newChapterTitle: String?, + sectionId: UUID?, + newSectionTitle: String?, + ): ContentArticleView { + val targetSection: ContentSection = + when { + sectionId != null && !newSectionTitle.isNullOrBlank() -> { + val anchor = + sections + .findById(sectionId) + .orElseThrow { NotFoundException("Раздел не найден") } + val node = createSection(anchor.chapterId, newSectionTitle, null) + sections + .findById(UUID.fromString(node.id)) + .orElseThrow { NotFoundException("Раздел не найден") } + } + sectionId != null -> + sections + .findById(sectionId) + .orElseThrow { NotFoundException("Раздел не найден") } + !newSectionTitle.isNullOrBlank() -> { + val chapter: ContentChapter = + when { + chapterId != null -> + chapters + .findById(chapterId) + .orElseThrow { NotFoundException("Глава не найдена") } + !newChapterTitle.isNullOrBlank() -> { + val node = createChapter(newChapterTitle, null) + chapters + .findById(UUID.fromString(node.id)) + .orElseThrow { NotFoundException("Глава не найдена") } + } + else -> throw BadRequestException("Укажите главу") + } + val node = createSection(chapter.id, newSectionTitle, null) + sections + .findById(UUID.fromString(node.id)) + .orElseThrow { NotFoundException("Раздел не найден") } + } + else -> throw BadRequestException("Укажите раздел") + } + return createArticle(targetSection.id, title, bodyMd) + } + + @Transactional + fun createArticle( + sectionId: UUID, + title: String, + bodyMd: String, + ): ContentArticleView { + val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } + val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") } + if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") + val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id) + val slug = Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet()) + val maxPos = siblings.maxOfOrNull { it.position } ?: 0 + val a = + articles.save( + ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1), + ) + return ContentArticleView( + id = a.id.toString(), + chapterSlug = ch.slug, + chapterTitle = ch.title, + sectionSlug = sec.slug, + sectionTitle = sec.title, + slug = a.slug, + title = a.title, + bodyMd = a.bodyMd, + ) + } + + @Transactional + fun moveArticle( + articleId: UUID, + newSectionId: UUID, + ): ContentArticleView { + val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } + val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") } + val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") } + val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id) + a.sectionId = sec.id + a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1 + articles.save(a) + return ContentArticleView( + id = a.id.toString(), + chapterSlug = ch.slug, + chapterTitle = ch.title, + sectionSlug = sec.slug, + sectionTitle = sec.title, + slug = a.slug, + title = a.title, + bodyMd = a.bodyMd, + ) + } +} diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentOrderService.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentOrderService.kt new file mode 100644 index 0000000..982c5a4 --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/content/ContentOrderService.kt @@ -0,0 +1,88 @@ +package ru.ysu.lms.academic.content + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.ysu.lms.academic.repo.ContentArticleRepository +import ru.ysu.lms.academic.repo.ContentChapterRepository +import ru.ysu.lms.academic.repo.ContentSectionRepository +import java.util.UUID + +@Service +class ContentOrderService( + private val chapters: ContentChapterRepository, + private val sections: ContentSectionRepository, + private val articles: ContentArticleRepository, +) { + /** Применяет новое расположение глав, разделов и статей (drag & drop в админке). */ + @Transactional + fun reorder(tree: ReorderTree) { + tree.chapters.forEachIndexed { chapterIndex, chapterDto -> + val chapter = + chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + chapter.position = chapterIndex + 1 + chapters.save(chapter) + + chapterDto.sections.forEachIndexed { sectionIndex, sectionDto -> + val section = + sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + section.chapterId = chapter.id + section.position = sectionIndex + 1 + sections.save(section) + + sectionDto.articles.forEachIndexed { articleIndex, articleDto -> + val article = + articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + article.sectionId = section.id + article.position = articleIndex + 1 + articles.save(article) + } + } + } + } + + /** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */ + @Transactional + fun reorderSections( + chapterId: UUID, + tree: List, + ) { + tree.forEachIndexed { sectionIndex, sectionDto -> + val section = + sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + if (section.chapterId != chapterId) return@forEachIndexed + section.position = sectionIndex + 1 + sections.save(section) + + sectionDto.articles.forEachIndexed { articleIndex, articleDto -> + val article = + articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + article.sectionId = section.id + article.position = articleIndex + 1 + articles.save(article) + } + } + } + + /** Порядок статей внутри одного раздела (drag & drop на странице раздела). */ + @Transactional + fun reorderArticles( + sectionId: UUID, + tree: List, + ) { + tree.forEachIndexed { articleIndex, articleDto -> + val article = + articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) + ?: return@forEachIndexed + if (article.sectionId != sectionId) return@forEachIndexed + article.position = articleIndex + 1 + articles.save(article) + } + } + + private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull() +} 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 1b8293e..448c0b4 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 @@ -2,13 +2,9 @@ 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 ru.ysu.lms.common.NotFoundException import java.util.UUID @@ -173,77 +169,6 @@ class ContentService( ) } - /** Применяет новое расположение глав, разделов и статей (drag & drop в админке). */ - @Transactional - fun reorder(tree: ReorderTree) { - tree.chapters.forEachIndexed { chapterIndex, chapterDto -> - val chapter = - chapters.findById(uuidOrNull(chapterDto.id) ?: return@forEachIndexed).orElse(null) - ?: return@forEachIndexed - chapter.position = chapterIndex + 1 - chapters.save(chapter) - - chapterDto.sections.forEachIndexed { sectionIndex, sectionDto -> - val section = - sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) - ?: return@forEachIndexed - section.chapterId = chapter.id - section.position = sectionIndex + 1 - sections.save(section) - - sectionDto.articles.forEachIndexed { articleIndex, articleDto -> - val article = - articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) - ?: return@forEachIndexed - article.sectionId = section.id - article.position = articleIndex + 1 - articles.save(article) - } - } - } - } - - /** Порядок разделов внутри главы и статей внутри них (drag & drop на странице главы). */ - @Transactional - fun reorderSections( - chapterId: UUID, - tree: List, - ) { - tree.forEachIndexed { sectionIndex, sectionDto -> - val section = - sections.findById(uuidOrNull(sectionDto.id) ?: return@forEachIndexed).orElse(null) - ?: return@forEachIndexed - if (section.chapterId != chapterId) return@forEachIndexed - section.position = sectionIndex + 1 - sections.save(section) - - sectionDto.articles.forEachIndexed { articleIndex, articleDto -> - val article = - articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) - ?: return@forEachIndexed - article.sectionId = section.id - article.position = articleIndex + 1 - articles.save(article) - } - } - } - - /** Порядок статей внутри одного раздела (drag & drop на странице раздела). */ - @Transactional - fun reorderArticles( - sectionId: UUID, - tree: List, - ) { - tree.forEachIndexed { articleIndex, articleDto -> - val article = - articles.findById(uuidOrNull(articleDto.id) ?: return@forEachIndexed).orElse(null) - ?: return@forEachIndexed - if (article.sectionId != sectionId) return@forEachIndexed - article.position = articleIndex + 1 - articles.save(article) - } - } - private fun requireFound( value: T?, message: String, @@ -257,8 +182,6 @@ class ContentService( if (!includeHidden && !published) throw NotFoundException(message) } - private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull() - @Transactional fun setChapterPublished( chapterId: UUID, @@ -309,230 +232,6 @@ class ContentService( } } - @Transactional - fun updateArticle( - articleId: UUID, - title: String, - bodyMd: String, - ) { - val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } - if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") - a.title = title.trim() - a.bodyMd = bodyMd - articles.save(a) - } - - @Transactional - fun deleteArticle(articleId: UUID) { - if (!articles.existsById(articleId)) throw NotFoundException("Статья не найдена") - articles.deleteById(articleId) - } - - @Transactional - fun createChapter( - title: String, - slug: String?, - ): ChapterTreeNode { - if (title.isBlank()) throw BadRequestException("Название главы не может быть пустым") - val cleanSlug = - 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( - id = ch.id.toString(), - title = ch.title, - slug = ch.slug, - position = ch.position, - published = ch.published, - sections = emptyList(), - ) - } - - @Transactional - fun renameChapter( - chapterId: UUID, - title: String, - ) { - val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } - if (title.isBlank()) throw BadRequestException("Название не может быть пустым") - ch.title = title.trim() - chapters.save(ch) - } - - @Transactional - fun deleteChapter(chapterId: UUID) { - if (!chapters.existsById(chapterId)) throw NotFoundException("Глава не найдена") - chapters.deleteById(chapterId) - } - - @Transactional - fun createSection( - chapterId: UUID, - title: String, - slug: String?, - ): SectionTreeNode { - val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") } - if (title.isBlank()) throw BadRequestException("Название раздела не может быть пустым") - val siblings = sections.findByChapterIdOrderByPositionAscIdAsc(ch.id) - val cleanSlug = - 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( - ContentSection(chapterId = ch.id, title = title.trim(), slug = cleanSlug, position = maxPos + 1), - ) - return SectionTreeNode( - id = sec.id.toString(), - title = sec.title, - slug = sec.slug, - position = sec.position, - published = sec.published, - articles = emptyList(), - ) - } - - @Transactional - fun renameSection( - sectionId: UUID, - title: String, - ) { - val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } - if (title.isBlank()) throw BadRequestException("Название не может быть пустым") - sec.title = title.trim() - sections.save(sec) - } - - @Transactional - fun deleteSection(sectionId: UUID) { - if (!sections.existsById(sectionId)) throw NotFoundException("Раздел не найден") - sections.deleteById(sectionId) - } - - /** Переносит все статьи раздела в другой и удаляет раздел. */ - @Transactional - fun moveArticlesAndDeleteSection( - sectionId: UUID, - targetSectionId: UUID, - ) { - val src = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } - val dst = sections.findById(targetSectionId).orElseThrow { NotFoundException("Раздел назначения не найден") } - if (src.id == dst.id) throw BadRequestException("Раздел назначения совпадает с исходным") - val moving = articles.findBySectionIdOrderByPositionAscIdAsc(src.id) - var pos = articles.findBySectionIdOrderByPositionAscIdAsc(dst.id).maxOfOrNull { it.position } ?: 0 - for (a in moving) { - pos += 1 - a.sectionId = dst.id - a.position = pos - articles.save(a) - } - sections.delete(src) - } - - /** Создаёт статью; если раздела нет — создаёт главу/раздел по названиям. */ - @Transactional - fun createArticlePlaced( - title: String, - bodyMd: String, - chapterId: UUID?, - newChapterTitle: String?, - sectionId: UUID?, - newSectionTitle: String?, - ): ContentArticleView { - val targetSection: ContentSection = - when { - sectionId != null && !newSectionTitle.isNullOrBlank() -> { - val anchor = - sections - .findById(sectionId) - .orElseThrow { NotFoundException("Раздел не найден") } - val node = createSection(anchor.chapterId, newSectionTitle, null) - sections - .findById(UUID.fromString(node.id)) - .orElseThrow { NotFoundException("Раздел не найден") } - } - sectionId != null -> - sections - .findById(sectionId) - .orElseThrow { NotFoundException("Раздел не найден") } - !newSectionTitle.isNullOrBlank() -> { - val chapter: ContentChapter = - when { - chapterId != null -> - chapters - .findById(chapterId) - .orElseThrow { NotFoundException("Глава не найдена") } - !newChapterTitle.isNullOrBlank() -> { - val node = createChapter(newChapterTitle, null) - chapters - .findById(UUID.fromString(node.id)) - .orElseThrow { NotFoundException("Глава не найдена") } - } - else -> throw BadRequestException("Укажите главу") - } - val node = createSection(chapter.id, newSectionTitle, null) - sections - .findById(UUID.fromString(node.id)) - .orElseThrow { NotFoundException("Раздел не найден") } - } - else -> throw BadRequestException("Укажите раздел") - } - return createArticle(targetSection.id, title, bodyMd) - } - - @Transactional - fun createArticle( - sectionId: UUID, - title: String, - bodyMd: String, - ): ContentArticleView { - val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") } - val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") } - if (title.isBlank()) throw BadRequestException("Заголовок не может быть пустым") - val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id) - val slug = Slugs.uniqueSlug(title, siblings.map { it.slug }.toSet()) - val maxPos = siblings.maxOfOrNull { it.position } ?: 0 - val a = - articles.save( - ContentArticle(sectionId = sec.id, title = title.trim(), slug = slug, bodyMd = bodyMd, position = maxPos + 1), - ) - return ContentArticleView( - id = a.id.toString(), - chapterSlug = ch.slug, - chapterTitle = ch.title, - sectionSlug = sec.slug, - sectionTitle = sec.title, - slug = a.slug, - title = a.title, - bodyMd = a.bodyMd, - ) - } - - @Transactional - fun moveArticle( - articleId: UUID, - newSectionId: UUID, - ): ContentArticleView { - val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") } - val sec = sections.findById(newSectionId).orElseThrow { NotFoundException("Раздел не найден") } - val ch = chapters.findById(sec.chapterId).orElseThrow { NotFoundException("Глава не найдена") } - val siblings = articles.findBySectionIdOrderByPositionAscIdAsc(sec.id) - a.sectionId = sec.id - a.position = (siblings.maxOfOrNull { it.position } ?: 0) + 1 - articles.save(a) - return ContentArticleView( - id = a.id.toString(), - chapterSlug = ch.slug, - chapterTitle = ch.title, - sectionSlug = sec.slug, - sectionTitle = sec.title, - slug = a.slug, - title = a.title, - bodyMd = a.bodyMd, - ) - } - @Transactional fun importZip( filename: String, diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt index e5c9deb..449a187 100644 --- a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentAdminController.kt @@ -12,11 +12,9 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController import org.springframework.web.multipart.MultipartFile +import ru.ysu.lms.academic.content.ContentAuthoringService import ru.ysu.lms.academic.content.ContentImportResult import ru.ysu.lms.academic.content.ContentService -import ru.ysu.lms.academic.content.ReorderArticles -import ru.ysu.lms.academic.content.ReorderSections -import ru.ysu.lms.academic.content.ReorderTree import ru.ysu.lms.common.BadRequestException import java.util.UUID @@ -25,6 +23,7 @@ import java.util.UUID @PreAuthorize("hasAuthority('ROLE_ADMIN')") class ContentAdminController( private val contentService: ContentService, + private val authoring: ContentAuthoringService, ) { data class ArticleUpdatePayload( val title: String, @@ -111,32 +110,6 @@ class ContentAdminController( contentService.setArticlePublished(id, body.published) } - /** Новое расположение материалов (drag & drop глав, разделов и статей). */ - @PostMapping("/reorder") - fun reorder( - @RequestBody body: ReorderTree, - ) { - contentService.reorder(body) - } - - /** Порядок разделов внутри главы (drag & drop на странице главы). */ - @PostMapping("/chapters/{id}/sections/reorder") - fun reorderSections( - @PathVariable id: UUID, - @RequestBody body: ReorderSections, - ) { - contentService.reorderSections(id, body.sections) - } - - /** Порядок статей внутри раздела (drag & drop на странице раздела). */ - @PostMapping("/sections/{id}/articles/reorder") - fun reorderArticles( - @PathVariable id: UUID, - @RequestBody body: ReorderArticles, - ) { - contentService.reorderArticles(id, body.articles) - } - @PostMapping("/import") fun import( @RequestParam("file") file: MultipartFile, @@ -152,19 +125,19 @@ class ContentAdminController( @PostMapping("/chapters") fun createChapter( @RequestBody body: ChapterCreatePayload, - ) = contentService.createChapter(body.title, body.slug) + ) = authoring.createChapter(body.title, body.slug) @PutMapping("/chapters/{id}") fun renameChapter( @PathVariable id: UUID, @RequestBody body: TitlePayload, - ) = contentService.renameChapter(id, body.title) + ) = authoring.renameChapter(id, body.title) @DeleteMapping("/chapters/{id}") fun deleteChapter( @PathVariable id: UUID, ): ResponseEntity { - contentService.deleteChapter(id) + authoring.deleteChapter(id) return ResponseEntity.noContent().build() } @@ -172,13 +145,13 @@ class ContentAdminController( fun createSection( @RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload, - ) = contentService.createSection(chapterId, body.title, body.slug) + ) = authoring.createSection(chapterId, body.title, body.slug) @PutMapping("/sections/{id}") fun renameSection( @PathVariable id: UUID, @RequestBody body: TitlePayload, - ) = contentService.renameSection(id, body.title) + ) = authoring.renameSection(id, body.title) @DeleteMapping("/sections/{id}") fun deleteSection( @@ -186,9 +159,9 @@ class ContentAdminController( @RequestParam(required = false) moveTo: UUID?, ): ResponseEntity { if (moveTo != null) { - contentService.moveArticlesAndDeleteSection(id, moveTo) + authoring.moveArticlesAndDeleteSection(id, moveTo) } else { - contentService.deleteSection(id) + authoring.deleteSection(id) } return ResponseEntity.noContent().build() } @@ -196,7 +169,7 @@ class ContentAdminController( @PostMapping("/articles") fun createArticle( @RequestBody body: ArticleCreatePayload, - ) = contentService.createArticlePlaced( + ) = authoring.createArticlePlaced( title = body.title, bodyMd = body.bodyMd, chapterId = body.chapterId?.let { UUID.fromString(it) }, @@ -210,20 +183,20 @@ class ContentAdminController( @PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload, ) { - contentService.updateArticle(id, body.title, body.bodyMd) + authoring.updateArticle(id, body.title, body.bodyMd) } @PostMapping("/articles/{id}/move") fun move( @PathVariable id: UUID, @RequestBody body: ArticleMovePayload, - ) = contentService.moveArticle(id, UUID.fromString(body.sectionId)) + ) = authoring.moveArticle(id, UUID.fromString(body.sectionId)) @DeleteMapping("/articles/{id}") fun delete( @PathVariable id: UUID, ): ResponseEntity { - contentService.deleteArticle(id) + authoring.deleteArticle(id) return ResponseEntity.noContent().build() } } diff --git a/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentOrderController.kt b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentOrderController.kt new file mode 100644 index 0000000..056040b --- /dev/null +++ b/services/academic-service/src/main/kotlin/ru/ysu/lms/academic/controller/ContentOrderController.kt @@ -0,0 +1,44 @@ +package ru.ysu.lms.academic.controller + +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import ru.ysu.lms.academic.content.ContentOrderService +import ru.ysu.lms.academic.content.ReorderArticles +import ru.ysu.lms.academic.content.ReorderSections +import ru.ysu.lms.academic.content.ReorderTree +import java.util.UUID + +@RestController +@RequestMapping("/api/v1") +class ContentOrderController( + private val order: ContentOrderService, +) { + /** Новое расположение материалов (drag & drop глав, разделов и статей). */ + @PostMapping("/reorder") + fun reorder( + @RequestBody body: ReorderTree, + ) { + order.reorder(body) + } + + /** Порядок разделов внутри главы (drag & drop на странице главы). */ + @PostMapping("/chapters/{id}/sections/reorder") + fun reorderSections( + @PathVariable id: UUID, + @RequestBody body: ReorderSections, + ) { + order.reorderSections(id, body.sections) + } + + /** Порядок статей внутри раздела (drag & drop на странице раздела). */ + @PostMapping("/sections/{id}/articles/reorder") + fun reorderArticles( + @PathVariable id: UUID, + @RequestBody body: ReorderArticles, + ) { + order.reorderArticles(id, body.articles) + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt new file mode 100644 index 0000000..211763d --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/GradeService.kt @@ -0,0 +1,101 @@ +package ru.ysu.lms.progress + +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import ru.ysu.lms.common.BadRequestException +import ru.ysu.lms.progress.domain.CommentTemplate +import ru.ysu.lms.progress.domain.GradeComment +import ru.ysu.lms.progress.domain.LabGrade +import ru.ysu.lms.progress.domain.LabGradeKey +import ru.ysu.lms.progress.repo.CommentTemplateRepository +import ru.ysu.lms.progress.repo.GradeCommentRepository +import ru.ysu.lms.progress.repo.LabGradeRepository +import java.math.BigDecimal +import java.util.UUID + +@Service +class GradeService( + private val grades: LabGradeRepository, + private val templates: CommentTemplateRepository, + private val gradeComments: GradeCommentRepository, +) { + @Transactional + fun setGrade(request: GradeSetRequest): GradeResponse { + if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) { + throw BadRequestException("Оценка должна быть в диапазоне 0–5") + } + val sid = UUID.fromString(request.studentId) + val lid = UUID.fromString(request.labId) + val key = LabGradeKey(sid, lid) + val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) } + grade.score = request.score + grades.save(grade) + replaceComments(sid, lid, request.comments) + return GradeResponse( + request.studentId, + request.labId, + request.score, + commentsOf(sid, lid).map { CommentOut(it.text, it.tone) }, + ) + } + + private fun replaceComments( + sid: UUID, + lid: UUID, + inputs: List, + ) { + val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet() + val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) } + if (existing.toSet() == incoming.toSet()) return + gradeComments.deleteByStudentIdAndLabId(sid, lid) + for ((text, tone) in incoming) { + gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone)) + val t = templates.findByText(text) + if (t == null) { + templates.save(CommentTemplate(text = text, tone = tone, useCount = 1)) + } else { + t.useCount += 1 + t.tone = tone + templates.save(t) + } + } + } + + private fun normalizeTone(tone: String): String = + when (tone.lowercase()) { + "positive", "положительный", "позитивный", "pos" -> "positive" + "negative", "негативный", "отрицательный", "neg" -> "negative" + else -> "neutral" + } + + @Transactional(readOnly = true) + fun commentsOf( + sid: UUID, + lid: UUID, + ): List = gradeComments.findByStudentIdAndLabId(sid, lid) + + @Transactional(readOnly = true) + fun suggestComments(query: String): List = + templates + .findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim()) + .map { CommentOut(it.text, it.tone) } + + @Transactional(readOnly = true) + fun myComments(studentId: UUID): List = gradeComments.findByStudentId(studentId) + + @Transactional(readOnly = true) + fun listGrades(): List { + val byGrade = + gradeComments + .findAll() + .groupBy { it.studentId to it.labId } + return grades.findAllBy().map { + GradeResponse( + studentId = it.studentId.toString(), + labId = it.labId.toString(), + score = it.score, + comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) }, + ) + } + } +} diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt index cfc5e8f..0e12359 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressController.kt @@ -1,31 +1,23 @@ package ru.ysu.lms.progress import jakarta.validation.Valid -import org.springframework.http.HttpHeaders -import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.oauth2.jwt.Jwt import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RequestPart import org.springframework.web.bind.annotation.RestController -import org.springframework.web.multipart.MultipartFile -import java.net.URLEncoder -import java.nio.charset.StandardCharsets import java.util.UUID @RestController @RequestMapping("/api/v1") class ProgressController( private val service: ProgressService, + private val grades: GradeService, private val publications: GradePublicationService, - private val submissions: SubmissionService, ) { @GetMapping("/me/assignments") fun meAssignments( @@ -86,11 +78,11 @@ class ProgressController( @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun setGrade( @Valid @RequestBody body: GradeSetRequest, - ): GradeResponse = service.setGrade(body) + ): GradeResponse = grades.setGrade(body) @GetMapping("/grades") @PreAuthorize("hasAuthority('ROLE_ADMIN')") - fun listGrades(): List = service.listGrades() + fun listGrades(): List = grades.listGrades() @PostMapping("/grades/publish") @PreAuthorize("hasAuthority('ROLE_ADMIN')") @@ -120,7 +112,7 @@ class ProgressController( @AuthenticationPrincipal jwt: Jwt, @RequestParam(required = false) groupId: java.util.UUID?, ): List { - val all = service.listGrades().filter { it.studentId == jwt.subject } + val all = grades.listGrades().filter { it.studentId == jwt.subject } if (groupId == null) return emptyList() return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) } } @@ -129,60 +121,5 @@ class ProgressController( @PreAuthorize("hasAuthority('ROLE_ADMIN')") fun suggestComments( @RequestParam q: String, - ): List = service.suggestComments(q) - - @PostMapping("/me/submissions/{labId}") - fun submit( - @PathVariable labId: UUID, - @RequestPart("files") files: List, - @AuthenticationPrincipal jwt: Jwt, - ): List { - val sid = UUID.fromString(jwt.subject) - val all = submissions.submit(sid, labId, files, jwt.tokenValue) - return submissions.mySubmissions(sid, labId) - } - - @GetMapping("/me/submissions") - fun mySubmissions( - @RequestParam labId: UUID, - @AuthenticationPrincipal jwt: Jwt, - ): List = submissions.mySubmissions(UUID.fromString(jwt.subject), labId) - - @GetMapping("/me/submissions/{id}/download") - fun downloadMine( - @PathVariable id: UUID, - @AuthenticationPrincipal jwt: Jwt, - ): ResponseEntity { - val sid = UUID.fromString(jwt.subject) - val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false) - return attachment(bytes, name) - } - - @GetMapping("/submissions") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") - fun labSubmissions( - @RequestParam labId: UUID, - ): List = submissions.latestPerStudent(labId) - - @GetMapping("/submissions/{id}/download") - @PreAuthorize("hasAuthority('ROLE_ADMIN')") - fun downloadForAdmin( - @PathVariable id: UUID, - @AuthenticationPrincipal jwt: Jwt, - ): ResponseEntity { - val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true) - return attachment(bytes, name) - } - - private fun attachment( - bytes: ByteArray, - name: String, - ): ResponseEntity = - ResponseEntity - .ok() - .header( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}", - ).contentType(MediaType.APPLICATION_OCTET_STREAM) - .body(bytes) + ): List = grades.suggestComments(q) } diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt index a973cb9..7a3bf37 100644 --- a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/ProgressService.kt @@ -3,28 +3,17 @@ package ru.ysu.lms.progress import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import ru.ysu.lms.common.BadRequestException -import ru.ysu.lms.progress.domain.CommentTemplate -import ru.ysu.lms.progress.domain.GradeComment -import ru.ysu.lms.progress.domain.LabGrade -import ru.ysu.lms.progress.domain.LabGradeKey import ru.ysu.lms.progress.domain.StudentLabLevel import ru.ysu.lms.progress.domain.StudentLabLevelKey import ru.ysu.lms.progress.domain.VariantAssignment -import ru.ysu.lms.progress.repo.CommentTemplateRepository -import ru.ysu.lms.progress.repo.GradeCommentRepository -import ru.ysu.lms.progress.repo.LabGradeRepository import ru.ysu.lms.progress.repo.StudentLabLevelRepository import ru.ysu.lms.progress.repo.VariantAssignmentRepository -import java.math.BigDecimal import java.util.UUID @Service class ProgressService( private val levels: StudentLabLevelRepository, private val assignments: VariantAssignmentRepository, - private val grades: LabGradeRepository, - private val templates: CommentTemplateRepository, - private val gradeComments: GradeCommentRepository, private val academic: AcademicClient, private val identity: IdentityClient, ) { @@ -270,86 +259,6 @@ class ProgressService( @Transactional(readOnly = true) fun getStudentAssignments(studentId: UUID): List = assignments.findByStudentId(studentId) - @Transactional - fun setGrade(request: GradeSetRequest): GradeResponse { - if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) { - throw BadRequestException("Оценка должна быть в диапазоне 0–5") - } - val sid = UUID.fromString(request.studentId) - val lid = UUID.fromString(request.labId) - val key = LabGradeKey(sid, lid) - val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) } - grade.score = request.score - grades.save(grade) - replaceComments(sid, lid, request.comments) - return GradeResponse( - request.studentId, - request.labId, - request.score, - commentsOf(sid, lid).map { CommentOut(it.text, it.tone) }, - ) - } - - private fun replaceComments( - sid: UUID, - lid: UUID, - inputs: List, - ) { - val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet() - val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) } - if (existing.toSet() == incoming.toSet()) return - gradeComments.deleteByStudentIdAndLabId(sid, lid) - for ((text, tone) in incoming) { - gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone)) - val t = templates.findByText(text) - if (t == null) { - templates.save(CommentTemplate(text = text, tone = tone, useCount = 1)) - } else { - t.useCount += 1 - t.tone = tone - templates.save(t) - } - } - } - - private fun normalizeTone(tone: String): String = - when (tone.lowercase()) { - "positive", "положительный", "позитивный", "pos" -> "positive" - "negative", "негативный", "отрицательный", "neg" -> "negative" - else -> "neutral" - } - - @Transactional(readOnly = true) - fun commentsOf( - sid: UUID, - lid: UUID, - ): List = gradeComments.findByStudentIdAndLabId(sid, lid) - - @Transactional(readOnly = true) - fun suggestComments(query: String): List = - templates - .findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim()) - .map { CommentOut(it.text, it.tone) } - - @Transactional(readOnly = true) - fun myComments(studentId: UUID): List = gradeComments.findByStudentId(studentId) - - @Transactional(readOnly = true) - fun listGrades(): List { - val byGrade = - gradeComments - .findAll() - .groupBy { it.studentId to it.labId } - return grades.findAllBy().map { - GradeResponse( - studentId = it.studentId.toString(), - labId = it.labId.toString(), - score = it.score, - comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) }, - ) - } - } - @Transactional(readOnly = true) fun getStudentAssignment( studentId: UUID, diff --git a/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionController.kt b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionController.kt new file mode 100644 index 0000000..698a278 --- /dev/null +++ b/services/progress-service/src/main/kotlin/ru/ysu/lms/progress/SubmissionController.kt @@ -0,0 +1,80 @@ +package ru.ysu.lms.progress + +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.security.oauth2.jwt.Jwt +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.multipart.MultipartFile +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.UUID + +@RestController +@RequestMapping("/api/v1") +class SubmissionController( + private val submissions: SubmissionService, +) { + @PostMapping("/me/submissions/{labId}") + fun submit( + @PathVariable labId: UUID, + @RequestPart("files") files: List, + @AuthenticationPrincipal jwt: Jwt, + ): List { + val sid = UUID.fromString(jwt.subject) + submissions.submit(sid, labId, files, jwt.tokenValue) + return submissions.mySubmissions(sid, labId) + } + + @GetMapping("/me/submissions") + fun mySubmissions( + @RequestParam labId: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): List = submissions.mySubmissions(UUID.fromString(jwt.subject), labId) + + @GetMapping("/me/submissions/{id}/download") + fun downloadMine( + @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + val sid = UUID.fromString(jwt.subject) + val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false) + return attachment(bytes, name) + } + + @GetMapping("/submissions") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun labSubmissions( + @RequestParam labId: UUID, + ): List = submissions.latestPerStudent(labId) + + @GetMapping("/submissions/{id}/download") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + fun downloadForAdmin( + @PathVariable id: UUID, + @AuthenticationPrincipal jwt: Jwt, + ): ResponseEntity { + val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true) + return attachment(bytes, name) + } + + private fun attachment( + bytes: ByteArray, + name: String, + ): ResponseEntity = + ResponseEntity + .ok() + .header( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}", + ).contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(bytes) +}