Refactor: split ContentService and ProgressService; defaults tightened

This commit is contained in:
2026-09-27 22:48:13 +03:00
parent 290b2a5a20
commit 4035bc5a0f
10 changed files with 577 additions and 502 deletions

View File

@@ -0,0 +1,101 @@
package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.CommentTemplate
import ru.ysu.lms.progress.domain.GradeComment
import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.LabGradeKey
import ru.ysu.lms.progress.repo.CommentTemplateRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository
import java.math.BigDecimal
import java.util.UUID
@Service
class GradeService(
private val grades: LabGradeRepository,
private val templates: CommentTemplateRepository,
private val gradeComments: GradeCommentRepository,
) {
@Transactional
fun setGrade(request: GradeSetRequest): GradeResponse {
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
}
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val key = LabGradeKey(sid, lid)
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
grade.score = request.score
grades.save(grade)
replaceComments(sid, lid, request.comments)
return GradeResponse(
request.studentId,
request.labId,
request.score,
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) },
)
}
private fun replaceComments(
sid: UUID,
lid: UUID,
inputs: List<CommentInput>,
) {
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
if (existing.toSet() == incoming.toSet()) return
gradeComments.deleteByStudentIdAndLabId(sid, lid)
for ((text, tone) in incoming) {
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
val t = templates.findByText(text)
if (t == null) {
templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
} else {
t.useCount += 1
t.tone = tone
templates.save(t)
}
}
}
private fun normalizeTone(tone: String): String =
when (tone.lowercase()) {
"positive", "положительный", "позитивный", "pos" -> "positive"
"negative", "негативный", "отрицательный", "neg" -> "negative"
else -> "neutral"
}
@Transactional(readOnly = true)
fun commentsOf(
sid: UUID,
lid: UUID,
): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
@Transactional(readOnly = true)
fun suggestComments(query: String): List<CommentOut> =
templates
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
.map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true)
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> {
val byGrade =
gradeComments
.findAll()
.groupBy { it.studentId to it.labId }
return grades.findAllBy().map {
GradeResponse(
studentId = it.studentId.toString(),
labId = it.labId.toString(),
score = it.score,
comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) },
)
}
}
}

View File

@@ -1,31 +1,23 @@
package ru.ysu.lms.progress
import jakarta.validation.Valid
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.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.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/v1")
class ProgressController(
private val service: ProgressService,
private val grades: GradeService,
private val publications: GradePublicationService,
private val submissions: SubmissionService,
) {
@GetMapping("/me/assignments")
fun meAssignments(
@@ -86,11 +78,11 @@ class ProgressController(
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun setGrade(
@Valid @RequestBody body: GradeSetRequest,
): GradeResponse = service.setGrade(body)
): GradeResponse = grades.setGrade(body)
@GetMapping("/grades")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun listGrades(): List<GradeResponse> = service.listGrades()
fun listGrades(): List<GradeResponse> = grades.listGrades()
@PostMapping("/grades/publish")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
@@ -120,7 +112,7 @@ class ProgressController(
@AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) groupId: java.util.UUID?,
): List<GradeResponse> {
val all = service.listGrades().filter { it.studentId == jwt.subject }
val all = grades.listGrades().filter { it.studentId == jwt.subject }
if (groupId == null) return emptyList()
return all.filter { publications.isPublished(groupId, java.util.UUID.fromString(it.labId)) }
}
@@ -129,60 +121,5 @@ class ProgressController(
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun suggestComments(
@RequestParam q: String,
): List<CommentOut> = service.suggestComments(q)
@PostMapping("/me/submissions/{labId}")
fun submit(
@PathVariable labId: UUID,
@RequestPart("files") files: List<MultipartFile>,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> {
val sid = UUID.fromString(jwt.subject)
val all = submissions.submit(sid, labId, files, jwt.tokenValue)
return submissions.mySubmissions(sid, labId)
}
@GetMapping("/me/submissions")
fun mySubmissions(
@RequestParam labId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
@GetMapping("/me/submissions/{id}/download")
fun downloadMine(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val sid = UUID.fromString(jwt.subject)
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
return attachment(bytes, name)
}
@GetMapping("/submissions")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun labSubmissions(
@RequestParam labId: UUID,
): List<SubmissionDto> = submissions.latestPerStudent(labId)
@GetMapping("/submissions/{id}/download")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun downloadForAdmin(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
return attachment(bytes, name)
}
private fun attachment(
bytes: ByteArray,
name: String,
): ResponseEntity<ByteArray> =
ResponseEntity
.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
).contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes)
): List<CommentOut> = grades.suggestComments(q)
}

View File

@@ -3,28 +3,17 @@ package ru.ysu.lms.progress
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import ru.ysu.lms.common.BadRequestException
import ru.ysu.lms.progress.domain.CommentTemplate
import ru.ysu.lms.progress.domain.GradeComment
import ru.ysu.lms.progress.domain.LabGrade
import ru.ysu.lms.progress.domain.LabGradeKey
import ru.ysu.lms.progress.domain.StudentLabLevel
import ru.ysu.lms.progress.domain.StudentLabLevelKey
import ru.ysu.lms.progress.domain.VariantAssignment
import ru.ysu.lms.progress.repo.CommentTemplateRepository
import ru.ysu.lms.progress.repo.GradeCommentRepository
import ru.ysu.lms.progress.repo.LabGradeRepository
import ru.ysu.lms.progress.repo.StudentLabLevelRepository
import ru.ysu.lms.progress.repo.VariantAssignmentRepository
import java.math.BigDecimal
import java.util.UUID
@Service
class ProgressService(
private val levels: StudentLabLevelRepository,
private val assignments: VariantAssignmentRepository,
private val grades: LabGradeRepository,
private val templates: CommentTemplateRepository,
private val gradeComments: GradeCommentRepository,
private val academic: AcademicClient,
private val identity: IdentityClient,
) {
@@ -270,86 +259,6 @@ class ProgressService(
@Transactional(readOnly = true)
fun getStudentAssignments(studentId: UUID): List<VariantAssignment> = assignments.findByStudentId(studentId)
@Transactional
fun setGrade(request: GradeSetRequest): GradeResponse {
if (request.score < BigDecimal.ZERO || request.score > BigDecimal("5")) {
throw BadRequestException("Оценка должна быть в диапазоне 0–5")
}
val sid = UUID.fromString(request.studentId)
val lid = UUID.fromString(request.labId)
val key = LabGradeKey(sid, lid)
val grade = grades.findById(key).orElseGet { LabGrade(studentId = key.studentId, labId = key.labId) }
grade.score = request.score
grades.save(grade)
replaceComments(sid, lid, request.comments)
return GradeResponse(
request.studentId,
request.labId,
request.score,
commentsOf(sid, lid).map { CommentOut(it.text, it.tone) },
)
}
private fun replaceComments(
sid: UUID,
lid: UUID,
inputs: List<CommentInput>,
) {
val existing = gradeComments.findByStudentIdAndLabId(sid, lid).map { it.text + "|" + it.tone }.toSet()
val incoming = inputs.filter { it.text.isNotBlank() }.map { it.text.trim() to normalizeTone(it.tone) }
if (existing.toSet() == incoming.toSet()) return
gradeComments.deleteByStudentIdAndLabId(sid, lid)
for ((text, tone) in incoming) {
gradeComments.save(GradeComment(studentId = sid, labId = lid, text = text, tone = tone))
val t = templates.findByText(text)
if (t == null) {
templates.save(CommentTemplate(text = text, tone = tone, useCount = 1))
} else {
t.useCount += 1
t.tone = tone
templates.save(t)
}
}
}
private fun normalizeTone(tone: String): String =
when (tone.lowercase()) {
"positive", "положительный", "позитивный", "pos" -> "positive"
"negative", "негативный", "отрицательный", "neg" -> "negative"
else -> "neutral"
}
@Transactional(readOnly = true)
fun commentsOf(
sid: UUID,
lid: UUID,
): List<GradeComment> = gradeComments.findByStudentIdAndLabId(sid, lid)
@Transactional(readOnly = true)
fun suggestComments(query: String): List<CommentOut> =
templates
.findTop30ByTextContainingIgnoreCaseOrderByUseCountDesc(query.trim())
.map { CommentOut(it.text, it.tone) }
@Transactional(readOnly = true)
fun myComments(studentId: UUID): List<GradeComment> = gradeComments.findByStudentId(studentId)
@Transactional(readOnly = true)
fun listGrades(): List<GradeResponse> {
val byGrade =
gradeComments
.findAll()
.groupBy { it.studentId to it.labId }
return grades.findAllBy().map {
GradeResponse(
studentId = it.studentId.toString(),
labId = it.labId.toString(),
score = it.score,
comments = (byGrade[it.studentId to it.labId] ?: emptyList()).map { c -> CommentOut(c.text, c.tone) },
)
}
}
@Transactional(readOnly = true)
fun getStudentAssignment(
studentId: UUID,

View File

@@ -0,0 +1,80 @@
package ru.ysu.lms.progress
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.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RequestPart
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.multipart.MultipartFile
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/v1")
class SubmissionController(
private val submissions: SubmissionService,
) {
@PostMapping("/me/submissions/{labId}")
fun submit(
@PathVariable labId: UUID,
@RequestPart("files") files: List<MultipartFile>,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> {
val sid = UUID.fromString(jwt.subject)
submissions.submit(sid, labId, files, jwt.tokenValue)
return submissions.mySubmissions(sid, labId)
}
@GetMapping("/me/submissions")
fun mySubmissions(
@RequestParam labId: UUID,
@AuthenticationPrincipal jwt: Jwt,
): List<SubmissionDto> = submissions.mySubmissions(UUID.fromString(jwt.subject), labId)
@GetMapping("/me/submissions/{id}/download")
fun downloadMine(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val sid = UUID.fromString(jwt.subject)
val (bytes, name) = submissions.download(sid, id, sid, isAdmin = false)
return attachment(bytes, name)
}
@GetMapping("/submissions")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun labSubmissions(
@RequestParam labId: UUID,
): List<SubmissionDto> = submissions.latestPerStudent(labId)
@GetMapping("/submissions/{id}/download")
@PreAuthorize("hasAuthority('ROLE_ADMIN')")
fun downloadForAdmin(
@PathVariable id: UUID,
@AuthenticationPrincipal jwt: Jwt,
): ResponseEntity<ByteArray> {
val (bytes, name) = submissions.download(UUID.fromString(jwt.subject), id, null, isAdmin = true)
return attachment(bytes, name)
}
private fun attachment(
bytes: ByteArray,
name: String,
): ResponseEntity<ByteArray> =
ResponseEntity
.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''${URLEncoder.encode(name, StandardCharsets.UTF_8)}",
).contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(bytes)
}