Build: enable ktlint+detekt, format sources, add core unit tests

This commit is contained in:
2026-09-27 21:46:05 +03:00
parent 802616918f
commit f3e8b2668f
103 changed files with 3834 additions and 1909 deletions

View File

@@ -18,23 +18,28 @@ 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(),
)
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)
fun setup(
@Valid @RequestBody body: SetupRequest,
): AuthResponse = authService.setup(body)
@PostMapping("/login")
fun login(@Valid @RequestBody body: LoginRequest): AuthResponse = authService.login(body)
fun login(
@Valid @RequestBody body: LoginRequest,
): AuthResponse = authService.login(body)
@GetMapping("/me")
fun me(@AuthenticationPrincipal jwt: Jwt): MeResponse =
authService.me(UUID.fromString(jwt.subject))
fun me(
@AuthenticationPrincipal jwt: Jwt,
): MeResponse = authService.me(UUID.fromString(jwt.subject))
@PostMapping("/change-password")
fun changePassword(

View File

@@ -28,7 +28,6 @@ class AuthService(
private val jwtEncoder: JwtEncoder,
private val jwtProperties: JwtProperties,
) {
fun adminExists(): Boolean = accounts.existsByRole(LmsRole.ADMIN)
@Transactional
@@ -41,20 +40,22 @@ class AuthService(
throw BadRequestException("Логин: минимум 3 символа")
}
validatePassword(request.password, request.password2)
val account = Account(
login = login,
passwordHash = passwordEncoder.encode(request.password) ?: "",
role = LmsRole.ADMIN,
mustChangePassword = false,
)
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("Неверный логин или пароль")
val account =
accounts.findByLogin(request.login.trim())
?: throw UnauthorizedException("Неверный логин или пароль")
if (!passwordEncoder.matches(request.password, account.passwordHash)) {
throw UnauthorizedException("Неверный логин или пароль")
}
@@ -63,8 +64,10 @@ class AuthService(
@Transactional(readOnly = true)
fun me(accountId: UUID): MeResponse {
val account = accounts.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
val account =
accounts
.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
return MeResponse(
id = account.id.toString(),
login = account.login,
@@ -75,9 +78,14 @@ class AuthService(
}
@Transactional
fun changePassword(accountId: UUID, request: ChangePasswordRequest) {
val account = accounts.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
fun changePassword(
accountId: UUID,
request: ChangePasswordRequest,
) {
val account =
accounts
.findById(accountId)
.orElseThrow { NotFoundException("Пользователь не найден") }
if (!passwordEncoder.matches(request.old, account.passwordHash)) {
throw UnauthorizedException("Текущий пароль неверный")
}
@@ -89,7 +97,10 @@ class AuthService(
accounts.save(account)
}
private fun validatePassword(password: String, confirmation: String) {
private fun validatePassword(
password: String,
confirmation: String,
) {
if (password.length < 8) {
throw BadRequestException("Пароль: минимум 8 символов")
}
@@ -100,33 +111,43 @@ class AuthService(
private fun displayName(account: Account): String =
if (account.role == LmsRole.STUDENT) {
students.findById(account.id)
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 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 {
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 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
}

View File

@@ -16,26 +16,19 @@ import java.util.UUID
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(),
)

View File

@@ -16,15 +16,12 @@ import java.util.UUID
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(),
)

View File

@@ -13,13 +13,10 @@ import java.util.UUID
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(),
)

View File

@@ -11,7 +11,6 @@ class Setting(
@Id
@Column(name = "key", length = 100)
var key: String = "",
@Column(name = "value", nullable = false)
var value: String = "",
)

View File

@@ -16,32 +16,26 @@ 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(" ")
get() =
listOf(lastName, firstName, middleName)
.filter { it.isNotBlank() }
.joinToString(" ")
}

View File

@@ -7,7 +7,10 @@ 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
}

View File

@@ -7,16 +7,18 @@ 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 findAllByGroupId(
@Param("groupId") groupId: UUID,
): List<Enrollment>
fun findByStudent_AccountId(accountId: UUID): Enrollment?
fun findFirstByStudent_AccountId(accountId: UUID): Enrollment?
fun findByStudentAccountId(accountId: UUID): Enrollment?
fun deleteByStudent_AccountId(accountId: UUID)
fun findFirstByStudentAccountId(accountId: UUID): Enrollment?
fun countByGroup_Id(groupId: UUID): Long
fun deleteByStudentAccountId(accountId: UUID)
fun countByGroupId(groupId: UUID): Long
}

View File

@@ -6,6 +6,8 @@ import java.util.UUID
interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun findByName(name: String): GroupEntity?
fun existsByName(name: String): Boolean
fun findAllByOrderByNameAsc(): List<GroupEntity>
}

View File

@@ -6,5 +6,6 @@ import java.util.UUID
interface StudentRepository : JpaRepository<Student, UUID> {
fun findByStudentNo(studentNo: String): Student?
fun existsByStudentNo(studentNo: String): Boolean
}

View File

@@ -4,21 +4,21 @@ 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")
builder
.append(
listOf(
row.fullName,
row.studentNo,
row.tempPassword ?: "—",
status(row),
).joinToString(";") { escapeCsv(it) },
).append("\r\n")
}
return ("\uFEFF" + builder).toByteArray(Charsets.UTF_8)
}
@@ -41,8 +41,7 @@ object CredentialsExporter {
}
}
private fun status(row: StudentRow): String =
if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
private fun status(row: StudentRow): String = if (row.mustChangePassword) "ожидает первую смену пароля" else "пароль изменён"
private fun escapeCsv(value: String): String =
if (value.contains(';') || value.contains('"') || value.contains('\n')) {

View File

@@ -5,6 +5,8 @@ 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.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@@ -17,20 +19,21 @@ import org.springframework.web.multipart.MultipartFile
import ru.ysu.lms.common.BadRequestException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import java.util.UUID
@RestController
@RequestMapping("/api/v1/groups")
@PreAuthorize("hasRole('ADMIN')")
class GroupController(private val groupService: GroupService) {
class GroupController(
private val groupService: GroupService,
) {
@GetMapping
fun list(): List<GroupSummary> = groupService.list()
@PostMapping
fun create(@Valid @RequestBody body: CreateGroupRequest): GroupSummary = groupService.create(body)
fun create(
@Valid @RequestBody body: CreateGroupRequest,
): GroupSummary = groupService.create(body)
@PostMapping("/{id}/students")
fun createStudent(
@@ -51,25 +54,34 @@ class GroupController(private val groupService: GroupService) {
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@GetMapping("/{id}/candidates")
fun candidates(@PathVariable id: UUID): List<StudentCandidate> = groupService.candidates(id)
fun candidates(
@PathVariable id: UUID,
): List<StudentCandidate> = groupService.candidates(id)
/** Зачислить существующего студента. */
@PostMapping("/{id}/students/{studentId}/enroll")
fun enroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
groupService.enroll(id, studentId)
fun enroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.enroll(id, studentId)
/** Отчислить студента из группы. */
@DeleteMapping("/{id}/students/{studentId}")
fun unenroll(@PathVariable id: UUID, @PathVariable studentId: UUID): GroupDetail =
groupService.unenroll(id, studentId)
fun unenroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.unenroll(id, studentId)
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")
fun myGroup(@AuthenticationPrincipal jwt: Jwt): List<GroupSummary> =
groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
fun myGroup(
@AuthenticationPrincipal jwt: Jwt,
): List<GroupSummary> = groupService.groupOfStudent(UUID.fromString(jwt.subject))?.let { listOf(it) } ?: emptyList()
@GetMapping("/{id}")
fun detail(@PathVariable id: UUID): GroupDetail = groupService.detail(id)
fun detail(
@PathVariable id: UUID,
): GroupDetail = groupService.detail(id)
@GetMapping("/{id}/credentials")
fun credentials(
@@ -78,7 +90,8 @@ class GroupController(private val groupService: GroupService) {
): ResponseEntity<ByteArray> {
val export = groupService.credentials(id, format)
val encodedName = URLEncoder.encode(export.filename, StandardCharsets.UTF_8).replace("+", "%20")
return ResponseEntity.ok()
return ResponseEntity
.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''$encodedName")
.contentType(MediaType.parseMediaType(export.contentType))
.body(export.bytes)

View File

@@ -27,12 +27,14 @@ class GroupService(
private val passwordEncoder: PasswordEncoder,
private val parser: StudentImportParser,
) {
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
@Transactional
fun createStudent(groupId: UUID, request: CreateStudentRequest): Credential {
fun createStudent(
groupId: UUID,
request: CreateStudentRequest,
): Credential {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val studentNo = request.studentNo.trim()
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
@@ -40,27 +42,29 @@ class GroupService(
throw ConflictException("Студент с номером $studentNo уже существует")
}
val password = Passwords.generate()
val account = accounts.save(
Account(
login = studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val account =
accounts.save(
Account(
login = studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
val student = students.save(
Student(
studentNo = studentNo,
lastName = request.lastName.trim(),
firstName = request.firstName.trim(),
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
account = account,
),
)
val student =
students.save(
Student(
studentNo = studentNo,
lastName = request.lastName.trim(),
firstName = request.firstName.trim(),
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
account = account,
),
)
enrollments.save(Enrollment(group = group, student = student))
return Credential(
id = account.id.toString(),
@@ -91,7 +95,8 @@ class GroupService(
val name = e.group?.name ?: continue
currentByStudent[sid] = gid to name
}
return students.findAll()
return students
.findAll()
.filter { it.accountId !in memberIds }
.map { s ->
val current = s.accountId?.let { currentByStudent[it] }
@@ -102,16 +107,18 @@ class GroupService(
currentGroupId = current?.first?.toString(),
currentGroupName = current?.second,
)
}
.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
}.sortedWith(compareBy({ it.fullName }, { it.studentNo }))
}
/** Зачислить существующего студента; если он в другой группе — переводим. */
@Transactional
fun enroll(groupId: UUID, studentId: UUID): GroupDetail {
fun enroll(
groupId: UUID,
studentId: UUID,
): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
val existing = enrollments.findByStudent_AccountId(studentId)
val existing = enrollments.findByStudentAccountId(studentId)
if (existing != null) {
if (existing.group?.id == group.id) {
throw ConflictException("Студент уже в этой группе")
@@ -127,15 +134,23 @@ class GroupService(
/** Отчислить из группы (студент и его аккаунт сохраняются). */
@Transactional
fun unenroll(groupId: UUID, studentId: UUID): GroupDetail {
val enrollment = enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
fun unenroll(
groupId: UUID,
studentId: UUID,
): GroupDetail {
val enrollment =
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment)
return detail(groupId)
}
@Transactional
fun import(requestedName: String?, filename: String, bytes: ByteArray): ImportResult {
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))
@@ -144,49 +159,52 @@ class GroupService(
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 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)
val enrollment = enrollments.findByStudentAccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
@@ -199,7 +217,7 @@ class GroupService(
@Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudent_AccountId(accountId)?.group ?: return null
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
return GroupSummary(
id = group.id.toString(),
name = group.name,
@@ -211,25 +229,30 @@ class GroupService(
@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(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
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(compareBy({ it.fullName.lowercase() }, { it.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 student =
students
.findById(studentAccountId)
.orElseThrow { NotFoundException("Студент не найден") }
val account = student.account ?: throw NotFoundException("Аккаунт студента не найден")
val password = Passwords.generate()
account.passwordHash = passwordEncoder.encode(password) ?: account.passwordHash
@@ -240,28 +263,34 @@ class GroupService(
}
@Transactional(readOnly = true)
fun credentials(groupId: UUID, format: String): ExportFile {
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",
)
"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 fun summary(group: GroupEntity): GroupSummary =
GroupSummary(
id = group.id.toString(),
name = group.name,
studentCount = enrollments.countByGroupId(group.id),
taskDurationMin = group.taskDurationMin,
)
}

View File

@@ -10,9 +10,11 @@ import java.util.UUID
@RestController
@RequestMapping("/api/v1/students")
@PreAuthorize("hasRole('ADMIN')")
class StudentController(private val groupService: GroupService) {
class StudentController(
private val groupService: GroupService,
) {
@PostMapping("/{id}/reset-password")
fun resetPassword(@PathVariable id: UUID): ResetPasswordResponse =
groupService.resetPassword(id)
fun resetPassword(
@PathVariable id: UUID,
): ResetPasswordResponse = groupService.resetPassword(id)
}

View File

@@ -25,8 +25,10 @@ data class ParsedGroup(
@Component
class StudentImportParser {
fun parse(filename: String, bytes: ByteArray): ParsedGroup {
fun parse(
filename: String,
bytes: ByteArray,
): ParsedGroup {
val lower = filename.lowercase()
return when {
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
@@ -65,7 +67,10 @@ class StudentImportParser {
return candidates.maxByOrNull { header.count { ch -> ch == it } } ?: ';'
}
private fun splitCsvLine(line: String, delimiter: Char): List<String> {
private fun splitCsvLine(
line: String,
delimiter: Char,
): List<String> {
val result = mutableListOf<String>()
val current = StringBuilder()
var inQuotes = false
@@ -83,7 +88,10 @@ class StudentImportParser {
return result
}
private fun parseRows(filename: String, rows: List<List<String>>): ParsedGroup {
private fun parseRows(
filename: String,
rows: List<List<String>>,
): ParsedGroup {
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
if (headerIndex < 0) {
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
@@ -92,25 +100,26 @@ class StudentImportParser {
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
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 (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("В таблице не найдено ни одного студента")
}
@@ -119,15 +128,21 @@ class StudentImportParser {
private fun isStudentHeader(value: String): Boolean {
val normalized = value.lowercase().replace("\u00a0", " ").trim()
return normalized.startsWith("студен") || normalized.startsWith("студ.") ||
normalized.startsWith("зачёт") || normalized.startsWith("зачет")
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) {
fun putIfAbsent(
key: String,
vararg prefixes: String,
) {
if (key !in result && prefixes.any { value.startsWith(it) }) {
result[key] = index
}
@@ -142,10 +157,12 @@ class StudentImportParser {
return result
}
private fun normalizeNumber(value: String): String =
if (value.endsWith(".0")) value.dropLast(2) else value
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 {
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) {

View File

@@ -17,17 +17,19 @@ import ru.ysu.lms.security.JwtProperties
@Configuration
@EnableConfigurationProperties(JwtProperties::class)
class JwtConfig(private val keyStore: JwtKeyStore) {
class JwtConfig(
private val keyStore: JwtKeyStore,
) {
@Bean
fun rsaKey(): RSAKey =
RSAKey
.Builder(keyStore.publicKey)
.privateKey(keyStore.privateKey)
.keyID("lms")
.build()
@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)))
fun jwtEncoder(rsaKey: RSAKey): JwtEncoder = NimbusJwtEncoder(ImmutableJWKSet<SecurityContext>(JWKSet(rsaKey)))
@Bean
fun jwtDecoder(): JwtDecoder = NimbusJwtDecoder.withPublicKey(keyStore.publicKey).build()

View File

@@ -15,8 +15,9 @@ import java.util.Base64
* in the `settings` table, so no private key ever lives in the repository.
*/
@Service
class JwtKeyStore(private val settings: SettingRepository) {
class JwtKeyStore(
private val settings: SettingRepository,
) {
lateinit var privateKey: RSAPrivateKey
lateinit var publicKey: RSAPublicKey
@@ -38,7 +39,10 @@ class JwtKeyStore(private val settings: SettingRepository) {
}
}
private fun toPem(label: String, der: ByteArray): String {
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"
}

View File

@@ -18,7 +18,6 @@ import org.springframework.security.web.SecurityFilterChain
@EnableWebSecurity
@EnableMethodSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
@@ -26,19 +25,19 @@ class SecurityConfig {
.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
.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 ->
}.oauth2ResourceServer { resource ->
resource.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
}
return http.build()

View File

@@ -8,20 +8,20 @@ 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
import ru.ysu.lms.identity.auth.AuthService
@SpringBootTest
@Testcontainers
class IdentitySmokeTest {
companion object {
@Container
val pg = PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
val pg =
PostgreSQLContainer("postgres:18").apply {
withDatabaseName("lms")
withUsername("lms")
withPassword("lms")
}
@JvmStatic
@DynamicPropertySource
@@ -42,21 +42,31 @@ class IdentitySmokeTest {
@Test
fun `setup creates admin and login works`() {
val token = authService.setup(
ru.ysu.lms.identity.auth.SetupRequest("admin", "test12345", "test12345")
)
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")
)
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 }
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" }
}
}
}