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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
37
services/academic-service/src/main/resources/application.yml
Normal file
37
services/academic-service/src/main/resources/application.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8082}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: academic-service
|
||||
datasource:
|
||||
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=academic}
|
||||
username: ${LMS_DB_USER:lms}
|
||||
password: ${LMS_DB_PASSWORD:lms}
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
properties:
|
||||
hibernate:
|
||||
default_schema: academic
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
schemas: academic
|
||||
default-schema: academic
|
||||
create-schemas: true
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Baseline for the academic schema (courses, articles, labs, levels, variants).
|
||||
CREATE SCHEMA IF NOT EXISTS academic;
|
||||
@@ -0,0 +1,88 @@
|
||||
-- Courses, articles, semesters, labs, levels, variants for the academic service.
|
||||
|
||||
CREATE TABLE courses (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
title varchar(200) NOT NULL,
|
||||
summary text NOT NULL DEFAULT '',
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
published boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE articles (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||
title varchar(200) NOT NULL,
|
||||
slug varchar(200) NOT NULL,
|
||||
summary text NOT NULL DEFAULT '',
|
||||
body_md text NOT NULL DEFAULT '',
|
||||
status varchar(16) NOT NULL DEFAULT 'draft',
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
published_at timestamptz,
|
||||
created_by uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT articles_course_slug_unique UNIQUE (course_id, slug)
|
||||
);
|
||||
|
||||
CREATE TABLE article_assets (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
article_id uuid REFERENCES articles(id) ON DELETE CASCADE,
|
||||
filename varchar(255) NOT NULL,
|
||||
stored_path varchar(512) NOT NULL,
|
||||
mime varchar(128) NOT NULL DEFAULT '',
|
||||
size bigint NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE semesters (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
title varchar(100) NOT NULL,
|
||||
year integer NOT NULL,
|
||||
active boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE TABLE labs (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
number integer NOT NULL,
|
||||
title varchar(200) NOT NULL,
|
||||
semester_id uuid NOT NULL REFERENCES semesters(id) ON DELETE RESTRICT,
|
||||
description_md text NOT NULL DEFAULT '',
|
||||
deadline_at timestamptz,
|
||||
defense_at timestamptz,
|
||||
points_max numeric(4,1) NOT NULL DEFAULT 5,
|
||||
status varchar(16) NOT NULL DEFAULT 'draft',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT labs_number_semester_unique UNIQUE (number, semester_id)
|
||||
);
|
||||
|
||||
CREATE TABLE lab_levels (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
lab_id uuid NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
|
||||
code varchar(32) NOT NULL,
|
||||
title varchar(100) NOT NULL,
|
||||
description text NOT NULL DEFAULT '',
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
points_max numeric(4,1),
|
||||
CONSTRAINT lab_levels_lab_code_unique UNIQUE (lab_id, code)
|
||||
);
|
||||
|
||||
CREATE TABLE variants (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
lab_id uuid NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
|
||||
lab_level_id uuid NOT NULL REFERENCES lab_levels(id) ON DELETE CASCADE,
|
||||
number varchar(32) NOT NULL,
|
||||
title varchar(200) NOT NULL DEFAULT '',
|
||||
statement_md text NOT NULL DEFAULT '',
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
CONSTRAINT variants_lab_level_number_unique UNIQUE (lab_id, lab_level_id, number)
|
||||
);
|
||||
|
||||
CREATE TABLE variant_examples (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
variant_id uuid NOT NULL REFERENCES variants(id) ON DELETE CASCADE,
|
||||
position integer NOT NULL DEFAULT 0,
|
||||
input text NOT NULL DEFAULT '',
|
||||
output text NOT NULL DEFAULT '',
|
||||
explanation text NOT NULL DEFAULT ''
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Seed data: default semester for development.
|
||||
INSERT INTO semesters (id, title, year, active)
|
||||
VALUES ('00000000-0000-7000-8000-000000000001', 'осень', 2026, true)
|
||||
ON CONFLICT DO NOTHING;
|
||||
Reference in New Issue
Block a user