feat(identity): внутренние API для профиля студента
This commit is contained in:
@@ -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 = "Неверный внутренний токен"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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("Некорректный идентификатор аккаунта") }
|
||||
}
|
||||
@@ -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/**",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user