feat(identity): группы-когорты, история и импорт v2

This commit is contained in:
2026-10-26 15:47:49 +03:00
parent 24a92632fd
commit 07a223b759
34 changed files with 1459 additions and 248 deletions

View File

@@ -13,6 +13,8 @@ import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.common.NotFoundException
import ru.ysu.lms.common.UnauthorizedException
import ru.ysu.lms.identity.domain.Account
import ru.ysu.lms.identity.domain.AccountStatus
import ru.ysu.lms.identity.domain.StudentStatus
import ru.ysu.lms.identity.repo.AccountRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.JwtProperties
@@ -61,10 +63,22 @@ class AuthService(
backoff.recordFailure(login)
throw UnauthorizedException("Неверный логин или пароль")
}
ensureLoginAllowed(account)
backoff.reset(login)
return toResponse(account, displayName(account))
}
private fun ensureLoginAllowed(account: Account) {
if (account.status != AccountStatus.ACTIVE) {
throw UnauthorizedException("Аккаунт архивирован")
}
if (account.role != LmsRole.STUDENT) return
val status = students.findById(account.id).map { it.status }.orElse(null)
if (status in setOf(StudentStatus.EXPELLED, StudentStatus.GRADUATED, StudentStatus.ARCHIVED)) {
throw UnauthorizedException("Доступ к системе закрыт")
}
}
@Transactional(readOnly = true)
fun me(accountId: UUID): MeResponse {
val account =

View File

@@ -27,6 +27,11 @@ class Account(
var mustChangePassword: Boolean = true,
@Column(name = "temp_password", length = 64)
var tempPassword: String? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var status: AccountStatus = AccountStatus.ACTIVE,
@Column(name = "archived_at")
var archivedAt: Instant? = null,
@Column(name = "created_at", nullable = false)
var createdAt: Instant = Instant.now(),
@Column(name = "updated_at", nullable = false)

View File

@@ -0,0 +1,6 @@
package ru.ysu.lms.identity.domain
enum class AccountStatus {
ACTIVE,
ARCHIVED,
}

View File

@@ -2,6 +2,8 @@ package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
@@ -13,8 +15,19 @@ import java.util.UUID
class GroupEntity(
@Id
var id: UUID = Ids.v7(),
@Column(nullable = false, unique = true, length = 100)
@Column(nullable = false, length = 100)
var name: String = "",
@Column(length = 50)
var stream: String? = null,
var course: Short? = null,
var number: Short? = null,
@Column(length = 2)
var degree: String? = null,
@Column(name = "enrollment_year")
var enrollmentYear: Short? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var status: GroupStatus = GroupStatus.ACTIVE,
@Column(name = "task_duration_min", nullable = false)
var taskDurationMin: Int = 30,
@Column(name = "created_at", nullable = false)

View File

@@ -0,0 +1,7 @@
package ru.ysu.lms.identity.domain
enum class GroupStatus {
ACTIVE,
GRADUATED,
ARCHIVED,
}

View File

@@ -0,0 +1,14 @@
package ru.ysu.lms.identity.domain
enum class MembershipAction {
ENROLL,
TRANSFER,
ACADEMIC,
RETURN,
GRADUATE,
EXPELL,
PROMOTE,
IMPORT,
ARCHIVE,
RESTORE,
}

View File

@@ -0,0 +1,35 @@
package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.Id
import jakarta.persistence.Table
import ru.ysu.lms.common.Ids
import java.time.Instant
import java.util.UUID
@Entity
@Table(name = "membership_history")
class MembershipHistory(
@Id
var id: UUID = Ids.v7(),
@Column(name = "student_id", nullable = false)
var studentId: UUID = UUID(0, 0),
@Column(name = "from_group_id")
var fromGroupId: UUID? = null,
@Column(name = "to_group_id")
var toGroupId: UUID? = null,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
var action: MembershipAction = MembershipAction.ENROLL,
@Column(nullable = false)
var reason: String = "",
@Column(name = "actor_account_id")
var actorAccountId: UUID? = null,
@Column(name = "promotion_run_id")
var promotionRunId: UUID? = null,
@Column(nullable = false)
var at: Instant = Instant.now(),
)

View File

@@ -2,6 +2,8 @@ package ru.ysu.lms.identity.domain
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.Id
import jakarta.persistence.JoinColumn
@@ -28,6 +30,11 @@ class Student(
var basis: String = "",
@Column(name = "note", nullable = false)
var note: String = "",
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
var status: StudentStatus = StudentStatus.ACTIVE,
@Column(name = "enrollment_year")
var enrollmentYear: Short? = null,
@OneToOne(fetch = FetchType.LAZY)
@MapsId
@JoinColumn(name = "account_id")

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.identity.domain
enum class StudentStatus {
ACTIVE,
ACADEMIC_LEAVE,
EXPELLED,
GRADUATED,
ARCHIVED,
}

View File

@@ -9,5 +9,15 @@ interface GroupRepository : JpaRepository<GroupEntity, UUID> {
fun existsByName(name: String): Boolean
fun findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
stream: String,
degree: String,
number: Short,
course: Short,
enrollmentYear: Short,
): GroupEntity?
fun findAllByOrderByNameAsc(): List<GroupEntity>
fun findAllByOrderByEnrollmentYearDescNameAsc(): List<GroupEntity>
}

View File

@@ -0,0 +1,9 @@
package ru.ysu.lms.identity.repo
import org.springframework.data.jpa.repository.JpaRepository
import ru.ysu.lms.identity.domain.MembershipHistory
import java.util.UUID
interface MembershipHistoryRepository : JpaRepository<MembershipHistory, UUID> {
fun findByStudentIdOrderByAtDesc(studentId: UUID): List<MembershipHistory>
}

View File

@@ -0,0 +1,40 @@
package ru.ysu.lms.identity.roster
/** Разобранная группа-когорта. Имя всегда производное от полей. */
data class GroupCohort(
val stream: String,
val course: Short,
val number: Short,
val degree: String,
val enrollmentYear: Short,
) {
val name: String
get() = "$stream-$course$number$degree"
}
object GroupNameParser {
private val pattern = Regex("^(.+)-(\\d)(\\d+)(БО|МО)$")
fun parse(name: String): GroupCohort? {
val match = pattern.matchEntire(name.trim()) ?: return null
val course = match.groupValues[2].toShortOrNull() ?: return null
val number = match.groupValues[3].toShortOrNull() ?: return null
return GroupCohort(
stream = match.groupValues[1],
course = course,
number = number,
degree = match.groupValues[4],
enrollmentYear = 0, // год набора из имени не выводится
)
}
fun validCourse(
degree: String,
course: Short,
): Boolean =
when (degree) {
"БО" -> course in 1..4
"МО" -> course in 1..2
else -> false
}
}

View File

@@ -0,0 +1,76 @@
package ru.ysu.lms.identity.roster
import org.springframework.stereotype.Component
import ru.ysu.lms.common.BadRequestException
import java.time.LocalDate
@Component
class GroupCohortResolver {
fun resolve(request: CreateGroupRequest): GroupCohort {
val fromName =
request.name
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { GroupNameParser.parse(it) }
val stream =
request.stream
?.trim()
?.takeIf { it.isNotBlank() }
?: fromName?.stream
val course = request.course ?: fromName?.course
val number = request.number ?: fromName?.number
val degree =
request.degree
?.trim()
?.uppercase()
?.takeIf { it.isNotBlank() }
?: fromName?.degree
val missing = listOf(stream, course, number, degree).any { it == null }
if (missing) {
throw BadRequestException("Укажите поток, курс, номер, степень и год набора")
}
val resolvedStream = stream!!
val resolvedCourse = course!!
val resolvedNumber = number!!
val resolvedDegree = degree!!
val year = request.enrollmentYear ?: defaultEnrollmentYear(resolvedCourse)
return validate(GroupCohort(resolvedStream, resolvedCourse, resolvedNumber, resolvedDegree, year))
}
fun validate(cohort: GroupCohort): GroupCohort {
validateStream(cohort.stream)
validateDegreeAndCourse(cohort)
validateNumberAndYear(cohort)
return cohort
}
private fun validateStream(stream: String) {
if (stream.isBlank() || stream.length > 50) {
throw BadRequestException("Поток: от 1 до 50 символов")
}
}
private fun validateDegreeAndCourse(cohort: GroupCohort) {
if (cohort.degree !in setOf("БО", "МО")) {
throw BadRequestException("Степень должна быть БО или МО")
}
if (!GroupNameParser.validCourse(cohort.degree, cohort.course)) {
throw BadRequestException("Курс ${cohort.course} недопустим для степени ${cohort.degree}")
}
}
private fun validateNumberAndYear(cohort: GroupCohort) {
if (cohort.number <= 0) {
throw BadRequestException("Номер группы должен быть положительным")
}
if (cohort.enrollmentYear !in 2000..2100) {
throw BadRequestException("Год набора вне диапазона 2000–2100")
}
}
fun defaultEnrollmentYear(course: Short): Short {
val now = LocalDate.now()
val academicYearStart = if (now.monthValue >= 9) now.year else now.year - 1
return (academicYearStart - (course - 1)).toShort()
}
}

View File

@@ -26,6 +26,7 @@ import java.util.UUID
@PreAuthorize("hasRole('ADMIN')")
class GroupController(
private val groupService: GroupService,
private val importService: GroupImportService,
) {
@GetMapping
fun list(): List<GroupSummary> = groupService.list()
@@ -39,17 +40,18 @@ class GroupController(
fun createStudent(
@PathVariable id: UUID,
@Valid @RequestBody body: CreateStudentRequest,
): Credential = groupService.createStudent(id, body)
@AuthenticationPrincipal jwt: Jwt,
): Credential = groupService.createStudent(id, body, UUID.fromString(jwt.subject))
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,
@RequestParam(name = "name", required = false) name: String?,
@AuthenticationPrincipal jwt: Jwt,
): ImportResult {
if (file.isEmpty) {
throw BadRequestException("Выберите файл со списком группы")
}
return groupService.import(name, file.originalFilename ?: "group.xlsx", file.bytes)
return importService.import(file.originalFilename ?: "group.xlsx", file.bytes, UUID.fromString(jwt.subject))
}
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@@ -63,14 +65,16 @@ class GroupController(
fun enroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.enroll(id, studentId)
@AuthenticationPrincipal jwt: Jwt,
): GroupDetail = groupService.enroll(id, studentId, UUID.fromString(jwt.subject))
/** Отчислить студента из группы. */
@DeleteMapping("/{id}/students/{studentId}")
fun unenroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
): GroupDetail = groupService.unenroll(id, studentId)
@AuthenticationPrincipal jwt: Jwt,
): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject))
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")

View File

@@ -0,0 +1,183 @@
package ru.ysu.lms.identity.roster
import org.springframework.security.crypto.password.PasswordEncoder
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.ConflictException
import ru.ysu.lms.identity.auth.Passwords
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.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.MembershipHistory
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.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.util.UUID
@Suppress("LongParameterList")
@Service
class GroupImportService(
private val groups: GroupRepository,
private val enrollments: EnrollmentRepository,
private val students: StudentRepository,
private val accounts: AccountRepository,
private val history: MembershipHistoryRepository,
private val passwordEncoder: PasswordEncoder,
private val parser: StudentImportParser,
) {
@Transactional
fun import(
filename: String,
bytes: ByteArray,
actorAccountId: UUID?,
): ImportResult {
val parsed = parser.parse(filename, bytes)
if (parsed.errors.isNotEmpty()) {
return ImportResult(
applied = false,
groupName = parsed.cohort?.name,
errors = parsed.errors.map { ImportError(it.row, it.message) },
)
}
val cohort = parsed.cohort
if (cohort == null) {
return ImportResult(
applied = false,
errors = listOf(ImportError(0, "Не удалось определить группу: проверьте поля потока, курса, номера и степени")),
)
}
val group = findOrCreateGroup(cohort)
val added = mutableListOf<Credential>()
val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent ->
val student = upsertStudent(parsedStudent, cohort, added, updated)
moveToGroup(student, group, actorAccountId)
}
return ImportResult(
applied = true,
groupId = group.id.toString(),
groupName = group.name,
added = added,
updated = updated,
)
}
private fun findOrCreateGroup(cohort: GroupCohort): GroupEntity {
val existing =
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
cohort.stream,
cohort.degree,
cohort.number,
cohort.course,
cohort.enrollmentYear,
)
if (existing != null) {
if (existing.status != GroupStatus.ACTIVE) {
throw ConflictException("Группа ${existing.name} не активна")
}
return existing
}
return groups.save(
GroupEntity(
name = cohort.name,
stream = cohort.stream,
course = cohort.course,
number = cohort.number,
degree = cohort.degree,
enrollmentYear = cohort.enrollmentYear,
status = GroupStatus.ACTIVE,
),
)
}
private fun upsertStudent(
parsed: ParsedStudent,
cohort: GroupCohort,
added: MutableList<Credential>,
updated: MutableList<String>,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
existing.lastName = parsed.lastName
existing.firstName = parsed.firstName
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
existing.status = StudentStatus.ACTIVE
existing.enrollmentYear = cohort.enrollmentYear
students.save(existing)
updated += parsed.studentNo
return existing
}
if (accounts.existsByLogin(parsed.studentNo)) {
throw ConflictException("Логин ${parsed.studentNo} уже занят другим аккаунтом")
}
val password = Passwords.generate()
val account =
accounts.save(
Account(
login = parsed.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val student =
Student(
studentNo = parsed.studentNo,
lastName = parsed.lastName,
firstName = parsed.firstName,
middleName = parsed.middleName,
basis = parsed.basis,
note = parsed.note,
status = StudentStatus.ACTIVE,
enrollmentYear = cohort.enrollmentYear,
account = account,
)
students.save(student)
added +=
Credential(
id = account.id.toString(),
studentNo = parsed.studentNo,
fullName = student.fullName,
password = password,
)
return student
}
private fun moveToGroup(
student: Student,
group: GroupEntity,
actorAccountId: UUID?,
) {
val accountId = student.accountId ?: return
val enrollment = enrollments.findByStudentAccountId(accountId)
val fromGroupId = enrollment?.group?.id
if (fromGroupId == group.id) return
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
history.save(
MembershipHistory(
studentId = accountId,
fromGroupId = fromGroupId,
toGroupId = group.id,
action = MembershipAction.IMPORT,
reason = "Импорт Excel",
actorAccountId = actorAccountId,
),
)
}
}

View File

@@ -10,37 +10,43 @@ import ru.ysu.lms.identity.auth.Passwords
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.GroupStatus
import ru.ysu.lms.identity.domain.MembershipAction
import ru.ysu.lms.identity.domain.MembershipHistory
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.MembershipHistoryRepository
import ru.ysu.lms.identity.repo.StudentRepository
import ru.ysu.lms.security.LmsRole
import java.util.UUID
@Suppress("LongParameterList")
@Service
class GroupService(
private val groups: GroupRepository,
private val enrollments: EnrollmentRepository,
private val students: StudentRepository,
private val accounts: AccountRepository,
private val history: MembershipHistoryRepository,
private val passwordEncoder: PasswordEncoder,
private val parser: StudentImportParser,
private val cohortResolver: GroupCohortResolver,
) {
@Transactional(readOnly = true)
fun list(): List<GroupSummary> = groups.findAllByOrderByNameAsc().map { summary(it) }
fun list(): List<GroupSummary> = groups.findAllByOrderByEnrollmentYearDescNameAsc().map { summary(it) }
@Transactional
fun createStudent(
groupId: UUID,
request: CreateStudentRequest,
actorAccountId: UUID? = null,
): Credential {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val group = activeGroup(groupId)
val studentNo = request.studentNo.trim()
if (studentNo.isBlank()) throw BadRequestException("Студенческий номер не может быть пустым")
if (students.existsByStudentNo(studentNo)) {
throw ConflictException("Студент с номером $studentNo уже существует")
}
ensureStudentNumberAvailable(studentNo)
val password = Passwords.generate()
val account =
accounts.save(
@@ -52,7 +58,6 @@ class GroupService(
tempPassword = password,
),
)
// Student.id — это account_id через @MapsId: ассоциацию задаём до persist.
val student =
students.save(
Student(
@@ -62,10 +67,19 @@ class GroupService(
middleName = request.middleName.trim(),
basis = request.basis.trim(),
note = request.note.trim(),
status = StudentStatus.ACTIVE,
enrollmentYear = group.enrollmentYear,
account = account,
),
)
enrollments.save(Enrollment(group = group, student = student))
recordHistory(
studentNo = student.accountId!!,
fromGroupId = null,
toGroupId = group.id,
action = MembershipAction.ENROLL,
actorAccountId = actorAccountId,
)
return Credential(
id = account.id.toString(),
studentNo = studentNo,
@@ -76,11 +90,31 @@ class GroupService(
@Transactional
fun create(request: CreateGroupRequest): GroupSummary {
val name = request.name.trim()
if (groups.existsByName(name)) {
throw ConflictException("Группа «$name» уже существует")
val cohort = cohortResolver.resolve(request)
val existing =
groups.findByStreamAndDegreeAndNumberAndCourseAndEnrollmentYear(
cohort.stream,
cohort.degree,
cohort.number,
cohort.course,
cohort.enrollmentYear,
)
if (existing != null) {
throw ConflictException("Группа «${cohort.name}» с таким годом набора уже существует")
}
return summary(groups.save(GroupEntity(name = name)))
val group =
groups.save(
GroupEntity(
name = cohort.name,
stream = cohort.stream,
course = cohort.course,
number = cohort.number,
degree = cohort.degree,
enrollmentYear = cohort.enrollmentYear,
status = GroupStatus.ACTIVE,
),
)
return summary(group)
}
/** Студенты, которых ещё нет в этой группе (включая состоящих в других). */
@@ -116,129 +150,52 @@ class GroupService(
fun enroll(
groupId: UUID,
studentId: UUID,
actorAccountId: UUID? = null,
): GroupDetail {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
val group = activeGroup(groupId)
val student = students.findById(studentId).orElseThrow { NotFoundException("Студент не найден") }
val existing = enrollments.findByStudentAccountId(studentId)
if (existing != null) {
if (existing.group?.id == group.id) {
throw ConflictException("Студент уже в этой группе")
}
// unique-констрейт на student_id: переводим перепривязкой, без delete+insert
val fromGroupId = existing?.group?.id
if (fromGroupId == group.id) {
throw ConflictException("Студент уже в этой группе")
}
if (existing == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
existing.group = group
enrollments.save(existing)
} else {
enrollments.save(Enrollment(group = group, student = student))
}
student.status = StudentStatus.ACTIVE
student.enrollmentYear = group.enrollmentYear
students.save(student)
recordHistory(studentId, fromGroupId, group.id, MembershipAction.TRANSFER, actorAccountId)
return detail(groupId)
}
/** Отчислить из группы (студент и его аккаунт сохраняются). */
/** Убрать студента из группы; аккаунт и данные сохраняются. */
@Transactional
fun unenroll(
groupId: UUID,
studentId: UUID,
actorAccountId: UUID? = null,
): GroupDetail {
val enrollment =
enrollments.findAllByGroupId(groupId).firstOrNull { it.student?.accountId == studentId }
?: throw NotFoundException("Студент не входит в эту группу")
enrollments.delete(enrollment)
val student = students.findById(studentId).orElse(null)
if (student != null) {
student.status = StudentStatus.EXPELLED
students.save(student)
}
recordHistory(studentId, groupId, null, MembershipAction.EXPELL, actorAccountId)
return detail(groupId)
}
@Transactional
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))
val added = mutableListOf<Credential>()
val updated = mutableListOf<String>()
parsed.students.forEach { parsedStudent ->
val student = upsertStudent(parsedStudent, added, updated)
enrollIntoGroup(group, student)
}
return ImportResult(group.id.toString(), group.name, added, updated)
}
/** Обновляет существующего студента или создаёт нового с временным паролем. */
private fun upsertStudent(
parsed: ParsedStudent,
added: MutableList<Credential>,
updated: MutableList<String>,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
existing.lastName = parsed.lastName
existing.firstName = parsed.firstName
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
students.save(existing)
updated.add(parsed.studentNo)
return existing
}
val password = Passwords.generate()
val account =
accounts.save(
Account(
login = parsed.studentNo,
passwordHash = passwordEncoder.encode(password) ?: "",
role = LmsRole.STUDENT,
mustChangePassword = true,
tempPassword = password,
),
)
val created =
Student(
studentNo = parsed.studentNo,
lastName = parsed.lastName,
firstName = parsed.firstName,
middleName = parsed.middleName,
basis = parsed.basis,
note = parsed.note,
)
created.account = account
students.save(created)
added.add(
Credential(
id = account.id.toString(),
studentNo = parsed.studentNo,
fullName = parsed.fullName,
password = password,
),
)
return created
}
/** Зачисляет студента в группу (или переводит из другой). */
private fun enrollIntoGroup(
group: GroupEntity,
student: Student,
) {
val accountId = student.accountId!!
val enrollment = enrollments.findByStudentAccountId(accountId)
if (enrollment == null) {
enrollments.save(Enrollment(group = group, student = student))
} else {
enrollment.group = group
enrollments.save(enrollment)
}
}
@Transactional(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null
return GroupSummary(
id = group.id.toString(),
name = group.name,
studentCount = enrollments.findAllByGroupId(group.id).size.toLong(),
taskDurationMin = group.taskDurationMin,
)
return summary(group)
}
@Transactional(readOnly = true)
@@ -255,11 +212,24 @@ class GroupService(
studentNo = student.studentNo,
fullName = student.fullName,
basis = student.basis,
status = student.status.name,
enrollmentYear = student.enrollmentYear,
mustChangePassword = account.mustChangePassword,
tempPassword = account.tempPassword,
)
}.sortedWith(compareBy({ it.fullName.lowercase() }, { it.studentNo }))
return GroupDetail(group.id.toString(), group.name, group.taskDurationMin, rows)
return GroupDetail(
id = group.id.toString(),
name = group.name,
stream = group.stream,
course = group.course,
number = group.number,
degree = group.degree,
enrollmentYear = group.enrollmentYear,
status = group.status.name,
taskDurationMin = group.taskDurationMin,
students = rows,
)
}
/** Список всех пользователей (студентов) с фильтром по подстроке. */
@@ -286,6 +256,8 @@ class GroupService(
studentNo = s.studentNo,
fullName = s.fullName,
basis = s.basis,
status = s.status.name,
enrollmentYear = s.enrollmentYear,
groupId = groupInfo?.first?.toString(),
groupName = groupInfo?.second,
)
@@ -341,27 +313,69 @@ class GroupService(
): ExportFile {
val detail = detail(groupId)
val safeName = detail.name.replace(Regex("[^\\p{L}\\p{N}._-]+"), "_")
val year = detail.enrollmentYear?.let { " ($it)" }.orEmpty()
return when (format.lowercase()) {
"csv" ->
ExportFile(
bytes = CredentialsExporter.csv(detail.students),
filename = "credentials_$safeName.csv",
filename = "credentials_$safeName$year.csv",
contentType = "text/csv; charset=utf-8",
)
"xlsx" ->
ExportFile(
bytes = CredentialsExporter.xlsx(detail.students),
filename = "credentials_$safeName.xlsx",
filename = "credentials_$safeName$year.xlsx",
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
else -> throw BadRequestException("Формат должен быть xlsx или csv")
}
}
private fun ensureStudentNumberAvailable(studentNo: String) {
if (students.existsByStudentNo(studentNo)) {
throw ConflictException("Студент с номером $studentNo уже существует")
}
if (accounts.existsByLogin(studentNo)) {
throw ConflictException("Логин $studentNo уже занят другим аккаунтом")
}
}
private fun activeGroup(groupId: UUID): GroupEntity {
val group = groups.findById(groupId).orElseThrow { NotFoundException("Группа не найдена") }
if (group.status != GroupStatus.ACTIVE) {
throw BadRequestException("Группа ${group.name} не активна")
}
return group
}
private fun recordHistory(
studentNo: UUID,
fromGroupId: UUID?,
toGroupId: UUID?,
action: MembershipAction,
actorAccountId: UUID?,
) {
history.save(
MembershipHistory(
studentId = studentNo,
fromGroupId = fromGroupId,
toGroupId = toGroupId,
action = action,
actorAccountId = actorAccountId,
),
)
}
private fun summary(group: GroupEntity): GroupSummary =
GroupSummary(
id = group.id.toString(),
name = group.name,
stream = group.stream,
course = group.course,
number = group.number,
degree = group.degree,
enrollmentYear = group.enrollmentYear,
status = group.status.name,
studentCount = enrollments.countByGroupId(group.id),
taskDurationMin = group.taskDurationMin,
)

View File

@@ -5,13 +5,23 @@ import jakarta.validation.constraints.NotBlank
data class GroupSummary(
val id: String,
val name: String,
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
val status: String = "ACTIVE",
val studentCount: Long,
val taskDurationMin: Int,
)
data class CreateGroupRequest(
@field:NotBlank(message = "Укажите название группы")
val name: String,
val name: String? = null,
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
)
data class CreateStudentRequest(
@@ -30,6 +40,8 @@ data class StudentRow(
val studentNo: String,
val fullName: String,
val basis: String,
val status: String = "ACTIVE",
val enrollmentYear: Short? = null,
val mustChangePassword: Boolean,
val tempPassword: String?,
)
@@ -37,6 +49,12 @@ data class StudentRow(
data class GroupDetail(
val id: String,
val name: String,
val stream: String? = null,
val course: Short? = null,
val number: Short? = null,
val degree: String? = null,
val enrollmentYear: Short? = null,
val status: String = "ACTIVE",
val taskDurationMin: Int,
val students: List<StudentRow>,
)
@@ -49,10 +67,17 @@ data class Credential(
)
data class ImportResult(
val groupId: String,
val groupName: String,
val added: List<Credential>,
val updated: List<String>,
val applied: Boolean = true,
val groupId: String? = null,
val groupName: String? = null,
val added: List<Credential> = emptyList(),
val updated: List<String> = emptyList(),
val errors: List<ImportError> = emptyList(),
)
data class ImportError(
val row: Int,
val message: String,
)
data class ResetPasswordResponse(
@@ -75,6 +100,8 @@ data class AdminUser(
val studentNo: String,
val fullName: String,
val basis: String,
val status: String = "ACTIVE",
val enrollmentYear: Short? = null,
val groupId: String?,
val groupName: String?,
)

View File

@@ -18,11 +18,45 @@ data class ParsedStudent(
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
}
data class ParsedGroup(
val groupName: String,
val students: List<ParsedStudent>,
data class ImportRowError(
val row: Int,
val message: String,
)
data class ParsedGroup(
val cohort: GroupCohort?,
val students: List<ParsedStudent>,
val errors: List<ImportRowError>,
)
private data class HeaderRule(
val key: String,
val exact: Set<String> = emptySet(),
val prefixes: List<String> = emptyList(),
) {
fun matches(value: String): Boolean = value in exact || prefixes.any { value.startsWith(it) }
}
private val HEADER_RULES =
listOf(
HeaderRule("stream", exact = setOf("поток"), prefixes = listOf("поток ")),
HeaderRule("course", exact = setOf("курс", "курс обучения")),
HeaderRule("groupNumber", exact = setOf("номер", "номер группы")),
HeaderRule("degree", prefixes = listOf("степень")),
HeaderRule("year", exact = setOf("год"), prefixes = listOf("год набора")),
HeaderRule(
"studentNo",
exact = setOf("№"),
prefixes = listOf("№ ", "студенч", "зачёт", "зачет", "номер студента"),
),
HeaderRule("fullName", prefixes = listOf("фио", "фамилия имя")),
HeaderRule("lastName", prefixes = listOf("фамили")),
HeaderRule("firstName", exact = setOf("имя"), prefixes = listOf("имя ")),
HeaderRule("middleName", prefixes = listOf("отчеств")),
HeaderRule("basis", prefixes = listOf("база", "основ")),
HeaderRule("note", prefixes = listOf("примеч")),
)
@Component
class StudentImportParser {
fun parse(
@@ -30,11 +64,18 @@ class StudentImportParser {
bytes: ByteArray,
): ParsedGroup {
val lower = filename.lowercase()
return when {
lower.endsWith(".csv") -> parseRows(filename, parseCsv(bytes))
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseRows(filename, parseExcel(bytes))
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
val rows =
when {
lower.endsWith(".csv") -> parseCsv(bytes)
lower.endsWith(".xls") || lower.endsWith(".xlsx") -> parseExcel(bytes)
else -> throw BadRequestException("Поддерживаются файлы .xls, .xlsx и .csv")
}
val headerIndex = rows.indexOfFirst { row -> mapColumns(row).isNotEmpty() }
if (headerIndex < 0) {
throw BadRequestException("Не найдена шапка таблицы. Нужны столбцы: поток, курс, номер, степень, год набора, ФИО, №.")
}
val columns = requireColumns(rows[headerIndex])
return parseRows(rows, headerIndex, columns)
}
private fun parseExcel(bytes: ByteArray): List<List<String>> {
@@ -55,9 +96,7 @@ class StudentImportParser {
private fun parseCsv(bytes: ByteArray): List<List<String>> {
val text = bytes.toString(Charsets.UTF_8).removePrefix("\uFEFF")
val lines = text.split(Regex("\r?\n")).filter { it.isNotBlank() }
if (lines.isEmpty()) {
throw BadRequestException("Файл пуст")
}
if (lines.isEmpty()) throw BadRequestException("Файл пуст")
val delimiter = detectDelimiter(lines.first())
return lines.map { splitCsvLine(it, delimiter) }
}
@@ -89,101 +128,228 @@ class StudentImportParser {
}
private fun parseRows(
filename: String,
rows: List<List<String>>,
headerIndex: Int,
columns: Map<String, Int>,
): ParsedGroup {
val headerIndex = requireHeader(rows)
val columns = requireColumns(rows[headerIndex])
val students = rows.drop(headerIndex + 1).mapNotNull { parseStudent(it, columns) }
if (students.isEmpty()) {
val errors = mutableListOf<ImportRowError>()
val students = mutableListOf<ParsedStudent>()
val seenStudentNos = mutableMapOf<String, Int>()
var cohort: GroupCohort? = null
rows.drop(headerIndex + 1).forEachIndexed { index, row ->
if (row.all { it.isBlank() }) return@forEachIndexed
val rowNumber = headerIndex + index + 2
val rowCohort = parseCohort(row, columns, rowNumber, errors)
if (rowCohort != null) {
if (cohort == null) {
cohort = rowCohort
} else if (cohort != rowCohort) {
errors += ImportRowError(rowNumber, "Все строки файла должны относиться к одной группе")
}
}
val student = parseStudent(row, columns, rowNumber, errors, seenStudentNos)
if (student != null) students += student
}
if (students.isEmpty() && errors.isEmpty()) {
throw BadRequestException("В таблице не найдено ни одного студента")
}
return ParsedGroup(groupNameFrom(filename, rows), students)
return ParsedGroup(cohort = cohort, students = students, errors = errors)
}
private fun requireHeader(rows: List<List<String>>): Int {
val headerIndex = rows.indexOfFirst { row -> row.any { isStudentHeader(it) } }
if (headerIndex < 0) {
throw BadRequestException("Не найден столбец «Студенческий номер». Проверьте шапку таблицы.")
}
return headerIndex
private fun parseCohort(
row: List<String>,
columns: Map<String, Int>,
rowNumber: Int,
errors: MutableList<ImportRowError>,
): GroupCohort? {
val fields =
CohortFields(
stream = cell(row, columns, "stream"),
course = cell(row, columns, "course"),
number = cell(row, columns, "groupNumber"),
degree = cell(row, columns, "degree").uppercase(),
year = cell(row, columns, "year"),
)
val before = errors.size
val numbers =
CohortNumbers(
course = parseInt(fields.course),
number = parseInt(fields.number),
year = parseInt(fields.year),
)
validateRequiredCohortFields(fields, rowNumber, errors)
validateCohortNumbers(fields, numbers, rowNumber, errors)
validateCohortDegree(fields.degree, numbers.course, rowNumber, errors)
if (errors.size > before) return null
return GroupCohort(
stream = fields.stream,
course = numbers.course!!.toShort(),
number = numbers.number!!.toShort(),
degree = fields.degree,
enrollmentYear = numbers.year!!.toShort(),
)
}
private fun requireColumns(header: List<String>): Map<String, Int> {
val columns = mapColumns(header)
if (columns["studentNo"] == null) {
throw BadRequestException("Не найден столбец «Студенческий номер»")
}
return columns
private fun validateRequiredCohortFields(
fields: CohortFields,
rowNumber: Int,
errors: MutableList<ImportRowError>,
) {
if (fields.stream.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен поток")
if (fields.course.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен курс")
if (fields.number.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен номер группы")
if (fields.degree.isBlank()) errors += ImportRowError(rowNumber, "Не заполнена степень")
if (fields.year.isBlank()) errors += ImportRowError(rowNumber, "Не заполнен год набора")
}
private fun validateCohortNumbers(
fields: CohortFields,
numbers: CohortNumbers,
rowNumber: Int,
errors: MutableList<ImportRowError>,
) {
if (fields.course.isNotBlank() && numbers.course == null) {
errors += ImportRowError(rowNumber, "Курс должен быть числом")
}
if (fields.number.isNotBlank() && numbers.number == null) {
errors += ImportRowError(rowNumber, "Номер группы должен быть числом")
}
if (fields.year.isNotBlank() && numbers.year == null) {
errors += ImportRowError(rowNumber, "Год набора должен быть числом")
}
if (numbers.number != null && numbers.number <= 0) {
errors += ImportRowError(rowNumber, "Номер группы должен быть положительным")
}
if (numbers.number != null && numbers.number > Short.MAX_VALUE) {
errors += ImportRowError(rowNumber, "Слишком большой номер группы")
}
if (numbers.year != null && numbers.year !in 2000..2100) {
errors += ImportRowError(rowNumber, "Год набора вне диапазона 2000–2100")
}
}
private data class CohortNumbers(
val course: Int?,
val number: Int?,
val year: Int?,
)
private fun validateCohortDegree(
degree: String,
course: Int?,
rowNumber: Int,
errors: MutableList<ImportRowError>,
) {
if (degree.isNotBlank() && degree !in setOf("БО", "МО")) {
errors += ImportRowError(rowNumber, "Степень должна быть БО или МО")
}
if (course != null && degree in setOf("БО", "МО") && !GroupNameParser.validCourse(degree, course.toShort())) {
errors += ImportRowError(rowNumber, "Курс $course недопустим для степени $degree")
}
}
private data class CohortFields(
val stream: String,
val course: String,
val number: String,
val degree: String,
val year: String,
)
private fun parseStudent(
row: List<String>,
columns: Map<String, Int>,
rowNumber: Int,
errors: MutableList<ImportRowError>,
seenStudentNos: MutableMap<String, Int>,
): ParsedStudent? {
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 null
val before = errors.size
val studentNo = normalizeNumber(cell(row, columns, "studentNo"))
if (studentNo.isBlank()) {
throw BadRequestException("В одной из строк отсутствует студенческий номер")
errors += ImportRowError(rowNumber, "Не заполнен № студента")
} else {
val previous = seenStudentNos.put(studentNo, rowNumber)
if (previous != null) {
errors += ImportRowError(rowNumber, "№ $studentNo уже встречается в строке $previous")
}
}
val fullName = cell(row, columns, "fullName")
val lastName: String
val firstName: String
val middleName: String
if (fullName.isNotBlank()) {
val parts = fullName.split(Regex("\\s+")).filter { it.isNotBlank() }
lastName = parts.getOrElse(0) { "" }
firstName = parts.getOrElse(1) { "" }
middleName = parts.drop(2).joinToString(" ")
} else {
lastName = cell(row, columns, "lastName")
firstName = cell(row, columns, "firstName")
middleName = cell(row, columns, "middleName")
}
if (lastName.isBlank()) errors += ImportRowError(rowNumber, "Не заполнено ФИО")
if (errors.size > before) return null
return ParsedStudent(
studentNo = studentNo,
lastName = lastName,
firstName = cell("firstName"),
middleName = cell("middleName"),
basis = cell("basis"),
note = cell("note"),
firstName = firstName,
middleName = middleName,
basis = cell(row, columns, "basis"),
note = cell(row, columns, "note"),
)
}
private fun isStudentHeader(value: String): Boolean {
val normalized = value.lowercase().replace("\u00a0", " ").trim()
return normalized.startsWith("студен") ||
normalized.startsWith("студ.") ||
normalized.startsWith("зачёт") ||
normalized.startsWith("зачет")
private fun requireColumns(header: List<String>): Map<String, Int> {
val columns = mapColumns(header)
val missing = mutableListOf<String>()
val names =
mapOf(
"stream" to "поток",
"course" to "курс",
"groupNumber" to "номер",
"degree" to "степень",
"year" to "год набора",
"studentNo" to "№",
)
names.forEach { (key, title) ->
if (columns[key] == null) missing += title
}
if (columns["fullName"] == null && columns["lastName"] == null) missing += "ФИО"
if (missing.isNotEmpty()) {
throw BadRequestException("Не найдены столбцы: ${missing.joinToString(", ")}. Проверьте шапку таблицы.")
}
return columns
}
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,
) {
if (key !in result && prefixes.any { value.startsWith(it) }) {
result[key] = index
}
}
putIfAbsent("studentNo", "студенч", "студ.", "студбил", "зачёт", "зачет")
putIfAbsent("lastName", "фамили")
putIfAbsent("firstName", "имя")
putIfAbsent("middleName", "отчеств")
putIfAbsent("basis", "основ")
putIfAbsent("note", "примеч")
val value = normalizeHeader(raw)
HEADER_RULES
.firstOrNull { it.matches(value) }
?.let { result.putIfAbsent(it.key, index) }
}
return result
}
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
private fun normalizeHeader(value: String): String =
value
.lowercase()
.replace('\u00a0', ' ')
.trim()
.replace(Regex("\\s+"), " ")
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) {
val value = cell.trim()
if (value.length in 2..30 && groupRegex.matches(value)) {
return value
}
}
}
return filename.substringBeforeLast('.').trim().ifBlank { "Группа" }
}
private fun cell(
row: List<String>,
columns: Map<String, Int>,
key: String,
): String = columns[key]?.let { row.getOrNull(it) }.orEmpty().trim()
private fun parseInt(value: String): Int? = normalizeNumber(value).takeIf { it.isNotBlank() }?.toIntOrNull()
private fun normalizeNumber(value: String): String = if (value.endsWith(".0")) value.dropLast(2) else value
}