fix: закрыть P0/P1 расхождения с целевой моделью

- академический отпуск: readOnly, запрет submit/start/finish защиты
- черновики лабораторных скрыты от студентов в списке/деталях/по UUID
- удалено действие «Убрать из группы» из UI и API
- импорт: restoreInactiveStatuses, skipped-отчёт, разблокировка архивного аккаунта
- глобальные материалы STUDENTS/TEACHERS с JWT-фильтрацией и сменой аудитории
- LDD-AUTH-005 переведён на unit-evidence, clean check проходит по P0
- backup: безопасный парсинг .env, verify-latest, кавычки cron
- robots/noindex, комментарии без автора, профиль без черновиков
- docs/18-goal-audit.md; baseline 57 claims / 366 тестов / release PASS
This commit is contained in:
2026-10-29 18:58:21 +03:00
parent 9cb4d0d263
commit 9412d87fed
63 changed files with 1281 additions and 162 deletions

View File

@@ -41,6 +41,7 @@ data class AuthResponse(
val mustChangePassword: Boolean,
val refreshToken: String? = null,
val accessTokenExpiresIn: Long = 0,
val readOnly: Boolean = false,
)
data class MeResponse(
@@ -50,4 +51,5 @@ data class MeResponse(
val name: String,
val mustChangePassword: Boolean,
val active: Boolean = true,
val readOnly: Boolean = false,
)

View File

@@ -94,6 +94,11 @@ class AuthService(
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
}
/** Академический отпуск: вход разрешён, но писать данные нельзя. */
private fun readOnly(account: Account): Boolean =
account.role == LmsRole.STUDENT &&
students.findById(account.id).map { it.status == StudentStatus.ACADEMIC_LEAVE }.orElse(false)
private fun ensureLoginAllowed(account: Account) {
loginDeniedReason(account)?.let { throw UnauthorizedException(it) }
}
@@ -129,6 +134,7 @@ class AuthService(
name = displayName(account),
mustChangePassword = account.mustChangePassword,
active = loginDeniedReason(account) == null,
readOnly = readOnly(account),
)
}
@@ -189,6 +195,7 @@ class AuthService(
mustChangePassword = account.mustChangePassword,
refreshToken = refreshToken,
accessTokenExpiresIn = jwtProperties.accessTokenTtl.seconds,
readOnly = readOnly(account),
)
private fun issueToken(

View File

@@ -7,7 +7,6 @@ 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
import org.springframework.web.bind.annotation.PostMapping
@@ -37,24 +36,62 @@ class GroupController(
@PostMapping
fun create(
@Valid @RequestBody body: CreateGroupRequest,
): GroupSummary = groupService.create(body)
@AuthenticationPrincipal jwt: Jwt,
): GroupSummary =
groupService.create(body).also {
auditService.record(
jwt.toAuditRequest(
action = "GROUP_CREATE",
entityType = "GROUP",
entityId = it.id,
details = "name=${it.name}",
),
)
}
@PostMapping("/{id}/students")
fun createStudent(
@PathVariable id: UUID,
@Valid @RequestBody body: CreateStudentRequest,
@AuthenticationPrincipal jwt: Jwt,
): Credential = groupService.createStudent(id, body, UUID.fromString(jwt.subject))
): Credential =
groupService.createStudent(id, body, UUID.fromString(jwt.subject)).also {
auditService.record(
jwt.toAuditRequest(
action = "STUDENT_CREATE",
entityType = "STUDENT",
entityId = it.id,
studentId = it.id,
details = "group=$id, studentNo=${it.studentNo}",
),
)
}
@PostMapping("/import")
fun import(
@RequestParam("file") file: MultipartFile,
@RequestParam(defaultValue = "false") restoreInactiveStatuses: Boolean,
@AuthenticationPrincipal jwt: Jwt,
): ImportResult {
if (file.isEmpty) {
throw BadRequestException("Выберите файл со списком группы")
}
return importService.import(file.originalFilename ?: "group.xlsx", file.bytes, UUID.fromString(jwt.subject))
val result =
importService.import(
file.originalFilename ?: "group.xlsx",
file.bytes,
UUID.fromString(jwt.subject),
restoreInactiveStatuses,
)
auditService.record(
jwt.toAuditRequest(
action = "GROUP_IMPORT",
entityType = "GROUP",
entityId = result.groupId,
details = "added=${result.added.size}, updated=${result.updated.size}, skipped=${result.skipped.size}",
),
)
return result
}
/** Студенты, которых можно зачислить в группу (в т.ч. из других групп). */
@@ -69,15 +106,18 @@ class GroupController(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): GroupDetail = groupService.enroll(id, studentId, UUID.fromString(jwt.subject))
/** Отчислить студента из группы. */
@DeleteMapping("/{id}/students/{studentId}")
fun unenroll(
@PathVariable id: UUID,
@PathVariable studentId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): GroupDetail = groupService.unenroll(id, studentId, UUID.fromString(jwt.subject))
): GroupDetail =
groupService.enroll(id, studentId, UUID.fromString(jwt.subject)).also {
auditService.record(
jwt.toAuditRequest(
action = "GROUP_ENROLL",
entityType = "STUDENT",
entityId = studentId.toString(),
studentId = studentId.toString(),
details = "group=$id",
),
)
}
@GetMapping("/me")
@PreAuthorize("isAuthenticated()")

View File

@@ -6,6 +6,7 @@ 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.AccountStatus
import ru.ysu.lms.identity.domain.Enrollment
import ru.ysu.lms.identity.domain.GroupEntity
import ru.ysu.lms.identity.domain.GroupStatus
@@ -37,6 +38,7 @@ class GroupImportService(
filename: String,
bytes: ByteArray,
actorAccountId: UUID?,
restoreInactiveStatuses: Boolean = false,
): ImportResult {
val parsed = parser.parse(filename, bytes)
if (parsed.errors.isNotEmpty()) {
@@ -56,10 +58,22 @@ class GroupImportService(
val group = findOrCreateGroup(cohort)
val added = mutableListOf<Credential>()
val updated = mutableListOf<String>()
val skipped = mutableListOf<ImportError>()
parsed.students.forEach { parsedStudent ->
val student = upsertStudent(parsedStudent, cohort, added, updated)
moveToGroup(student, group, actorAccountId)
val student = upsertStudent(parsedStudent, cohort, added, updated, restoreInactiveStatuses)
if (student.status == StudentStatus.ACTIVE) {
moveToGroup(student, group, actorAccountId)
} else {
skipped +=
ImportError(
row = parsedStudent.row,
message =
"Студент ${parsedStudent.studentNo} в статусе ${student.status.name}: " +
"статус и группа не изменены. Для восстановления включите явное восстановление " +
"отчисленных/выпущенных/архивных/академических студентов.",
)
}
}
return ImportResult(
applied = true,
@@ -67,6 +81,7 @@ class GroupImportService(
groupName = group.name,
added = added,
updated = updated,
skipped = skipped,
)
}
@@ -103,6 +118,7 @@ class GroupImportService(
cohort: GroupCohort,
added: MutableList<Credential>,
updated: MutableList<String>,
restoreInactiveStatuses: Boolean,
): Student {
val existing = students.findByStudentNo(parsed.studentNo)
if (existing != null) {
@@ -111,8 +127,15 @@ class GroupImportService(
existing.middleName = parsed.middleName
existing.basis = parsed.basis
existing.note = parsed.note
existing.status = StudentStatus.ACTIVE
existing.enrollmentYear = cohort.enrollmentYear
if (existing.status == StudentStatus.ACTIVE || restoreInactiveStatuses) {
existing.status = StudentStatus.ACTIVE
existing.enrollmentYear = cohort.enrollmentYear
existing.account?.takeIf { it.status == AccountStatus.ARCHIVED }?.let { account ->
account.status = AccountStatus.ACTIVE
account.archivedAt = null
accounts.save(account)
}
}
students.save(existing)
updated += parsed.studentNo
return existing

View File

@@ -172,26 +172,6 @@ class GroupService(
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(readOnly = true)
fun groupOfStudent(accountId: UUID): GroupSummary? {
val group = enrollments.findByStudentAccountId(accountId)?.group ?: return null

View File

@@ -72,6 +72,7 @@ data class ImportResult(
val groupName: String? = null,
val added: List<Credential> = emptyList(),
val updated: List<String> = emptyList(),
val skipped: List<ImportError> = emptyList(),
val errors: List<ImportError> = emptyList(),
)

View File

@@ -13,6 +13,7 @@ data class ParsedStudent(
val middleName: String,
val basis: String,
val note: String,
val row: Int = 0,
) {
val fullName: String
get() = listOf(lastName, firstName, middleName).filter { it.isNotBlank() }.joinToString(" ")
@@ -300,6 +301,7 @@ class StudentImportParser {
middleName = middleName,
basis = cell(row, columns, "basis"),
note = cell(row, columns, "note"),
row = rowNumber,
)
}

View File

@@ -50,6 +50,8 @@ class GroupServiceIntegrationTest {
@Autowired lateinit var importService: GroupImportService
@Autowired lateinit var lifecycleService: StudentLifecycleService
@Autowired lateinit var authService: AuthService
@Autowired lateinit var groups: GroupRepository
@@ -119,7 +121,7 @@ class GroupServiceIntegrationTest {
}
@Test
fun `transfer and expulsion keep history`() {
fun `transfer keeps history`() {
val first = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 1, degree = "БО", enrollmentYear = 2025))
val second = groupService.create(CreateGroupRequest(stream = "ПИ", course = 1, number = 2, degree = "БО", enrollmentYear = 2025))
val imported = importService.import("group.csv", csv(validRows()), null)
@@ -137,9 +139,6 @@ class GroupServiceIntegrationTest {
)
assertEquals(MembershipAction.TRANSFER, history.findByStudentIdOrderByAtDesc(studentId).first().action)
groupService.unenroll(UUID.fromString(second.id), studentId, null)
assertEquals(StudentStatus.EXPELLED, students.findById(studentId).orElseThrow().status)
assertEquals(MembershipAction.EXPELL, history.findByStudentIdOrderByAtDesc(studentId).first().action)
assertEquals(1, groups.findAll().count { it.id == UUID.fromString(first.id) })
assertTrue(imported.applied)
}
@@ -155,12 +154,118 @@ class GroupServiceIntegrationTest {
)
authService.login(LoginRequest("9001", credential.password))
groupService.unenroll(UUID.fromString(group.id), UUID.fromString(credential.id), null)
lifecycleService.expel(UUID.fromString(credential.id), null, null)
val error = runCatching { authService.login(LoginRequest("9001", credential.password)) }.exceptionOrNull()
assertTrue(error is UnauthorizedException)
}
@Test
fun `import does not resurrect expelled student without explicit flag`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
student.status = StudentStatus.EXPELLED
students.saveAndFlush(student)
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
null,
)
val preserved = students.findById(studentId).orElseThrow()
assertTrue(result.applied)
assertEquals(StudentStatus.EXPELLED, preserved.status)
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals(2025.toShort(), preserved.enrollmentYear)
assertEquals("контракт", preserved.basis)
assertEquals(1, result.skipped.size)
val skipped = result.skipped.single()
assertEquals(2, skipped.row)
assertTrue(skipped.message.contains("EXPELLED"))
}
@Test
fun `import restores expelled student with explicit flag and records import history`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
student.status = StudentStatus.EXPELLED
students.saveAndFlush(student)
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
null,
restoreInactiveStatuses = true,
)
val restored = students.findById(studentId).orElseThrow()
val target = groups.findByName("ПИ-22БО")!!
assertTrue(result.applied)
assertTrue(result.skipped.isEmpty())
assertEquals(StudentStatus.ACTIVE, restored.status)
assertEquals(2024.toShort(), restored.enrollmentYear)
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals(MembershipAction.IMPORT, history.findByStudentIdOrderByAtDesc(studentId).first().action)
}
@Test
fun `import preserves academic leave without explicit flag`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
val originalGroupId = enrollments.findByStudentAccountId(studentId)?.group?.id
student.status = StudentStatus.ACADEMIC_LEAVE
students.saveAndFlush(student)
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван;2001;контракт"),
null,
)
val preserved = students.findById(studentId).orElseThrow()
assertTrue(result.applied)
assertEquals(StudentStatus.ACADEMIC_LEAVE, preserved.status)
assertEquals(originalGroupId, enrollments.findByStudentAccountId(studentId)?.group?.id)
assertEquals(2025.toShort(), preserved.enrollmentYear)
assertEquals(1, result.skipped.size)
val skipped = result.skipped.single()
assertEquals(2, skipped.row)
assertTrue(skipped.message.contains("ACADEMIC_LEAVE"))
}
@Test
fun `import still updates an active student`() {
importService.import("group.csv", csv(validRows()), null)
val student = students.findByStudentNo("2001")!!
val studentId = student.accountId!!
val result =
importService.import(
"group.csv",
csv("ПИ;2;2;БО;2024;Иванов Иван Иванович;2001;контракт"),
null,
)
val updated = students.findById(studentId).orElseThrow()
val target = groups.findByName("ПИ-22БО")!!
assertTrue(result.applied)
assertTrue(result.skipped.isEmpty())
assertTrue(result.updated.contains("2001"))
assertEquals(StudentStatus.ACTIVE, updated.status)
assertEquals("Иванов Иван Иванович", updated.fullName)
assertEquals("контракт", updated.basis)
assertEquals(2024.toShort(), updated.enrollmentYear)
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
}
private fun validRows(): String =
"""
ПИ;2;1;БО;2025;Иванов Иван;2001;бюджет

View File

@@ -20,6 +20,8 @@ class StudentImportParserTest {
assertEquals("Иван", result.students[0].firstName)
assertEquals("Иванович", result.students[0].middleName)
assertEquals("1001", result.students[0].studentNo)
assertEquals(2, result.students[0].row)
assertEquals(3, result.students[1].row)
}
@Test

View File

@@ -95,6 +95,8 @@ class StudentLifecycleIntegrationTest {
val student = students.findById(studentId).orElseThrow()
assertEquals(StudentStatus.ACADEMIC_LEAVE, student.status)
assertEquals(expectedYear, student.enrollmentYear)
assertTrue(authService.me(studentId).readOnly)
assertTrue(authService.login(LoginRequest(credential.studentNo, credential.password)).readOnly)
assertEquals(target.id, enrollments.findByStudentAccountId(studentId)?.group?.id)
val event =
@@ -140,6 +142,18 @@ class StudentLifecycleIntegrationTest {
assertEquals(StudentStatus.ACADEMIC_LEAVE, students.findById(studentId).orElseThrow().status)
}
@Test
fun `academic leave login is read only`() {
val group = createGroup(course = 1, number = 6, enrollmentYear = 2025)
val credential = createStudent(group, "7105", "Орлов", "Олег")
val studentId = UUID.fromString(credential.id)
lifecycle.academicLeave(studentId, null, null)
val login = authService.login(LoginRequest(credential.studentNo, credential.password))
assertTrue(login.readOnly)
assertTrue(authService.me(studentId).readOnly)
}
@Test
fun `return from academic leave moves to requested active group`() {
val source = createGroup(course = 2, number = 3, enrollmentYear = 2024)