Refactor: split ContentService and ProgressService; defaults tightened

This commit is contained in:
2026-09-27 22:48:13 +03:00
parent 290b2a5a20
commit 4035bc5a0f
10 changed files with 577 additions and 502 deletions

View File

@@ -26,8 +26,8 @@ complexity:
- 'GetMapping' - 'GetMapping'
TooManyFunctions: TooManyFunctions:
# ContentService (26) — следующий кандидат на разделение. # ContentService (26) — следующий кандидат на разделение.
thresholdInFiles: 27 thresholdInFiles: 20
thresholdInClasses: 27 thresholdInClasses: 20
thresholdInInterfaces: 20 thresholdInInterfaces: 20
thresholdInObjects: 20 thresholdInObjects: 20
thresholdInEnums: 20 thresholdInEnums: 20

View File

@@ -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,
)
}
}

View File

@@ -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<ReorderSection>,
) {
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<ReorderArticle>,
) {
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()
}

View File

@@ -2,13 +2,9 @@ package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional 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.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository 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.NotFoundException import ru.ysu.lms.common.NotFoundException
import java.util.UUID 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<ReorderSection>,
) {
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<ReorderArticle>,
) {
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 <T> requireFound( private fun <T> requireFound(
value: T?, value: T?,
message: String, message: String,
@@ -257,8 +182,6 @@ class ContentService(
if (!includeHidden && !published) throw NotFoundException(message) if (!includeHidden && !published) throw NotFoundException(message)
} }
private fun uuidOrNull(value: String): UUID? = runCatching { UUID.fromString(value) }.getOrNull()
@Transactional @Transactional
fun setChapterPublished( fun setChapterPublished(
chapterId: UUID, 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 @Transactional
fun importZip( fun importZip(
filename: String, filename: String,

View File

@@ -12,11 +12,9 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile 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.ContentImportResult
import ru.ysu.lms.academic.content.ContentService 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 ru.ysu.lms.common.BadRequestException
import java.util.UUID import java.util.UUID
@@ -25,6 +23,7 @@ import java.util.UUID
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
class ContentAdminController( class ContentAdminController(
private val contentService: ContentService, private val contentService: ContentService,
private val authoring: ContentAuthoringService,
) { ) {
data class ArticleUpdatePayload( data class ArticleUpdatePayload(
val title: String, val title: String,
@@ -111,32 +110,6 @@ class ContentAdminController(
contentService.setArticlePublished(id, body.published) 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") @PostMapping("/import")
fun import( fun import(
@RequestParam("file") file: MultipartFile, @RequestParam("file") file: MultipartFile,
@@ -152,19 +125,19 @@ class ContentAdminController(
@PostMapping("/chapters") @PostMapping("/chapters")
fun createChapter( fun createChapter(
@RequestBody body: ChapterCreatePayload, @RequestBody body: ChapterCreatePayload,
) = contentService.createChapter(body.title, body.slug) ) = authoring.createChapter(body.title, body.slug)
@PutMapping("/chapters/{id}") @PutMapping("/chapters/{id}")
fun renameChapter( fun renameChapter(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: TitlePayload, @RequestBody body: TitlePayload,
) = contentService.renameChapter(id, body.title) ) = authoring.renameChapter(id, body.title)
@DeleteMapping("/chapters/{id}") @DeleteMapping("/chapters/{id}")
fun deleteChapter( fun deleteChapter(
@PathVariable id: UUID, @PathVariable id: UUID,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
contentService.deleteChapter(id) authoring.deleteChapter(id)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@@ -172,13 +145,13 @@ class ContentAdminController(
fun createSection( fun createSection(
@RequestParam chapterId: UUID, @RequestParam chapterId: UUID,
@RequestBody body: SectionCreatePayload, @RequestBody body: SectionCreatePayload,
) = contentService.createSection(chapterId, body.title, body.slug) ) = authoring.createSection(chapterId, body.title, body.slug)
@PutMapping("/sections/{id}") @PutMapping("/sections/{id}")
fun renameSection( fun renameSection(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: TitlePayload, @RequestBody body: TitlePayload,
) = contentService.renameSection(id, body.title) ) = authoring.renameSection(id, body.title)
@DeleteMapping("/sections/{id}") @DeleteMapping("/sections/{id}")
fun deleteSection( fun deleteSection(
@@ -186,9 +159,9 @@ class ContentAdminController(
@RequestParam(required = false) moveTo: UUID?, @RequestParam(required = false) moveTo: UUID?,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
if (moveTo != null) { if (moveTo != null) {
contentService.moveArticlesAndDeleteSection(id, moveTo) authoring.moveArticlesAndDeleteSection(id, moveTo)
} else { } else {
contentService.deleteSection(id) authoring.deleteSection(id)
} }
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
@@ -196,7 +169,7 @@ class ContentAdminController(
@PostMapping("/articles") @PostMapping("/articles")
fun createArticle( fun createArticle(
@RequestBody body: ArticleCreatePayload, @RequestBody body: ArticleCreatePayload,
) = contentService.createArticlePlaced( ) = authoring.createArticlePlaced(
title = body.title, title = body.title,
bodyMd = body.bodyMd, bodyMd = body.bodyMd,
chapterId = body.chapterId?.let { UUID.fromString(it) }, chapterId = body.chapterId?.let { UUID.fromString(it) },
@@ -210,20 +183,20 @@ class ContentAdminController(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: ArticleUpdatePayload, @RequestBody body: ArticleUpdatePayload,
) { ) {
contentService.updateArticle(id, body.title, body.bodyMd) authoring.updateArticle(id, body.title, body.bodyMd)
} }
@PostMapping("/articles/{id}/move") @PostMapping("/articles/{id}/move")
fun move( fun move(
@PathVariable id: UUID, @PathVariable id: UUID,
@RequestBody body: ArticleMovePayload, @RequestBody body: ArticleMovePayload,
) = contentService.moveArticle(id, UUID.fromString(body.sectionId)) ) = authoring.moveArticle(id, UUID.fromString(body.sectionId))
@DeleteMapping("/articles/{id}") @DeleteMapping("/articles/{id}")
fun delete( fun delete(
@PathVariable id: UUID, @PathVariable id: UUID,
): ResponseEntity<Void> { ): ResponseEntity<Void> {
contentService.deleteArticle(id) authoring.deleteArticle(id)
return ResponseEntity.noContent().build() return ResponseEntity.noContent().build()
} }
} }

View File

@@ -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)
}
}

View File

@@ -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<CommentInput>,
) {
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<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
@Transactional(readOnly = true)
fun suggestComments(query: String): List<CommentOut> =
templates
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
.map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true)
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> {
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) },
)
}
}
}

View File

@@ -1,31 +1,23 @@
package ru.ysu.lms.progress package ru.ysu.lms.progress
import jakarta.validation.Valid 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.access.prepost.PreAuthorize
import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping 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.PostMapping
import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam 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.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID import java.util.UUID
@RestController @RestController
@RequestMapping("/api/v1") @RequestMapping("/api/v1")
class ProgressController( class ProgressController(
private val service: ProgressService, private val service: ProgressService,
private val grades: GradeService,
private val publications: GradePublicationService, private val publications: GradePublicationService,
private val submissions: SubmissionService,
) { ) {
@GetMapping("/me/assignments") @GetMapping("/me/assignments")
fun meAssignments( fun meAssignments(
@@ -86,11 +78,11 @@ class ProgressController(
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setGrade( fun setGrade(
@Valid @RequestBody body: GradeSetRequest, @Valid @RequestBody body: GradeSetRequest,
): GradeResponse = service.setGrade(body) ): GradeResponse = grades.setGrade(body)
@GetMapping("/grades") @GetMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun listGrades(): List<GradeResponse> = service.listGrades() fun listGrades(): List<GradeResponse> = grades.listGrades()
@PostMapping("/grades/publish") @PostMapping("/grades/publish")
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -120,7 +112,7 @@ class ProgressController(
@AuthenticationPrincipal jwt: Jwt, @AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) groupId: java.util.UUID?, @RequestParam(required = false) groupId: java.util.UUID?,
): List<GradeResponse> { ): List<GradeResponse> {
val all = service.listGrades().filter { it.studentId == jwt.subject } val all = grades.listGrades().filter { it.studentId == jwt.subject }
if (groupId == null) return emptyList() if (groupId == null) return emptyList()
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) } return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
} }
@@ -129,60 +121,5 @@ class ProgressController(
@PreAuthorize("hasAuthority('ROLE_ADMIN')") @PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun suggestComments( fun suggestComments(
@RequestParam q: String, @RequestParam q: String,
): List<CommentOut> = service.suggestComments(q) ): List<CommentOut> = grades.suggestComments(q)
@PostMapping("/me/submissions/{labId}")
fun submit(
@PathVariable labId: UUID,
@RequestPart("files") files: List<MultipartFile>,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> {
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<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
@GetMapping("/me/submissions/{id}/download")
fun downloadMine(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
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<SubmissionDto> = submissions.latestPerStudent(labId)
@GetMapping("/submissions/{id}/download")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun downloadForAdmin(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
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<ByteArray> =
ResponseEntity
.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
).contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes)
} }

View File

@@ -3,28 +3,17 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException 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.StudentLabLevel
import ru.ysu.lms.progress.domain.StudentLabLevelKey import ru.ysu.lms.progress.domain.StudentLabLevelKey
import ru.ysu.lms.progress.domain.VariantAssignment 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.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import java.math.BigDecimal
import java.util.UUID import java.util.UUID
@Service @Service
class ProgressService( class ProgressService(
private val levels: StudentLabLevelRepository, private val levels: StudentLabLevelRepository,
private val assignments: VariantAssignmentRepository, private val assignments: VariantAssignmentRepository,
private val grades: LabGradeRepository,
private val templates: CommentTemplateRepository,
private val gradeComments: GradeCommentRepository,
private val academic: AcademicClient, private val academic: AcademicClient,
private val identity: IdentityClient, private val identity: IdentityClient,
) { ) {
@@ -270,86 +259,6 @@ class ProgressService(
@Transactional(readOnly = true) @Transactional(readOnly = true)
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId) fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = 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<CommentInput>,
) {
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<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
@Transactional(readOnly = true)
fun suggestComments(query: String): List<CommentOut> =
templates
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
.map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true)
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> {
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) @Transactional(readOnly = true)
fun getStudentAssignment( fun getStudentAssignment(
studentId: UUID, studentId: UUID,

View File

@@ -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<MultipartFile>,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> {
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<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
@GetMapping("/me/submissions/{id}/download")
fun downloadMine(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
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<SubmissionDto> = submissions.latestPerStudent(labId)
@GetMapping("/submissions/{id}/download")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun downloadForAdmin(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
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<ByteArray> =
ResponseEntity
.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
).contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes)
}