Compare commits

...

17 Commits

Author SHA1 Message Date
debf157194 Refactor: bind admin forms via @ModelAttribute DTOs; detekt fully default 2026-09-27 23:13:29 +03:00
6129055962 Refactor: extract helpers from groupDetail/editLab; detekt thresholds to defaults 2026-09-27 22:58:35 +03:00
4035bc5a0f Refactor: split ContentService and ProgressService; defaults tightened 2026-09-27 22:48:13 +03:00
290b2a5a20 Refactor: split content order API; near-default detekt thresholds 2026-09-27 22:38:48 +03:00
3ed48b35fc Refactor: split AdminController into AdminSupport and domain controllers 2026-09-27 22:36:41 +03:00
f08ab16af0 Refactor: move defense API methods into separate extension file 2026-09-27 22:31:06 +03:00
c22eb15d3e Refactor: split ApiClient into domain extension files 2026-09-27 22:29:57 +03:00
6e891e4326 Test: Testcontainers integration tests for distribution and content import 2026-09-27 22:22:54 +03:00
47ec9884a3 Add ContentImporter tests; drop redundant elvis; document lint/test commands 2026-09-27 22:17:59 +03:00
a87110580b Lint: strict mode on; tune detekt thresholds; more decompositions 2026-09-27 22:15:00 +03:00
4a8a10971c Refactor: bind content admin forms via @ModelAttribute DTOs 2026-09-27 22:06:09 +03:00
9a2a4023b5 Refactor: split loop/throws helpers; drop deprecated JSON accessors 2026-09-27 22:02:56 +03:00
511df7977e Refactor: extract helpers to satisfy detekt (throws, returns, loops) 2026-09-27 22:00:15 +03:00
b7d96d2ea4 Refactor: reduce detekt findings (unused params, throws, long methods, config) 2026-09-27 21:54:00 +03:00
f3e8b2668f Build: enable ktlint+detekt, format sources, add core unit tests 2026-09-27 21:46:05 +03:00
802616918f Admin: name submission archives as group-studentNo-name-attempt 2026-09-27 20:46:17 +03:00
a8785d48d9 Submissions: reject XML project files, show submitted file names to admin 2026-09-24 11:32:19 +03:00
132 changed files with 6743 additions and 3716 deletions

14
.editorconfig Normal file
View File

@@ -0,0 +1,14 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
trim_trailing_whitespace = true
[*.{kt,kts}]
max_line_length = 140
# Значения enum используются как строковые статусы в API/БД — оставляем lowerCamelCase.
ktlint_standard_enum-entry-name-case = disabled

View File

@@ -48,6 +48,20 @@ UML- и архитектурные диаграммы (Mermaid) — в ката
Порты по умолчанию: gateway `:8080`, identity `:8081`, academic `:8082`,
progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`.
## Качество кода и тесты
```bash
./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты
./gradlew ktlintFormat # автоформат Kotlin
```
- `ktlint` отвечает за формат, `detekt` — за качество (сложность, вложенность, ошибки).
Оба подключены во всех Kotlin-модулях конвенцией `lms.lint` и **ломают сборку** при замечаниях.
- Конфигурация: `.editorconfig`, `config/detekt/detekt.yml`.
- Gradle daemon фиксируется на JDK 21 (`gradle/gradle-daemon-jvm.properties`): detekt 1.23
не работает на JDK 25.
- Тестовая инфраструктура — `libs:test-support` (JUnit 5, MockK, Testcontainers `postgres:18`).
## Запуск в Docker (для сервера)
На сервере Java не нужна: всё собирается и работает в контейнерах (multi-stage

View File

@@ -13,4 +13,6 @@ dependencies {
implementation(libs.kotlinAllopenPlugin)
implementation(libs.kotlinNoargPlugin)
implementation(libs.springBootGradlePlugin)
implementation(libs.ktlintGradlePlugin)
implementation(libs.detektGradlePlugin)
}

View File

@@ -4,6 +4,7 @@ dependencyResolutionManagement {
@Suppress("UnstableApiUsage")
repositories {
mavenCentral()
gradlePluginPortal()
}
// Reuse the version catalog from the main build.

View File

@@ -0,0 +1,34 @@
// Единая конфигурация статического анализа Kotlin: ktlint (формат) + detekt (качество).
// На время миграции ignoreFailures=true — задачи только отчитываются, не ломая сборку.
import io.gitlab.arturbosch.detekt.Detekt
plugins {
id("org.jlleitschuh.gradle.ktlint")
id("io.gitlab.arturbosch.detekt")
}
ktlint {
ignoreFailures.set(false)
filter {
exclude("**/build/**")
}
}
detekt {
buildUponDefaultConfig = true
config.setFrom(rootProject.files("config/detekt/detekt.yml"))
ignoreFailures = false
parallel = true
}
tasks.withType<Detekt>().configureEach {
jvmTarget = "21"
exclude("**/build/**")
reports {
html.required.set(true)
xml.required.set(false)
txt.required.set(false)
sarif.required.set(false)
md.required.set(false)
}
}

View File

@@ -6,6 +6,7 @@ plugins {
id("org.jetbrains.kotlin.plugin.spring")
id("org.jetbrains.kotlin.plugin.jpa")
id("org.springframework.boot")
id("lms.lint")
}
group = "ru.ysu.lms"

View File

@@ -4,6 +4,7 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent
plugins {
kotlin("jvm")
id("org.jetbrains.kotlin.plugin.spring")
id("lms.lint")
}
group = "ru.ysu.lms"

71
config/detekt/detekt.yml Normal file
View File

@@ -0,0 +1,71 @@
# Конфигурация detekt для LMS. Значения подобраны под текущий стиль кода:
# базовые правила включены (buildUponDefaultConfig = true), шумные отключены или ослаблены.
build:
maxIssues: 0
weights: {}
config:
validation: true
warningsAsErrors: false
complexity:
# Все правила сложности — строго на значениях detekt по умолчанию. Крупные агрегаты
# раздроблены: ApiClient — по доменам, AdminController — AdminSupport + 5 контроллеров,
# импорт — ContentImporter, авторинг/порядок и оценки — отдельные сервисы, submissions —
# отдельный контроллер; MVC-формы связаны через @ModelAttribute-DTO.
LongMethod:
threshold: 60
LongParameterList:
functionThreshold: 6
constructorThreshold: 7
ignoreDefaultParameters: true
TooManyFunctions:
thresholdInFiles: 20
thresholdInClasses: 20
thresholdInInterfaces: 20
thresholdInObjects: 20
thresholdInEnums: 20
LargeClass:
threshold: 600
ComplexCondition:
threshold: 4
CyclomaticComplexMethod:
threshold: 15
style:
MagicNumber:
active: false
MaxLineLength:
active: false
ReturnCount:
max: 4
WildcardImport:
active: true
UnusedPrivateMember:
active: true
LoopWithTooManyJumpStatements:
maxJumpCount: 2
# MVC-контроллеры возвращают имя шаблона — это не «магическая константа».
FunctionOnlyReturningConstant:
ignoreAnnotated:
- 'GetMapping'
- 'PostMapping'
performance:
# runApplication<App>(*args) — идиоматичная точка входа Spring Boot.
SpreadOperator:
active: false
naming:
FunctionNaming:
ignoreAnnotated:
- 'Test'
MatchingDeclarationName:
active: false
# Enum-значения хранятся в БД и API строками в lowerCamelCase.
EnumNaming:
active: false
empty-blocks:
EmptyCatchBlock:
allowedExceptionNameRegex: 'ignored|expected|_'

View File

@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cb205e54cea236622eee3d62deb2c768/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/452acd2202c1005abda962c6dae7fc5f/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cb205e54cea236622eee3d62deb2c768/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/452acd2202c1005abda962c6dae7fc5f/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/faa59212208d027d066d1bbe337c5da5/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/adf5b89d64773eff73f900e61dd2d632/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cb205e54cea236622eee3d62deb2c768/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/452acd2202c1005abda962c6dae7fc5f/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/e0e37f85a97c5f2df685b37b14a25072/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ccc3884e65cd85bb71a09d2223c77229/redirect
toolchainVersion=21

View File

@@ -10,6 +10,9 @@ springdoc = "3.0.3"
flexmark = "0.64.8"
owaspSanitizer = "20260313.1"
apachePoi = "5.5.1"
ktlint = "14.2.0"
detekt = "1.23.8"
mockk = "1.14.11"
[libraries]
# --- Gradle plugins used from buildSrc ---
@@ -17,6 +20,8 @@ kotlinGradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ver
kotlinAllopenPlugin = { module = "org.jetbrains.kotlin:kotlin-allopen", version.ref = "kotlin" }
kotlinNoargPlugin = { module = "org.jetbrains.kotlin:kotlin-noarg", version.ref = "kotlin" }
springBootGradlePlugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "springBoot" }
ktlintGradlePlugin = { module = "org.jlleitschuh.gradle:ktlint-gradle", version.ref = "ktlint" }
detektGradlePlugin = { module = "io.gitlab.arturbosch.detekt:detekt-gradle-plugin", version.ref = "detekt" }
# --- Platform BOMs (versions only) ---
springBootBom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "springBoot" }
@@ -65,6 +70,8 @@ owaspHtmlSanitizer = { module = "com.googlecode.owasp-java-html-sanitizer:owasp-
apachePoiOoxml = { module = "org.apache.poi:poi-ooxml", version.ref = "apachePoi" }
# --- Tests / Testcontainers (versions managed by the Boot BOM) ---
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
junitPlatformLauncher = { module = "org.junit.platform:junit-platform-launcher" }
springBootTestcontainers = { module = "org.springframework.boot:spring-boot-testcontainers" }
testcontainersPostgresql = { module = "org.testcontainers:testcontainers-postgresql" }
testcontainersJunitJupiter = { module = "org.testcontainers:testcontainers-junit-jupiter" }
@@ -78,3 +85,5 @@ kotlinPluginJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinPluginSpring = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" }
kotlinPluginJpa = { id = "org.jetbrains.kotlin.plugin.jpa", version.ref = "kotlin" }
springBootPlugin = { id = "org.springframework.boot", version.ref = "springBoot" }
ktlintPlugin = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" }
detektPlugin = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" }

View File

@@ -6,7 +6,6 @@ import org.springframework.context.annotation.Bean
@AutoConfiguration
class LmsCommonAutoConfiguration {
@Bean
@ConditionalOnMissingBean
fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler()

View File

@@ -13,17 +13,27 @@ open class LmsException(
val violations: List<FieldViolation> = emptyList(),
) : RuntimeException(message)
class BadRequestException(message: String, code: String? = null) :
LmsException(HttpStatus.BAD_REQUEST, message, code)
class BadRequestException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.BAD_REQUEST, message, code)
class NotFoundException(message: String, code: String? = null) :
LmsException(HttpStatus.NOT_FOUND, message, code)
class NotFoundException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.NOT_FOUND, message, code)
class ConflictException(message: String, code: String? = null) :
LmsException(HttpStatus.CONFLICT, message, code)
class ConflictException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.CONFLICT, message, code)
class ForbiddenException(message: String, code: String? = null) :
LmsException(HttpStatus.FORBIDDEN, message, code)
class ForbiddenException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.FORBIDDEN, message, code)
class UnauthorizedException(message: String, code: String? = null) :
LmsException(HttpStatus.UNAUTHORIZED, message, code)
class UnauthorizedException(
message: String,
code: String? = null,
) : LmsException(HttpStatus.UNAUTHORIZED, message, code)

View File

@@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class LmsExceptionHandler {
@ExceptionHandler(LmsException::class)
fun handleDomain(ex: LmsException): ResponseEntity<ApiError> =
ResponseEntity.status(ex.status).body(
@@ -22,9 +21,10 @@ class LmsExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> {
val violations = ex.bindingResult.fieldErrors.map {
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
}
val violations =
ex.bindingResult.fieldErrors.map {
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
}
return ResponseEntity.badRequest().body(
ApiError(
title = "Validation failed",

View File

@@ -9,4 +9,6 @@ dependencies {
api(libs.owaspHtmlSanitizer)
api(libs.springContext)
api(libs.springBootAutoconfigure)
testImplementation(project(":libs:test-support"))
}

View File

@@ -8,9 +8,7 @@ import org.springframework.context.annotation.Bean
@AutoConfiguration
@EnableConfigurationProperties(MarkdownProperties::class)
class LmsMarkdownAutoConfiguration {
@Bean
@ConditionalOnMissingBean
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer =
MarkdownRenderer(allowHtml = properties.allowHtml)
fun markdownRenderer(properties: MarkdownProperties): MarkdownRenderer = MarkdownRenderer(allowHtml = properties.allowHtml)
}

View File

@@ -32,20 +32,21 @@ import org.owasp.html.PolicyFactory
class MarkdownRenderer(
private val allowHtml: Boolean = false,
) {
private val options = MutableDataSet().apply {
set(
Parser.EXTENSIONS,
listOf(
TablesExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create(),
FootnoteExtension.create(),
AutolinkExtension.create(),
),
)
set(HtmlRenderer.GENERATE_HEADER_ID, true)
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
}
private val options =
MutableDataSet().apply {
set(
Parser.EXTENSIONS,
listOf(
TablesExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create(),
FootnoteExtension.create(),
AutolinkExtension.create(),
),
)
set(HtmlRenderer.GENERATE_HEADER_ID, true)
set(HtmlRenderer.ESCAPE_HTML, !allowHtml)
}
private val parser: Parser = Parser.builder(options).build()
private val renderer: HtmlRenderer = HtmlRenderer.builder(options).build()
@@ -55,21 +56,26 @@ class MarkdownRenderer(
/** Оборачивает таблицы в прокручиваемый контейнер, чтобы на мобильных они не выезжали. */
private fun wrapTables(html: String): String =
html.replace("<table", "<div class=\"table-scroll\"><table")
html
.replace("<table", "<div class=\"table-scroll\"><table")
.replace("</table>", "</table></div>")
private fun renderRaw(markdown: String, depth: Int): String {
private fun renderRaw(
markdown: String,
depth: Int,
): String {
val out = StringBuilder()
var cursor = 0
for (match in SPOILER.findAll(markdown)) {
out.append(renderWithDirectives(markdown.substring(cursor, match.range.first)))
val title = match.groupValues[1].trim().ifBlank { "Спойлер" }
val body = match.groupValues[2]
val inner = if (depth < MAX_DEPTH) {
renderRaw(body, depth + 1)
} else {
renderWithDirectives(body)
}
val inner =
if (depth < MAX_DEPTH) {
renderRaw(body, depth + 1)
} else {
renderWithDirectives(body)
}
out.append("<details class=\"spoiler\"><summary>")
out.append(escapeHtml(title))
out.append("</summary><div class=\"spoiler-body\">")
@@ -81,8 +87,7 @@ class MarkdownRenderer(
return out.toString()
}
private fun renderSegment(markdown: String): String =
renderer.render(parser.parse(markdown))
private fun renderSegment(markdown: String): String = renderer.render(parser.parse(markdown))
/** Обрабатывает построчные директивы `:::figure` (картинка с подписью) и `:::video`. */
private fun renderWithDirectives(markdown: String): String {
@@ -118,51 +123,114 @@ class MarkdownRenderer(
return out.toString()
}
private fun renderInline(text: String): String =
renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
private fun renderInline(text: String): String = renderSegment(text).removePrefix("<p>").removeSuffix("</p>").trim()
private fun escapeHtml(value: String): String = value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
private fun escapeHtml(value: String): String =
value
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
private fun buildSanitizer(): PolicyFactory = HtmlPolicyBuilder()
.allowElements(
"p", "br", "hr",
"h1", "h2", "h3", "h4", "h5", "h6",
"strong", "b", "em", "i", "del", "s", "blockquote",
"ul", "ol", "li",
"a", "img",
"code", "pre",
"table", "thead", "tbody", "tr", "th", "td",
"details", "summary", "div", "span", "sup", "sub", "kbd", "figure", "figcaption",
"video", "source", "track", "audio",
)
.allowAttributes("href").onElements("a")
.allowAttributes("title").onElements("a", "img")
.allowAttributes("src", "alt").onElements("img")
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
.onElements("video")
.allowAttributes("src", "type").onElements("source")
.allowAttributes("src", "kind", "label", "srclang", "default").onElements("track")
.allowAttributes("class").onElements(
"code", "pre", "div", "span", "details", "summary", "table", "th", "td",
"figure", "figcaption", "video", "img",
)
.allowAttributes("id").onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("colspan", "rowspan").onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory()
private fun buildSanitizer(): PolicyFactory =
HtmlPolicyBuilder()
.allowElements(*ALLOWED_ELEMENTS)
.allowAttributes("href")
.onElements("a")
.allowAttributes("title")
.onElements("a", "img")
.allowAttributes("src", "alt")
.onElements("img")
.allowAttributes("src", "poster", "controls", "preload", "width", "height", "loop", "muted", "playsinline")
.onElements("video")
.allowAttributes("src", "type")
.onElements("source")
.allowAttributes("src", "kind", "label", "srclang", "default")
.onElements("track")
.allowAttributes("class")
.onElements(*CLASS_ATTR_ELEMENTS)
.allowAttributes("id")
.onElements("h1", "h2", "h3", "h4", "h5", "h6")
.allowAttributes("colspan", "rowspan")
.onElements("td", "th")
.allowUrlProtocols("http", "https", "mailto")
.requireRelNofollowOnLinks()
.toFactory()
private companion object {
const val MAX_DEPTH = 5
val MEDIA = Regex(
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
)
val SPOILER = Regex(
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
)
/** Теги, разрешённые в отрендеренном HTML (allow-list санитайзера). */
val ALLOWED_ELEMENTS =
arrayOf(
"p",
"br",
"hr",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"strong",
"b",
"em",
"i",
"del",
"s",
"blockquote",
"ul",
"ol",
"li",
"a",
"img",
"code",
"pre",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
"details",
"summary",
"div",
"span",
"sup",
"sub",
"kbd",
"figure",
"figcaption",
"video",
"source",
"track",
"audio",
)
/** Элементы, которым разрешён атрибут class (подсветка кода, фигуры и т.п.). */
val CLASS_ATTR_ELEMENTS =
arrayOf(
"code",
"pre",
"div",
"span",
"details",
"summary",
"table",
"th",
"td",
"figure",
"figcaption",
"video",
"img",
)
val MEDIA =
Regex(
pattern = "(?m)^[ \\t]*:::[ \\t]*(figure|video)[ \\t]+(\\S+)[ \\t]*(?:\\|[ \\t]*(.*?))?[ \\t]*$",
)
val SPOILER =
Regex(
pattern = "(?ms)^[ \\t]*:::[ \\t]*spoiler(?:[ \\t]+(.+?))?[ \\t]*\\r?\\n(.*?)^[ \\t]*:::[ \\t]*$",
)
}
}

View File

@@ -0,0 +1,61 @@
package ru.ysu.lms.markdown
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class MarkdownRendererTest {
private val renderer = MarkdownRenderer()
@Test
fun `renders basic markdown`() {
val html = renderer.render("# Заголовок\n\n**жирный** и *курсив*")
assertThat(html).contains("<h1")
assertThat(html).contains("Заголовок")
assertThat(html).contains("<strong>жирный</strong>")
assertThat(html).contains("<em>курсив</em>")
}
@Test
fun `renders lists and code blocks`() {
val html = renderer.render("- один\n- два\n\n```c\nint x = 1;\n```")
assertThat(html).contains("<ul>")
assertThat(html).contains("<li>")
assertThat(html).contains("один")
assertThat(html).contains("<pre>")
assertThat(html).contains("language-c")
assertThat(html).contains("int x")
}
@Test
fun `wraps tables into scrollable container`() {
val md =
"""
| № | Задание |
|---|---|
| 1 | Сумма |
""".trimIndent()
val html = renderer.render(md)
assertThat(html).contains("<div class=\"table-scroll\"><table")
assertThat(html).contains("</table></div>")
}
@Test
fun `escapes raw html and strips scripts`() {
val html = renderer.render("<script>alert(1)</script>")
assertThat(html).doesNotContain("<script>")
assertThat(html).contains("&lt;script&gt;")
}
@Test
fun `renders spoiler container`() {
val html = renderer.render(":::spoiler Ответ\nскрытый текст\n:::")
assertThat(html).contains("spoiler")
assertThat(html).contains("скрытый текст")
}
}

View File

@@ -2,7 +2,8 @@ package ru.ysu.lms.security
enum class LmsRole {
ADMIN,
STUDENT;
STUDENT,
;
fun authority(): String = "ROLE_$name"
}

View File

@@ -19,14 +19,15 @@ import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
@AutoConfiguration
@EnableConfigurationProperties(JwtProperties::class)
class LmsSecurityAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "lms.jwt", name = ["private-key"])
fun jwtEncoder(properties: JwtProperties): JwtEncoder {
val rsaKey = RSAKey.Builder(RsaKeys.publicKey(properties.publicKey))
.privateKey(RsaKeys.privateKey(properties.privateKey))
.build()
val rsaKey =
RSAKey
.Builder(RsaKeys.publicKey(properties.publicKey))
.privateKey(RsaKeys.privateKey(properties.privateKey))
.build()
return NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
}

View File

@@ -9,24 +9,26 @@ import java.util.Base64
/** Parses PEM-encoded RSA keys (PKCS#8 private, X.509 public). */
object RsaKeys {
fun publicKey(pem: String): RSAPublicKey {
val der = decode(pem)
return KeyFactory.getInstance("RSA")
return KeyFactory
.getInstance("RSA")
.generatePublic(X509EncodedKeySpec(der)) as RSAPublicKey
}
fun privateKey(pem: String): RSAPrivateKey {
val der = decode(pem)
return KeyFactory.getInstance("RSA")
return KeyFactory
.getInstance("RSA")
.generatePrivate(PKCS8EncodedKeySpec(der)) as RSAPrivateKey
}
private fun decode(pem: String): ByteArray {
val body = pem
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
.replace(Regex("-----END [A-Z ]+-----"), "")
.replace(Regex("\\s"), "")
val body =
pem
.replace(Regex("-----BEGIN [A-Z ]+-----"), "")
.replace(Regex("-----END [A-Z ]+-----"), "")
.replace(Regex("\\s"), "")
return Base64.getDecoder().decode(body)
}
}

View File

@@ -3,11 +3,14 @@ plugins {
}
dependencies {
implementation(platform(libs.springBootBom))
api(platform(libs.springBootBom))
// Shared integration-test infrastructure. Consumers add this module as testImplementation.
api(libs.springBootTestcontainers)
api(libs.testcontainersPostgresql)
api(libs.testcontainersJunitJupiter)
api(libs.springBootStarterTest)
api(libs.mockk)
// Gradle 9 требует launcher JUnit Platform на тестовом classpath явно.
api(libs.junitPlatformLauncher)
}

View File

@@ -8,30 +8,29 @@ import java.nio.file.Path
import java.util.UUID
@Component
class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") root: String) {
class AssetStorage(
@Value("\${lms.content.assets-dir:/data/content-assets}") root: String,
) {
private val rootDir: Path = Path.of(root)
init {
Files.createDirectories(rootDir)
}
data class StoredAsset(val publicName: String, val storedPath: Path, val mime: String)
data class StoredAsset(
val publicName: String,
val storedPath: Path,
val mime: String,
)
fun store(originalName: String, bytes: ByteArray): StoredAsset {
fun store(
originalName: String,
bytes: ByteArray,
): StoredAsset {
val ext = originalName.substringAfterLast('.', "").lowercase()
val isVideo = ext in VIDEO_EXTENSIONS
val isImage = ext in IMAGE_EXTENSIONS
if (!isVideo && !isImage) {
throw BadRequestException(
"Недопустимый тип файла: $originalName " +
"(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " +
"видео: ${VIDEO_EXTENSIONS.joinToString(", ")})",
)
}
val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES
if (bytes.size > limit) {
throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)")
}
requireAllowedType(originalName, isVideo, isImage = ext in IMAGE_EXTENSIONS)
requireSizeWithinLimit(originalName, bytes.size, isVideo)
val publicName = "${UUID.randomUUID()}.$ext"
val target = rootDir.resolve(publicName).normalize()
if (!target.startsWith(rootDir)) {
@@ -41,6 +40,31 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
return StoredAsset(publicName, target, mimeFor(ext))
}
private fun requireAllowedType(
originalName: String,
isVideo: Boolean,
isImage: Boolean,
) {
if (!isVideo && !isImage) {
throw BadRequestException(
"Недопустимый тип файла: $originalName " +
"(изображения: ${IMAGE_EXTENSIONS.joinToString(", ")}; " +
"видео: ${VIDEO_EXTENSIONS.joinToString(", ")})",
)
}
}
private fun requireSizeWithinLimit(
originalName: String,
size: Int,
isVideo: Boolean,
) {
val limit = if (isVideo) MAX_VIDEO_BYTES else MAX_IMAGE_BYTES
if (size > limit) {
throw BadRequestException("Файл $originalName слишком большой (> ${limit / 1024 / 1024} МБ)")
}
}
fun read(publicName: String): Pair<Path, String>? {
val clean = publicName.substringAfterLast('/')
val target = rootDir.resolve(clean).normalize()
@@ -50,22 +74,23 @@ class AssetStorage(@Value("\${lms.content.assets-dir:/data/content-assets}") roo
return target to mimeFor(ext)
}
private fun mimeFor(ext: String): String = when (ext) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"gif" -> "image/gif"
"svg" -> "image/svg+xml"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"ogg", "ogv" -> "video/ogg"
else -> "application/octet-stream"
}
private fun mimeFor(ext: String): String =
when (ext) {
"png" -> "image/png"
"jpg", "jpeg" -> "image/jpeg"
"gif" -> "image/gif"
"svg" -> "image/svg+xml"
"webp" -> "image/webp"
"mp4" -> "video/mp4"
"webm" -> "video/webm"
"ogg", "ogv" -> "video/ogg"
else -> "application/octet-stream"
}
private companion object {
val IMAGE_EXTENSIONS = setOf("png", "jpg", "jpeg", "gif", "svg", "webp")
val VIDEO_EXTENSIONS = setOf("mp4", "webm", "ogg", "ogv")
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
const val MAX_IMAGE_BYTES = 10 * 1024 * 1024 // 10 МБ на картинку
const val MAX_VIDEO_BYTES = 100 * 1024 * 1024 // 100 МБ на видео
}
}

View File

@@ -0,0 +1,246 @@
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
data class ArticlePlacement(
val title: String,
val bodyMd: String,
val chapterId: UUID?,
val newChapterTitle: String?,
val sectionId: UUID?,
val newSectionTitle: String?,
)
@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(placement: ArticlePlacement): ContentArticleView {
val targetSection: ContentSection =
when {
placement.sectionId != null && !placement.newSectionTitle.isNullOrBlank() -> {
val anchor =
sections
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
val node = createSection(anchor.chapterId, placement.newSectionTitle, null)
sections
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
placement.sectionId != null ->
sections
.findById(placement.sectionId)
.orElseThrow { NotFoundException("Раздел не найден") }
!placement.newSectionTitle.isNullOrBlank() -> {
val chapter: ContentChapter =
when {
placement.chapterId != null ->
chapters
.findById(placement.chapterId)
.orElseThrow { NotFoundException("Глава не найдена") }
!placement.newChapterTitle.isNullOrBlank() -> {
val node = createChapter(placement.newChapterTitle, null)
chapters
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Глава не найдена") }
}
else -> throw BadRequestException("Укажите главу")
}
val node = createSection(chapter.id, placement.newSectionTitle, null)
sections
.findById(UUID.fromString(node.id))
.orElseThrow { NotFoundException("Раздел не найден") }
}
else -> throw BadRequestException("Укажите раздел")
}
return createArticle(targetSection.id, placement.title, placement.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,288 @@
package ru.ysu.lms.academic.content
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.ContentArticle
import ru.ysu.lms.academic.domain.ContentChapter
import ru.ysu.lms.academic.domain.ContentSection
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.util.UUID
import java.util.zip.ZipInputStream
/**
* Импорт материалов из zip со структурой каталогов:
* `chapters/01-глава/01-раздел/01-статья.md` (front-matter: `title`, `slug`),
* картинки — в `assets/`.
*
* Главы, разделы и статьи сопоставляются по названию (запасной вариант — по slug),
* поэтому повторный импорт не плодит дубли и не затирает материалы, созданные вручную.
*/
@Service
class ContentImporter(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
private val assets: AssetStorage,
) {
@Transactional
fun importZip(
filename: String,
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult {
requireZip(filename)
val files = readZip(bytes)
if (files.isEmpty()) throw BadRequestException("Архив пуст")
val contentFiles = files.filterKeys(::isContentPath)
if (contentFiles.isEmpty()) {
throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
}
val linkRewrite = storeAssets(files.filterKeys(::isAssetsPath))
val parsed = parseArticles(contentFiles)
return mergeIntoDb(parsed, linkRewrite, sync)
}
private fun requireZip(filename: String) {
if (!filename.lowercase().endsWith(".zip")) throw BadRequestException("Ожидается .zip архив")
}
private fun mergeIntoDb(
parsed: List<ParsedArticle>,
linkRewrite: Map<String, String>,
sync: Boolean,
): ContentImportResult {
val state = ImportState()
for (p in parsed) {
val chapter = chapterFor(p, state)
val section = sectionFor(chapter, p, state)
val body = linkRewrite.entries.fold(p.body) { acc, (from, to) -> acc.replace(from, to) }
upsertArticle(section, p, body, state)
}
val removed = if (sync) removeMissing(state) else 0
return ContentImportResult(
chapters = state.chapters,
sections = state.sections,
articles = state.articles,
assets = linkRewrite.size,
removed = removed,
)
}
private fun chapterFor(
p: ParsedArticle,
state: ImportState,
): ContentChapter {
if (state.chapter?.title != p.chapterDir) {
val slug = Slugs.slugify(p.chapterDir) ?: "chapter-${state.chapters + 1}"
val existing = chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug)
val entity =
existing?.apply {
title = p.chapterDir
position = p.chapterPos
} ?: ContentChapter(
title = p.chapterDir,
slug = Slugs.uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
position = p.chapterPos,
)
state.chapter = chapters.save(entity)
state.touchedChapters.add(state.chapter!!.id)
state.chapters += 1
state.section = null
}
return state.chapter ?: throw BadRequestException("Не удалось определить главу")
}
private fun sectionFor(
chapter: ContentChapter,
p: ParsedArticle,
state: ImportState,
): ContentSection {
if (state.section?.title != p.sectionDir) {
val slug = Slugs.slugify(p.sectionDir) ?: "section-${state.sections + 1}"
val existing =
sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug)
val entity =
existing?.apply {
title = p.sectionDir
position = p.sectionPos
} ?: ContentSection(
chapterId = chapter.id,
title = p.sectionDir,
slug =
Slugs.uniqueSlug(
p.sectionDir,
sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet(),
),
position = p.sectionPos,
)
state.section = sections.save(entity)
state.seenSections.add(state.section!!.id)
state.sections += 1
}
return state.section ?: throw BadRequestException("Не удалось определить раздел")
}
private fun upsertArticle(
section: ContentSection,
p: ParsedArticle,
body: String,
state: ImportState,
) {
val slug = p.fmSlug?.takeIf { it.isNotBlank() } ?: Slugs.slugify(p.fileTitle) ?: "article-${state.articles + 1}"
val existing =
articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
?: articles.findBySectionIdAndSlug(section.id, slug)
val article =
(existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
title = p.fileTitle
bodyMd = body
position = p.articlePos
}
articles.save(article)
state.seenArticles.add(article.id)
state.articles += 1
}
/** При синхронизации удаляет разделы и статьи главы, которых не было в архиве. */
private fun removeMissing(state: ImportState): Int =
state.touchedChapters.sumOf { chapterId ->
sections
.findByChapterIdOrderByPositionAscIdAsc(chapterId)
.sumOf { section -> removeMissingInSection(section, state) }
}
private fun removeMissingInSection(
section: ContentSection,
state: ImportState,
): Int {
if (section.id !in state.seenSections) {
val removedCount = articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
sections.delete(section)
return removedCount
}
var removed = 0
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
if (article.id !in state.seenArticles) {
articles.delete(article)
removed++
}
}
return removed
}
private fun storeAssets(assetFiles: Map<String, ByteArray>): Map<String, String> {
val linkRewrite = mutableMapOf<String, String>()
for ((path, data) in assetFiles) {
val name = path.substringAfterLast('/')
if (name.isBlank()) continue
val stored = assets.store(name, data)
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
}
return linkRewrite
}
private fun isAssetsPath(path: String): Boolean = path.contains("/assets/") || path.startsWith("assets/")
private fun isContentPath(path: String): Boolean = (path.contains("/chapters/") || path.startsWith("chapters/")) && path.endsWith(".md")
private fun parseArticles(contentFiles: Map<String, ByteArray>): List<ParsedArticle> =
contentFiles
.map { (path, data) -> parseArticle(path, data) }
.sortedWith(
compareBy(
{ it.chapterPos },
{ it.chapterDir },
{ it.sectionPos },
{ it.sectionDir },
{ it.articlePos },
),
)
private fun parseArticle(
path: String,
data: ByteArray,
): ParsedArticle {
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
val parts = clean.split('/')
if (parts.size < 3) {
throw BadRequestException(
"Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)",
)
}
val chapterDir = parts[0]
val sectionDir = parts[1]
val fileName = parts.drop(2).joinToString("-")
val (frontMatter, body) = splitFrontMatter(String(data, Charsets.UTF_8))
return ParsedArticle(
chapterPos = Slugs.positionOf(chapterDir),
chapterDir = Slugs.titleFromDir(chapterDir),
sectionPos = Slugs.positionOf(sectionDir),
sectionDir = Slugs.titleFromDir(sectionDir),
articlePos = Slugs.positionOf(fileName),
fileTitle = frontMatter["title"] ?: Slugs.titleFromDir(fileName),
fmSlug = frontMatter["slug"],
body = body,
)
}
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) result[entry.name.removePrefix("/")] = zis.readAllBytes()
entry = zis.nextEntry
}
}
return result
}
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines()
val end =
lines.withIndex().firstOrNull { it.index != 0 && it.value.trim() == "---" }?.index
?: return emptyMap<String, String>() to content
val frontMatter = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) frontMatter[parts[0].trim()] = unquote(parts[1].trim())
}
return frontMatter to lines.drop(end + 1).joinToString("\n").trim()
}
private fun unquote(value: String): String =
if (value.length >= 2 && value.first() == '"' && value.last() == '"') {
value.substring(1, value.length - 1)
} else {
value
}
private data class ParsedArticle(
val chapterPos: Int,
val chapterDir: String,
val sectionPos: Int,
val sectionDir: String,
val articlePos: Int,
val fileTitle: String,
val fmSlug: String?,
val body: String,
)
private class ImportState {
var chapter: ContentChapter? = null
var section: ContentSection? = null
var chapters: Int = 0
var sections: Int = 0
var articles: Int = 0
val touchedChapters: MutableSet<UUID> = mutableSetOf()
val seenSections: MutableSet<UUID> = mutableSetOf()
val seenArticles: MutableSet<UUID> = mutableSetOf()
}
}

View File

@@ -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,17 +2,11 @@ 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.io.ByteArrayInputStream
import java.util.UUID
import java.util.zip.ZipInputStream
data class ArticleTreeNode(
val id: String,
@@ -40,7 +34,9 @@ data class ChapterTreeNode(
val sections: List<SectionTreeNode>,
)
data class ContentTree(val chapters: List<ChapterTreeNode>)
data class ContentTree(
val chapters: List<ChapterTreeNode>,
)
data class ContentArticleView(
val id: String,
@@ -68,27 +64,32 @@ data class ContentImportResult(
* (и перенос разделов между главами), порядок статей внутри разделов
* (и перенос статей между разделами). Применяется одним запросом.
*/
data class ReorderArticle(val id: String)
data class ReorderSection(val id: String, val articles: List<ReorderArticle> = emptyList())
data class ReorderChapter(val id: String, val sections: List<ReorderSection> = emptyList())
data class ReorderTree(val chapters: List<ReorderChapter> = emptyList())
data class ReorderArticle(
val id: String,
)
data class ReorderSection(
val id: String,
val articles: List<ReorderArticle> = emptyList(),
)
data class ReorderChapter(
val id: String,
val sections: List<ReorderSection> = emptyList(),
)
data class ReorderTree(
val chapters: List<ReorderChapter> = emptyList(),
)
/** Порядок разделов внутри одной главы (drag & drop на странице главы). */
data class ReorderSections(val sections: List<ReorderSection> = emptyList())
data class ReorderSections(
val sections: List<ReorderSection> = emptyList(),
)
/** Порядок статей внутри одного раздела (drag & drop на странице раздела). */
data class ReorderArticles(val articles: List<ReorderArticle> = emptyList())
private data class ParsedArticle(
val path: String,
val chapterPos: Int,
val chapterDir: String,
val sectionPos: Int,
val sectionDir: String,
val articlePos: Int,
val fileTitle: String,
val fmSlug: String?,
val body: String,
data class ReorderArticles(
val articles: List<ReorderArticle> = emptyList(),
)
@Service
@@ -96,18 +97,21 @@ class ContentService(
private val chapters: ContentChapterRepository,
private val sections: ContentSectionRepository,
private val articles: ContentArticleRepository,
private val assets: AssetStorage,
private val importer: ContentImporter,
) {
@Transactional(readOnly = true)
fun tree(includeHidden: Boolean = false): ContentTree {
val chapterList = chapters.findAllByOrderByPositionAscIdAsc().filter { includeHidden || it.published }
val sectionList = sections.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.chapterId }
val articleList = articles.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.sectionId }
val sectionList =
sections
.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.chapterId }
val articleList =
articles
.findAllByOrderByPositionAscIdAsc()
.filter { includeHidden || it.published }
.groupBy { it.sectionId }
return ContentTree(
chapterList.map { ch ->
ChapterTreeNode(
@@ -116,18 +120,20 @@ class ContentService(
slug = ch.slug,
position = ch.position,
published = ch.published,
sections = (sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode(
id = sec.id.toString(),
title = sec.title,
slug = sec.slug,
position = sec.position,
published = sec.published,
articles = (articleList[sec.id] ?: emptyList()).map { a ->
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
},
)
},
sections =
(sectionList[ch.id] ?: emptyList()).map { sec ->
SectionTreeNode(
id = sec.id.toString(),
title = sec.title,
slug = sec.slug,
position = sec.position,
published = sec.published,
articles =
(articleList[sec.id] ?: emptyList()).map { a ->
ArticleTreeNode(a.id.toString(), a.title, a.slug, a.position, a.published)
},
)
},
)
},
)
@@ -144,12 +150,12 @@ class ContentService(
articleSlug: String,
includeHidden: Boolean = false,
): ContentArticleView {
val ch = chapters.findBySlug(chapterSlug) ?: throw NotFoundException("Глава не найдена")
if (!includeHidden && !ch.published) throw NotFoundException("Глава скрыта")
val sec = sections.findByChapterIdAndSlug(ch.id, sectionSlug) ?: throw NotFoundException("Раздел не найден")
if (!includeHidden && !sec.published) throw NotFoundException("Раздел скрыт")
val a = articles.findBySectionIdAndSlug(sec.id, articleSlug) ?: throw NotFoundException("Статья не найдена")
if (!includeHidden && !a.published) throw NotFoundException("Статья скрыта")
val ch = requireFound(chapters.findBySlug(chapterSlug), "Глава не найдена")
requireVisible(ch.published, includeHidden, "Глава скрыта")
val sec = requireFound(sections.findByChapterIdAndSlug(ch.id, sectionSlug), "Раздел не найден")
requireVisible(sec.published, includeHidden, "Раздел скрыт")
val a = requireFound(articles.findBySectionIdAndSlug(sec.id, articleSlug), "Статья не найдена")
requireVisible(a.published, includeHidden, "Статья скрыта")
return ContentArticleView(
id = a.id.toString(),
chapterSlug = ch.slug,
@@ -163,84 +169,44 @@ 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)
private fun <T> requireFound(
value: T?,
message: String,
): T = value ?: throw NotFoundException(message)
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)
}
}
}
private fun requireVisible(
published: Boolean,
includeHidden: Boolean,
message: String,
) {
if (!includeHidden && !published) throw NotFoundException(message)
}
/** Порядок разделов внутри главы и статей внутри них (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()
@Transactional
fun setChapterPublished(chapterId: UUID, published: Boolean) {
fun setChapterPublished(
chapterId: UUID,
published: Boolean,
) {
val ch = chapters.findById(chapterId).orElseThrow { NotFoundException("Глава не найдена") }
ch.published = published
chapters.save(ch)
}
@Transactional
fun setSectionPublished(sectionId: UUID, published: Boolean) {
fun setSectionPublished(
sectionId: UUID,
published: Boolean,
) {
val sec = sections.findById(sectionId).orElseThrow { NotFoundException("Раздел не найден") }
sec.published = published
sections.save(sec)
}
@Transactional
fun setArticlePublished(articleId: UUID, published: Boolean) {
fun setArticlePublished(
articleId: UUID,
published: Boolean,
) {
val a = articles.findById(articleId).orElseThrow { NotFoundException("Статья не найдена") }
a.published = published
articles.save(a)
@@ -267,400 +233,9 @@ 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 { slugify(it) }
?: 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 { slugify(it) }
?: 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 = 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,
)
}
private fun uniqueSlug(title: String, taken: Set<String>): String {
val base = slugify(title) ?: "item"
if (base !in taken) return base
var i = 2
while ("$base-$i" in taken) i += 1
return "$base-$i"
}
/**
* Импорт zip со структурой каталогов:
* chapters/01-глава/01-раздел/01-статья.md (front-matter: title, slug),
* картинки в assets/.
*/
@Transactional
fun importZip(filename: String, bytes: ByteArray, sync: Boolean = true): ContentImportResult {
if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Ожидается .zip архив")
}
val files = readZip(bytes)
if (files.isEmpty()) throw BadRequestException("Архив пуст")
val assetFiles = files.filterKeys { it.contains("/assets/") || it.startsWith("assets/") }
val contentFiles = files.filterKeys { k ->
(k.contains("/chapters/") || k.startsWith("chapters/")) && k.endsWith(".md")
}
if (contentFiles.isEmpty()) throw BadRequestException("В архиве нет каталога chapters/ с .md файлами")
val linkRewrite = mutableMapOf<String, String>()
for ((path, data) in assetFiles) {
val name = path.substringAfterLast('/')
if (name.isBlank()) continue
val stored = assets.store(name, data)
linkRewrite["assets/$name"] = "/api/v1/content/assets/${stored.publicName}"
}
val parsed = contentFiles.map { (path, data) ->
val clean = path.removePrefix("/").removePrefix("chapters/").removeSuffix(".md")
val parts = clean.split('/')
if (parts.size < 3) {
throw BadRequestException("Неверная структура: $path (ожидается chapters/NN-глава/NN-раздел/NN-статья.md)")
}
val chapterDir = parts[0]
val sectionDir = parts[1]
val fileName = parts.drop(2).joinToString("-")
val parsedContent = splitFrontMatter(String(data, Charsets.UTF_8))
ParsedArticle(
path = path,
chapterPos = positionOf(chapterDir),
chapterDir = titleFromDir(chapterDir),
sectionPos = positionOf(sectionDir),
sectionDir = titleFromDir(sectionDir),
articlePos = positionOf(fileName),
fileTitle = parsedContent.first["title"] ?: titleFromDir(fileName),
fmSlug = parsedContent.first["slug"],
body = parsedContent.second,
)
}.sortedWith(
compareBy(
{ it.chapterPos },
{ it.chapterDir },
{ it.sectionPos },
{ it.sectionDir },
{ it.articlePos },
),
)
var chCount = 0
var secCount = 0
var artCount = 0
var removedCount = 0
var currentChapter: ContentChapter? = null
var currentSection: ContentSection? = null
val touchedChapters = mutableSetOf<UUID>()
val seenSections = mutableSetOf<UUID>()
val seenArticles = mutableSetOf<UUID>()
for (p in parsed) {
if (currentChapter?.title != p.chapterDir) {
val slug = chapterSlug(p) ?: "chapter-${chCount + 1}"
// Ищем по названию, затем по slug: так импорт находит главу, созданную
// вручную или импортированную ранее, и не плодит дубли.
currentChapter = (chapters.findByTitleIgnoreCase(p.chapterDir) ?: chapters.findBySlug(slug))?.apply {
title = p.chapterDir
position = p.chapterPos
} ?: chapters.save(
ContentChapter(
title = p.chapterDir,
slug = uniqueSlug(p.chapterDir, chapters.findAllByOrderByPositionAscIdAsc().map { it.slug }.toSet()),
position = p.chapterPos,
),
)
currentChapter = chapters.save(currentChapter!!)
touchedChapters.add(currentChapter!!.id)
chCount += 1
currentSection = null
}
val chapter = currentChapter ?: throw BadRequestException("Не удалось определить главу")
if (currentSection?.title != p.sectionDir) {
val slug = sectionSlug(p) ?: "section-${secCount + 1}"
currentSection = (sections.findByChapterIdAndTitleIgnoreCase(chapter.id, p.sectionDir)
?: sections.findByChapterIdAndSlug(chapter.id, slug))?.apply {
title = p.sectionDir
position = p.sectionPos
} ?: sections.save(
ContentSection(
chapterId = chapter.id,
title = p.sectionDir,
slug = uniqueSlug(p.sectionDir, sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id).map { it.slug }.toSet()),
position = p.sectionPos,
),
)
currentSection = sections.save(currentSection!!)
seenSections.add(currentSection!!.id)
secCount += 1
}
val section = currentSection ?: throw BadRequestException("Не удалось определить раздел")
var body = p.body
for ((from, to) in linkRewrite) {
body = body.replace(from, to)
}
val slug = p.fmSlug?.takeIf { it.isNotBlank() }
?: articleSlug(p)
?: "article-${artCount + 1}"
val existing = articles.findBySectionIdAndTitleIgnoreCase(section.id, p.fileTitle)
?: articles.findBySectionIdAndSlug(section.id, slug)
val article = (existing ?: ContentArticle(sectionId = section.id, slug = slug)).apply {
title = p.fileTitle
bodyMd = body
position = p.articlePos
}
articles.save(article)
seenArticles.add(article.id)
artCount += 1
}
// Синхронизация (если включена): в главах из архива удаляем разделы и статьи,
// которых в архиве нет. В режиме merge (sync = false) ничего не удаляем —
// удобно догружать отдельные статьи, не затрагивая правки, сделанные в интерфейсе.
for (chapterId in if (sync) touchedChapters else emptySet()) {
for (section in sections.findByChapterIdOrderByPositionAscIdAsc(chapterId)) {
if (section.id in seenSections) {
for (article in articles.findBySectionIdOrderByPositionAscIdAsc(section.id)) {
if (article.id !in seenArticles) {
articles.delete(article)
removedCount++
}
}
} else {
removedCount += articles.findBySectionIdOrderByPositionAscIdAsc(section.id).size + 1
sections.delete(section)
}
}
}
return ContentImportResult(
chapters = chCount,
sections = secCount,
articles = artCount,
assets = linkRewrite.size,
removed = removedCount,
)
}
// Slug без числового префикса — такой же, как при ручном создании через интерфейс.
// Номер каталога по-прежнему задаёт порядок (position), но не попадает в адрес.
private fun chapterSlug(p: ParsedArticle): String? = slugify(p.chapterDir)
private fun sectionSlug(p: ParsedArticle): String? = slugify(p.sectionDir)
private fun articleSlug(p: ParsedArticle): String? = slugify(p.fileTitle)
private fun readZip(bytes: ByteArray): Map<String, ByteArray> {
val result = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
result[entry.name.removePrefix("/")] = zis.readAllBytes()
}
entry = zis.nextEntry
}
}
return result
}
private fun splitFrontMatter(content: String): Pair<Map<String, String>, String> {
if (!content.startsWith("---")) return emptyMap<String, String>() to content
val lines = content.lines()
val end = lines.withIndex()
.firstOrNull { it.index != 0 && it.value.trim() == "---" }
?.index ?: return emptyMap<String, String>() to content
val fm = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) {
val v = parts[1].trim()
val unquoted = if (v.length >= 2 && v.first() == '"' && v.last() == '"') {
v.substring(1, v.length - 1)
} else {
v
}
fm[parts[0].trim()] = unquoted
}
}
return fm to lines.drop(end + 1).joinToString("\n").trim()
}
private fun positionOf(name: String): Int {
val m = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
return m?.groupValues?.get(1)?.toIntOrNull() ?: 999
}
private fun titleFromDir(dir: String): String {
val raw = dir.substringAfterLast('/')
.replace(Regex("^\\d{1,3}[-_]"), "")
.replace('_', ' ')
return raw.ifBlank { dir }
}
private fun slugify(value: String): String? {
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
val slug = translit
.replace(Regex("\\s+"), "-")
.replace(Regex("[^a-z0-9-]"), "")
.replace(Regex("-+"), "-")
.trim('-')
return slug.ifBlank { null }
}
private companion object {
val TRANSLIT = mapOf(
"а" to "a", "б" to "b", "в" to "v", "г" to "g", "д" to "d", "е" to "e", "ё" to "e",
"ж" to "zh", "з" to "z", "и" to "i", "й" to "y", "к" to "k", "л" to "l", "м" to "m",
"н" to "n", "о" to "o", "п" to "p", "р" to "r", "с" to "s", "т" to "t", "у" to "u",
"ф" to "f", "х" to "h", "ц" to "c", "ч" to "ch", "ш" to "sh", "щ" to "sch", "ъ" to "",
"ы" to "y", "ь" to "", "э" to "e", "ю" to "yu", "я" to "ya",
)
}
fun importZip(
filename: String,
bytes: ByteArray,
sync: Boolean = true,
): ContentImportResult = importer.importZip(filename, bytes, sync)
}

View File

@@ -0,0 +1,81 @@
package ru.ysu.lms.academic.content
/** Транслитерация и построение slug для глав, разделов и статей. */
object Slugs {
private val TRANSLIT =
mapOf(
"а" to "a",
"б" to "b",
"в" to "v",
"г" to "g",
"д" to "d",
"е" to "e",
"ё" to "e",
"ж" to "zh",
"з" to "z",
"и" to "i",
"й" to "y",
"к" to "k",
"л" to "l",
"м" to "m",
"н" to "n",
"о" to "o",
"п" to "p",
"р" to "r",
"с" to "s",
"т" to "t",
"у" to "u",
"ф" to "f",
"х" to "h",
"ц" to "c",
"ч" to "ch",
"ш" to "sh",
"щ" to "sch",
"ъ" to "",
"ы" to "y",
"ь" to "",
"э" to "e",
"ю" to "yu",
"я" to "ya",
)
/** «01-Ввод-вывод» -> «vvod-vyvod»; null, если не осталось допустимых символов. */
fun slugify(value: String): String? {
val translit = TRANSLIT.entries.fold(value.lowercase()) { acc, (from, to) -> acc.replace(from, to) }
val slug =
translit
.replace(Regex("\\s+"), "-")
.replace(Regex("[^a-z0-9-]"), "")
.replace(Regex("-+"), "-")
.trim('-')
return slug.ifBlank { null }
}
/** Уникальный slug на основе заголовка: добавляет «-2», «-3», если базовый занят. */
fun uniqueSlug(
title: String,
taken: Set<String>,
): String {
val base = slugify(title) ?: "item"
if (base !in taken) return base
var i = 2
while ("$base-$i" in taken) i += 1
return "$base-$i"
}
/** Числовой префикс каталога («02-Матрицы» -> 2); 999, если префикса нет. */
fun positionOf(name: String): Int {
val match = Regex("^(\\d{1,3})[-_]").find(name.substringAfterLast('/'))
return match?.groupValues?.get(1)?.toIntOrNull() ?: 999
}
/** «02-Матрицы» -> «Матрицы» (без числового префикса, подчёркивания → пробелы). */
fun titleFromDir(dir: String): String {
val raw =
dir
.substringAfterLast('/')
.replace(Regex("^\\d{1,3}[-_]"), "")
.replace('_', ' ')
return raw.ifBlank { dir }
}
}

View File

@@ -12,20 +12,25 @@ 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.ArticlePlacement
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
@RestController
@RequestMapping("/api/v1/content")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class ContentAdminController(private val contentService: ContentService) {
class ContentAdminController(
private val contentService: ContentService,
private val authoring: ContentAuthoringService,
) {
data class ArticleUpdatePayload(
val title: String,
val bodyMd: String,
)
data class ArticleUpdatePayload(val title: String, val bodyMd: String)
data class ArticleCreatePayload(
val chapterId: String? = null,
val sectionId: String? = null,
@@ -34,11 +39,28 @@ class ContentAdminController(private val contentService: ContentService) {
val title: String,
val bodyMd: String = "",
)
data class ArticleMovePayload(val sectionId: String)
data class TitlePayload(val title: String)
data class PublishedPayload(val published: Boolean)
data class ChapterCreatePayload(val title: String, val slug: String? = null)
data class SectionCreatePayload(val title: String, val slug: String? = null)
data class ArticleMovePayload(
val sectionId: String,
)
data class TitlePayload(
val title: String,
)
data class PublishedPayload(
val published: Boolean,
)
data class ChapterCreatePayload(
val title: String,
val slug: String? = null,
)
data class SectionCreatePayload(
val title: String,
val slug: String? = null,
)
/** Полное дерево материалов, включая скрытые (для админки). */
@GetMapping("/tree/all")
@@ -53,45 +75,42 @@ class ContentAdminController(private val contentService: ContentService) {
): ContentPublicController.ContentArticleViewDto {
val a = contentService.findArticle(chapter, section, article, includeHidden = true)
return ContentPublicController.ContentArticleViewDto(
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
id = a.id,
chapterSlug = a.chapterSlug,
chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
@PutMapping("/chapters/{id}/published")
fun chapterPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
fun chapterPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setChapterPublished(id, body.published)
}
@PutMapping("/sections/{id}/published")
fun sectionPublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
fun sectionPublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
contentService.setSectionPublished(id, body.published)
}
@PutMapping("/articles/{id}/published")
fun articlePublished(@PathVariable id: UUID, @RequestBody body: PublishedPayload) {
fun articlePublished(
@PathVariable id: UUID,
@RequestBody body: PublishedPayload,
) {
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,
@@ -105,25 +124,35 @@ class ContentAdminController(private val contentService: ContentService) {
fun list() = contentService.listAll()
@PostMapping("/chapters")
fun createChapter(@RequestBody body: ChapterCreatePayload) = contentService.createChapter(body.title, body.slug)
fun createChapter(
@RequestBody body: ChapterCreatePayload,
) = authoring.createChapter(body.title, body.slug)
@PutMapping("/chapters/{id}")
fun renameChapter(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
contentService.renameChapter(id, body.title)
fun renameChapter(
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
) = authoring.renameChapter(id, body.title)
@DeleteMapping("/chapters/{id}")
fun deleteChapter(@PathVariable id: UUID): ResponseEntity<Void> {
contentService.deleteChapter(id)
fun deleteChapter(
@PathVariable id: UUID,
): ResponseEntity<Void> {
authoring.deleteChapter(id)
return ResponseEntity.noContent().build()
}
@PostMapping("/sections")
fun createSection(@RequestParam chapterId: UUID, @RequestBody body: SectionCreatePayload) =
contentService.createSection(chapterId, body.title, body.slug)
fun createSection(
@RequestParam chapterId: UUID,
@RequestBody body: SectionCreatePayload,
) = authoring.createSection(chapterId, body.title, body.slug)
@PutMapping("/sections/{id}")
fun renameSection(@PathVariable id: UUID, @RequestBody body: TitlePayload) =
contentService.renameSection(id, body.title)
fun renameSection(
@PathVariable id: UUID,
@RequestBody body: TitlePayload,
) = authoring.renameSection(id, body.title)
@DeleteMapping("/sections/{id}")
fun deleteSection(
@@ -131,35 +160,46 @@ class ContentAdminController(private val contentService: ContentService) {
@RequestParam(required = false) moveTo: UUID?,
): ResponseEntity<Void> {
if (moveTo != null) {
contentService.moveArticlesAndDeleteSection(id, moveTo)
authoring.moveArticlesAndDeleteSection(id, moveTo)
} else {
contentService.deleteSection(id)
authoring.deleteSection(id)
}
return ResponseEntity.noContent().build()
}
@PostMapping("/articles")
fun createArticle(@RequestBody body: ArticleCreatePayload) = contentService.createArticlePlaced(
title = body.title,
bodyMd = body.bodyMd,
chapterId = body.chapterId?.let { UUID.fromString(it) },
newChapterTitle = body.newChapterTitle,
sectionId = body.sectionId?.let { UUID.fromString(it) },
newSectionTitle = body.newSectionTitle,
fun createArticle(
@RequestBody body: ArticleCreatePayload,
) = authoring.createArticlePlaced(
ArticlePlacement(
title = body.title,
bodyMd = body.bodyMd,
chapterId = body.chapterId?.let { UUID.fromString(it) },
newChapterTitle = body.newChapterTitle,
sectionId = body.sectionId?.let { UUID.fromString(it) },
newSectionTitle = body.newSectionTitle,
),
)
@PutMapping("/articles/{id}")
fun update(@PathVariable id: UUID, @RequestBody body: ArticleUpdatePayload) {
contentService.updateArticle(id, body.title, body.bodyMd)
fun update(
@PathVariable id: UUID,
@RequestBody body: ArticleUpdatePayload,
) {
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))
fun move(
@PathVariable id: UUID,
@RequestBody body: ArticleMovePayload,
) = authoring.moveArticle(id, UUID.fromString(body.sectionId))
@DeleteMapping("/articles/{id}")
fun delete(@PathVariable id: UUID): ResponseEntity<Void> {
contentService.deleteArticle(id)
fun delete(
@PathVariable id: UUID,
): ResponseEntity<Void> {
authoring.deleteArticle(id)
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

@@ -9,7 +9,6 @@ import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.academic.content.AssetStorage
import ru.ysu.lms.academic.content.ContentService
import java.nio.file.Files
/** Публичное чтение учебных материалов — доступно без аутентификации. */
@RestController
@@ -18,7 +17,6 @@ class ContentPublicController(
private val contentService: ContentService,
private val assetStorage: AssetStorage,
) {
@GetMapping("/tree")
fun tree() = contentService.tree()
@@ -30,9 +28,15 @@ class ContentPublicController(
): ContentArticleViewDto {
val a = contentService.findArticle(chapter, section, article)
return ContentArticleViewDto(
id = a.id, chapterSlug = a.chapterSlug, chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug, sectionTitle = a.sectionTitle,
slug = a.slug, title = a.title, bodyMd = a.bodyMd, published = a.published,
id = a.id,
chapterSlug = a.chapterSlug,
chapterTitle = a.chapterTitle,
sectionSlug = a.sectionSlug,
sectionTitle = a.sectionTitle,
slug = a.slug,
title = a.title,
bodyMd = a.bodyMd,
published = a.published,
)
}
@@ -49,13 +53,19 @@ class ContentPublicController(
)
@GetMapping("/assets/{name}")
fun asset(@PathVariable name: String): ResponseEntity<FileSystemResource> {
val (path, mime) = assetStorage.read(name)
?: return ResponseEntity.notFound().build()
fun asset(
@PathVariable name: String,
): ResponseEntity<FileSystemResource> {
val (path, mime) =
assetStorage.read(name)
?: return ResponseEntity.notFound().build()
val media = runCatching { MediaType.parseMediaType(mime) }.getOrElse { MediaType.APPLICATION_OCTET_STREAM }
return ResponseEntity.ok()
return ResponseEntity
.ok()
.contentType(media)
.cacheControl(org.springframework.http.CacheControl.maxAge(java.time.Duration.ofDays(7)))
.body(FileSystemResource(path))
.cacheControl(
org.springframework.http.CacheControl
.maxAge(java.time.Duration.ofDays(7)),
).body(FileSystemResource(path))
}
}
}

View File

@@ -1,46 +1,72 @@
package ru.ysu.lms.academic.controller
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import jakarta.validation.Valid
import ru.ysu.lms.academic.lab.*
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.DeleteMapping
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.PutMapping
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.RestController
import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.academic.lab.LabCreateRequest
import ru.ysu.lms.academic.lab.LabDetail
import ru.ysu.lms.academic.lab.LabImportResult
import ru.ysu.lms.academic.lab.LabService
import ru.ysu.lms.academic.lab.LabSummary
import ru.ysu.lms.academic.lab.LabUpdateRequest
import ru.ysu.lms.academic.lab.LevelRenameRequest
import ru.ysu.lms.academic.lab.LevelSaveRequest
import ru.ysu.lms.academic.lab.VariantSaveRequest
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
@RestController
@RequestMapping("/api/v1/labs")
class LabController(private val labService: LabService) {
class LabController(
private val labService: LabService,
) {
@GetMapping
fun list(): List<LabSummary> = labService.list()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id)
fun detail(
@PathVariable id: UUID,
): LabDetail = labService.detail(id)
@PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun importLab(@RequestParam("file") file: MultipartFile): LabImportResult {
fun importLab(
@RequestParam("file") file: MultipartFile,
): LabImportResult {
if (file.isEmpty) throw BadRequestException("Выберите файл")
return labService.importFromFile(file.originalFilename ?: "lab.zip", file.bytes)
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun delete(@PathVariable id: UUID) {
fun delete(
@PathVariable id: UUID,
) {
labService.delete(id)
}
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun update(@PathVariable id: UUID, @Valid @RequestBody body: LabUpdateRequest): LabDetail =
labService.update(id, body)
fun update(
@PathVariable id: UUID,
@Valid @RequestBody body: LabUpdateRequest,
): LabDetail = labService.update(id, body)
@PostMapping("/{id}/variants")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun addVariant(@PathVariable id: UUID, @Valid @RequestBody body: VariantSaveRequest): LabDetail =
labService.addVariant(id, body)
fun addVariant(
@PathVariable id: UUID,
@Valid @RequestBody body: VariantSaveRequest,
): LabDetail = labService.addVariant(id, body)
@PutMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -52,22 +78,31 @@ class LabController(private val labService: LabService) {
@DeleteMapping("/{id}/variants/{variantId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun deleteVariant(@PathVariable id: UUID, @PathVariable variantId: UUID): LabDetail =
labService.deleteVariant(id, variantId)
fun deleteVariant(
@PathVariable id: UUID,
@PathVariable variantId: UUID,
): LabDetail = labService.deleteVariant(id, variantId)
@PostMapping("/{id}/toggle-status")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun toggleStatus(@PathVariable id: UUID) {
fun toggleStatus(
@PathVariable id: UUID,
) {
labService.toggleStatus(id)
}
@PostMapping
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun create(@Valid @RequestBody body: LabCreateRequest): LabDetail = labService.create(body)
fun create(
@Valid @RequestBody body: LabCreateRequest,
): LabDetail = labService.create(body)
@PostMapping("/{id}/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun addLevel(@PathVariable id: UUID, @Valid @RequestBody body: LevelSaveRequest): LabDetail {
fun addLevel(
@PathVariable id: UUID,
@Valid @RequestBody body: LevelSaveRequest,
): LabDetail {
labService.addLevel(id, body)
return labService.detail(id)
}
@@ -82,6 +117,8 @@ class LabController(private val labService: LabService) {
@DeleteMapping("/{id}/levels/{levelId}")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun deleteLevel(@PathVariable id: UUID, @PathVariable levelId: UUID): LabDetail =
labService.deleteLevel(id, levelId)
}
fun deleteLevel(
@PathVariable id: UUID,
@PathVariable levelId: UUID,
): LabDetail = labService.deleteLevel(id, levelId)
}

View File

@@ -38,4 +38,4 @@ class ContentArticle(
@Column(nullable = false) var bodyMd: String = "",
@Column(nullable = false) var position: Int = 0,
@Column(nullable = false) var published: Boolean = true,
)
)

View File

@@ -6,8 +6,6 @@ import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.OneToMany
import jakarta.persistence.OrderBy
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
@@ -37,4 +35,4 @@ class Lab(
var status: String = "draft",
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)
)

View File

@@ -28,4 +28,4 @@ class LabLevel(
var position: Int = 0,
@Column(name = "points_max", columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal? = null,
)
)

View File

@@ -18,4 +18,4 @@ class Semester(
var year: Int = 0,
@Column(nullable = false)
var active: Boolean = false,
)
)

View File

@@ -29,4 +29,4 @@ class Variant(
var statementMd: String = "",
@Column(nullable = false)
var position: Int = 0,
)
)

View File

@@ -119,4 +119,4 @@ data class LabCreateRequest(
data class LevelRenameRequest(
val title: String,
val description: String? = null,
)
)

View File

@@ -16,12 +16,16 @@ import java.util.zip.ZipInputStream
*/
@Component
class LabImportParser {
private val yaml = ObjectMapper(YAMLFactory())
.registerModule(KotlinModule.Builder().build())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
private val yaml =
ObjectMapper(YAMLFactory())
.registerModule(KotlinModule.Builder().build())
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
fun parse(
filename: String,
bytes: ByteArray,
): LabImportBundle {
if (!filename.lowercase().endsWith(".zip")) {
throw BadRequestException("Формат импорта — .zip (lab.yaml + README.md + variants/)")
}
@@ -29,91 +33,117 @@ class LabImportParser {
}
private fun parseZip(bytes: ByteArray): LabImportBundle {
val entries = readEntries(bytes)
val meta = readMeta(entries)
val readmeMd = readReadme(entries)
val variants = mutableListOf<VariantMeta>()
val assets = mutableMapOf<String, ByteArray>()
for ((path, data) in entries) {
val normalized = path.replace('\\', '/')
when {
isInDir(normalized, "variants") ->
parseVariantFile(normalized, String(data, Charsets.UTF_8), meta.levels)?.let(variants::add)
isInDir(normalized, "assets") -> assets[normalized] = data
}
}
if (variants.isEmpty()) {
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
}
return LabImportBundle(meta, readmeMd, variants, assets)
}
private fun readEntries(bytes: ByteArray): Map<String, ByteArray> {
val entries = mutableMapOf<String, ByteArray>()
ZipInputStream(ByteArrayInputStream(bytes)).use { zis ->
var entry = zis.nextEntry
while (entry != null) {
if (!entry.isDirectory) {
entries[entry.name] = zis.readAllBytes()
}
if (!entry.isDirectory) entries[entry.name] = zis.readAllBytes()
entry = zis.nextEntry
}
}
val metaEntry = entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
val meta = yaml.readValue(metaEntry.value, LabMeta::class.java)
val readmeEntry = entries.entries.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
val readmeMd = readmeEntry?.let { String(it.value, Charsets.UTF_8) } ?: ""
val variants = mutableListOf<VariantMeta>()
val assets = mutableMapOf<String, ByteArray>()
for ((path, data) in entries) {
val p = path.replace('\\', '/')
when {
p.contains("/variants/") || p.startsWith("variants/") -> {
val content = String(data, Charsets.UTF_8)
val variant = parseVariantFile(p, content, meta.levels)
if (variant != null) variants.add(variant)
}
p.contains("/assets/") || p.startsWith("assets/") -> {
assets[p] = data
}
}
}
if (variants.isEmpty()) {
throw BadRequestException("В архиве не найдены варианты (папка variants/)")
}
return LabImportBundle(meta, readmeMd, variants, assets)
return entries
}
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
val levelMap = levels.associateBy { it.code }
private fun readMeta(entries: Map<String, ByteArray>): LabMeta {
val metaEntry =
entries.entries.firstOrNull { it.key.endsWith("lab.yaml") || it.key.endsWith("lab.yml") }
?: throw BadRequestException("В архиве не найден lab.yaml")
return yaml.readValue(metaEntry.value, LabMeta::class.java)
}
private fun readReadme(entries: Map<String, ByteArray>): String =
entries.entries
.firstOrNull { it.key.endsWith("/README.md") || it.key == "README.md" }
?.let { String(it.value, Charsets.UTF_8) }
?: ""
private fun isInDir(
path: String,
dir: String,
): Boolean = path.contains("/$dir/") || path.startsWith("$dir/")
private fun parseVariantFile(
path: String,
content: String,
levels: List<LabLevelMeta>,
): VariantMeta? {
val lines = content.lines()
val firstSep = lines.indexOfFirst { it.trim() == "---" }
if (firstSep != 0) {
return VariantMeta(
level = "basic",
number = path.substringAfterLast("/").removeSuffix(".md"),
title = "",
statement = content.trim(),
)
}
val secondSep = lines.withIndex().firstOrNull { it.index != firstSep && it.value.trim() == "---" }?.index ?: -1
if (secondSep < 0) return null
val fmLines = lines.subList(1, secondSep)
val fm = mutableMapOf<String, String>()
for (line in fmLines) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) fm[parts[0].trim()] = unquote(parts[1].trim())
}
val levelCode = normalizeLevel(fm["level"] ?: "basic")
if (levelMap[levelCode] == null) return null
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
val frontMatter =
readFrontMatter(lines)
?: return VariantMeta(
level = "basic",
number = path.substringAfterLast("/").removeSuffix(".md"),
title = "",
statement = content.trim(),
)
val levelCode = normalizeLevel(frontMatter.values["level"] ?: "basic")
val number = frontMatter.values["number"]?.takeIf { it.isNotBlank() }
if (levels.none { it.code == levelCode } || number == null) return null
val body = lines.drop(frontMatter.bodyStart).joinToString("\n").trim()
return VariantMeta(
level = levelCode,
number = fm["number"]?.takeIf { it.isNotBlank() } ?: return null,
title = fm["title"] ?: "",
number = number,
title = frontMatter.values["title"] ?: "",
statement = body.ifBlank { "—" },
)
}
private fun unquote(value: String): String =
if (value.length >= 2 &&
((value.first() == '"' && value.last() == '"') || (value.first() == '\'' && value.last() == '\''))
) {
value.substring(1, value.length - 1)
} else {
value
}
private data class FrontMatter(
val values: Map<String, String>,
val bodyStart: Int,
)
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
/** Читает YAML-front-matter (между двумя `---`), иначе возвращает null. */
private fun readFrontMatter(lines: List<String>): FrontMatter? {
if (lines.firstOrNull()?.trim() != "---") return null
val end =
lines
.withIndex()
.drop(1)
.firstOrNull { it.value.trim() == "---" }
?.index ?: return null
val values = mutableMapOf<String, String>()
for (line in lines.subList(1, end)) {
val parts = line.split(":", limit = 2)
if (parts.size == 2) values[parts[0].trim()] = unquote(parts[1].trim())
}
return FrontMatter(values, end + 1)
}
}
private fun unquote(value: String): String = if (isQuoted(value)) value.substring(1, value.length - 1) else value
private fun isQuoted(value: String): Boolean {
if (value.length < 2) return false
val first = value.first()
val last = value.last()
return (first == '"' && last == '"') || (first == '\'' && last == '\'')
}
private fun normalizeLevel(s: String): String =
when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
}
}

View File

@@ -10,9 +10,9 @@ import ru.ysu.lms.academic.repo.LabLevelRepository
import ru.ysu.lms.academic.repo.LabRepository
import ru.ysu.lms.academic.repo.SemesterRepository
import ru.ysu.lms.academic.repo.VariantRepository
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDateTime
@@ -28,12 +28,15 @@ class LabService(
private val semesters: SemesterRepository,
private val parser: LabImportParser,
) {
@Transactional
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult {
fun importFromFile(
filename: String,
bytes: ByteArray,
): LabImportResult {
val bundle = parser.parse(filename, bytes)
val semester = semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
val semester =
semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
if (labs.existsByNumberAndSemesterId(bundle.meta.number, semester.id)) {
throw ConflictException(
@@ -41,47 +44,44 @@ class LabService(
)
}
val lab = labs.save(Lab(
number = bundle.meta.number,
title = bundle.meta.title,
semester = semester,
descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status,
deadlineAt = parseInstant(bundle.meta.deadlineAt),
defenseAt = parseInstant(bundle.meta.defenseAt),
))
val levelMetas = bundle.meta.levels.ifEmpty {
listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
val lab =
labs.save(
Lab(
number = bundle.meta.number,
title = bundle.meta.title,
semester = semester,
descriptionMd = bundle.descriptionMd,
pointsMax = bundle.meta.pointsMax,
status = bundle.meta.status,
deadlineAt = parseInstant(bundle.meta.deadlineAt),
defenseAt = parseInstant(bundle.meta.defenseAt),
),
)
}
val levelEntities = levelMetas.map { meta ->
labLevels.save(LabLevel(
lab = lab,
code = meta.code,
title = meta.title,
description = meta.description.orEmpty(),
position = meta.position ?: 0,
pointsMax = meta.pointsMax,
))
}
val levelMetas =
bundle.meta.levels.ifEmpty {
listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
val levelEntities =
levelMetas.map { meta ->
labLevels.save(
LabLevel(
lab = lab,
code = meta.code,
title = meta.title,
description = meta.description.orEmpty(),
position = meta.position ?: 0,
pointsMax = meta.pointsMax,
),
)
}
val levelByCode = levelEntities.associateBy { it.code }
bundle.variants.forEach { vmeta ->
val level = levelByCode[vmeta.level]
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
variantsRepo.save(Variant(
lab = lab,
labLevel = level,
number = vmeta.number,
title = vmeta.title,
statementMd = vmeta.statement,
))
}
saveVariants(lab, levelByCode, bundle.variants)
return LabImportResult(
labId = lab.id.toString(),
@@ -122,25 +122,29 @@ class LabService(
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
levels = levels.map { level ->
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
LabLevelDetail(
id = level.id.toString(),
code = level.code,
title = level.title,
variants = vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
levels =
levels.map { level ->
// Сортировка по числу в номере: «2» раньше «10» (номер хранится строкой).
val vs =
variantsRepo
.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
.sortedWith(compareBy({ naturalNumber(it.number) }, { it.number }))
LabLevelDetail(
id = level.id.toString(),
code = level.code,
title = level.title,
variants =
vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
)
}
@@ -153,7 +157,10 @@ class LabService(
}
@Transactional
fun update(labId: UUID, request: LabUpdateRequest): LabDetail {
fun update(
labId: UUID,
request: LabUpdateRequest,
): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.title.isBlank()) throw BadRequestException("Название не может быть пустым")
lab.title = request.title.trim()
@@ -167,70 +174,77 @@ class LabService(
}
@Transactional
fun updateVariant(labId: UUID, variantId: UUID, request: VariantSaveRequest): LabDetail {
val variant = variantsRepo.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
fun updateVariant(
labId: UUID,
variantId: UUID,
request: VariantSaveRequest,
): LabDetail {
val variant =
variantsRepo
.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
variant.number = request.number.trim()
variant.title = request.title.orEmpty().trim()
variant.statementMd = request.statementMd.orEmpty()
request.labLevelId.takeIf { it.isNotBlank() }?.let { levelId ->
variant.labLevel = labLevels.findById(UUID.fromString(levelId))
.orElseThrow { BadRequestException("Уровень не найден") }
variant.labLevel =
labLevels
.findById(UUID.fromString(levelId))
.orElseThrow { BadRequestException("Уровень не найден") }
}
variantsRepo.save(variant)
return detail(labId)
}
@Transactional
fun addVariant(labId: UUID, request: VariantSaveRequest): LabDetail {
fun addVariant(
labId: UUID,
request: VariantSaveRequest,
): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.number.isNullOrBlank()) throw BadRequestException("Номер варианта не может быть пустым")
val level = labLevels.findById(UUID.fromString(request.labLevelId))
.orElseThrow { BadRequestException("Уровень не найден") }
variantsRepo.save(Variant(
lab = lab,
labLevel = level,
number = request.number.trim(),
title = request.title.orEmpty().trim(),
statementMd = request.statementMd.orEmpty(),
))
val level =
labLevels
.findById(UUID.fromString(request.labLevelId))
.orElseThrow { BadRequestException("Уровень не найден") }
variantsRepo.save(
Variant(
lab = lab,
labLevel = level,
number = request.number.trim(),
title = request.title.orEmpty().trim(),
statementMd = request.statementMd.orEmpty(),
),
)
return detail(labId)
}
@Transactional
fun deleteVariant(labId: UUID, variantId: UUID): LabDetail {
val variant = variantsRepo.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
fun deleteVariant(
labId: UUID,
variantId: UUID,
): LabDetail {
val variant =
variantsRepo
.findById(variantId)
.orElseThrow { NotFoundException("Вариант не найден") }
if (variant.lab?.id != labId) throw NotFoundException("Вариант не найден")
variantsRepo.delete(variant)
return detail(labId)
}
@Transactional
fun create(request: LabCreateRequest): LabDetail {
private fun validateCreate(request: LabCreateRequest) {
if (request.title.isNullOrBlank()) throw BadRequestException("Название не может быть пустым")
if ((request.number ?: 0) <= 0) throw BadRequestException("Номер лабораторной должен быть положительным")
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
}
val lab = labs.save(
Lab(
number = request.number ?: 0,
title = request.title.orEmpty().trim(),
semester = semester,
descriptionMd = request.descriptionMd.orEmpty(),
deadlineAt = parseInstant(request.deadlineAt),
defenseAt = parseInstant(request.defenseAt),
pointsMax = request.pointsMax ?: BigDecimal(5),
status = request.status ?: "draft",
),
)
val levelMetas = request.levels.ifEmpty {
listOf(LevelSaveRequest(code = "basic", title = "Основная группа"))
}
}
private fun saveLevels(
lab: Lab,
levels: List<LevelSaveRequest>,
) {
val levelMetas = levels.ifEmpty { listOf(LevelSaveRequest(code = "basic", title = "Основная группа")) }
levelMetas.forEachIndexed { index, meta ->
if (meta.code.isNullOrBlank()) throw BadRequestException("Код уровня не может быть пустым")
labLevels.save(
@@ -243,11 +257,59 @@ class LabService(
),
)
}
}
private fun saveVariants(
lab: Lab,
levelByCode: Map<String, LabLevel>,
variantMetas: List<VariantMeta>,
) {
variantMetas.forEach { vmeta ->
val level =
levelByCode[vmeta.level]
?: throw BadRequestException("Неизвестный уровень ${vmeta.level} у варианта ${vmeta.number}")
variantsRepo.save(
Variant(
lab = lab,
labLevel = level,
number = vmeta.number,
title = vmeta.title,
statementMd = vmeta.statement,
),
)
}
}
@Transactional
fun create(request: LabCreateRequest): LabDetail {
validateCreate(request)
val semester = findOrCreateSemester(request.semesterTitle.orEmpty(), request.semesterYear ?: 0)
if (labs.existsByNumberAndSemesterId(request.number ?: 0, semester.id)) {
throw ConflictException("Лабораторная №${request.number} уже существует в этом семестре")
}
val lab =
labs.save(
Lab(
number = request.number ?: 0,
title = request.title.orEmpty().trim(),
semester = semester,
descriptionMd = request.descriptionMd.orEmpty(),
deadlineAt = parseInstant(request.deadlineAt),
defenseAt = parseInstant(request.defenseAt),
pointsMax = request.pointsMax ?: BigDecimal(5),
status = request.status ?: "draft",
),
)
saveLevels(lab, request.levels)
return detail(lab.id)
}
@Transactional
fun renameLevel(labId: UUID, levelId: UUID, request: LevelRenameRequest): LabDetail {
fun renameLevel(
labId: UUID,
levelId: UUID,
request: LevelRenameRequest,
): LabDetail {
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
level.title = request.title.ifBlank { level.title }
@@ -257,7 +319,10 @@ class LabService(
}
@Transactional
fun deleteLevel(labId: UUID, levelId: UUID): LabDetail {
fun deleteLevel(
labId: UUID,
levelId: UUID,
): LabDetail {
val level = labLevels.findById(levelId).orElseThrow { NotFoundException("Уровень не найден") }
if (level.lab?.id != labId) throw NotFoundException("Уровень не найден")
if (variantsRepo.findByLabIdOrderByNumber(labId).any { it.labLevel?.id == levelId }) {
@@ -267,8 +332,11 @@ class LabService(
return detail(labId)
}
@Transactional
fun addLevel(labId: UUID, request: LevelSaveRequest) {
@Transactional
fun addLevel(
labId: UUID,
request: LevelSaveRequest,
) {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
if (request.code.isBlank()) throw BadRequestException("Код уровня не может быть пустым")
val existing = labLevels.findByLabIdOrderByPosition(labId)
@@ -290,15 +358,24 @@ class LabService(
labs.save(lab)
}
private fun findOrCreateSemester(title: String, year: Int): Semester {
private fun findOrCreateSemester(
title: String,
year: Int,
): Semester {
semesters.findFirstByActiveTrue()?.let { return it }
val t = title.ifBlank { "осень" }
val y = if (year > 0) year else java.time.LocalDate.now().year
val y =
if (year > 0) {
year
} else {
java.time.LocalDate
.now()
.year
}
return semesters.save(Semester(title = t, year = y, active = true))
}
private fun naturalNumber(value: String): Int =
Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
private fun naturalNumber(value: String): Int = Regex("\\d+").find(value)?.value?.toIntOrNull() ?: Int.MAX_VALUE
private fun parseInstant(value: String?): Instant? {
val v = value?.takeIf { it.isNotBlank() } ?: return null
@@ -307,4 +384,4 @@ class LabService(
.recoverCatching { LocalDateTime.parse(v).atZone(ZoneId.of("Europe/Moscow")).toInstant() }
.getOrNull()
}
}
}

View File

@@ -8,20 +8,40 @@ import java.util.UUID
interface ContentChapterRepository : JpaRepository<ContentChapter, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentChapter>
fun findBySlug(slug: String): ContentChapter?
fun findByTitleIgnoreCase(title: String): ContentChapter?
}
interface ContentSectionRepository : JpaRepository<ContentSection, UUID> {
fun findAllByOrderByPositionAscIdAsc(): List<ContentSection>
fun findByChapterIdOrderByPositionAscIdAsc(chapterId: UUID): List<ContentSection>
fun findByChapterIdAndSlug(chapterId: UUID, slug: String): ContentSection?
fun findByChapterIdAndTitleIgnoreCase(chapterId: UUID, title: String): ContentSection?
fun findByChapterIdAndSlug(
chapterId: UUID,
slug: String,
): ContentSection?
fun findByChapterIdAndTitleIgnoreCase(
chapterId: UUID,
title: String,
): ContentSection?
}
interface ContentArticleRepository : JpaRepository<ContentArticle, UUID> {
fun findBySectionIdOrderByPositionAscIdAsc(sectionId: UUID): List<ContentArticle>
fun findBySectionIdAndSlug(sectionId: UUID, slug: String): ContentArticle?
fun findBySectionIdAndTitleIgnoreCase(sectionId: UUID, title: String): ContentArticle?
fun findBySectionIdAndSlug(
sectionId: UUID,
slug: String,
): ContentArticle?
fun findBySectionIdAndTitleIgnoreCase(
sectionId: UUID,
title: String,
): ContentArticle?
fun findAllByOrderByPositionAscIdAsc(): List<ContentArticle>
}
}

View File

@@ -6,5 +6,9 @@ import java.util.UUID
interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab>
fun existsByNumberAndSemesterId(number: Int, semesterId: UUID): Boolean
}
fun existsByNumberAndSemesterId(
number: Int,
semesterId: UUID,
): Boolean
}

View File

@@ -6,4 +6,4 @@ import java.util.UUID
interface SemesterRepository : JpaRepository<Semester, UUID> {
fun findFirstByActiveTrue(): Semester?
}
}

View File

@@ -11,4 +11,4 @@ interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
}
}

View File

@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder
@@ -20,18 +19,19 @@ import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder =
NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@Bean
fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt ->
val role = jwt.getClaimAsString("role")
if (role.isNullOrBlank()) emptyList()
else listOf(SimpleGrantedAuthority("ROLE_$role"))
if (role.isNullOrBlank()) {
emptyList()
} else {
listOf(SimpleGrantedAuthority("ROLE_$role"))
}
}
return converter
}
@@ -43,17 +43,17 @@ class SecurityConfig {
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers(
"/actuator/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/api/v1/content/tree",
"/api/v1/content/assets/**",
"/api/v1/content/{chapter}/{section}/{article}",
).permitAll()
auth
.requestMatchers(
"/actuator/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/api/v1/content/tree",
"/api/v1/content/assets/**",
"/api/v1/content/{chapter}/{section}/{article}",
).permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth ->
}.oauth2ResourceServer { oauth ->
oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
@@ -64,4 +64,4 @@ class SecurityConfig {
companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
}
}
}

View File

@@ -0,0 +1,87 @@
package ru.ysu.lms.academic.content
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.academic.repo.ContentArticleRepository
import ru.ysu.lms.academic.repo.ContentChapterRepository
import ru.ysu.lms.academic.repo.ContentSectionRepository
import java.io.ByteArrayOutputStream
import java.nio.file.Files
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
/**
* Интеграционный тест импорта материалов: реальная PostgreSQL (Testcontainers) + Flyway.
* Проверяет, что повторный импорт не плодит дубли (сопоставление по названию).
*/
@Testcontainers
@SpringBootTest
class ContentImportIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
@JvmStatic
@DynamicPropertySource
fun assetsDir(registry: DynamicPropertyRegistry) {
registry.add("lms.content.assets-dir") { Files.createTempDirectory("lms-assets").toString() }
}
private fun articleZip(): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zos ->
zos.putNextEntry(ZipEntry("chapters/05-Тестовая/01-Раздел/01-Статья.md"))
zos.write(
"---\ntitle: \"Тестовая статья\"\nslug: \"test-article\"\n---\nТело статьи".toByteArray(),
)
zos.closeEntry()
}
return out.toByteArray()
}
}
@Autowired
private lateinit var importer: ContentImporter
@Autowired
private lateinit var chapters: ContentChapterRepository
@Autowired
private lateinit var sections: ContentSectionRepository
@Autowired
private lateinit var articles: ContentArticleRepository
@Test
fun `re-import does not duplicate chapter, section or article`() {
val zip = articleZip()
val first = importer.importZip("content.zip", zip, sync = false)
assertThat(first.chapters).isEqualTo(1)
val chapter = chapters.findByTitleIgnoreCase("Тестовая")
assertThat(chapter).isNotNull
val section = sections.findByChapterIdAndTitleIgnoreCase(chapter!!.id, "Раздел")
assertThat(section).isNotNull
val article = articles.findBySectionIdAndTitleIgnoreCase(section!!.id, "Тестовая статья")
assertThat(article).isNotNull
assertThat(article!!.slug).isEqualTo("test-article")
// повторный импорт того же архива обновляет существующие записи, а не создаёт новые
importer.importZip("content.zip", zip, sync = false)
assertThat(chapters.findByTitleIgnoreCase("Тестовая")!!.id).isEqualTo(chapter.id)
assertThat(sections.findByChapterIdOrderByPositionAscIdAsc(chapter.id)).hasSize(1)
assertThat(articles.findBySectionIdOrderByPositionAscIdAsc(section.id)).hasSize(1)
}
}

View File

@@ -0,0 +1,96 @@
package ru.ysu.lms.academic.content
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
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 java.io.ByteArrayOutputStream
import java.nio.file.Path
import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class ContentImporterTest {
@TempDir
lateinit var tempDir: Path
private val chapters = mockk<ContentChapterRepository>()
private val sections = mockk<ContentSectionRepository>()
private val articles = mockk<ContentArticleRepository>()
private fun importer(): ContentImporter {
every { chapters.findByTitleIgnoreCase(any()) } returns null
every { chapters.findBySlug(any()) } returns null
every { chapters.findAllByOrderByPositionAscIdAsc() } returns emptyList()
every { chapters.save(any()) } answers { firstArg() }
every { sections.findByChapterIdAndTitleIgnoreCase(any(), any()) } returns null
every { sections.findByChapterIdAndSlug(any(), any()) } returns null
every { sections.findByChapterIdOrderByPositionAscIdAsc(any()) } returns emptyList()
every { sections.save(any()) } answers { firstArg() }
every { articles.findBySectionIdAndTitleIgnoreCase(any(), any()) } returns null
every { articles.findBySectionIdAndSlug(any(), any()) } returns null
every { articles.save(any()) } answers { firstArg() }
return ContentImporter(chapters, sections, articles, AssetStorage(tempDir.toString()))
}
private fun articleZip(
title: String,
slug: String,
): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zos ->
zos.putNextEntry(ZipEntry("chapters/01-Глава/01-Раздел/01-Статья.md"))
zos.write("---\ntitle: \"$title\"\nslug: \"$slug\"\n---\nТекст статьи".toByteArray())
zos.closeEntry()
}
return out.toByteArray()
}
@Test
fun `creates chapter, section and article with title-based slug`() {
val importer = importer()
val savedChapter = slot<ContentChapter>()
val savedArticle = slot<ContentArticle>()
every { chapters.save(capture(savedChapter)) } answers { firstArg() }
every { articles.save(capture(savedArticle)) } answers { firstArg() }
val result = importer.importZip("content.zip", articleZip("Ввод-вывод", "c-input"), sync = false)
assertThat(result.chapters).isEqualTo(1)
assertThat(result.sections).isEqualTo(1)
assertThat(result.articles).isEqualTo(1)
assertThat(savedChapter.captured.title).isEqualTo("Глава")
assertThat(savedChapter.captured.slug).isEqualTo("glava")
assertThat(savedArticle.captured.title).isEqualTo("Ввод-вывод")
assertThat(savedArticle.captured.slug).isEqualTo("c-input")
}
@Test
fun `reuses existing chapter and section by title instead of duplicating`() {
val importer = importer()
val chapter = ContentChapter(title = "Глава", slug = "glava", position = 1)
val section = ContentSection(chapterId = chapter.id, title = "Раздел", slug = "razdel", position = 1)
every { chapters.findByTitleIgnoreCase("Глава") } returns chapter
every { sections.findByChapterIdAndTitleIgnoreCase(chapter.id, "Раздел") } returns section
val chapterSaves = mutableListOf<UUID>()
every { chapters.save(any()) } answers {
firstArg<ContentChapter>().also { chapterSaves.add(it.id) }
}
val result = importer.importZip("content.zip", articleZip("Тема", "c-topic"), sync = false)
assertThat(result.chapters).isEqualTo(1)
assertThat(chapterSaves).containsExactly(chapter.id)
}
}

View File

@@ -0,0 +1,95 @@
package ru.ysu.lms.academic.lab
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
class LabImportParserTest {
private val parser = LabImportParser()
private fun zip(vararg files: Pair<String, String>): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zos ->
for ((name, content) in files) {
zos.putNextEntry(ZipEntry(name))
zos.write(content.toByteArray(Charsets.UTF_8))
zos.closeEntry()
}
}
return out.toByteArray()
}
private val labYaml =
"""
number: 2
title: "Многомерные массивы и функции"
levels:
- code: basic
title: "Основная"
- code: advanced
title: "Повышенная"
""".trimIndent()
@Test
fun `parses lab metadata, readme and variants`() {
val bundle =
parser.parse(
"lab.zip",
zip(
"lab.yaml" to labYaml,
"README.md" to "# Условие лабораторной",
"variants/1.md" to "---\nlevel: basic\nnumber: \"1\"\ntitle: \"Сумма\"\n---\nНайдите сумму",
"variants/p1.md" to "---\nlevel: advanced\nnumber: \"П1\"\ntitle: \"Сложная\"\n---\nЗадание",
),
)
assertThat(bundle.meta.number).isEqualTo(2)
assertThat(bundle.meta.title).isEqualTo("Многомерные массивы и функции")
assertThat(bundle.meta.levels).hasSize(2)
assertThat(bundle.descriptionMd).contains("Условие лабораторной")
assertThat(bundle.variants).hasSize(2)
assertThat(bundle.variants.map { it.number }).containsExactlyInAnyOrder("1", "П1")
assertThat(bundle.variants.first { it.number == "1" }.level).isEqualTo("basic")
assertThat(bundle.variants.first { it.number == "1" }.statement).isEqualTo("Найдите сумму")
}
@Test
fun `treats file without front matter as basic variant`() {
val bundle =
parser.parse(
"lab.zip",
zip(
"lab.yaml" to labYaml,
"variants/7.md" to "Просто текст задания",
),
)
assertThat(bundle.variants).hasSize(1)
assertThat(bundle.variants.single().level).isEqualTo("basic")
assertThat(bundle.variants.single().statement).contains("Просто текст задания")
}
@Test
fun `rejects non zip file`() {
assertThatThrownBy { parser.parse("lab.txt", ByteArray(0)) }
.isInstanceOf(BadRequestException::class.java)
}
@Test
fun `rejects zip without lab yaml`() {
assertThatThrownBy { parser.parse("lab.zip", zip("README.md" to "# Условие")) }
.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("lab.yaml")
}
@Test
fun `rejects zip without variants`() {
assertThatThrownBy { parser.parse("lab.zip", zip("lab.yaml" to labYaml, "README.md" to "# Условие")) }
.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("варианты")
}
}

View File

@@ -16,22 +16,30 @@ import reactor.core.publisher.Mono
* identity to the downstream services as `X-Account-*` headers.
*/
@Component
class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter, Ordered {
override fun filter(exchange: ServerWebExchange, chain: GatewayFilterChain): Mono<Void> {
class JwtAuthenticationFilter(
private val jwtDecoder: JwtDecoder,
) : GlobalFilter,
Ordered {
override fun filter(
exchange: ServerWebExchange,
chain: GatewayFilterChain,
): Mono<Void> {
val path = exchange.request.path.value()
if (isPublic(path)) {
return chain.filter(exchange)
}
val token = bearerToken(exchange)
?: return unauthorized(exchange, "Требуется вход")
val token =
bearerToken(exchange)
?: return unauthorized(exchange, "Требуется вход")
return try {
val jwt = jwtDecoder.decode(token)
val request = exchange.request.mutate()
.header("X-Account-Id", jwt.subject.orEmpty())
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
.build()
val request =
exchange.request
.mutate()
.header("X-Account-Id", jwt.subject.orEmpty())
.header("X-Account-Login", jwt.getClaimAsString("login").orEmpty())
.header("X-Account-Role", jwt.getClaimAsString("role").orEmpty())
.build()
chain.filter(exchange.mutate().request(request).build())
} catch (_: JwtException) {
unauthorized(exchange, "Недействительный или истёкший токен")
@@ -56,7 +64,10 @@ class JwtAuthenticationFilter(private val jwtDecoder: JwtDecoder) : GlobalFilter
return header.removePrefix("Bearer ").trim().ifBlank { null }
}
private fun unauthorized(exchange: ServerWebExchange, message: String): Mono<Void> {
private fun unauthorized(
exchange: ServerWebExchange,
message: String,
): Mono<Void> {
val response = exchange.response
response.statusCode = HttpStatus.UNAUTHORIZED
response.headers.contentType = MediaType.APPLICATION_JSON

View File

@@ -18,23 +18,28 @@ class AuthController(
private val authService: AuthService,
private val rsaKey: RSAKey,
) {
@GetMapping("/health")
fun health(): Map<String, Any> = mapOf(
"ok" to true,
"service" to "identity-service",
"adminExists" to authService.adminExists(),
)
fun health(): Map<String, Any> =
mapOf(
"ok" to true,
"service" to "identity-service",
"adminExists" to authService.adminExists(),
)
@PostMapping("/setup")
fun setup(@Valid @RequestBody body: SetupRequest): AuthResponse = authService.setup(body)
fun setup(
@Valid @RequestBody body: SetupRequest,
): AuthResponse = authService.setup(body)
@PostMapping("/login")
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body)
fun login(
@Valid @RequestBody body: LoginRequest,
): AuthResponse = authService.login(body)
@GetMapping("/me")
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse =
authService.me(UUID.fromString(jwt.subject))
fun me(
@AuthenticationPrincipal jwt: Jwt,
): MeResponse = authService.me(UUID.fromString(jwt.subject))
@PostMapping("/change-password")
fun changePassword(

View File

@@ -28,7 +28,6 @@ class AuthService(
private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties,
) {
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
@Transactional
@@ -41,20 +40,22 @@ class AuthService(
throw BadRequestException("Логин: минимум 3 символа")
}
validatePassword(request.password, request.password2)
val account = Account(
login = login,
passwordHash = passwordEncoder.encode(request.password) ?: "",
role = LmsRole.ADMIN,
mustChangePassword = false,
)
val account =
Account(
login = login,
passwordHash = passwordEncoder.encode(request.password) ?: "",
role = LmsRole.ADMIN,
mustChangePassword = false,
)
accounts.save(account)
return toResponse(account, account.login)
}
@Transactional(readOnly = true)
fun login(request: LoginRequest): AuthResponse {
val account = accounts.findByLogin(request.login.trim())
?: throw UnauthorizedException("Неверный логин или пароль")
val account =
accounts.findByLogin(request.login.trim())
?: throw UnauthorizedException("Неверный логин или пароль")
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
throw UnauthorizedException("Неверный логин или пароль")
}
@@ -63,8 +64,10 @@ class AuthService(
@Transactional(readOnly = true)
fun me(accountId: UUID): MeResponse {
val account = accounts.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
val account =
accounts
.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
return MeResponse(
id = account.id.toString(),
login = account.login,
@@ -75,9 +78,14 @@ class AuthService(
}
@Transactional
fun changePassword(accountId: UUID, request: ChangePasswordRequest) {
val account = accounts.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
fun changePassword(
accountId: UUID,
request: ChangePasswordRequest,
) {
val account =
accounts
.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
throw UnauthorizedException("Текущий пароль неверный")
}
@@ -89,7 +97,10 @@ class AuthService(
accounts.save(account)
}
private fun validatePassword(password: String, confirmation: String) {
private fun validatePassword(
password: String,
confirmation: String,
) {
if (password.length < 8) {
throw BadRequestException("Пароль: минимум 8 символов")
}
@@ -100,33 +111,43 @@ class AuthService(
private fun displayName(account: Account): String =
if (account.role == LmsRole.STUDENT) {
students.findById(account.id)
students
.findById(account.id)
.map { it.fullName.ifBlank { account.login } }
.orElse(account.login)
} else {
account.login
}
private fun toResponse(account: Account, name: String): AuthResponse = AuthResponse(
token = issueToken(account, name),
id = account.id.toString(),
login = account.login,
role = account.role.name,
name = name,
mustChangePassword = account.mustChangePassword,
)
private fun toResponse(
account: Account,
name: String,
): AuthResponse =
AuthResponse(
token = issueToken(account, name),
id = account.id.toString(),
login = account.login,
role = account.role.name,
name = name,
mustChangePassword = account.mustChangePassword,
)
private fun issueToken(account: Account, name: String): String {
private fun issueToken(
account: Account,
name: String,
): String {
val now = Instant.now()
val claims = JwtClaimsSet.builder()
.issuer(jwtProperties.issuer)
.issuedAt(now)
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
.subject(account.id.toString())
.claim("role", account.role.name)
.claim("login", account.login)
.claim("name", name)
.build()
val claims =
JwtClaimsSet
.builder()
.issuer(jwtProperties.issuer)
.issuedAt(now)
.expiresAt(now.plus(jwtProperties.accessTokenTtl))
.subject(account.id.toString())
.claim("role", account.role.name)
.claim("login", account.login)
.claim("name", name)
.build()
val header = JwsHeader.with(SignatureAlgorithm.RS256).build()
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
}

View File

@@ -16,26 +16,19 @@ import java.util.UUID
class Account(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 64)
var login: String = "",
@Column(name = "password_hash", nullable = false)
var passwordHash: String = "",
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var role: LmsRole = LmsRole.STUDENT,
@Column(name = "must_change_password", nullable = false)
var mustChangePassword: Boolean = true,
@Column(name = "temp_password", length = 64)
var tempPassword: String? = null,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false)
var updatedAt: Instant = Instant.now(),
)

View File

@@ -16,15 +16,12 @@ import java.util.UUID
class Enrollment(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "group_id", nullable = false)
var group: GroupEntity? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "student_id", nullable = false)
var student: Student? = null,
@Column(name = "enrolled_at", nullable = false)
var enrolledAt: Instant = Instant.now(),
)

View File

@@ -13,13 +13,10 @@ import java.util.UUID
class GroupEntity(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 100)
var name: String = "",
@Column(name = "task_duration_min", nullable = false)
var taskDurationMin: Int = 30,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)

View File

@@ -11,7 +11,6 @@ class Setting(
@Id
@Column(name = "key", length = 100)
var key: String = "",
@Column(name = "value", nullable = false)
var value: String = "",
)

View File

@@ -16,32 +16,26 @@ class Student(
@Id
@Column(name = "account_id")
var accountId: UUID? = null,
@Column(name = "student_no", nullable = false, unique = true, length = 32)
var studentNo: String = "",
@Column(name = "last_name", nullable = false, length = 100)
var lastName: String = "",
@Column(name = "first_name", nullable = false, length = 100)
var firstName: String = "",
@Column(name = "middle_name", nullable = false, length = 100)
var middleName: String = "",
@Column(name = "basis", nullable = false, length = 100)
var basis: String = "",
@Column(name = "note", nullable = false)
var note: String = "",
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "account_id")
var account: Account? = null,
) {
val fullName: String
get() = listOf(lastName, firstName, middleName)
.filter { it.isNotBlank() }
.joinToString(" ")
get() =
listOf(lastName, firstName, middleName)
.filter { it.isNotBlank() }
.joinToString(" ")
}

View File

@@ -7,7 +7,10 @@ import java.util.UUID
interface AccountRepository : JpaRepository<Account, UUID> {
fun findByLogin(login: String): Account?
fun existsByLogin(login: String): Boolean
fun existsByRole(role: LmsRole): Boolean
fun countByRole(role: LmsRole): Long
}

View File

@@ -7,16 +7,18 @@ import ru.ysu.lms.identity.domain.Enrollment
import java.util.UUID
interface EnrollmentRepository : JpaRepository<Enrollment, UUID> {
@Query(
"select e from Enrollment e join fetch e.student s join fetch s.account where e.group.id = :groupId",
)
fun findAllByGroupId(@Param("groupId") groupId: UUID): List<Enrollment>
fun findAllByGroupId(
@Param("groupId") groupId: UUID,
): List<Enrollment>
fun findByStudent_AccountId(accountId: UUID): Enrollment?
fun findFirstByStudent_AccountId(accountId: UUID): Enrollment?
fun findByStudentAccountId(accountId: UUID): Enrollment?
fun deleteByStudent_AccountId(accountId: UUID)
fun findFirstByStudentAccountId(accountId: UUID): Enrollment?
fun countByGroup_Id(groupId: UUID): Long
fun deleteByStudentAccountId(accountId: UUID)
fun countByGroupId(groupId: UUID): Long
}

View File

@@ -6,6 +6,8 @@ import java.util.UUID
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun findByName(name: String): GroupEntity?
fun existsByName(name: String): Boolean
fun findAllByOrderByNameAsc(): List<GroupEntity>
}

View File

@@ -6,5 +6,6 @@ import java.util.UUID
interface StudentRepository : JpaRepository<Student, UUID> {
fun findByStudentNo(studentNo: String): Student?
fun existsByStudentNo(studentNo: String): Boolean
}

View File

@@ -4,21 +4,21 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.ByteArrayOutputStream
object CredentialsExporter {
private val HEADERS = listOf("ФИО", "Логин", "Пароль", "Статус")
fun csv(rows: List<StudentRow>): ByteArray {
val builder = StringBuilder()
builder.append(HEADERS.joinToString(";")).append("\r\n")
rows.forEach { row ->
builder.append(
listOf(
row.fullName,
row.studentNo,
row.tempPassword ?: "—",
status(row),
).joinToString(";") { escapeCsv(it) },
).append("\r\n")
builder
.append(
listOf(
row.fullName,
row.studentNo,
row.tempPassword ?: "—",
status(row),
).joinToString(";") { escapeCsv(it) },
).append("\r\n")
}
return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8)
}
@@ -41,8 +41,7 @@ object CredentialsExporter {
}
}
private fun status(row: StudentRow): String =
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
private fun status(row: StudentRow): String = if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
private fun escapeCsv(value: String): String =
if (value.contains(';') || value.contains('"') || value.contains('\n')) {

View File

@@ -5,6 +5,8 @@ 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.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@@ -17,20 +19,21 @@ import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.common.BadRequestException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import java.util.UUID
@RestController
@RequestMapping("/api/v1/groups")
@PreAuthorize("hasRole('ADMIN')")
class GroupController(private val groupService: GroupService) {
class GroupController(
private val groupService: GroupService,
) {
@GetMapping
fun list(): List<GroupSummary> = groupService.list()
@PostMapping
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
fun create(
@Valid @RequestBody body: CreateGroupRequest,
): GroupSummary = groupService.create(body)
@PostMapping("/{id}/students")
fun createStudent(
@@ -51,25 +54,34 @@ class GroupController(private val groupService: GroupService) {
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@GetMapping("/{id}/candidates")
fun candidates(@PathVariable id: UUID): List<StudentCandidate> = groupService.candidates(id)
fun candidates(
@PathVariable id: UUID,
): List<StudentCandidate> = groupService.candidates(id)
/** Зачислить существующего студента. */
@PostMapping("/{id}/students/{studentId}/enroll")
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
groupService.enroll(id, studentId)
fun enroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.enroll(id, studentId)
/** Отчислить студента из группы. */
@DeleteMapping("/{id}/students/{studentId}")
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
groupService.unenroll(id, studentId)
fun unenroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.unenroll(id, studentId)
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> =
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
fun myGroup(
@AuthenticationPrincipal jwt: Jwt,
): List<GroupSummary> = groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
fun detail(
@PathVariable id: UUID,
): GroupDetail = groupService.detail(id)
@GetMapping("/{id}/credentials")
fun credentials(
@@ -78,7 +90,8 @@ class GroupController(private val groupService: GroupService) {
): ResponseEntity<ByteArray> {
val export = groupService.credentials(id, format)
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
return ResponseEntity.ok()
return ResponseEntity
.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
.contentType(MediaType.parseMediaType(export.contentType))
.body(export.bytes)

View File

@@ -27,12 +27,14 @@ class GroupService(
private val passwordEncoder: PasswordEncoder,
private val parser: StudentImportParser,
) {
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
@Transactional
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential {
fun createStudent(
groupId: UUID,
request: CreateStudentRequest,
): Credential {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val studentNo = request.studentNo.trim()
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
@@ -40,27 +42,29 @@ class GroupService(
throw ConflictException("Студент с номером $studentNo уже существует")
}
val password = Passwords.generate()
val account = accounts.save(
Account(
login = studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val account =
accounts.save(
Account(
login = studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
val student = students.save(
Student(
studentNo = studentNo,
lastName = request.lastName.trim(),
firstName = request.firstName.trim(),
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
account = account,
),
)
val student =
students.save(
Student(
studentNo = studentNo,
lastName = request.lastName.trim(),
firstName = request.firstName.trim(),
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
account = account,
),
)
enrollments.save(Enrollment(group = group, student = student))
return Credential(
id = account.id.toString(),
@@ -85,13 +89,15 @@ class GroupService(
groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val memberIds = enrollments.findAllByGroupId(groupId).mapNotNull { it.student?.accountId }.toSet()
val currentByStudent = HashMap<UUID, Pair<UUID, String>>()
for (e in enrollments.findAll()) {
val sid = e.student?.accountId ?: continue
val gid = e.group?.id ?: continue
val name = e.group?.name ?: continue
currentByStudent[sid] = gid to name
}
return students.findAll()
enrollments
.findAll()
.mapNotNull { enrollment ->
val sid = enrollment.student?.accountId ?: return@mapNotNull null
val group = enrollment.group ?: return@mapNotNull null
sid to (group.id to group.name)
}.forEach { (sid, group) -> currentByStudent[sid] = group }
return students
.findAll()
.filter { it.accountId !in memberIds }
.map { s ->
val current = s.accountId?.let { currentByStudent[it] }
@@ -102,16 +108,18 @@ class GroupService(
currentGroupId = current?.first?.toString(),
currentGroupName = current?.second,
)
}
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
}.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
}
/** Зачислить существующего студента; если он в другой группе — переводим. */
@Transactional
fun enroll(groupId: UUID, studentId: UUID): GroupDetail {
fun enroll(
groupId: UUID,
studentId: UUID,
): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
val existing = enrollments.findByStudent_AccountId(studentId)
val existing = enrollments.findByStudentAccountId(studentId)
if (existing != null) {
if (existing.group?.id == group.id) {
throw ConflictException("Студент уже в этой группе")
@@ -127,15 +135,23 @@ class GroupService(
/** Отчислить из группы (студент и его аккаунт сохраняются). */
@Transactional
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail {
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
fun unenroll(
groupId: UUID,
studentId: UUID,
): GroupDetail {
val enrollment =
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment)
return detail(groupId)
}
@Transactional
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
fun import(
requestedName: String?,
filename: String,
bytes: ByteArray,
): ImportResult {
val parsed = parser.parse(filename, bytes)
val groupName = requestedName?.trim().takeUnless { it.isNullOrBlank() } ?: parsed.groupName
val group = groups.findByName(groupName) ?: groups.save(GroupEntity(name = groupName))
@@ -143,63 +159,80 @@ class GroupService(
val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent ->
val existing = students.findByStudentNo(parsedStudent.studentNo)
val student = if (existing != null) {
existing.lastName = parsedStudent.lastName
existing.firstName = parsedStudent.firstName
existing.middleName = parsedStudent.middleName
existing.basis = parsedStudent.basis
existing.note = parsedStudent.note
students.save(existing)
updated.add(parsedStudent.studentNo)
existing
} else {
val password = Passwords.generate()
val account = accounts.save(
Account(
login = parsedStudent.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created = Student(
studentNo = parsedStudent.studentNo,
lastName = parsedStudent.lastName,
firstName = parsedStudent.firstName,
middleName = parsedStudent.middleName,
basis = parsedStudent.basis,
note = parsedStudent.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsedStudent.studentNo,
fullName = parsedStudent.fullName,
password = password,
),
)
created
}
val accountId = student.accountId!!
val enrollment = enrollments.findByStudent_AccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
val student = upsertStudent(parsedStudent, added, updated)
enrollIntoGroup(group, student)
}
return ImportResult(group.id.toString(), group.name, added, updated)
}
/** Обновляет существующего студента или создаёт нового с временным паролем. */
private fun upsertStudent(
parsed: ParsedStudent,
added: MutableList<Credential>,
updated: MutableList<String>,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
existing.lastName = parsed.lastName
existing.firstName = parsed.firstName
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
students.save(existing)
updated.add(parsed.studentNo)
return existing
}
val password = Passwords.generate()
val account =
accounts.save(
Account(
login = parsed.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created =
Student(
studentNo = parsed.studentNo,
lastName = parsed.lastName,
firstName = parsed.firstName,
middleName = parsed.middleName,
basis = parsed.basis,
note = parsed.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsed.studentNo,
fullName = parsed.fullName,
password = password,
),
)
return created
}
/** Зачисляет студента в группу (или переводит из другой). */
private fun enrollIntoGroup(
group: GroupEntity,
student: Student,
) {
val accountId = student.accountId!!
val enrollment = enrollments.findByStudentAccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
}
@Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
return GroupSummary(
id = group.id.toString(),
name = group.name,
@@ -211,25 +244,30 @@ class GroupService(
@Transactional(readOnly = true)
fun detail(groupId: UUID): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val rows = enrollments.findAllByGroupId(groupId).map { enrollment ->
val student = enrollment.student!!
val account = student.account!!
StudentRow(
id = account.id.toString(),
studentNo = student.studentNo,
fullName = student.fullName,
basis = student.basis,
mustChangePassword = account.mustChangePassword,
tempPassword = account.tempPassword,
)
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
val rows =
enrollments
.findAllByGroupId(groupId)
.map { enrollment ->
val student = enrollment.student!!
val account = student.account!!
StudentRow(
id = account.id.toString(),
studentNo = student.studentNo,
fullName = student.fullName,
basis = student.basis,
mustChangePassword = account.mustChangePassword,
tempPassword = account.tempPassword,
)
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
}
@Transactional
fun resetPassword(studentAccountId: UUID): ResetPasswordResponse {
val student = students.findById(studentAccountId)
.orElseThrow { NotFoundException("Студент не найден") }
val student =
students
.findById(studentAccountId)
.orElseThrow { NotFoundException("Студент не найден") }
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
val password = Passwords.generate()
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
@@ -240,28 +278,34 @@ class GroupService(
}
@Transactional(readOnly = true)
fun credentials(groupId: UUID, format: String): ExportFile {
fun credentials(
groupId: UUID,
format: String,
): ExportFile {
val detail = detail(groupId)
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
return when (format.lowercase()) {
"csv" -> ExportFile(
bytes = CredentialsExporter.csv(detail.students),
filename = "credentials_$safeName.csv",
contentType = "text/csv; charset=utf-8",
)
"xlsx" -> ExportFile(
bytes = CredentialsExporter.xlsx(detail.students),
filename = "credentials_$safeName.xlsx",
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
"csv" ->
ExportFile(
bytes = CredentialsExporter.csv(detail.students),
filename = "credentials_$safeName.csv",
contentType = "text/csv; charset=utf-8",
)
"xlsx" ->
ExportFile(
bytes = CredentialsExporter.xlsx(detail.students),
filename = "credentials_$safeName.xlsx",
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
else -> throw BadRequestException("Формат должен быть xlsx или csv")
}
}
private fun summary(group: GroupEntity): GroupSummary = GroupSummary(
id = group.id.toString(),
name = group.name,
studentCount = enrollments.countByGroup_Id(group.id),
taskDurationMin = group.taskDurationMin,
)
private fun summary(group: GroupEntity): GroupSummary =
GroupSummary(
id = group.id.toString(),
name = group.name,
studentCount = enrollments.countByGroupId(group.id),
taskDurationMin = group.taskDurationMin,
)
}

View File

@@ -10,9 +10,11 @@ import java.util.UUID
@RestController
@RequestMapping("/api/v1/students")
@PreAuthorize("hasRole('ADMIN')")
class StudentController(private val groupService: GroupService) {
class StudentController(
private val groupService: GroupService,
) {
@PostMapping("/{id}/reset-password")
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse =
groupService.resetPassword(id)
fun resetPassword(
@PathVariable id: UUID,
): ResetPasswordResponse = groupService.resetPassword(id)
}

View File

@@ -25,8 +25,10 @@ data class ParsedGroup(
@Component
class StudentImportParser {
fun parse(filename: String, bytes: ByteArray): ParsedGroup {
fun parse(
filename: String,
bytes: ByteArray,
): ParsedGroup {
val lower = filename.lowercase()
return when {
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
@@ -65,7 +67,10 @@ class StudentImportParser {
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
}
private fun splitCsvLine(line: String, delimiter: Char): List<String> {
private fun splitCsvLine(
line: String,
delimiter: Char,
): List<String> {
val result = mutableListOf<String>()
val current = StringBuilder()
var inQuotes = false
@@ -83,51 +88,73 @@ class StudentImportParser {
return result
}
private fun parseRows(filename: String, rows: List<List<String>>): ParsedGroup {
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
if (headerIndex < 0) {
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
}
val columns = mapColumns(rows[headerIndex])
if (columns["studentNo"] == null) {
throw BadRequestException("Не найден столбец «Студенческий номер»")
}
val students = rows.drop(headerIndex + 1).mapNotNull { row ->
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
val studentNo = normalizeNumber(cell("studentNo"))
val lastName = cell("lastName")
if (studentNo.isBlank() && lastName.isBlank()) {
return@mapNotNull null
}
if (studentNo.isBlank()) {
throw BadRequestException("В одной из строк отсутствует студенческий номер")
}
ParsedStudent(
studentNo = studentNo,
lastName = lastName,
firstName = cell("firstName"),
middleName = cell("middleName"),
basis = cell("basis"),
note = cell("note"),
)
}
private fun parseRows(
filename: String,
rows: List<List<String>>,
): ParsedGroup {
val headerIndex = requireHeader(rows)
val columns = requireColumns(rows[headerIndex])
val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) }
if (students.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента")
}
return ParsedGroup(groupNameFrom(filename, rows), students)
}
private fun requireHeader(rows: List<List<String>>): Int {
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
if (headerIndex < 0) {
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
}
return headerIndex
}
private fun requireColumns(header: List<String>): Map<String, Int> {
val columns = mapColumns(header)
if (columns["studentNo"] == null) {
throw BadRequestException("Не найден столбец «Студенческий номер»")
}
return columns
}
private fun parseStudent(
row: List<String>,
columns: Map<String, Int>,
): ParsedStudent? {
fun cell(key: String): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
val studentNo = normalizeNumber(cell("studentNo"))
val lastName = cell("lastName")
if (studentNo.isBlank() && lastName.isBlank()) return null
if (studentNo.isBlank()) {
throw BadRequestException("В одной из строк отсутствует студенческий номер")
}
return ParsedStudent(
studentNo = studentNo,
lastName = lastName,
firstName = cell("firstName"),
middleName = cell("middleName"),
basis = cell("basis"),
note = cell("note"),
)
}
private fun isStudentHeader(value: String): Boolean {
val normalized = value.lowercase().replace("\u00a0", " ").trim()
return normalized.startsWith("студен") || normalized.startsWith("студ.") ||
normalized.startsWith("зачёт") || normalized.startsWith("зачет")
return normalized.startsWith("студен") ||
normalized.startsWith("студ.") ||
normalized.startsWith("зачёт") ||
normalized.startsWith("зачет")
}
private fun mapColumns(header: List<String>): Map<String, Int> {
val result = mutableMapOf<String, Int>()
header.forEachIndexed { index, raw ->
val value = raw.lowercase().replace("\u00a0", " ").trim()
fun putIfAbsent(key: String, vararg prefixes: String) {
fun putIfAbsent(
key: String,
vararg prefixes: String,
) {
if (key !in result && prefixes.any { value.startsWith(it) }) {
result[key] = index
}
@@ -142,10 +169,12 @@ class StudentImportParser {
return result
}
private fun normalizeNumber(value: String): String =
if (value.endsWith(".0")) value.dropLast(2) else value
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
private fun groupNameFrom(filename: String, rows: List<List<String>>): String {
private fun groupNameFrom(
filename: String,
rows: List<List<String>>,
): String {
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
for (row in rows.take(12)) {
for (cell in row) {

View File

@@ -17,17 +17,19 @@ import ru.ysu.lms.security.JwtProperties
@Configuration
@EnableConfigurationProperties(JwtProperties::class)
class JwtConfig(private val keyStore: JwtKeyStore) {
class JwtConfig(
private val keyStore: JwtKeyStore,
) {
@Bean
fun rsaKey(): RSAKey =
RSAKey
.Builder(keyStore.publicKey)
.privateKey(keyStore.privateKey)
.keyID("lms")
.build()
@Bean
fun rsaKey(): RSAKey = RSAKey.Builder(keyStore.publicKey)
.privateKey(keyStore.privateKey)
.keyID("lms")
.build()
@Bean
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder =
NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder = NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
@Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()

View File

@@ -15,8 +15,9 @@ import java.util.Base64
* in the `settings` table, so no private key ever lives in the repository.
*/
@Service
class JwtKeyStore(private val settings: SettingRepository) {
class JwtKeyStore(
private val settings: SettingRepository,
) {
lateinit var privateKey: RSAPrivateKey
lateinit var publicKey: RSAPublicKey
@@ -38,7 +39,10 @@ class JwtKeyStore(private val settings: SettingRepository) {
}
}
private fun toPem(label: String, der: ByteArray): String {
private fun toPem(
label: String,
der: ByteArray,
): String {
val encoded = Base64.getMimeEncoder(64, "\n".toByteArray()).encodeToString(der)
return "-----BEGIN $label-----\n$encoded\n-----END $label-----\n"
}

View File

@@ -18,7 +18,6 @@ import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
@@ -26,19 +25,19 @@ class SecurityConfig {
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers(
"/api/v1/auth/health",
"/api/v1/auth/setup",
"/api/v1/auth/login",
"/api/v1/auth/jwks.json",
"/actuator/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
).permitAll()
auth
.requestMatchers(
"/api/v1/auth/health",
"/api/v1/auth/setup",
"/api/v1/auth/login",
"/api/v1/auth/jwks.json",
"/actuator/**",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html",
).permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { resource ->
}.oauth2ResourceServer { resource ->
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
}
return http.build()

View File

@@ -8,20 +8,20 @@ import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import ru.ysu.lms.identity.auth.AuthService
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.auth.AuthService
@SpringBootTest
@Testcontainers
class IdentitySmokeTest {
companion object {
@Container
val pg = PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
@@ -42,21 +42,31 @@ class IdentitySmokeTest {
@Test
fun `setup creates admin and login works`() {
val token = authService.setup(
ru.ysu.lms.identity.auth.SetupRequest("admin", "test12345", "test12345")
)
val token =
authService.setup(
ru.ysu.lms.identity.auth
.SetupRequest("admin", "test12345", "test12345"),
)
assert(token.token.isNotBlank()) { "token should not be blank" }
assert(token.role == "ADMIN")
val login = authService.login(
ru.ysu.lms.identity.auth.LoginRequest("admin", "test12345")
)
val login =
authService.login(
ru.ysu.lms.identity.auth
.LoginRequest("admin", "test12345"),
)
assert(login.token.isNotBlank())
val ex = try {
authService.login(ru.ysu.lms.identity.auth.LoginRequest("admin", "bad"))
null
} catch (e: UnauthorizedException) { e }
val ex =
try {
authService.login(
ru.ysu.lms.identity.auth
.LoginRequest("admin", "bad"),
)
null
} catch (e: UnauthorizedException) {
e
}
assert(ex != null) { "bad password should throw" }
}
}
}

View File

@@ -23,4 +23,4 @@ dependencies {
springBoot {
mainClass.set("ru.ysu.lms.progress.ProgressServiceApplicationKt")
}
}

View File

@@ -12,16 +12,28 @@ class AcademicClient(
) {
private val client = RestClient.builder().baseUrl(baseUrl).build()
fun getLab(labId: String, token: String): LabInfo =
client.get().uri("/api/v1/labs/{id}", labId)
fun getLab(
labId: String,
token: String,
): LabInfo =
client
.get()
.uri("/api/v1/labs/{id}", labId)
.header("Authorization", "Bearer $token")
.retrieve().body(LabInfo::class.java)
.retrieve()
.body(LabInfo::class.java)
?: throw NotFoundException("Лабораторная не найдена")
fun getLabSummary(labId: String, token: String): LabInfo =
client.get().uri("/api/v1/labs/{id}", labId)
fun getLabSummary(
labId: String,
token: String,
): LabInfo =
client
.get()
.uri("/api/v1/labs/{id}", labId)
.header("Authorization", "Bearer $token")
.retrieve().body(LabInfo::class.java)
.retrieve()
.body(LabInfo::class.java)
?: throw NotFoundException("Лабораторная не найдена")
}
@@ -31,16 +43,25 @@ class IdentityClient(
) {
private val client = RestClient.builder().baseUrl(baseUrl).build()
fun getGroup(groupId: String, token: String): GroupInfo =
client.get().uri("/api/v1/groups/{id}", groupId)
fun getGroup(
groupId: String,
token: String,
): GroupInfo =
client
.get()
.uri("/api/v1/groups/{id}", groupId)
.header("Authorization", "Bearer $token")
.retrieve().body(GroupInfo::class.java)
.retrieve()
.body(GroupInfo::class.java)
?: throw NotFoundException("Группа не найдена")
fun listGroups(token: String): List<Map<String, Any>> {
val type = object : ParameterizedTypeReference<List<Map<String, Any>>>() {}
return client.get().uri("/api/v1/groups")
return client
.get()
.uri("/api/v1/groups")
.header("Authorization", "Bearer $token")
.retrieve().body(type) ?: emptyList()
.retrieve()
.body(type) ?: emptyList()
}
}
}

View File

@@ -1,10 +1,21 @@
package ru.ysu.lms.progress
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.bind.annotation.DeleteMapping
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.RestController
import java.util.UUID
data class DefenseAssignPayload(val studentId: String, val labId: String, val assignmentMd: String)
data class DefenseAssignPayload(
val studentId: String,
val labId: String,
val assignmentMd: String,
)
data class DefenseAdminDto(
val id: String,
@@ -22,27 +33,48 @@ data class DefenseAdminDto(
@RestController
@RequestMapping("/api/v1/defenses")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
class DefenseAdminController(private val service: DefenseService) {
class DefenseAdminController(
private val service: DefenseService,
) {
@PostMapping("/assign")
fun assign(@RequestBody body: DefenseAssignPayload): DefenseDto =
service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
fun assign(
@RequestBody body: DefenseAssignPayload,
): DefenseDto = service.assign(UUID.fromString(body.studentId), UUID.fromString(body.labId), body.assignmentMd)
@DeleteMapping("/assign")
fun removeAssignment(@RequestParam studentId: String, @RequestParam labId: String) {
fun removeAssignment(
@RequestParam studentId: String,
@RequestParam labId: String,
) {
service.resetByStudentLab(UUID.fromString(studentId), UUID.fromString(labId))
}
@GetMapping
fun list(@RequestParam labId: UUID): List<DefenseAdminDto> =
fun list(
@RequestParam labId: UUID,
): List<DefenseAdminDto> =
service.listForLab(labId).map {
DefenseAdminDto(it.id, it.studentId, it.labId, it.status, it.code, it.assignmentMd,
it.startedAt, it.readyAt, it.readyReason, it.secondsLeft)
DefenseAdminDto(
it.id,
it.studentId,
it.labId,
it.status,
it.code,
it.assignmentMd,
it.startedAt,
it.readyAt,
it.readyReason,
it.secondsLeft,
)
}
@PostMapping("/{id}/issue-code")
fun issueCode(@PathVariable id: UUID): Map<String, String> = mapOf("code" to service.issueCode(id))
fun issueCode(
@PathVariable id: UUID,
): Map<String, String> = mapOf("code" to service.issueCode(id))
@PostMapping("/{id}/reset")
fun reset(@PathVariable id: UUID) = service.reset(id)
fun reset(
@PathVariable id: UUID,
) = service.reset(id)
}

View File

@@ -2,18 +2,27 @@ package ru.ysu.lms.progress
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
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.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/v1/me/defenses")
class DefenseMyController(private val service: DefenseService) {
data class StartByCodePayload(val code: String)
class DefenseMyController(
private val service: DefenseService,
) {
data class StartByCodePayload(
val code: String,
)
@GetMapping
fun my(@AuthenticationPrincipal jwt: Jwt): List<DefenseDto> =
service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
fun my(
@AuthenticationPrincipal jwt: Jwt,
): List<DefenseDto> = service.mySessions(UUID.fromString(jwt.subject)).map { hideCode(it) }
@GetMapping("/{labId}")
fun one(
@@ -21,8 +30,7 @@ class DefenseMyController(private val service: DefenseService) {
@AuthenticationPrincipal jwt: Jwt,
): DefenseDto? = service.mySession(UUID.fromString(jwt.subject), labId)?.let { hideCode(it) }
private fun hideCode(d: DefenseDto): DefenseDto =
if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
private fun hideCode(d: DefenseDto): DefenseDto = if (d.status == DefenseStatus.waiting.name) d.copy(code = null) else d
@PostMapping("/start")
fun start(

View File

@@ -31,7 +31,6 @@ data class DefenseDto(
class DefenseService(
private val sessions: DefenseSessionRepository,
) {
companion object {
val DURATION: Duration = Duration.ofMinutes(30)
private val random = SecureRandom()
@@ -55,7 +54,11 @@ class DefenseService(
}
@Transactional
fun assign(studentId: UUID, labId: UUID, assignmentMd: String): DefenseDto {
fun assign(
studentId: UUID,
labId: UUID,
assignmentMd: String,
): DefenseDto {
val existing = sessions.findByStudentIdAndLabId(studentId, labId)
val s = existing ?: DefenseSession(studentId = studentId, labId = labId)
// смена задания сбрасывает активную попытку защиты
@@ -106,19 +109,44 @@ class DefenseService(
}
@Transactional
fun startByCode(studentId: UUID, code: String): DefenseDto {
fun startByCode(
studentId: UUID,
code: String,
): DefenseDto {
syncTimeouts()
val s = sessions.findByCode(code.trim())
?: throw BadRequestException("Код не найден")
if (s.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
if (s.status != DefenseStatus.waiting.name) throw BadRequestException("Защита по этому коду уже начата")
val s = requireWaitingSession(studentId, code)
s.status = DefenseStatus.started.name
s.startedAt = Instant.now()
return toDto(sessions.save(s))
}
private fun requireWaitingSession(
studentId: UUID,
code: String,
): DefenseSession {
val session = findSessionByCode(code)
requireOwnerAndWaiting(session, studentId)
return session
}
private fun findSessionByCode(code: String): DefenseSession =
sessions.findByCode(code.trim()) ?: throw BadRequestException("Код не найден")
private fun requireOwnerAndWaiting(
session: DefenseSession,
studentId: UUID,
) {
if (session.studentId != studentId) throw ForbiddenException("Этот код выдан другому студенту")
if (session.status != DefenseStatus.waiting.name) {
throw BadRequestException("Защита по этому коду уже начата")
}
}
@Transactional
fun finish(studentId: UUID, sessionId: UUID): DefenseDto {
fun finish(
studentId: UUID,
sessionId: UUID,
): DefenseDto {
val s = sessions.findById(sessionId).orElseThrow { NotFoundException("Сессия не найдена") }
if (s.studentId != studentId) throw ForbiddenException("Не ваша сессия")
if (s.status != DefenseStatus.started.name) throw BadRequestException("Защита ещё не начата")
@@ -135,13 +163,19 @@ class DefenseService(
}
@Transactional
fun mySession(studentId: UUID, labId: UUID): DefenseDto? {
fun mySession(
studentId: UUID,
labId: UUID,
): DefenseDto? {
syncTimeouts()
return sessions.findByStudentIdAndLabId(studentId, labId)?.let { toDto(it) }
}
@Transactional
fun resetByStudentLab(studentId: UUID, labId: UUID) {
fun resetByStudentLab(
studentId: UUID,
labId: UUID,
) {
val s = sessions.findByStudentIdAndLabId(studentId, labId) ?: return
s.status = DefenseStatus.waiting.name
s.code = null
@@ -152,11 +186,18 @@ class DefenseService(
}
private fun toDto(s: DefenseSession): DefenseDto {
val left = if (s.status == DefenseStatus.started.name) {
val started = s.startedAt ?: Instant.now()
val remain = java.time.Duration.between(Instant.now(), started.plus(DURATION)).seconds.coerceAtLeast(0)
remain
} else null
val left =
if (s.status == DefenseStatus.started.name) {
val started = s.startedAt ?: Instant.now()
val remain =
java.time.Duration
.between(Instant.now(), started.plus(DURATION))
.seconds
.coerceAtLeast(0)
remain
} else {
null
}
return DefenseDto(
id = s.id.toString(),
studentId = s.studentId.toString(),

View File

@@ -136,8 +136,15 @@ data class GradeSetRequest(
val comments: List<CommentInput> = emptyList(),
)
data class CommentInput(val text: String, val tone: String = "neutral")
data class CommentOut(val text: String, val tone: String)
data class CommentInput(
val text: String,
val tone: String = "neutral",
)
data class CommentOut(
val text: String,
val tone: String,
)
data class GradeResponse(
val studentId: String,
@@ -153,4 +160,4 @@ data class SubmissionDto(
val labId: String,
val createdAt: String,
val files: List<String>,
)
)

View File

@@ -3,29 +3,33 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.progress.domain.GradePublication
import ru.ysu.lms.progress.domain.GradePublicationKey
import ru.ysu.lms.progress.repo.GradePublicationRepository
import java.util.UUID
@Service
class GradePublicationService(private val repo: GradePublicationRepository) {
class GradePublicationService(
private val repo: GradePublicationRepository,
) {
@Transactional
fun setPublished(groupId: UUID, labId: UUID, published: Boolean) {
fun setPublished(
groupId: UUID,
labId: UUID,
published: Boolean,
) {
val existing = repo.findByLabIdAndGroupId(labId, groupId)
if (published && existing == null) repo.save(GradePublication(groupId, labId))
if (!published && existing != null) repo.delete(existing)
}
@Transactional(readOnly = true)
fun isPublished(groupId: UUID, labId: UUID): Boolean =
repo.findByLabIdAndGroupId(labId, groupId) != null
fun isPublished(
groupId: UUID,
labId: UUID,
): Boolean = repo.findByLabIdAndGroupId(labId, groupId) != null
@Transactional(readOnly = true)
fun publishedGroupIdsForLab(labId: UUID): List<String> =
repo.findByLabId(labId).map { it.groupId.toString() }
fun publishedGroupIdsForLab(labId: UUID): List<String> = repo.findByLabId(labId).map { it.groupId.toString() }
@Transactional(readOnly = true)
fun publishedLabIdsForGroup(groupId: UUID): List<String> =
repo.findByGroupId(groupId).map { it.labId.toString() }
fun publishedLabIdsForGroup(groupId: UUID): List<String> = repo.findByGroupId(groupId).map { it.labId.toString() }
}

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,29 +1,28 @@
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.*
import org.springframework.web.multipart.MultipartFile
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import org.springframework.web.bind.annotation.GetMapping
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.RestController
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,
private val academic: AcademicClient,
) {
@GetMapping("/me/assignments")
fun meAssignments(@AuthenticationPrincipal jwt: Jwt): List<MeAssignmentResponse> {
fun meAssignments(
@AuthenticationPrincipal jwt: Jwt,
): List<MeAssignmentResponse> {
val sid = UUID.fromString(jwt.subject)
return service.getStudentAssignments(sid).mapNotNull { va ->
service.getStudentAssignment(sid, va.labId, jwt.tokenValue)
@@ -31,9 +30,7 @@ class ProgressController(
}
@GetMapping("/me/labs")
fun meLabs(@AuthenticationPrincipal jwt: Jwt): List<Map<String, Any>> {
return listOf()
}
fun meLabs(): List<Map<String, Any>> = listOf()
@PostMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -44,7 +41,9 @@ class ProgressController(
@GetMapping("/levels")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun getLevels(@RequestParam labId: UUID): List<LevelAssignmentResponse> =
fun getLevels(
@RequestParam labId: UUID,
): List<LevelAssignmentResponse> =
service.getLevelsForLab(labId).map { sll ->
LevelAssignmentResponse(
studentId = sll.studentId.toString(),
@@ -77,84 +76,50 @@ class ProgressController(
@PostMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setGrade(@Valid @RequestBody body: GradeSetRequest): GradeResponse = service.setGrade(body)
fun setGrade(
@Valid @RequestBody body: GradeSetRequest,
): GradeResponse = grades.setGrade(body)
@GetMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun listGrades(): List<GradeResponse> = service.listGrades()
fun listGrades(): List<GradeResponse> = grades.listGrades()
@PostMapping("/grades/publish")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun publishGrades(@RequestParam labId: java.util.UUID, @RequestParam groupId: java.util.UUID, @RequestParam published: Boolean) {
fun publishGrades(
@RequestParam labId: java.util.UUID,
@RequestParam groupId: java.util.UUID,
@RequestParam published: Boolean,
) {
publications.setPublished(groupId, labId, published)
}
@GetMapping("/grades/published")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun gradesPublished(@RequestParam("labId") labId: java.util.UUID, @RequestParam groupId: java.util.UUID): Boolean =
publications.isPublished(groupId, labId)
fun gradesPublished(
@RequestParam("labId") labId: java.util.UUID,
@RequestParam groupId: java.util.UUID,
): Boolean = publications.isPublished(groupId, labId)
@GetMapping("/grades/publications")
@PreAuthorize("isAuthenticated()")
fun groupPublications(@RequestParam groupId: java.util.UUID): List<String> =
publications.publishedLabIdsForGroup(groupId)
fun groupPublications(
@RequestParam groupId: java.util.UUID,
): List<String> = publications.publishedLabIdsForGroup(groupId)
@GetMapping("/me/grades")
fun myGrades(
@AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) groupId: java.util.UUID?,
): 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()
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
}
@GetMapping("/comment-templates")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun suggestComments(@RequestParam q: String): List<CommentOut> = service.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)
}
fun suggestComments(
@RequestParam q: String,
): List<CommentOut> = grades.suggestComments(q)
}

View File

@@ -3,34 +3,25 @@ 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 ru.ysu.lms.progress.domain.StudentLabLevelKey
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,
) {
@Transactional
fun setLevel(request: LevelAssignmentRequest, token: String): LevelAssignmentResponse {
fun setLevel(
request: LevelAssignmentRequest,
token: String,
): LevelAssignmentResponse {
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val key = StudentLabLevelKey(sid, lid)
@@ -46,20 +37,27 @@ class ProgressService(
val level = lab?.levels?.firstOrNull { it.id == request.labLevelId }
if (level != null && level.variants.isNotEmpty()) {
val assignment = assignments.findByStudentIdAndLabId(sid, lid)
val currentInLevel = assignment?.variantId?.toString()
?.let { vid -> level.variants.any { it.id == vid } } == true
val currentInLevel =
assignment
?.variantId
?.toString()
?.let { vid -> level.variants.any { it.id == vid } } == true
if (!currentInLevel) {
val used = assignments.findByLabId(lid)
.map { it.variantId.toString() }
.filter { it != assignment?.variantId?.toString() }
.toMutableSet()
val used =
assignments
.findByLabId(lid)
.map { it.variantId.toString() }
.filter { it != assignment?.variantId?.toString() }
.toMutableSet()
val pool = level.variants.map { it.id }
val chosen = pool.filter { it !in used }.shuffled().firstOrNull()
?: pool.shuffled().first()
val target = (assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
variantId = UUID.fromString(chosen)
manual = false
}
val chosen =
pool.filter { it !in used }.shuffled().firstOrNull()
?: pool.shuffled().first()
val target =
(assignment ?: VariantAssignment(studentId = sid, labId = lid)).apply {
variantId = UUID.fromString(chosen)
manual = false
}
assignments.save(target)
}
}
@@ -72,98 +70,37 @@ class ProgressService(
}
@Transactional(readOnly = true)
fun getLevel(studentId: UUID, labId: UUID): StudentLabLevel? =
levels.findByStudentIdAndLabId(studentId, labId)
fun getLevel(
studentId: UUID,
labId: UUID,
): StudentLabLevel? = levels.findByStudentIdAndLabId(studentId, labId)
@Transactional(readOnly = true)
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> =
levels.findByLabId(labId)
fun getLevelsForLab(labId: UUID): List<StudentLabLevel> = levels.findByLabId(labId)
@Transactional
fun distribute(request: DistributeRequest, token: String): DistributeResult {
val labIds = request.labIds.orEmpty().ifEmpty { listOfNotNull(request.labId) }
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
fun distribute(
request: DistributeRequest,
token: String,
): DistributeResult {
val labIds = resolveLabIds(request)
val force = request.force == true
val groupFilter = request.groupIds.orEmpty().filter { it.isNotBlank() }.toSet()
val studentIds = mutableListOf<UUID>()
for (g in identity.listGroups(token)) {
val gid = g["id"] as String
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
for (s in identity.getGroup(gid, token).students) {
studentIds.add(UUID.fromString(s.id))
}
}
val allStudents = studentIds.distinct()
val allStudents = collectStudentIds(request, token)
var assignedTotal = 0
var totalStudents = 0
var lastLabId: String? = null
var lastLabTitle: String? = null
for (labIdStr in labIds) {
val labId = UUID.fromString(labIdStr)
val lab = academic.getLab(labIdStr, token)
// force сбрасывает авто-назначения только у выбранных студентов;
// закреплённые вручную варианты остаются как есть.
if (force) {
assignments.findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) }
}
val kept = mutableMapOf<UUID, VariantAssignment>()
assignedTotal += assignments.findByLabId(labId).count { it.studentId in allStudents }
assignments.findByLabId(labId)
.filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it }
val toAssign = allStudents.filterNot { kept.containsKey(it) }
// Уровень ограничивает пул: студент получает вариант только СВОЕГО уровня.
// При этом очередь одна общая для всех выбранных групп: круг не
// «перезапускается» на каждой группе, а продолжается по наименее
// занятым вариантам уровня (пока хоть один не выдан — повторов нет).
val poolsByLevel = lab.levels.associate { lv -> lv.id to lv.variants.map { it.id } }
val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isNotEmpty() && poolsByLevel.isNotEmpty()) {
val usage = assignments.findByLabId(labId)
.groupingBy { it.variantId.toString() }
.eachCount()
.toMutableMap()
for (sid in toAssign.shuffled()) {
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString()
?: defaultLevel
val pool = levelId?.let { poolsByLevel[it] }.orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
if (pool.isEmpty()) continue
val minCount = pool.minOf { usage[it] ?: 0 }
val chosen = pool.filter { (usage[it] ?: 0) == minCount }.random()
assignments.save(
VariantAssignment(
studentId = sid,
labId = labId,
variantId = UUID.fromString(chosen),
manual = false,
),
)
usage[chosen] = (usage[chosen] ?: 0) + 1
assignedTotal++
}
}
if (force) clearAutoAssignments(labId, allStudents)
val kept = keepExisting(labId, allStudents)
assignedTotal += kept.size
assignedTotal += assignNew(lab, labId, allStudents.filterNot { it in kept })
totalStudents += allStudents.size
lastLabId = labIdStr
lastLabTitle = lab.title
}
return DistributeResult(
labs = labIds.size,
assigned = assignedTotal,
@@ -173,24 +110,121 @@ class ProgressService(
)
}
private fun resolveLabIds(request: DistributeRequest): List<String> {
val labIds =
request.labIds
.orEmpty()
.ifEmpty { listOfNotNull(request.labId) }
.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
if (labIds.isEmpty()) throw BadRequestException("Не указана ни одна лабораторная")
return labIds
}
private fun collectStudentIds(
request: DistributeRequest,
token: String,
): List<UUID> {
val groupFilter =
request.groupIds
.orEmpty()
.filter { it.isNotBlank() }
.toSet()
val studentIds = mutableListOf<UUID>()
for (g in identity.listGroups(token)) {
val gid = g["id"] as String
if (groupFilter.isNotEmpty() && gid !in groupFilter) continue
for (s in identity.getGroup(gid, token).students) studentIds.add(UUID.fromString(s.id))
}
return studentIds.distinct()
}
private fun clearAutoAssignments(
labId: UUID,
allStudents: List<UUID>,
) {
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents && !it.manual }
.forEach { assignments.delete(it) }
}
private fun keepExisting(
labId: UUID,
allStudents: List<UUID>,
): Map<UUID, VariantAssignment> {
val kept = mutableMapOf<UUID, VariantAssignment>()
assignments
.findByLabId(labId)
.filter { it.studentId in allStudents }
.forEach { kept[it.studentId] = it }
return kept
}
/** Выдаёт варианты студентам без назначения; очередь общая, повторы — только когда пул исчерпан. */
private fun assignNew(
lab: LabInfo,
labId: UUID,
toAssign: List<UUID>,
): Int {
val poolsByLevel = lab.levels.associate { level -> level.id to level.variants.map { it.id } }
val defaultLevel = lab.levels.firstOrNull()?.id
if (toAssign.isEmpty() || poolsByLevel.isEmpty()) return 0
val usage =
assignments
.findByLabId(labId)
.groupingBy { it.variantId.toString() }
.eachCount()
.toMutableMap()
var assigned = 0
for (sid in toAssign.shuffled()) {
val levelId = levels.findByStudentIdAndLabId(sid, labId)?.labLevelId?.toString() ?: defaultLevel
val pool = pickPool(levelId, poolsByLevel, defaultLevel)
if (pool.isEmpty()) continue
val minCount = pool.minOf { id -> usage[id] ?: 0 }
val chosen = pool.filter { id -> (usage[id] ?: 0) == minCount }.random()
assignments.save(
VariantAssignment(studentId = sid, labId = labId, variantId = UUID.fromString(chosen), manual = false),
)
usage[chosen] = (usage[chosen] ?: 0) + 1
assigned++
}
return assigned
}
private fun pickPool(
levelId: String?,
poolsByLevel: Map<String, List<String>>,
defaultLevel: String?,
): List<String> =
levelId
?.let { poolsByLevel[it] }
.orEmpty()
.ifEmpty { defaultLevel?.let { poolsByLevel[it] }.orEmpty() }
.ifEmpty { poolsByLevel.values.firstOrNull { it.isNotEmpty() }.orEmpty() }
@Transactional(readOnly = true)
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> =
assignments.findByLabId(labId)
fun getAssignmentsForLab(labId: UUID): List<VariantAssignment> = assignments.findByLabId(labId)
@Transactional
fun assign(request: AssignRequest): VariantAssignment {
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val existing = assignments.findByStudentIdAndLabId(sid, lid)
val entity = (existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
variantId = UUID.fromString(request.variantId)
manual = true
}
val entity =
(existing ?: VariantAssignment(studentId = sid, labId = lid)).apply {
variantId = UUID.fromString(request.variantId)
manual = true
}
return assignments.save(entity)
}
@Transactional
fun assignWithInfo(request: AssignRequest, token: String): VariantAssignmentResponse {
fun assignWithInfo(
request: AssignRequest,
token: String,
): VariantAssignmentResponse {
assign(request)
val lab = academic.getLab(request.labId, token)
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == request.variantId }
@@ -204,7 +238,10 @@ class ProgressService(
}
@Transactional(readOnly = true)
fun assignmentsForLabEnriched(labId: UUID, token: String): List<VariantAssignmentResponse> {
fun assignmentsForLabEnriched(
labId: UUID,
token: String,
): List<VariantAssignmentResponse> {
val lab = academic.getLab(labId.toString(), token)
val variants = lab.levels.flatMap { it.variants }.associateBy { it.id }
return assignments.findByLabId(labId).map { va ->
@@ -220,76 +257,22 @@ class ProgressService(
}
@Transactional(readOnly = true)
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"
}
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId)
@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)
fun getStudentAssignment(studentId: UUID, labId: UUID, token: String): MeAssignmentResponse? {
fun getStudentAssignment(
studentId: UUID,
labId: UUID,
token: String,
): MeAssignmentResponse? {
val va = assignments.findByStudentIdAndLabId(studentId, labId) ?: return null
return enrich(va, token)
}
private fun enrich(va: VariantAssignment, token: String): MeAssignmentResponse? {
private fun enrich(
va: VariantAssignment,
token: String,
): MeAssignmentResponse? {
val lab = runCatching { academic.getLab(va.labId.toString(), token) }.getOrNull() ?: return null
val variant = lab.levels.flatMap { it.variants }.firstOrNull { it.id == va.variantId.toString() }
val level = lab.levels.firstOrNull { lv -> lv.variants.any { it.id == va.variantId.toString() } }
@@ -306,4 +289,4 @@ class ProgressService(
pointsMax = lab.pointsMax,
)
}
}
}

View File

@@ -2,11 +2,10 @@ package ru.ysu.lms.progress
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
@SpringBootApplication
class ProgressServiceApplication
fun main(args: Array<String>) {
runApplication<ProgressServiceApplication>(*args)
}
}

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

View File

@@ -18,7 +18,6 @@ class SubmissionService(
private val storage: SubmissionStorage,
private val academic: AcademicClient,
) {
@Transactional
fun submit(
studentId: UUID,
@@ -26,30 +25,71 @@ class SubmissionService(
files: List<MultipartFile>,
token: String,
): SubmissionDto {
if (files.isEmpty() || files.all { it.isEmpty }) {
throw BadRequestException("Приложите файлы или архив")
}
val lab = runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
?: throw NotFoundException("Лабораторная не найдена")
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
if (deadline != null && Instant.now().isAfter(deadline)) {
throw BadRequestException("Срок сдачи истёк")
}
val last = submissions.findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
val seq = (last?.seq ?: 0) + 1
val payload = files.mapIndexed { i, f -> (f.originalFilename ?: "file$i") to f.bytes }
val payload = validatedPayload(files)
ensureDeadline(requireLab(labId, token))
val seq = nextSeq(studentId, labId)
val stored = storage.store(studentId, labId, seq, payload)
val entity = submissions.save(Submission(studentId = studentId, labId = labId, seq = seq))
return toDto(entity, stored.files)
}
private fun validatedPayload(files: List<MultipartFile>): List<Pair<String, ByteArray>> {
if (files.isEmpty() || files.all { it.isEmpty }) {
throw BadRequestException("Приложите файлы или архив")
}
return files
.mapIndexed { i, f -> (f.originalFilename ?: "file$i") to f.bytes }
.onEach { (name, bytes) -> rejectProjectFile(name, bytes) }
}
private fun rejectProjectFile(
name: String,
bytes: ByteArray,
) {
if (looksLikeProjectFile(name, bytes)) {
throw BadRequestException(
"Файл «$name» похож на файл проекта (XML), а не на исходник. " +
"Приложите файл .c/.h или zip с проектом.",
)
}
}
private fun requireLab(
labId: UUID,
token: String,
): LabInfo =
runCatching { academic.getLabSummary(labId.toString(), token) }.getOrNull()
?: throw NotFoundException("Лабораторная не найдена")
private fun ensureDeadline(lab: LabInfo) {
val deadline = lab.deadlineAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
if (deadline != null && Instant.now().isAfter(deadline)) {
throw BadRequestException("Срок сдачи истёк")
}
}
private fun nextSeq(
studentId: UUID,
labId: UUID,
): Int = (submissions.findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)?.seq ?: 0) + 1
@Transactional(readOnly = true)
fun mySubmissions(studentId: UUID, labId: UUID): List<SubmissionDto> =
submissions.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId).map { it to filesOf(it) }
fun mySubmissions(
studentId: UUID,
labId: UUID,
): List<SubmissionDto> =
submissions
.findByStudentIdAndLabIdOrderBySeqDesc(studentId, labId)
.map { it to filesOf(it) }
.map { (e, fs) -> toDto(e, fs) }
@Transactional
fun download(studentId: UUID, submissionId: UUID, callerStudentId: UUID?, isAdmin: Boolean): Pair<ByteArray, String> {
fun download(
studentId: UUID,
submissionId: UUID,
callerStudentId: UUID?,
isAdmin: Boolean,
): Pair<ByteArray, String> {
val s = submissions.findById(submissionId).orElseThrow { NotFoundException("Сдача не найдена") }
if (!isAdmin && (callerStudentId == null || s.studentId != callerStudentId)) {
throw ForbiddenException("Нет доступа к этой сдаче")
@@ -70,10 +110,32 @@ class SubmissionService(
return perStudent.values.map { toDto(it, filesOf(it)) }
}
private fun filesOf(s: Submission): List<String> =
storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
/** Файлы с "исходниковым" расширением, содержимое которых — XML-файл проекта (VS/IDE). */
private val sourceExtensions = setOf("c", "h", "cpp", "hpp", "cc", "cxx", "txt")
private fun toDto(s: Submission, files: List<String>): SubmissionDto =
private fun looksLikeProjectFile(
name: String,
bytes: ByteArray,
): Boolean {
val ext = name.substringAfterLast('.', "").lowercase()
if (ext !in sourceExtensions) return false
val head =
String(bytes, 0, minOf(bytes.size, 512), Charsets.UTF_8)
.removePrefix("\uFEFF")
.trimStart()
.lowercase()
return head.startsWith("<?xml") ||
head.startsWith("<solution") ||
head.startsWith("<project") ||
head.startsWith("<configurations")
}
private fun filesOf(s: Submission): List<String> = storage.listFiles(storage.dirFor(s.studentId, s.labId, s.seq))
private fun toDto(
s: Submission,
files: List<String>,
): SubmissionDto =
SubmissionDto(
id = s.id.toString(),
seq = s.seq,

View File

@@ -9,14 +9,29 @@ import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
data class StoredSubmission(val dir: Path, val files: List<String>)
data class StoredSubmission(
val dir: Path,
val files: List<String>,
)
@Component
class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root: String) {
class SubmissionStorage(
@Value("\${lms.submissions.dir:data/submissions}") root: String,
) {
private val rootDir: Path = Path.of(root)
fun store(studentId: UUID, labId: UUID, seq: Int, files: List<Pair<String, ByteArray>>): StoredSubmission {
val dir = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString()).normalize()
fun store(
studentId: UUID,
labId: UUID,
seq: Int,
files: List<Pair<String, ByteArray>>,
): StoredSubmission {
val dir =
rootDir
.resolve(studentId.toString())
.resolve(labId.toString())
.resolve(seq.toString())
.normalize()
require(dir.startsWith(rootDir)) { "path escape blocked" }
Files.createDirectories(dir)
val names = mutableListOf<String>()
@@ -46,13 +61,20 @@ class SubmissionStorage(@Value("\${lms.submissions.dir:data/submissions}") root:
fun listFiles(storedDir: Path): List<String> =
if (Files.isDirectory(storedDir)) {
Files.list(storedDir).map { it.fileName.toString() }.sorted().toList()
Files
.list(storedDir)
.map { it.fileName.toString() }
.sorted()
.toList()
} else {
emptyList()
}
fun dirFor(studentId: UUID, labId: UUID, seq: Int): Path =
rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
fun dirFor(
studentId: UUID,
labId: UUID,
seq: Int,
): Path = rootDir.resolve(studentId.toString()).resolve(labId.toString()).resolve(seq.toString())
private fun sanitize(name: String): String {
val base = name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "file" }

View File

@@ -20,4 +20,4 @@ class DefenseSession(
@Column(name = "started_at") var startedAt: Instant? = null,
@Column(name = "ready_at") var readyAt: Instant? = null,
@Column(name = "ready_reason") var readyReason: String? = null,
)
)

View File

@@ -21,4 +21,8 @@ class GradePublication(
data class GradePublicationKey(
val groupId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0),
) : Serializable
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

View File

@@ -23,4 +23,8 @@ class LabGrade(
data class LabGradeKey(
val studentId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0),
) : Serializable
) : Serializable {
companion object {
private const val serialVersionUID: Long = 1L
}
}

View File

@@ -11,13 +11,13 @@ import java.util.UUID
@Table(name = "student_lab_levels")
@IdClass(StudentLabLevelKey::class)
class StudentLabLevel(
@Id @Column(nullable = false) var studentId: UUID = UUID(0,0),
@Id @Column(nullable = false) var labId: UUID = UUID(0,0),
@Column(nullable = false) var labLevelId: UUID = UUID(0,0),
@Id @Column(nullable = false) var studentId: UUID = UUID(0, 0),
@Id @Column(nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var labLevelId: UUID = UUID(0, 0),
@Column(nullable = false) var manual: Boolean = false,
)
data class StudentLabLevelKey(
val studentId: UUID = UUID(0,0),
val labId: UUID = UUID(0,0),
) : java.io.Serializable
val studentId: UUID = UUID(0, 0),
val labId: UUID = UUID(0, 0),
) : java.io.Serializable

View File

@@ -16,4 +16,4 @@ class Submission(
@Column(nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var seq: Int = 1,
@Column(nullable = false) var createdAt: Instant = Instant.now(),
)
)

View File

@@ -12,9 +12,9 @@ import java.util.UUID
@Table(name = "variant_assignments")
class VariantAssignment(
@Id var id: UUID = Ids.v7(),
@Column(nullable = false) var studentId: UUID = UUID(0,0),
@Column(nullable = false) var labId: UUID = UUID(0,0),
@Column(nullable = false) var variantId: UUID = UUID(0,0),
@Column(nullable = false) var studentId: UUID = UUID(0, 0),
@Column(nullable = false) var labId: UUID = UUID(0, 0),
@Column(nullable = false) var variantId: UUID = UUID(0, 0),
@Column(nullable = false) var manual: Boolean = false,
@Column(nullable = false) var assignedAt: Instant = Instant.now(),
)
)

View File

@@ -7,11 +7,20 @@ import java.util.UUID
interface CommentTemplateRepository : JpaRepository<CommentTemplate, UUID> {
fun findByText(text: String): CommentTemplate?
fun findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(text: String): List<CommentTemplate>
}
interface GradeCommentRepository : JpaRepository<GradeComment, UUID> {
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): List<GradeComment>
fun deleteByStudentIdAndLabId(studentId: UUID, labId: UUID)
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
): List<GradeComment>
fun deleteByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
)
fun findByStudentId(studentId: UUID): List<GradeComment>
}

View File

@@ -6,8 +6,15 @@ import java.util.UUID
interface DefenseSessionRepository : JpaRepository<DefenseSession, UUID> {
fun findByLabIdOrderByStudentIdAsc(labId: UUID): List<DefenseSession>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): DefenseSession?
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
): DefenseSession?
fun findByStudentId(studentId: UUID): List<DefenseSession>
fun findByStatus(status: String): List<DefenseSession>
fun findByCode(code: String): DefenseSession?
}

View File

@@ -6,7 +6,12 @@ import ru.ysu.lms.progress.domain.GradePublicationKey
import java.util.UUID
interface GradePublicationRepository : JpaRepository<GradePublication, GradePublicationKey> {
fun findByLabIdAndGroupId(labId: UUID, groupId: UUID): GradePublication?
fun findByLabIdAndGroupId(
labId: UUID,
groupId: UUID,
): GradePublication?
fun findByLabId(labId: UUID): List<GradePublication>
fun findByGroupId(groupId: UUID): List<GradePublication>
}

View File

@@ -7,6 +7,11 @@ import java.util.UUID
interface LabGradeRepository : JpaRepository<LabGrade, LabGradeKey> {
fun findAllBy(): List<LabGrade>
fun findByStudentId(studentId: UUID): List<LabGrade>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): LabGrade?
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
): LabGrade?
}

View File

@@ -7,7 +7,13 @@ import java.util.UUID
interface StudentLabLevelRepository : JpaRepository<StudentLabLevel, StudentLabLevelKey> {
fun findByLabId(labId: UUID): List<StudentLabLevel>
fun findByStudentId(studentId: UUID): List<StudentLabLevel>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): StudentLabLevel?
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
): StudentLabLevel?
fun deleteByLabId(labId: UUID)
}
}

View File

@@ -6,7 +6,16 @@ import java.util.UUID
interface SubmissionRepository : JpaRepository<Submission, UUID> {
fun findByStudentIdOrderByCreatedAtDesc(studentId: UUID): List<Submission>
fun findByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): List<Submission>
fun findByStudentIdAndLabIdOrderBySeqDesc(
studentId: UUID,
labId: UUID,
): List<Submission>
fun findByLabIdOrderByCreatedAtDesc(labId: UUID): List<Submission>
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(studentId: UUID, labId: UUID): Submission?
}
fun findFirstByStudentIdAndLabIdOrderBySeqDesc(
studentId: UUID,
labId: UUID,
): Submission?
}

View File

@@ -6,7 +6,13 @@ import java.util.UUID
interface VariantAssignmentRepository : JpaRepository<VariantAssignment, UUID> {
fun findByStudentId(studentId: UUID): List<VariantAssignment>
fun findByLabId(labId: UUID): List<VariantAssignment>
fun findByStudentIdAndLabId(studentId: UUID, labId: UUID): VariantAssignment?
fun findByStudentIdAndLabId(
studentId: UUID,
labId: UUID,
): VariantAssignment?
fun deleteByLabId(labId: UUID)
}
}

View File

@@ -8,7 +8,6 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.security.oauth2.jwt.JwtDecoder
@@ -20,7 +19,6 @@ import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
@@ -29,8 +27,11 @@ class SecurityConfig {
val converter = JwtAuthenticationConverter()
converter.setJwtGrantedAuthoritiesConverter { jwt ->
val role = jwt.getClaimAsString("role")
if (role.isNullOrBlank()) emptyList()
else listOf(SimpleGrantedAuthority("ROLE_$role"))
if (role.isNullOrBlank()) {
emptyList()
} else {
listOf(SimpleGrantedAuthority("ROLE_$role"))
}
}
return converter
}
@@ -44,8 +45,7 @@ class SecurityConfig {
.authorizeHttpRequests { auth ->
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth ->
}.oauth2ResourceServer { oauth ->
oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
@@ -56,4 +56,4 @@ class SecurityConfig {
companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
}
}
}

View File

@@ -0,0 +1,82 @@
package ru.ysu.lms.progress
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.mockito.Mockito.`when`
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.testcontainers.service.connection.ServiceConnection
import org.springframework.test.context.bean.override.mockito.MockitoBean
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
import org.testcontainers.postgresql.PostgreSQLContainer
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import java.util.UUID
/**
* Интеграционный тест распределения вариантов: реальная PostgreSQL (Testcontainers) + Flyway,
* внешние сервисы (identity/academic) подменены заглушками.
*/
@Testcontainers
@SpringBootTest
class ProgressDistributionIntegrationTest {
companion object {
@Container
@ServiceConnection
@JvmStatic
val postgres = PostgreSQLContainer("postgres:18")
}
@Autowired
private lateinit var service: ProgressService
@Autowired
private lateinit var assignments: VariantAssignmentRepository
@MockitoBean
private lateinit var academic: AcademicClient
@MockitoBean
private lateinit var identity: IdentityClient
@Test
fun `distributes variants to every student of the selected group`() {
val labId = UUID.randomUUID()
val variantA = UUID.randomUUID().toString()
val variantB = UUID.randomUUID().toString()
val student1 = UUID.randomUUID().toString()
val student2 = UUID.randomUUID().toString()
`when`(identity.listGroups("t")).thenReturn(listOf(mapOf("id" to "g1")))
`when`(identity.getGroup("g1", "t")).thenReturn(
GroupInfo(id = "g1", name = "Группа", students = listOf(StudentInfo(student1), StudentInfo(student2))),
)
`when`(academic.getLab(labId.toString(), "t")).thenReturn(
LabInfo(
id = labId.toString(),
levels =
listOf(
LevelInfo(
id = "lvl",
code = "basic",
title = "Основная",
variants =
listOf(
VariantInfo(id = variantA, number = "1"),
VariantInfo(id = variantB, number = "2"),
),
),
),
),
)
val result = service.distribute(DistributeRequest(labIds = listOf(labId.toString()), groupIds = listOf("g1")), "t")
assertThat(result.assigned).isEqualTo(2)
assertThat(result.labs).isEqualTo(1)
val saved = assignments.findByLabId(labId)
assertThat(saved).hasSize(2)
assertThat(saved.map { it.studentId.toString() }).containsExactlyInAnyOrder(student1, student2)
assertThat(saved.map { it.variantId.toString() }).containsAnyOf(variantA, variantB)
}
}

View File

@@ -0,0 +1,87 @@
package ru.ysu.lms.progress
import io.mockk.every
import io.mockk.mockk
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
import org.springframework.mock.web.MockMultipartFile
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.Submission
import ru.ysu.lms.progress.repo.SubmissionRepository
import java.nio.file.Path
import java.util.UUID
import java.util.zip.ZipInputStream
class SubmissionServiceTest {
@TempDir
lateinit var tempDir: Path
private val studentId = UUID.randomUUID()
private val labId = UUID.randomUUID()
private fun service(): Pair<SubmissionService, SubmissionRepository> {
val repo = mockk<SubmissionRepository>()
val academic = mockk<AcademicClient>()
var saved: Submission? = null
every { academic.getLabSummary(any(), any()) } returns LabInfo(id = labId.toString(), deadlineAt = null)
every { repo.findFirstByStudentIdAndLabIdOrderBySeqDesc(any(), any()) } returns null
every { repo.save(any()) } answers {
firstArg<Submission>().also { saved = it }
}
every { repo.findById(any()) } answers { java.util.Optional.ofNullable(saved) }
val storage = SubmissionStorage(tempDir.toString())
return SubmissionService(repo, storage, academic) to repo
}
@Test
fun `stores submitted source file and returns it in zip`() {
val (service, _) = service()
val file = MockMultipartFile("files", "main.c", "text/plain", "#include <stdio.h>\n".toByteArray())
val dto = service.submit(studentId, labId, listOf(file), "token")
assertThat(dto.files).containsExactly("main.c")
assertThat(dto.seq).isEqualTo(1)
val (bytes, name) = service.download(studentId, UUID.fromString(dto.id), studentId, isAdmin = false)
assertThat(name).endsWith(".zip")
val entries = zipEntries(bytes)
assertThat(entries.keys).containsExactly("main.c")
assertThat(String(entries.getValue("main.c"), Charsets.UTF_8)).contains("#include")
}
@Test
fun `rejects xml project file renamed to source extension`() {
val (service, _) = service()
val xml = "<Solution>\n <Configurations/>\n</Solution>".toByteArray()
val file = MockMultipartFile("files", "ConsoleApplication1.C", "text/plain", xml)
assertThatThrownBy { service.submit(studentId, labId, listOf(file), "token") }
.isInstanceOf(BadRequestException::class.java)
.hasMessageContaining("файл проекта")
}
@Test
fun `allows zip archive`() {
val (service, _) = service()
val file = MockMultipartFile("files", "project.zip", "application/zip", byteArrayOf(0x50, 0x4B, 0x03, 0x04))
val dto = service.submit(studentId, labId, listOf(file), "token")
assertThat(dto.files).containsExactly("project.zip")
}
private fun zipEntries(bytes: ByteArray): Map<String, ByteArray> {
val result = linkedMapOf<String, ByteArray>()
ZipInputStream(bytes.inputStream()).use { zin ->
var e = zin.nextEntry
while (e != null) {
if (!e.isDirectory) result[e.name] = zin.readAllBytes()
e = zin.nextEntry
}
}
return result
}
}

View File

@@ -12,18 +12,17 @@ object DateUtils {
private val INPUT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
/** ISO instant string -> "02.11.2026 23:59" в Москве. */
fun formatMsk(value: String?): String? =
toZoned(value)?.format(FORMAT)
fun formatMsk(value: String?): String? = toZoned(value)?.format(FORMAT)
/** ISO instant string -> "2026-11-02T02:59" для <input type="datetime-local"> (в московском времени). */
fun localInput(value: String?): String? =
toZoned(value)?.format(INPUT_FORMAT) ?: ""
fun localInput(value: String?): String? = toZoned(value)?.format(INPUT_FORMAT) ?: ""
/** Значение datetime-local (локальное московское) -> ISO instant строка, понятная API. */
fun toUtcIso(localValue: String?): String? =
localValue?.takeIf { it.isNotBlank() }?.let { s ->
try {
LocalDateTime.parse(s, INPUT_FORMAT)
LocalDateTime
.parse(s, INPUT_FORMAT)
.atZone(MSK)
.toInstant()
.toString()
@@ -38,4 +37,4 @@ object DateUtils {
} catch (_: Exception) {
null
}
}
}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More