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:
30
services/academic-service/build.gradle.kts
Normal file
30
services/academic-service/build.gradle.kts
Normal file
@@ -0,0 +1,30 @@
|
||||
plugins {
|
||||
id("lms.spring-boot")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.springBootBom))
|
||||
|
||||
implementation(libs.springBootStarterWeb)
|
||||
implementation(libs.springBootStarterValidation)
|
||||
implementation(libs.springBootStarterActuator)
|
||||
implementation(libs.springBootStarterDataJpa)
|
||||
implementation(libs.springBootStarterSecurity)
|
||||
implementation(libs.springBootStarterOauth2ResourceServer)
|
||||
implementation(libs.springBootStarterFlyway)
|
||||
implementation(libs.flywayPostgresql)
|
||||
runtimeOnly(libs.postgresql)
|
||||
implementation(libs.jacksonYaml)
|
||||
implementation(libs.jacksonModuleKotlin)
|
||||
implementation(libs.springdocStarterWebmvcUi)
|
||||
|
||||
implementation(project(":libs:common-http"))
|
||||
implementation(project(":libs:markdown"))
|
||||
implementation(project(":libs:security-jwt"))
|
||||
|
||||
testImplementation(project(":libs:test-support"))
|
||||
}
|
||||
|
||||
springBoot {
|
||||
mainClass.set("ru.ysu.lms.academic.AcademicServiceApplicationKt")
|
||||
}
|
||||
@@ -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;
|
||||
19
services/gateway/build.gradle.kts
Normal file
19
services/gateway/build.gradle.kts
Normal file
@@ -0,0 +1,19 @@
|
||||
plugins {
|
||||
id("lms.spring-boot")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.springBootBom))
|
||||
implementation(platform(libs.springCloudBom))
|
||||
|
||||
implementation(libs.springCloudStarterGatewayWebflux)
|
||||
implementation(libs.springBootStarterActuator)
|
||||
|
||||
implementation(project(":libs:security-jwt"))
|
||||
|
||||
testImplementation(project(":libs:test-support"))
|
||||
}
|
||||
|
||||
springBoot {
|
||||
mainClass.set("ru.ysu.lms.gateway.GatewayApplicationKt")
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.ysu.lms.gateway
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
class GatewayApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<GatewayApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package ru.ysu.lms.gateway.security
|
||||
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter
|
||||
import org.springframework.core.Ordered
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.JwtException
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.server.ServerWebExchange
|
||||
import reactor.core.publisher.Mono
|
||||
|
||||
/**
|
||||
* Validates the bearer token on every routed request and forwards the resolved
|
||||
* 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> {
|
||||
val path = exchange.request.path.value()
|
||||
if (isPublic(path)) {
|
||||
return chain.filter(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()
|
||||
chain.filter(exchange.mutate().request(request).build())
|
||||
} catch (_: JwtException) {
|
||||
unauthorized(exchange, "Недействительный или истёкший токен")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getOrder(): Int = -100
|
||||
|
||||
private fun isPublic(path: String): Boolean =
|
||||
path.startsWith("/actuator") ||
|
||||
path == "/api/v1/auth/login" ||
|
||||
path == "/api/v1/auth/setup" ||
|
||||
path == "/api/v1/auth/health" ||
|
||||
path == "/api/v1/auth/jwks.json"
|
||||
|
||||
private fun bearerToken(exchange: ServerWebExchange): String? {
|
||||
val header = exchange.request.headers.getFirst("Authorization") ?: return null
|
||||
if (!header.startsWith("Bearer ")) {
|
||||
return null
|
||||
}
|
||||
return header.removePrefix("Bearer ").trim().ifBlank { null }
|
||||
}
|
||||
|
||||
private fun unauthorized(exchange: ServerWebExchange, message: String): Mono<Void> {
|
||||
val response = exchange.response
|
||||
response.statusCode = HttpStatus.UNAUTHORIZED
|
||||
response.headers.contentType = MediaType.APPLICATION_JSON
|
||||
val body = """{"status":401,"title":"Unauthorized","detail":"$message"}"""
|
||||
val buffer = response.bufferFactory().wrap(body.toByteArray(Charsets.UTF_8))
|
||||
return response.writeWith(Mono.just(buffer))
|
||||
}
|
||||
}
|
||||
37
services/gateway/src/main/resources/application.yml
Normal file
37
services/gateway/src/main/resources/application.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: gateway
|
||||
cloud:
|
||||
gateway:
|
||||
server:
|
||||
webflux:
|
||||
routes:
|
||||
- id: identity
|
||||
uri: ${LMS_IDENTITY_URI:http://localhost:8081}
|
||||
predicates:
|
||||
- Path=/api/v1/auth/**,/api/v1/accounts/**,/api/v1/groups/**,/api/v1/students/**
|
||||
- id: academic
|
||||
uri: ${LMS_ACADEMIC_URI:http://localhost:8082}
|
||||
predicates:
|
||||
- Path=/api/v1/courses/**,/api/v1/articles/**,/api/v1/labs/**,/api/v1/assets/**
|
||||
- id: progress
|
||||
uri: ${LMS_PROGRESS_URI:http://localhost:8083}
|
||||
predicates:
|
||||
- Path=/api/v1/assignments/**,/api/v1/levels/**,/api/v1/me/**,/api/v1/distribute/**
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info,gateway
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
|
||||
lms:
|
||||
jwt:
|
||||
jwk-set-uri: ${LMS_JWT_JWK_SET_URI:http://localhost:8081/api/v1/auth/jwks.json}
|
||||
31
services/identity-service/build.gradle.kts
Normal file
31
services/identity-service/build.gradle.kts
Normal file
@@ -0,0 +1,31 @@
|
||||
plugins {
|
||||
id("lms.spring-boot")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.springBootBom))
|
||||
|
||||
implementation(libs.springBootStarterWeb)
|
||||
implementation(libs.springBootStarterValidation)
|
||||
implementation(libs.springBootStarterActuator)
|
||||
implementation(libs.springBootStarterDataJpa)
|
||||
implementation(libs.springBootStarterSecurity)
|
||||
implementation(libs.springBootStarterOauth2ResourceServer)
|
||||
implementation(libs.springBootStarterFlyway)
|
||||
implementation(libs.flywayPostgresql)
|
||||
runtimeOnly(libs.postgresql)
|
||||
implementation(libs.jacksonYaml)
|
||||
implementation(libs.springdocStarterWebmvcUi)
|
||||
implementation(libs.apachePoiOoxml)
|
||||
|
||||
implementation(project(":libs:common-http"))
|
||||
implementation(project(":libs:security-jwt"))
|
||||
|
||||
testImplementation(project(":libs:test-support"))
|
||||
testImplementation(libs.springBootStarterTest)
|
||||
testImplementation(libs.springSecurityTest)
|
||||
}
|
||||
|
||||
springBoot {
|
||||
mainClass.set("ru.ysu.lms.identity.IdentityServiceApplicationKt")
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.ysu.lms.identity
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
import ru.ysu.lms.security.LmsSecurityAutoConfiguration
|
||||
|
||||
// The identity service manages its own JWT keys at runtime, so the generic
|
||||
// auto-configuration from the shared library is excluded.
|
||||
@SpringBootApplication(exclude = [LmsSecurityAutoConfiguration::class])
|
||||
class IdentityServiceApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<IdentityServiceApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package ru.ysu.lms.identity.auth
|
||||
|
||||
import com.nimbusds.jose.jwk.JWKSet
|
||||
import com.nimbusds.jose.jwk.RSAKey
|
||||
import jakarta.validation.Valid
|
||||
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.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/auth")
|
||||
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(),
|
||||
)
|
||||
|
||||
@PostMapping("/setup")
|
||||
fun setup(@Valid @RequestBody body: SetupRequest): AuthResponse = authService.setup(body)
|
||||
|
||||
@PostMapping("/login")
|
||||
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body)
|
||||
|
||||
@GetMapping("/me")
|
||||
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse =
|
||||
authService.me(UUID.fromString(jwt.subject))
|
||||
|
||||
@PostMapping("/change-password")
|
||||
fun changePassword(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@Valid @RequestBody body: ChangePasswordRequest,
|
||||
): Map<String, Any> {
|
||||
authService.changePassword(UUID.fromString(jwt.subject), body)
|
||||
return mapOf("ok" to true)
|
||||
}
|
||||
|
||||
@GetMapping("/jwks.json")
|
||||
fun jwks(): Map<String, Any> = JWKSet(rsaKey).toPublicJWKSet().toJSONObject()
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.ysu.lms.identity.auth
|
||||
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
|
||||
data class SetupRequest(
|
||||
@field:NotBlank(message = "Укажите логин")
|
||||
val login: String,
|
||||
@field:NotBlank(message = "Укажите пароль")
|
||||
val password: String,
|
||||
@field:NotBlank(message = "Повторите пароль")
|
||||
val password2: String,
|
||||
)
|
||||
|
||||
data class LoginRequest(
|
||||
@field:NotBlank(message = "Укажите логин")
|
||||
val login: String,
|
||||
@field:NotBlank(message = "Укажите пароль")
|
||||
val password: String,
|
||||
)
|
||||
|
||||
data class ChangePasswordRequest(
|
||||
@field:NotBlank(message = "Укажите текущий пароль")
|
||||
val old: String,
|
||||
@field:NotBlank(message = "Укажите новый пароль")
|
||||
val new: String,
|
||||
@field:NotBlank(message = "Повторите новый пароль")
|
||||
val new2: String,
|
||||
)
|
||||
|
||||
data class AuthResponse(
|
||||
val token: String,
|
||||
val id: String,
|
||||
val login: String,
|
||||
val role: String,
|
||||
val name: String,
|
||||
val mustChangePassword: Boolean,
|
||||
)
|
||||
|
||||
data class MeResponse(
|
||||
val id: String,
|
||||
val login: String,
|
||||
val role: String,
|
||||
val name: String,
|
||||
val mustChangePassword: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,133 @@
|
||||
package ru.ysu.lms.identity.auth
|
||||
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm
|
||||
import org.springframework.security.oauth2.jwt.JwsHeader
|
||||
import org.springframework.security.oauth2.jwt.JwtClaimsSet
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoderParameters
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.common.UnauthorizedException
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
import ru.ysu.lms.identity.repo.AccountRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.security.JwtProperties
|
||||
import ru.ysu.lms.security.LmsRole
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class AuthService(
|
||||
private val accounts: AccountRepository,
|
||||
private val students: StudentRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val jwtEncoder: JwtEncoder,
|
||||
private val jwtProperties: JwtProperties,
|
||||
) {
|
||||
|
||||
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
|
||||
|
||||
@Transactional
|
||||
fun setup(request: SetupRequest): AuthResponse {
|
||||
if (adminExists()) {
|
||||
throw ConflictException("Аккаунт администратора уже существует")
|
||||
}
|
||||
val login = request.login.trim()
|
||||
if (login.length < 3) {
|
||||
throw BadRequestException("Логин: минимум 3 символа")
|
||||
}
|
||||
validatePassword(request.password, request.password2)
|
||||
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("Неверный логин или пароль")
|
||||
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
|
||||
throw UnauthorizedException("Неверный логин или пароль")
|
||||
}
|
||||
return toResponse(account, displayName(account))
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun me(accountId: UUID): MeResponse {
|
||||
val account = accounts.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
return MeResponse(
|
||||
id = account.id.toString(),
|
||||
login = account.login,
|
||||
role = account.role.name,
|
||||
name = displayName(account),
|
||||
mustChangePassword = account.mustChangePassword,
|
||||
)
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun changePassword(accountId: UUID, request: ChangePasswordRequest) {
|
||||
val account = accounts.findById(accountId)
|
||||
.orElseThrow { NotFoundException("Пользователь не найден") }
|
||||
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
|
||||
throw UnauthorizedException("Текущий пароль неверный")
|
||||
}
|
||||
validatePassword(request.new, request.new2)
|
||||
account.passwordHash = passwordEncoder.encode(request.new) ?: account.passwordHash
|
||||
account.mustChangePassword = false
|
||||
account.tempPassword = null
|
||||
account.updatedAt = Instant.now()
|
||||
accounts.save(account)
|
||||
}
|
||||
|
||||
private fun validatePassword(password: String, confirmation: String) {
|
||||
if (password.length < 8) {
|
||||
throw BadRequestException("Пароль: минимум 8 символов")
|
||||
}
|
||||
if (password != confirmation) {
|
||||
throw BadRequestException("Пароли не совпадают")
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayName(account: Account): String =
|
||||
if (account.role == LmsRole.STUDENT) {
|
||||
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 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 header = JwsHeader.with(SignatureAlgorithm.RS256).build()
|
||||
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.ysu.lms.identity.auth
|
||||
|
||||
import java.security.SecureRandom
|
||||
|
||||
object Passwords {
|
||||
private const val ALPHABET = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun generate(length: Int = 10): String =
|
||||
buildString(length) {
|
||||
repeat(length) { append(ALPHABET[random.nextInt(ALPHABET.length)]) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.EnumType
|
||||
import jakarta.persistence.Enumerated
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
import ru.ysu.lms.common.Ids
|
||||
import ru.ysu.lms.security.LmsRole
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "accounts")
|
||||
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(),
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package ru.ysu.lms.identity.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.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "enrollments")
|
||||
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(),
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
package ru.ysu.lms.identity.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.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "groups")
|
||||
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(),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.Table
|
||||
|
||||
@Entity
|
||||
@Table(name = "settings")
|
||||
class Setting(
|
||||
@Id
|
||||
@Column(name = "key", length = 100)
|
||||
var key: String = "",
|
||||
|
||||
@Column(name = "value", nullable = false)
|
||||
var value: String = "",
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package ru.ysu.lms.identity.domain
|
||||
|
||||
import jakarta.persistence.Column
|
||||
import jakarta.persistence.Entity
|
||||
import jakarta.persistence.FetchType
|
||||
import jakarta.persistence.Id
|
||||
import jakarta.persistence.JoinColumn
|
||||
import jakarta.persistence.MapsId
|
||||
import jakarta.persistence.OneToOne
|
||||
import jakarta.persistence.Table
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "students")
|
||||
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(" ")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
import ru.ysu.lms.security.LmsRole
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.data.repository.query.Param
|
||||
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 findByStudent_AccountId(accountId: UUID): Enrollment?
|
||||
|
||||
fun deleteByStudent_AccountId(accountId: UUID)
|
||||
|
||||
fun countByGroup_Id(groupId: UUID): Long
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.identity.domain.GroupEntity
|
||||
import java.util.UUID
|
||||
|
||||
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
|
||||
fun findByName(name: String): GroupEntity?
|
||||
fun existsByName(name: String): Boolean
|
||||
fun findAllByOrderByNameAsc(): List<GroupEntity>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.identity.domain.Setting
|
||||
|
||||
interface SettingRepository : JpaRepository<Setting, String>
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.ysu.lms.identity.repo
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import ru.ysu.lms.identity.domain.Student
|
||||
import java.util.UUID
|
||||
|
||||
interface StudentRepository : JpaRepository<Student, UUID> {
|
||||
fun findByStudentNo(studentNo: String): Student?
|
||||
fun existsByStudentNo(studentNo: String): Boolean
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
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")
|
||||
}
|
||||
return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
fun xlsx(rows: List<StudentRow>): ByteArray {
|
||||
XSSFWorkbook().use { workbook ->
|
||||
val sheet = workbook.createSheet("Логины")
|
||||
val header = sheet.createRow(0)
|
||||
HEADERS.forEachIndexed { index, title -> header.createCell(index).setCellValue(title) }
|
||||
rows.forEachIndexed { index, row ->
|
||||
val cells = sheet.createRow(index + 1)
|
||||
cells.createCell(0).setCellValue(row.fullName)
|
||||
cells.createCell(1).setCellValue(row.studentNo)
|
||||
cells.createCell(2).setCellValue(row.tempPassword ?: "—")
|
||||
cells.createCell(3).setCellValue(status(row))
|
||||
}
|
||||
val out = ByteArrayOutputStream()
|
||||
workbook.write(out)
|
||||
return out.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun status(row: StudentRow): String =
|
||||
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
|
||||
|
||||
private fun escapeCsv(value: String): String =
|
||||
if (value.contains(';') || value.contains('"') || value.contains('\n')) {
|
||||
"\"" + value.replace("\"", "\"\"") + "\""
|
||||
} else {
|
||||
value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
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.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 org.springframework.web.multipart.MultipartFile
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/groups")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class GroupController(private val groupService: GroupService) {
|
||||
|
||||
@GetMapping
|
||||
fun list(): List<GroupSummary> = groupService.list()
|
||||
|
||||
@PostMapping
|
||||
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
|
||||
|
||||
@PostMapping("/import")
|
||||
fun import(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
@RequestParam(name = "name", required = false) name: String?,
|
||||
): ImportResult {
|
||||
if (file.isEmpty) {
|
||||
throw BadRequestException("Выберите файл со списком группы")
|
||||
}
|
||||
return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
|
||||
|
||||
@GetMapping("/{id}/credentials")
|
||||
fun credentials(
|
||||
@PathVariable id: UUID,
|
||||
@RequestParam(defaultValue = "xlsx") format: String,
|
||||
): ResponseEntity<ByteArray> {
|
||||
val export = groupService.credentials(id, format)
|
||||
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
|
||||
.contentType(MediaType.parseMediaType(export.contentType))
|
||||
.body(export.bytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.stereotype.Service
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import ru.ysu.lms.common.ConflictException
|
||||
import ru.ysu.lms.common.NotFoundException
|
||||
import ru.ysu.lms.identity.auth.Passwords
|
||||
import ru.ysu.lms.identity.domain.Account
|
||||
import ru.ysu.lms.identity.domain.Enrollment
|
||||
import ru.ysu.lms.identity.domain.GroupEntity
|
||||
import ru.ysu.lms.identity.domain.Student
|
||||
import ru.ysu.lms.identity.repo.AccountRepository
|
||||
import ru.ysu.lms.identity.repo.EnrollmentRepository
|
||||
import ru.ysu.lms.identity.repo.GroupRepository
|
||||
import ru.ysu.lms.identity.repo.StudentRepository
|
||||
import ru.ysu.lms.security.LmsRole
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class GroupService(
|
||||
private val groups: GroupRepository,
|
||||
private val enrollments: EnrollmentRepository,
|
||||
private val students: StudentRepository,
|
||||
private val accounts: AccountRepository,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val parser: StudentImportParser,
|
||||
) {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
|
||||
|
||||
@Transactional
|
||||
fun create(request: CreateGroupRequest): GroupSummary {
|
||||
val name = request.name.trim()
|
||||
if (groups.existsByName(name)) {
|
||||
throw ConflictException("Группа «$name» уже существует")
|
||||
}
|
||||
return summary(groups.save(GroupEntity(name = name)))
|
||||
}
|
||||
|
||||
@Transactional
|
||||
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))
|
||||
val added = mutableListOf<Credential>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
return ImportResult(group.id.toString(), group.name, added, updated)
|
||||
}
|
||||
|
||||
@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(Comparator { a, b -> studentNoComparator.compare(a.studentNo, b.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 account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
|
||||
val password = Passwords.generate()
|
||||
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
|
||||
account.mustChangePassword = true
|
||||
account.tempPassword = password
|
||||
accounts.save(account)
|
||||
return ResetPasswordResponse(password)
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
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",
|
||||
)
|
||||
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 companion object {
|
||||
val studentNoComparator = Comparator<String> { a, b ->
|
||||
val left = a.toIntOrNull()
|
||||
val right = b.toIntOrNull()
|
||||
when {
|
||||
left != null && right != null -> left.compareTo(right)
|
||||
left != null -> -1
|
||||
right != null -> 1
|
||||
else -> a.compareTo(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
|
||||
data class GroupSummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val studentCount: Long,
|
||||
val taskDurationMin: Int,
|
||||
)
|
||||
|
||||
data class CreateGroupRequest(
|
||||
@field:NotBlank(message = "Укажите название группы")
|
||||
val name: String,
|
||||
)
|
||||
|
||||
data class StudentRow(
|
||||
val id: String,
|
||||
val studentNo: String,
|
||||
val fullName: String,
|
||||
val basis: String,
|
||||
val mustChangePassword: Boolean,
|
||||
val tempPassword: String?,
|
||||
)
|
||||
|
||||
data class GroupDetail(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val taskDurationMin: Int,
|
||||
val students: List<StudentRow>,
|
||||
)
|
||||
|
||||
data class Credential(
|
||||
val id: String,
|
||||
val studentNo: String,
|
||||
val fullName: String,
|
||||
val password: String,
|
||||
)
|
||||
|
||||
data class ImportResult(
|
||||
val groupId: String,
|
||||
val groupName: String,
|
||||
val added: List<Credential>,
|
||||
val updated: List<String>,
|
||||
)
|
||||
|
||||
data class ResetPasswordResponse(
|
||||
val password: String,
|
||||
)
|
||||
|
||||
class ExportFile(
|
||||
val bytes: ByteArray,
|
||||
val filename: String,
|
||||
val contentType: String,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
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.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/students")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
class StudentController(private val groupService: GroupService) {
|
||||
|
||||
@PostMapping("/{id}/reset-password")
|
||||
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse =
|
||||
groupService.resetPassword(id)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package ru.ysu.lms.identity.roster
|
||||
|
||||
import org.apache.poi.ss.usermodel.DataFormatter
|
||||
import org.apache.poi.ss.usermodel.WorkbookFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import ru.ysu.lms.common.BadRequestException
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
data class ParsedStudent(
|
||||
val studentNo: String,
|
||||
val lastName: String,
|
||||
val firstName: String,
|
||||
val middleName: String,
|
||||
val basis: String,
|
||||
val note: String,
|
||||
) {
|
||||
val fullName: String
|
||||
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
|
||||
}
|
||||
|
||||
data class ParsedGroup(
|
||||
val groupName: String,
|
||||
val students: List<ParsedStudent>,
|
||||
)
|
||||
|
||||
@Component
|
||||
class StudentImportParser {
|
||||
|
||||
fun parse(filename: String, bytes: ByteArray): ParsedGroup {
|
||||
val lower = filename.lowercase()
|
||||
return when {
|
||||
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
|
||||
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseRows(filename, parseExcel(bytes))
|
||||
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseExcel(bytes: ByteArray): List<List<String>> {
|
||||
WorkbookFactory.create(ByteArrayInputStream(bytes)).use { workbook ->
|
||||
val sheet = workbook.getSheetAt(0)
|
||||
val formatter = DataFormatter()
|
||||
return (0..sheet.lastRowNum).map { rowIndex ->
|
||||
val row = sheet.getRow(rowIndex) ?: return@map emptyList()
|
||||
val cells = mutableListOf<String>()
|
||||
for (col in 0 until row.lastCellNum.toInt()) {
|
||||
cells.add(formatter.formatCellValue(row.getCell(col)).trim())
|
||||
}
|
||||
cells
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCsv(bytes: ByteArray): List<List<String>> {
|
||||
val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF")
|
||||
val lines = text.split(Regex("\r?\n")).filter { it.isNotBlank() }
|
||||
if (lines.isEmpty()) {
|
||||
throw BadRequestException("Файл пуст")
|
||||
}
|
||||
val delimiter = detectDelimiter(lines.first())
|
||||
return lines.map { splitCsvLine(it, delimiter) }
|
||||
}
|
||||
|
||||
private fun detectDelimiter(header: String): Char {
|
||||
val candidates = listOf(';', ',', '\t')
|
||||
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
|
||||
}
|
||||
|
||||
private fun splitCsvLine(line: String, delimiter: Char): List<String> {
|
||||
val result = mutableListOf<String>()
|
||||
val current = StringBuilder()
|
||||
var inQuotes = false
|
||||
for (ch in line) {
|
||||
when {
|
||||
ch == '"' -> inQuotes = !inQuotes
|
||||
ch == delimiter && !inQuotes -> {
|
||||
result.add(current.toString().trim())
|
||||
current.clear()
|
||||
}
|
||||
else -> current.append(ch)
|
||||
}
|
||||
}
|
||||
result.add(current.toString().trim())
|
||||
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"),
|
||||
)
|
||||
}
|
||||
if (students.isEmpty()) {
|
||||
throw BadRequestException("В таблице не найдено ни одного студента")
|
||||
}
|
||||
return ParsedGroup(groupNameFrom(filename, rows), students)
|
||||
}
|
||||
|
||||
private fun isStudentHeader(value: String): Boolean {
|
||||
val normalized = value.lowercase().replace("\u00a0", " ").trim()
|
||||
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) {
|
||||
if (key !in result && prefixes.any { value.startsWith(it) }) {
|
||||
result[key] = index
|
||||
}
|
||||
}
|
||||
putIfAbsent("studentNo", "студенч", "студ.", "студбил", "зачёт", "зачет")
|
||||
putIfAbsent("lastName", "фамили")
|
||||
putIfAbsent("firstName", "имя")
|
||||
putIfAbsent("middleName", "отчеств")
|
||||
putIfAbsent("basis", "основ")
|
||||
putIfAbsent("note", "примеч")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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 {
|
||||
val groupRegex = Regex("[А-ЯA-Z][А-ЯA-Za-z]*[- ]?\\d{1,4}[А-ЯA-Za-z]*")
|
||||
for (row in rows.take(12)) {
|
||||
for (cell in row) {
|
||||
val value = cell.trim()
|
||||
if (value.length in 2..30 && groupRegex.matches(value)) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return filename.substringBeforeLast('.').trim().ifBlank { "Группа" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.ysu.lms.identity.security
|
||||
|
||||
import com.nimbusds.jose.jwk.JWKSet
|
||||
import com.nimbusds.jose.jwk.RSAKey
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet
|
||||
import com.nimbusds.jose.proc.SecurityContext
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
import org.springframework.security.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.JwtEncoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder
|
||||
import ru.ysu.lms.security.JwtProperties
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(JwtProperties::class)
|
||||
class JwtConfig(private val keyStore: JwtKeyStore) {
|
||||
|
||||
@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)))
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()
|
||||
|
||||
@Bean
|
||||
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package ru.ysu.lms.identity.security
|
||||
|
||||
import jakarta.annotation.PostConstruct
|
||||
import org.springframework.stereotype.Service
|
||||
import ru.ysu.lms.identity.domain.Setting
|
||||
import ru.ysu.lms.identity.repo.SettingRepository
|
||||
import ru.ysu.lms.security.RsaKeys
|
||||
import java.security.KeyPairGenerator
|
||||
import java.security.interfaces.RSAPrivateKey
|
||||
import java.security.interfaces.RSAPublicKey
|
||||
import java.util.Base64
|
||||
|
||||
/**
|
||||
* RSA signing keys for the identity service. Generated on first start and kept
|
||||
* in the `settings` table, so no private key ever lives in the repository.
|
||||
*/
|
||||
@Service
|
||||
class JwtKeyStore(private val settings: SettingRepository) {
|
||||
|
||||
lateinit var privateKey: RSAPrivateKey
|
||||
|
||||
lateinit var publicKey: RSAPublicKey
|
||||
|
||||
@PostConstruct
|
||||
fun init() {
|
||||
val storedPrivate = settings.findById(PRIVATE_KEY).map { it.value }.orElse(null)
|
||||
val storedPublic = settings.findById(PUBLIC_KEY).map { it.value }.orElse(null)
|
||||
if (storedPrivate != null && storedPublic != null) {
|
||||
privateKey = RsaKeys.privateKey(storedPrivate)
|
||||
publicKey = RsaKeys.publicKey(storedPublic)
|
||||
} else {
|
||||
val generator = KeyPairGenerator.getInstance("RSA").apply { initialize(2048) }
|
||||
val pair = generator.generateKeyPair()
|
||||
privateKey = pair.private as RSAPrivateKey
|
||||
publicKey = pair.public as RSAPublicKey
|
||||
settings.save(Setting(PRIVATE_KEY, toPem("PRIVATE KEY", privateKey.encoded)))
|
||||
settings.save(Setting(PUBLIC_KEY, toPem("PUBLIC KEY", publicKey.encoded)))
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PRIVATE_KEY = "jwt_private_key"
|
||||
const val PUBLIC_KEY = "jwt_public_key"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ru.ysu.lms.identity.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.server.resource.authentication.JwtAuthenticationConverter
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
.csrf { it.disable() }
|
||||
.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.anyRequest().authenticated()
|
||||
}
|
||||
.oauth2ResourceServer { resource ->
|
||||
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
|
||||
}
|
||||
return http.build()
|
||||
}
|
||||
|
||||
private fun jwtAuthenticationConverter(): Converter<Jwt, out AbstractAuthenticationToken> {
|
||||
val converter = JwtAuthenticationConverter()
|
||||
converter.setJwtGrantedAuthoritiesConverter(roleAuthoritiesConverter())
|
||||
return converter
|
||||
}
|
||||
|
||||
private fun roleAuthoritiesConverter(): Converter<Jwt, Collection<GrantedAuthority>> =
|
||||
Converter { jwt ->
|
||||
val role = jwt.getClaimAsString("role").orEmpty()
|
||||
if (role.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(SimpleGrantedAuthority("ROLE_$role"))
|
||||
}
|
||||
}
|
||||
}
|
||||
37
services/identity-service/src/main/resources/application.yml
Normal file
37
services/identity-service/src/main/resources/application.yml
Normal file
@@ -0,0 +1,37 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8081}
|
||||
|
||||
spring:
|
||||
application:
|
||||
name: identity-service
|
||||
datasource:
|
||||
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=identity}
|
||||
username: ${LMS_DB_USER:lms}
|
||||
password: ${LMS_DB_PASSWORD:lms}
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
properties:
|
||||
hibernate:
|
||||
default_schema: identity
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
schemas: identity
|
||||
default-schema: identity
|
||||
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 identity schema (accounts, students, groups, enrollments).
|
||||
CREATE SCHEMA IF NOT EXISTS identity;
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Accounts, students, groups and enrollments for the identity service.
|
||||
|
||||
CREATE TABLE accounts (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
login varchar(64) NOT NULL UNIQUE,
|
||||
password_hash varchar(255) NOT NULL,
|
||||
role varchar(16) NOT NULL,
|
||||
must_change_password boolean NOT NULL DEFAULT true,
|
||||
temp_password varchar(64),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT accounts_role_check CHECK (role IN ('ADMIN', 'STUDENT'))
|
||||
);
|
||||
|
||||
CREATE TABLE students (
|
||||
account_id uuid PRIMARY KEY REFERENCES accounts (id) ON DELETE CASCADE,
|
||||
student_no varchar(32) NOT NULL UNIQUE,
|
||||
last_name varchar(100) NOT NULL DEFAULT '',
|
||||
first_name varchar(100) NOT NULL DEFAULT '',
|
||||
middle_name varchar(100) NOT NULL DEFAULT '',
|
||||
basis varchar(100) NOT NULL DEFAULT '',
|
||||
note text NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE groups (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
name varchar(100) NOT NULL UNIQUE,
|
||||
task_duration_min integer NOT NULL DEFAULT 30,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE enrollments (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
group_id uuid NOT NULL REFERENCES groups (id) ON DELETE CASCADE,
|
||||
student_id uuid NOT NULL REFERENCES students (account_id) ON DELETE CASCADE,
|
||||
enrolled_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT enrollments_student_unique UNIQUE (student_id)
|
||||
);
|
||||
|
||||
CREATE INDEX enrollments_group_idx ON enrollments (group_id);
|
||||
|
||||
CREATE TABLE settings (
|
||||
key varchar(100) PRIMARY KEY,
|
||||
value text NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,62 @@
|
||||
package ru.ysu.lms.identity
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
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
|
||||
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
class IdentitySmokeTest {
|
||||
|
||||
companion object {
|
||||
@Container
|
||||
val pg = PostgreSQLContainer("postgres:18").apply {
|
||||
withDatabaseName("lms")
|
||||
withUsername("lms")
|
||||
withPassword("lms")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun datasource(registry: DynamicPropertyRegistry) {
|
||||
registry.add("spring.datasource.url") { pg.jdbcUrl }
|
||||
registry.add("spring.datasource.username") { "lms" }
|
||||
registry.add("spring.datasource.password") { "lms" }
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired
|
||||
lateinit var authService: AuthService
|
||||
|
||||
@Test
|
||||
fun `context loads and admin not exists`() {
|
||||
assert(!authService.adminExists()) { "admin should not exist" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `setup creates admin and login works`() {
|
||||
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")
|
||||
)
|
||||
assert(login.token.isNotBlank())
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
26
services/progress-service/build.gradle.kts
Normal file
26
services/progress-service/build.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
plugins {
|
||||
id("lms.spring-boot")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.springBootBom))
|
||||
|
||||
implementation(libs.springBootStarterWeb)
|
||||
implementation(libs.springBootStarterValidation)
|
||||
implementation(libs.springBootStarterActuator)
|
||||
implementation(libs.springBootStarterDataJpa)
|
||||
implementation(libs.springBootStarterSecurity)
|
||||
implementation(libs.springBootStarterOauth2ResourceServer)
|
||||
implementation(libs.springBootStarterFlyway)
|
||||
implementation(libs.flywayPostgresql)
|
||||
runtimeOnly(libs.postgresql)
|
||||
implementation(libs.springdocStarterWebmvcUi)
|
||||
implementation(project(":libs:common-http"))
|
||||
implementation(project(":libs:security-jwt"))
|
||||
|
||||
testImplementation(project(":libs:test-support"))
|
||||
}
|
||||
|
||||
springBoot {
|
||||
mainClass.set("ru.ysu.lms.progress.ProgressServiceApplicationKt")
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.ysu.lms.progress.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.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@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 manual: Boolean = false,
|
||||
@Column(nullable = false) var assignedAt: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
@Entity
|
||||
@Table(name = "student_lab_levels")
|
||||
class StudentLabLevel(
|
||||
@Id
|
||||
@Column(name = "student_id", insertable = false, updatable = false)
|
||||
var studentId: UUID = UUID(0,0),
|
||||
@Id
|
||||
@Column(name = "lab_id", insertable = false, updatable = false)
|
||||
var labId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var labLevelId: UUID = UUID(0,0),
|
||||
@Column(nullable = false) var manual: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.ysu.lms.progress.security
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
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.oauth2.jwt.JwtDecoder
|
||||
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity
|
||||
class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withJwkSetUri(JWK_URI).build()
|
||||
|
||||
@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 { it.jwt { } }
|
||||
return http.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val JWK_URI = System.getenv("LMS_JWT_JWK_SET_URI") ?: "http://localhost:8081/api/v1/auth/jwks.json"
|
||||
}
|
||||
}
|
||||
30
services/progress-service/src/main/resources/application.yml
Normal file
30
services/progress-service/src/main/resources/application.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
spring:
|
||||
application:
|
||||
name: progress-service
|
||||
datasource:
|
||||
url: ${LMS_DB_URL:jdbc:postgresql://localhost:5432/lms?currentSchema=progress}
|
||||
username: ${LMS_DB_USER:lms}
|
||||
password: ${LMS_DB_PASSWORD:lms}
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: validate
|
||||
properties:
|
||||
hibernate:
|
||||
default_schema: progress
|
||||
flyway:
|
||||
enabled: true
|
||||
locations: classpath:db/migration
|
||||
schemas: progress
|
||||
default-schema: progress
|
||||
create-schemas: true
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
springdoc:
|
||||
api-docs:
|
||||
path: /v3/api-docs
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Baseline: student lab levels and variant assignments.
|
||||
CREATE SCHEMA IF NOT EXISTS progress;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Student levels per lab and variant assignments.
|
||||
|
||||
CREATE TABLE student_lab_levels (
|
||||
student_id uuid NOT NULL,
|
||||
lab_id uuid NOT NULL,
|
||||
lab_level_id uuid NOT NULL,
|
||||
manual boolean NOT NULL DEFAULT false,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (student_id, lab_id)
|
||||
);
|
||||
|
||||
CREATE TABLE variant_assignments (
|
||||
id uuid PRIMARY KEY DEFAULT uuidv7(),
|
||||
student_id uuid NOT NULL,
|
||||
lab_id uuid NOT NULL,
|
||||
variant_id uuid NOT NULL,
|
||||
manual boolean NOT NULL DEFAULT false,
|
||||
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT va_student_lab_unique UNIQUE (student_id, lab_id)
|
||||
);
|
||||
|
||||
CREATE INDEX va_lab_idx ON variant_assignments (lab_id);
|
||||
Reference in New Issue
Block a user