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}