Initial commit: LMS microservices scaffold

- Gradle multi-module: libs/{common-http,security-jwt,markdown,test-support},
  services/{identity,academic,progress,gateway}, web (Thymeleaf+htmx)
- identity-service: JWT auth (RSA), accounts, students, groups, enrollment,
  import from XLSX/CSV, credentials export, password reset
- academic-service: lab import from JSON/zip (markdown + YAML + variants),
  levels, API
- progress-service: schema for student levels and variant assignments
- gateway: Spring Cloud Gateway, JWT validation, route propagation
- web: Server-side UI with flex/footer layout, light/dark theme toggle,
  admin dashboard, groups CRUD, student portal placeholder
- docker-compose: PostgreSQL 18
- Tests: identity smoke test with Testcontainers
This commit is contained in:
2026-09-10 19:46:13 +03:00
commit 2cee104633
125 changed files with 4752 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
package ru.ysu.lms.academic
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
@SpringBootApplication
class AcademicServiceApplication
fun main(args: Array<String>) {
runApplication<AcademicServiceApplication>(*args)
}

View File

@@ -0,0 +1,33 @@
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 ru.ysu.lms.academic.lab.*
import ru.ysu.lms.common.BadRequestException
import java.util.UUID
@RestController
@RequestMapping("/api/v1/labs")
class LabController(private val labService: LabService) {
@GetMapping
fun list(): List<LabSummary> = labService.list()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): LabDetail = labService.detail(id)
@PostMapping("/import")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
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) {
labService.delete(id)
}
}

View File

@@ -0,0 +1,40 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
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
import java.util.UUID
@Entity
@Table(name = "labs")
class Lab(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false)
var number: Int = 0,
@Column(nullable = false, length = 200)
var title: String = "",
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "semester_id", nullable = false)
var semester: Semester? = null,
@Column(name = "description_md", nullable = false)
var descriptionMd: String = "",
@Column(name = "deadline_at")
var deadlineAt: Instant? = null,
@Column(name = "defense_at")
var defenseAt: Instant? = null,
@Column(name = "points_max", nullable = false, columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal = java.math.BigDecimal.valueOf(5.0),
@Column(nullable = false, length = 16)
var status: String = "draft",
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
)

View File

@@ -0,0 +1,31 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "lab_levels")
class LabLevel(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_id", nullable = false)
var lab: Lab? = null,
@Column(nullable = false, length = 32)
var code: String = "",
@Column(nullable = false, length = 100)
var title: String = "",
@Column(nullable = false)
var description: String = "",
@Column(nullable = false)
var position: Int = 0,
@Column(name = "points_max", columnDefinition = "numeric(4,1)")
var pointsMax: java.math.BigDecimal? = null,
)

View File

@@ -0,0 +1,21 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "semesters")
class Semester(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, length = 100)
var title: String = "",
@Column(nullable = false)
var year: Int = 0,
@Column(nullable = false)
var active: Boolean = false,
)

View File

@@ -0,0 +1,32 @@
package ru.ysu.lms.academic.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.util.UUID
@Entity
@Table(name = "variants")
class Variant(
@Id
var id: UUID = Ids.v7(),
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_id", nullable = false)
var lab: Lab? = null,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "lab_level_id", nullable = false)
var labLevel: LabLevel? = null,
@Column(nullable = false, length = 32)
var number: String = "",
@Column(nullable = false, length = 200)
var title: String = "",
@Column(name = "statement_md", nullable = false)
var statementMd: String = "",
@Column(nullable = false)
var position: Int = 0,
)

View File

@@ -0,0 +1,80 @@
package ru.ysu.lms.academic.lab
import java.math.BigDecimal
data class LabMeta(
val number: Int,
val title: String,
val pointsMax: BigDecimal = BigDecimal.valueOf(5.0),
val deadlineAt: String? = null,
val defenseAt: String? = null,
val status: String = "draft",
val levels: List<LabLevelMeta> = emptyList(),
)
data class LabLevelMeta(
val code: String,
val title: String,
val description: String = "",
val position: Int = 0,
val pointsMax: BigDecimal? = null,
)
data class VariantMeta(
val level: String,
val number: String,
val title: String = "",
val statement: String = "",
)
data class LabImportBundle(
val meta: LabMeta,
val descriptionMd: String,
val variants: List<VariantMeta>,
val assets: Map<String, ByteArray> = emptyMap(),
)
data class LabImportResult(
val labId: String,
val number: Int,
val title: String,
val levelCount: Int,
val variantCount: Int,
)
data class LabSummary(
val id: String,
val number: Int,
val title: String,
val status: String,
val semesterTitle: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: BigDecimal,
val variantCount: Int,
)
data class LabDetail(
val id: String,
val number: Int,
val title: String,
val status: String,
val descriptionMd: String,
val deadlineAt: String?,
val defenseAt: String?,
val pointsMax: BigDecimal,
val levels: List<LabLevelDetail>,
)
data class LabLevelDetail(
val code: String,
val title: String,
val variants: List<VariantDetail>,
)
data class VariantDetail(
val id: String,
val number: String,
val title: String,
val statementMd: String,
)

View File

@@ -0,0 +1,165 @@
package ru.ysu.lms.academic.lab
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.PropertyNamingStrategies
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory
import com.fasterxml.jackson.module.kotlin.KotlinModule
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.io.ByteArrayInputStream
import java.math.BigDecimal
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 json = ObjectMapper()
fun parse(filename: String, bytes: ByteArray): LabImportBundle {
val lower = filename.lowercase()
if (!(lower.endsWith(".zip") || lower.endsWith(".json"))) {
throw BadRequestException("Формат должен быть .zip (рекомендуется) или .json")
}
return if (lower.endsWith(".json")) {
parseJson(bytes)
} else {
parseZip(bytes)
}
}
private fun parseJson(bytes: ByteArray): LabImportBundle {
val tree = json.readTree(bytes)
val meta = LabMeta(
number = tree.path("number").asInt(),
title = tree.path("title").asText(),
pointsMax = BigDecimal.valueOf(tree.path("points_max").asDouble(5.0)),
deadlineAt = tree.path("deadline").asText("").ifBlank { null },
defenseAt = tree.path("defense_at").asText("").ifBlank { null },
status = tree.path("status").asText("published"),
levels = parseLevels(tree),
)
val descriptionMd = tree.path("common_requirements").asText("")
val variants = parseVariantsFromTree(tree, meta.levels)
return LabImportBundle(meta, descriptionMd, variants)
}
private fun parseZip(bytes: ByteArray): LabImportBundle {
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()
}
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)
}
private fun parseLevels(tree: JsonNode): List<LabLevelMeta> {
val levels = tree.path("levels")
if (levels.isArray && levels.size() > 0) {
return levels.map { n ->
LabLevelMeta(
code = n.path("code").asText(),
title = n.path("title").asText(""),
description = n.path("description").asText(""),
position = n.path("position").asInt(0),
)
}
}
return listOf(
LabLevelMeta("basic", "Основная группа", position = 1),
LabLevelMeta("advanced", "Повышенная сложность", position = 2),
)
}
private fun parseVariantsFromTree(tree: JsonNode, levels: List<LabLevelMeta>): List<VariantMeta> {
val variants = tree.path("variants")
if (!variants.isArray) return emptyList()
val levelMap = levels.associateBy { it.code }
return variants.map { v ->
val levelCode = normalizeLevel(v.path("level").asText("basic"))
if (!levelMap.containsKey(levelCode)) {
throw BadRequestException("Неизвестный уровень: ${v.path("level").asText()}")
}
VariantMeta(
level = levelCode,
number = v.path("number").asText(),
title = v.path("title").asText(""),
statement = v.path("statement").asText(""),
)
}
}
private fun parseVariantFile(path: String, content: String, levels: List<LabLevelMeta>): VariantMeta? {
val levelMap = levels.associateBy { it.code }
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()] = parts[1].trim()
}
val levelCode = normalizeLevel(fm["level"] ?: "basic")
if (levelMap[levelCode] == null) return null
val body = lines.drop(secondSep + 1).joinToString("\n").trim()
return VariantMeta(
level = levelCode,
number = fm["number"] ?: return null,
title = fm["title"] ?: "",
statement = body.ifBlank { "—" },
)
}
private fun normalizeLevel(s: String): String = when (s.lowercase().trim()) {
"main", "basic", "обычная", "основная" -> "basic"
"advanced", "повышенная", "повышенной", "hard" -> "advanced"
else -> s.lowercase().trim()
}
}

View File

@@ -0,0 +1,137 @@
package ru.ysu.lms.academic.lab
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.academic.domain.Lab
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
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 java.math.BigDecimal
import java.util.UUID
@Service
class LabService(
private val labs: LabRepository,
private val labLevels: LabLevelRepository,
private val variantsRepo: VariantRepository,
private val semesters: SemesterRepository,
private val parser: LabImportParser,
) {
@Transactional
fun importFromFile(filename: String, bytes: ByteArray): LabImportResult {
val bundle = parser.parse(filename, bytes)
val semester = semesters.findFirstByActiveTrue()
?: throw BadRequestException("Нет активного семестра. Создайте семестр.")
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,
))
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,
position = meta.position,
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,
))
}
return LabImportResult(
labId = lab.id.toString(),
number = lab.number,
title = lab.title,
levelCount = levelEntities.size,
variantCount = bundle.variants.size,
)
}
@Transactional(readOnly = true)
fun list(): List<LabSummary> =
labs.findAllByOrderByNumberAsc().map { lab ->
val variants = variantsRepo.findByLabIdOrderByNumber(lab.id)
LabSummary(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
semesterTitle = lab.semester?.let { "${it.title} ${it.year}" } ?: "—",
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
variantCount = variants.size,
)
}
@Transactional(readOnly = true)
fun detail(labId: UUID): LabDetail {
val lab = labs.findById(labId).orElseThrow { NotFoundException("Лабораторная не найдена") }
val levels = labLevels.findByLabIdOrderByPosition(lab.id)
return LabDetail(
id = lab.id.toString(),
number = lab.number,
title = lab.title,
status = lab.status,
descriptionMd = lab.descriptionMd,
deadlineAt = lab.deadlineAt?.toString(),
defenseAt = lab.defenseAt?.toString(),
pointsMax = lab.pointsMax,
levels = levels.map { level ->
val vs = variantsRepo.findByLabIdOrderByNumber(lab.id)
.filter { it.labLevel?.id == level.id }
LabLevelDetail(
code = level.code,
title = level.title,
variants = vs.map { v ->
VariantDetail(
id = v.id.toString(),
number = v.number,
title = v.title,
statementMd = v.statementMd,
)
},
)
},
)
}
@Transactional
fun delete(labId: UUID) {
if (!labs.existsById(labId)) {
throw NotFoundException("Лабораторная не найдена")
}
labs.deleteById(labId)
}
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Lab
import java.util.UUID
interface LabRepository : JpaRepository<Lab, UUID> {
fun findAllByOrderByNumberAsc(): List<Lab>
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.Semester
import java.util.UUID
interface SemesterRepository : JpaRepository<Semester, UUID> {
fun findFirstByActiveTrue(): Semester?
}

View File

@@ -0,0 +1,14 @@
package ru.ysu.lms.academic.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.academic.domain.LabLevel
import ru.ysu.lms.academic.domain.Variant
import java.util.UUID
interface LabLevelRepository : JpaRepository<LabLevel, UUID> {
fun findByLabIdOrderByPosition(labId: UUID): List<LabLevel>
}
interface VariantRepository : JpaRepository<Variant, UUID> {
fun findByLabIdOrderByNumber(labId: UUID): List<Variant>
}

View File

@@ -0,0 +1,60 @@
package ru.ysu.lms.academic.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.core.convert.converter.Converter
import org.springframework.security.authentication.AbstractAuthenticationToken
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity
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
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
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"))
}
return converter
}
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.cors { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth.requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**").permitAll()
auth.anyRequest().authenticated()
}
.oauth2ResourceServer { oauth ->
oauth.jwt { jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())
}
}
return http.build()
}
companion object {
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
}
}