feat(identity): внутренние API для профиля студента

This commit is contained in:
2026-10-27 17:44:48 +03:00
parent dd1129fe80
commit c11a308ff8
8 changed files with 528 additions and 2 deletions

View File

@@ -0,0 +1,65 @@
package ru.ysu.lms.identity.internal
import org.springframework.beans.factory.annotation.Value
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.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import ru.ysu.lms.common.ForbiddenException
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.UUID
/**
* Внутренние эндпоинты identity. Gateway их не маршрутизирует, поэтому вместо
* пользовательского JWT используется общий сервисный токен.
*/
@RestController
@RequestMapping("/api/v1/internal")
class InternalController(
private val service: InternalService,
@Value("\${lms.internal.token}") private val internalToken: String,
) {
@GetMapping("/students/{studentId}/context")
fun studentContext(
@RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?,
@PathVariable studentId: UUID,
): StudentContextDto {
ensureToken(token)
return service.studentContext(studentId)
}
@PostMapping("/accounts/lookup")
fun lookupAccounts(
@RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?,
@RequestBody request: AccountLookupRequest,
): List<AccountInfoDto> {
ensureToken(token)
return service.lookupAccounts(request.ids)
}
@GetMapping("/groups/{groupId}")
fun group(
@RequestHeader(name = INTERNAL_TOKEN_HEADER, required = false) token: String?,
@PathVariable groupId: UUID,
): InternalGroupDto {
ensureToken(token)
return service.group(groupId)
}
private fun ensureToken(token: String?) {
if (token == null ||
!MessageDigest.isEqual(token.toByteArray(StandardCharsets.UTF_8), internalToken.toByteArray(StandardCharsets.UTF_8))
) {
throw ForbiddenException(INVALID_TOKEN_MESSAGE)
}
}
private companion object {
const val INTERNAL_TOKEN_HEADER = "X-Internal-Token"
const val INVALID_TOKEN_MESSAGE = "Неверный внутренний токен"
}
}

View File

@@ -0,0 +1,38 @@
package ru.ysu.lms.identity.internal
/** Контекст студента для внутренних агрегаторов (профиль преподавателя). */
data class StudentContextDto(
val id: String,
val login: String,
val fullName: String,
val studentNo: String,
val status: String,
val enrollmentYear: Short? = null,
val groupId: String? = null,
val groupName: String? = null,
)
/** Пакетный запрос логинов по идентификаторам аккаунтов. */
data class AccountLookupRequest(
val ids: List<String> = emptyList(),
)
data class AccountInfoDto(
val id: String,
val login: String,
val role: String,
)
/** Группа с текущим составом — без учётных данных. */
data class InternalGroupDto(
val id: String,
val name: String,
val students: List<InternalGroupStudentDto>,
)
data class InternalGroupStudentDto(
val id: String,
val studentNo: String,
val fullName: String,
val status: String,
)

View File

@@ -0,0 +1,81 @@
package ru.ysu.lms.identity.internal
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.common.NotFoundException
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 java.util.UUID
/**
* Read-model для внутренних вызовов: отдаёт данные без админского JWT.
* Доступ ограничен сервисным токеном на уровне [InternalController].
*/
@Service
class InternalService(
private val accounts: AccountRepository,
private val students: StudentRepository,
private val enrollments: EnrollmentRepository,
private val groups: GroupRepository,
) {
@Transactional(readOnly = true)
fun studentContext(studentId: UUID): StudentContextDto {
val student =
students.findById(studentId).orElseThrow {
NotFoundException("Студент не найден")
}
val account = student.account ?: accounts.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
val enrollment = enrollments.findByStudentAccountId(studentId)
return StudentContextDto(
id = account.id.toString(),
login = account.login,
fullName = student.fullName,
studentNo = student.studentNo,
status = student.status.name,
enrollmentYear = student.enrollmentYear,
groupId = enrollment?.group?.id?.toString(),
groupName = enrollment?.group?.name,
)
}
/** Группа и её текущий состав. Пароли и временные пароли сюда не попадают. */
@Transactional(readOnly = true)
fun group(groupId: UUID): InternalGroupDto {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val members =
enrollments
.findAllByGroupId(groupId)
.mapNotNull { enrollment ->
val student = enrollment.student ?: return@mapNotNull null
student.accountId?.let { accountId ->
InternalGroupStudentDto(
id = accountId.toString(),
studentNo = student.studentNo,
fullName = student.fullName,
status = student.status.name,
)
}
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
return InternalGroupDto(id = group.id.toString(), name = group.name, students = members)
}
/** Логины авторов комментариев: неизвестные id пропускаются, дубликаты схлопываются. */
@Transactional(readOnly = true)
fun lookupAccounts(ids: List<String>): List<AccountInfoDto> {
val requested = ids.map(::parseId).distinct()
if (requested.isEmpty()) return emptyList()
val found = accounts.findAllById(requested).associateBy { it.id }
return requested.mapNotNull { id ->
found[id]?.let { account ->
AccountInfoDto(id = account.id.toString(), login = account.login, role = account.role.name)
}
}
}
private fun parseId(raw: String): UUID =
runCatching { UUID.fromString(raw.trim()) }
.getOrElse { throw BadRequestException("Некорректный идентификатор аккаунта") }
}

View File

@@ -31,6 +31,8 @@ class SecurityConfig {
"/api/v1/auth/setup",
"/api/v1/auth/login",
"/api/v1/auth/jwks.json",
// Внутренние эндпоинты защищены сервисным токеном в самом контроллере.
"/api/v1/internal/**",
"/actuator/**",
"/v3/api-docs/**",
"/swagger-ui/**",

View File

@@ -37,6 +37,8 @@ springdoc:
path: /v3/api-docs
lms:
internal:
token: ${LMS_INTERNAL_TOKEN:dev-internal-token}
jwt:
# Срок жизни access-токена; сессия web-BFF — 30 дней (см. web/application.yml).
access-token-ttl: ${LMS_JWT_ACCESS_TOKEN_TTL:30d}

View File

@@ -0,0 +1,331 @@
package ru.ysu.lms.identity.internal
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.get
import org.springframework.test.web.servlet.post
import org.springframework.test.web.servlet.setup.DefaultMockMvcBuilder
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.context.WebApplicationContext
import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers
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.domain.StudentStatus
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
@SpringBootTest
@Testcontainers
@Transactional
class InternalControllerIntegrationTest {
companion object {
private const val TOKEN = "test-internal-token"
@Container
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
registry.add("spring.datasource.url") { pg.jdbcUrl }
registry.add("spring.datasource.username") { "lms" }
registry.add("spring.datasource.password") { "lms" }
registry.add("lms.internal.token") { TOKEN }
}
}
@Autowired lateinit var accounts: AccountRepository
@Autowired lateinit var students: StudentRepository
@Autowired lateinit var enrollments: EnrollmentRepository
@Autowired lateinit var groups: GroupRepository
@Autowired lateinit var passwordEncoder: PasswordEncoder
@Autowired lateinit var context: WebApplicationContext
private val mockMvc: MockMvc by lazy {
MockMvcBuilders
.webAppContextSetup(context)
.apply<DefaultMockMvcBuilder>(springSecurity())
.build()
}
@Test
fun `valid token returns student context with current group`() {
val student = student(login = "ivanov", studentNo = "123456", groupName = "ПИ-21БО")
mockMvc
.get("/api/v1/internal/students/${student.accountId}/context") {
header("X-Internal-Token", TOKEN)
}.andExpect {
status { isOk() }
jsonPath("$.id") { value(student.accountId.toString()) }
jsonPath("$.login") { value("ivanov") }
jsonPath("$.fullName") { value("Иванов Иван Иванович") }
jsonPath("$.studentNo") { value("123456") }
jsonPath("$.status") { value("ACTIVE") }
jsonPath("$.enrollmentYear") { value(2025) }
jsonPath("$.groupId") { isNotEmpty() }
jsonPath("$.groupName") { value("ПИ-21БО") }
}
}
@Test
fun `context without group returns nulls`() {
val student = student(login = "petrov", studentNo = "654321")
mockMvc
.get("/api/v1/internal/students/${student.accountId}/context") {
header("X-Internal-Token", TOKEN)
}.andExpect {
status { isOk() }
jsonPath("$.groupId") { doesNotExist() }
jsonPath("$.groupName") { doesNotExist() }
}
}
@Test
fun `valid token returns account logins for lookup`() {
val first = account("a.ivanov")
val second = account("p.petrov")
mockMvc
.post("/api/v1/internal/accounts/lookup") {
header("X-Internal-Token", TOKEN)
contentType = MediaType.APPLICATION_JSON
content = """{"ids":["${first.id}","${second.id}"]}"""
}.andExpect {
status { isOk() }
jsonPath("$.length()") { value(2) }
jsonPath("$[0].id") { value(first.id.toString()) }
jsonPath("$[0].login") { value("a.ivanov") }
jsonPath("$[0].role") { value("STUDENT") }
jsonPath("$[1].login") { value("p.petrov") }
}
}
@Test
fun `lookup ignores missing accounts and duplicates`() {
val existing = account("a.ivanov")
val missing = UUID.randomUUID()
mockMvc
.post("/api/v1/internal/accounts/lookup") {
header("X-Internal-Token", TOKEN)
contentType = MediaType.APPLICATION_JSON
content = """{"ids":["${existing.id}","$missing","${existing.id}"]}"""
}.andExpect {
status { isOk() }
jsonPath("$.length()") { value(1) }
jsonPath("$[0].id") { value(existing.id.toString()) }
jsonPath("$[0].login") { value("a.ivanov") }
}
}
@Test
fun `empty lookup returns empty list`() {
mockMvc
.post("/api/v1/internal/accounts/lookup") {
header("X-Internal-Token", TOKEN)
contentType = MediaType.APPLICATION_JSON
content = """{"ids":[]}"""
}.andExpect {
status { isOk() }
jsonPath("$.length()") { value(0) }
}
}
@Test
fun `invalid lookup id is rejected`() {
mockMvc
.post("/api/v1/internal/accounts/lookup") {
header("X-Internal-Token", TOKEN)
contentType = MediaType.APPLICATION_JSON
content = """{"ids":["not-a-uuid"]}"""
}.andExpect {
status { isBadRequest() }
}
}
@Test
fun `invalid token is forbidden`() {
val student = student(login = "sidorov", studentNo = "111111", groupName = "ПИ-21БО")
mockMvc
.get("/api/v1/internal/students/${student.accountId}/context") {
header("X-Internal-Token", "wrong-token")
}.andExpect {
status { isForbidden() }
jsonPath("$.detail") { value("Неверный внутренний токен") }
}
mockMvc
.post("/api/v1/internal/accounts/lookup") {
header("X-Internal-Token", "wrong-token")
contentType = MediaType.APPLICATION_JSON
content = """{"ids":[]}"""
}.andExpect {
status { isForbidden() }
}
}
@Test
fun `missing token is forbidden`() {
val student = student(login = "sidorov", studentNo = "111111")
mockMvc
.get("/api/v1/internal/students/${student.accountId}/context") {
}.andExpect {
status { isForbidden() }
}
mockMvc
.post("/api/v1/internal/accounts/lookup") {
contentType = MediaType.APPLICATION_JSON
content = """{"ids":[]}"""
}.andExpect {
status { isForbidden() }
}
}
@Test
fun `unknown student is not found`() {
mockMvc
.get("/api/v1/internal/students/${UUID.randomUUID()}/context") {
header("X-Internal-Token", TOKEN)
}.andExpect {
status { isNotFound() }
}
}
@Test
fun `valid token returns group with students and no credentials`() {
val group = group("ПИ-21БО")
val petrov = enroll(group, login = "p.petrov", studentNo = "111111", lastName = "Петров")
val ivanov = enroll(group, login = "a.ivanov", studentNo = "222222", lastName = "Иванов")
mockMvc
.get("/api/v1/internal/groups/${group.id}") {
header("X-Internal-Token", TOKEN)
}.andExpect {
status { isOk() }
jsonPath("$.id") { value(group.id.toString()) }
jsonPath("$.name") { value("ПИ-21БО") }
jsonPath("$.students.length()") { value(2) }
jsonPath("$.students[0].id") { value(ivanov.accountId.toString()) }
jsonPath("$.students[0].studentNo") { value("222222") }
jsonPath("$.students[0].fullName") { value("Иванов Иван Иванович") }
jsonPath("$.students[0].status") { value("ACTIVE") }
jsonPath("$.students[1].id") { value(petrov.accountId.toString()) }
jsonPath("$.students[1].studentNo") { value("111111") }
jsonPath("$.students[0].tempPassword") { doesNotExist() }
jsonPath("$.students[0].login") { doesNotExist() }
jsonPath("$.students[0].passwordHash") { doesNotExist() }
}
}
@Test
fun `group with invalid token is forbidden`() {
val group = group("ПИ-21БО")
mockMvc
.get("/api/v1/internal/groups/${group.id}") {
header("X-Internal-Token", "wrong-token")
}.andExpect {
status { isForbidden() }
jsonPath("$.detail") { value("Неверный внутренний токен") }
}
}
@Test
fun `unknown group is not found`() {
mockMvc
.get("/api/v1/internal/groups/${UUID.randomUUID()}") {
header("X-Internal-Token", TOKEN)
}.andExpect {
status { isNotFound() }
}
}
private fun account(login: String): Account =
accounts.save(
Account(
login = login,
passwordHash = passwordEncoder.encode("secret123") ?: "",
role = LmsRole.STUDENT,
),
)
private fun group(name: String): GroupEntity = groups.save(GroupEntity(name = name, enrollmentYear = 2025))
private fun enroll(
group: GroupEntity,
login: String,
studentNo: String,
lastName: String,
): Student {
val student = createStudent(login = login, studentNo = studentNo, lastName = lastName)
enrollments.save(Enrollment(group = group, student = student))
return student
}
private fun student(
login: String,
studentNo: String,
groupName: String? = null,
): Student {
val student = createStudent(login = login, studentNo = studentNo, lastName = "Иванов")
if (groupName != null) {
enrollments.save(Enrollment(group = group(groupName), student = student))
}
return student
}
private fun createStudent(
login: String,
studentNo: String,
lastName: String,
): Student {
val account = account(login)
val student =
students.save(
Student(
studentNo = studentNo,
lastName = lastName,
firstName = "Иван",
middleName = "Иванович",
basis = "бюджет",
status = StudentStatus.ACTIVE,
enrollmentYear = 2025,
account = account,
),
)
assertEquals(account.id, student.accountId)
return student
}
}