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:
10
libs/common-http/build.gradle.kts
Normal file
10
libs/common-http/build.gradle.kts
Normal file
@@ -0,0 +1,10 @@
|
||||
plugins {
|
||||
id("lms.spring-library")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(platform(libs.springBootBom))
|
||||
|
||||
api(libs.springWeb)
|
||||
api(libs.springBootAutoconfigure)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/**
|
||||
* Uniform error payload, close to RFC 9457 "Problem Details for HTTP APIs".
|
||||
*/
|
||||
data class ApiError(
|
||||
val type: String = "about:blank",
|
||||
val title: String,
|
||||
val status: Int,
|
||||
val detail: String? = null,
|
||||
val code: String? = null,
|
||||
val violations: List<FieldViolation> = emptyList(),
|
||||
val timestamp: Instant = Instant.now(),
|
||||
)
|
||||
|
||||
data class FieldViolation(
|
||||
val field: String,
|
||||
val message: String,
|
||||
)
|
||||
21
libs/common-http/src/main/kotlin/ru/ysu/lms/common/Ids.kt
Normal file
21
libs/common-http/src/main/kotlin/ru/ysu/lms/common/Ids.kt
Normal file
@@ -0,0 +1,21 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Time-ordered UUIDv7 identifiers (the same layout PostgreSQL 18 provides with
|
||||
* `uuidv7()`), so application-generated and database-generated ids match.
|
||||
*/
|
||||
object Ids {
|
||||
private val random = SecureRandom()
|
||||
|
||||
fun v7(): UUID {
|
||||
val timestamp = System.currentTimeMillis() and 0x0000_FFFF_FFFF_FFFFL
|
||||
val randA = (random.nextInt() and 0x0FFF).toLong()
|
||||
val randB = random.nextLong() and 0x3FFF_FFFF_FFFF_FFFFL
|
||||
val msb = (timestamp shl 16) or 0x7000L or randA
|
||||
val lsb = Long.MIN_VALUE or randB
|
||||
return UUID(msb, lsb)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
|
||||
import org.springframework.context.annotation.Bean
|
||||
|
||||
@AutoConfiguration
|
||||
class LmsCommonAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
fun lmsExceptionHandler(): LmsExceptionHandler = LmsExceptionHandler()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import org.springframework.http.HttpStatus
|
||||
|
||||
/**
|
||||
* Base class for expected, domain-level failures. Mapped to [ApiError] by
|
||||
* [LmsExceptionHandler].
|
||||
*/
|
||||
open class LmsException(
|
||||
val status: HttpStatus,
|
||||
override val message: String,
|
||||
val code: String? = null,
|
||||
val violations: List<FieldViolation> = emptyList(),
|
||||
) : RuntimeException(message)
|
||||
|
||||
class BadRequestException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.BAD_REQUEST, message, code)
|
||||
|
||||
class NotFoundException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.NOT_FOUND, message, code)
|
||||
|
||||
class ConflictException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.CONFLICT, message, code)
|
||||
|
||||
class ForbiddenException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.FORBIDDEN, message, code)
|
||||
|
||||
class UnauthorizedException(message: String, code: String? = null) :
|
||||
LmsException(HttpStatus.UNAUTHORIZED, message, code)
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.ysu.lms.common
|
||||
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
@RestControllerAdvice
|
||||
class LmsExceptionHandler {
|
||||
|
||||
@ExceptionHandler(LmsException::class)
|
||||
fun handleDomain(ex: LmsException): ResponseEntity<ApiError> =
|
||||
ResponseEntity.status(ex.status).body(
|
||||
ApiError(
|
||||
title = ex.status.reasonPhrase,
|
||||
status = ex.status.value(),
|
||||
detail = ex.message,
|
||||
code = ex.code,
|
||||
violations = ex.violations,
|
||||
),
|
||||
)
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||
fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity<ApiError> {
|
||||
val violations = ex.bindingResult.fieldErrors.map {
|
||||
FieldViolation(field = it.field, message = it.defaultMessage ?: "invalid value")
|
||||
}
|
||||
return ResponseEntity.badRequest().body(
|
||||
ApiError(
|
||||
title = "Validation failed",
|
||||
status = 400,
|
||||
detail = "Request validation failed",
|
||||
violations = violations,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ru.ysu.lms.common.LmsCommonAutoConfiguration
|
||||
Reference in New Issue
Block a user