From 3ccb43cb6f6213cc835dc345defff958fe2a89e1 Mon Sep 17 00:00:00 2001 From: Anton Dzyk Date: Sat, 10 Jan 2026 16:33:41 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B0=D1=83=D0=B4=D0=B8=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D1=83=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 7 +- docs/class-diagram.puml | 145 ++++++++++++++++++ docs/lti-arhitecture.puml | 39 +++++ .../controller/LaunchController.java | 25 ++- .../controller/TaskController.java | 49 +++++- .../repository/HistoryRepository.java | 16 ++ .../infrastructure/runner/RunnerImpl.java | 31 +++- .../service/history/HistoryService.java | 25 +++ .../service/history/HistoryServiceImpl.java | 61 ++++++++ .../service/task/TaskServiceImpl.java | 29 +++- 10 files changed, 419 insertions(+), 8 deletions(-) create mode 100644 docs/class-diagram.puml create mode 100644 docs/lti-arhitecture.puml create mode 100644 src/main/java/ru/oa2/lti/application/infrastructure/repository/HistoryRepository.java create mode 100644 src/main/java/ru/oa2/lti/application/service/history/HistoryService.java create mode 100644 src/main/java/ru/oa2/lti/application/service/history/HistoryServiceImpl.java diff --git a/Dockerfile b/Dockerfile index 9fa533a..ce4c201 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,10 @@ FROM eclipse-temurin:21-jre LABEL org.opencontainers.image.title="LTI Provider" LABEL org.opencontainers.image.description="LTI провайдер для лабораторных по Docker и Kubernetes" -LABEL org.opencontainers.image.url="TODO" -LABEL org.opencontainers.image.source="TODO" -LABEL org.opencontainers.image.documentation="#TODO" +LABEL org.opencontainers.image.url="https://git.oa2.ru/dzyk/lti-provider" +LABEL org.opencontainers.image.source="https://git.oa2.ru/dzyk/lti-provider" +LABEL org.opencontainers.image.documentation="External Tool для LMS, для лабораторных работо по Docker и Kubernetes. +Поддерживает протокол LTI 1.3. Протестирован с OpenOLAT" WORKDIR /opt diff --git a/docs/class-diagram.puml b/docs/class-diagram.puml new file mode 100644 index 0000000..9c4287f --- /dev/null +++ b/docs/class-diagram.puml @@ -0,0 +1,145 @@ +@startuml LTI Provider + +' Настройка для A4 портретной ориентации с ГОСТ отступами (3см слева) +skinparam dpi 150 +skinparam defaultFontSize 10 + +skinparam class { + BackgroundColor<> LightBlue + BackgroundColor<> LightGreen + BackgroundColor<> Wheat + BackgroundColor<> LightSalmon + FontSize 10 +} + +skinparam packageStyle rectangle +skinparam linetype polyline + +' Слой Controllers - верх по центру +package "Controller Layer" { + class LaunchController <> { + + login() + + redirect() + } + + class TaskController <> { + + getTask() + + updateTask() + + submitTask() + } +} + +' Левая колонка - Service/Repository/Infrastructure +package "Service Layer" { + class TaskServiceImpl <> { + + getTask(UUID): TaskData + + saveTask(RequestUpdateTask) + + checkTask(ResultRequest) + } + + class JwtService <> { + + getPayload(jwt): Payload + + getTokenPayload(jwt): IdTokenPayload + } + + class HistoryService <> { + + logEvent() + + logAction() + } +} + +package "Repository Layer" { + interface LMSContentRepository { + + getLMSContentByContentUuid() + } + + interface HistoryRepository { + + findByContentUuid() + + findByDeploymentId() + } +} + +package "Infrastructure Layer" { + class LMSService <> { + + ltiAuth() + + exchangeForAccessToken() + } + + class Runner <> { + + run(userId, script) + + check(userId, script) + } +} + +' Правая колонка - Entity/Domain +package "Entity Layer" { + class LMSContent <> { + - id: long + - contentUuid: UUID + - deploymentId: UUID + - task: Task + } + + class Task <> { + - id: long + - name: String + - description: String + - initScript: String + - verificationScript: String + - deleteScript: String + } + + class History <> { + - id: long + - contentUuid: UUID + - message: String + } +} + +package "Domain Models" { + class TaskData { + - name: String + - description: String + - initScript: String + - verificationScript: String + - deleteScript: String + } +} + +' Вертикальное выравнивание левой колонки +"Controller Layer" -[hidden]down- "Service Layer" +"Service Layer" -[hidden]down- "Repository Layer" +"Repository Layer" -[hidden]down- "Infrastructure Layer" + +' Вертикальное выравнивание правой колонки +"Entity Layer" -[hidden]down- "Domain Models" + +' Горизонтальное расположение колонок +"Service Layer" -[hidden]right- "Entity Layer" +"Repository Layer" -[hidden]right- "Entity Layer" +"Infrastructure Layer" -[hidden]right- "Domain Models" + +' Связи Controllers -> Services +LaunchController --> JwtService +LaunchController --> LMSService +LaunchController --> HistoryService +TaskController --> TaskServiceImpl +TaskController --> HistoryService + +' Связи Services -> Infrastructure/Repository +TaskServiceImpl --> LMSContentRepository +TaskServiceImpl --> Runner +TaskServiceImpl --> LMSService +TaskServiceImpl --> HistoryService +TaskServiceImpl ..> TaskData + +Runner --> HistoryService + +' Связи Repository -> Entity +LMSContentRepository ..> LMSContent +HistoryRepository ..> History + +' Связи Entity +LMSContent "*" --> "1" Task + +@enduml diff --git a/docs/lti-arhitecture.puml b/docs/lti-arhitecture.puml new file mode 100644 index 0000000..34eb90d --- /dev/null +++ b/docs/lti-arhitecture.puml @@ -0,0 +1,39 @@ +@startuml +skinparam backgroundColor white +skinparam componentStyle rectangle + +scale 1024 width + +' Определяем участников системы +package "Learning Platform" { + [openOLAT] --> [External Tool] +} + +package "CI/CD Pipeline" { + [GitLab] --> [GitLab Runner] + [GitLab Runner] --> [Harbor] +} + +package "Deployment" { + [Kubernetes] + [Docker] +} + +actor User + +' Связи +User --> [External Tool] +User --> [GitLab] +User --> [Kubernetes] +User --> [Docker] + +[External Tool] --> [GitLab] : init/delete script +[External Tool] --> [Kubernetes] : init/delete script +[Harbor] --> [Kubernetes] : Pull images + +"Learning Platform" -[hidden]down- "CI/CD Pipeline" +"Learning Platform" -[hidden]down- "Deployment" + + + +@enduml \ No newline at end of file diff --git a/src/main/java/ru/oa2/lti/application/controller/LaunchController.java b/src/main/java/ru/oa2/lti/application/controller/LaunchController.java index 14384ae..5e296c7 100644 --- a/src/main/java/ru/oa2/lti/application/controller/LaunchController.java +++ b/src/main/java/ru/oa2/lti/application/controller/LaunchController.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; import ru.oa2.lti.application.infrastructure.lms.LMSService; +import ru.oa2.lti.application.service.history.HistoryService; import ru.oa2.lti.application.service.jwt.JwtService; import ru.oa2.lti.domain.model.auth.LtiLogin; @@ -21,11 +22,14 @@ public class LaunchController { private final JwtService jwtService; private final LMSService lmsService; + private final HistoryService historyService; public LaunchController(JwtService jwtService, - LMSService lmsService) { + LMSService lmsService, + HistoryService historyService) { this.jwtService = jwtService; this.lmsService = lmsService; + this.historyService = historyService; } @ResponseBody @@ -57,6 +61,14 @@ public class LaunchController { session.setAttribute("lti_user_id", ltiLogin.getClientId()); session.setAttribute("payload", payload); + // Логирование события входа + historyService.logAction( + payload.getDeploymentId(), + payload.getContextId(), + "LTI_LOGIN", + String.format("User login via LTI 1.3, clientId=%s", clientId) + ); + if (payload.getContextId() != null) { return lmsService.ltiAuth(ltiMessageHint, iss, loginHint); } @@ -79,6 +91,17 @@ public class LaunchController { session.setAttribute("id_token", idToken); session.setAttribute("state", state); + // Декодируем id_token для получения информации о контексте + var idTokenPayload = jwtService.getTokenPayload(idToken); + + // Логирование перенаправления после авторизации + historyService.logAction( + idTokenPayload.getDeploymentId(), + idTokenPayload.getContext() != null ? idTokenPayload.getContext().id() : null, + "LTI_REDIRECT", + "User redirected after OAuth authentication" + ); + return "redirect:/tool/lti/task"; } } diff --git a/src/main/java/ru/oa2/lti/application/controller/TaskController.java b/src/main/java/ru/oa2/lti/application/controller/TaskController.java index 1362f07..b5ca50a 100644 --- a/src/main/java/ru/oa2/lti/application/controller/TaskController.java +++ b/src/main/java/ru/oa2/lti/application/controller/TaskController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import ru.oa2.lti.application.service.history.HistoryService; import ru.oa2.lti.application.service.task.TaskService; import ru.oa2.lti.domain.model.ResultRequest; import ru.oa2.lti.domain.model.auth.LtiLogin; @@ -33,9 +34,11 @@ POST /submit - отправка на автоматическую проверк public class TaskController { private final TaskService taskService; + private final HistoryService historyService; - public TaskController(TaskService taskService) { + public TaskController(TaskService taskService, HistoryService historyService) { this.taskService = taskService; + this.historyService = historyService; } @GetMapping @@ -49,12 +52,26 @@ public class TaskController { var data = taskService.getTask(payload.getContextId()); if (data == null) { + historyService.logAction( + payload.getDeploymentId(), + payload.getContextId(), + "TASK_ERROR", + "Task not found" + ); return "redirect:/error"; } model.addAttribute("name", data.getName()); model.addAttribute("description", data.getDescription()); + // Логирование просмотра задачи + historyService.logAction( + payload.getDeploymentId(), + payload.getContextId(), + "TASK_VIEW", + String.format("Task viewed: %s, isCoach=%s", data.getName(), payload.getCoach()) + ); + if (Objects.requireNonNull(payload).getCoach()) { model.addAttribute("initScript", data.getInitScript()); model.addAttribute("checkScript", data.getVerificationScript()); @@ -75,6 +92,14 @@ public class TaskController { var payload = (Payload) session.getAttribute("payload"); if (payload != null && payload.getCoach()) { + // Логирование обновления задачи + historyService.logAction( + payload.getDeploymentId(), + payload.getContextId(), + "TASK_UPDATE", + String.format("Task updated by coach: %s", data.getName()) + ); + return ResponseEntity.accepted().body( taskService.saveTask(RequestUpdateTask.builder() .contextId(payload.getContextId()) @@ -82,6 +107,17 @@ public class TaskController { .build()) ); } + + // Логирование попытки несанкционированного обновления + if (payload != null) { + historyService.logAction( + payload.getDeploymentId(), + payload.getContextId(), + "TASK_UPDATE_DENIED", + "Unauthorized task update attempt" + ); + } + return ResponseEntity.status(HttpStatusCode.valueOf(403)) .body(new ResultResponse("forbidden")); } @@ -94,6 +130,17 @@ public class TaskController { var ltiLogin = (LtiLogin) session.getAttribute("lti_login"); var idToken = (String) session.getAttribute("id_token"); + var payload = (Payload) session.getAttribute("payload"); + + // Логирование отправки задачи на проверку + if (payload != null) { + historyService.logAction( + payload.getDeploymentId(), + payload.getContextId(), + "TASK_SUBMIT", + String.format("Task submitted for checking, clientId=%s", ltiLogin.getClientId()) + ); + } return ResponseEntity .accepted() diff --git a/src/main/java/ru/oa2/lti/application/infrastructure/repository/HistoryRepository.java b/src/main/java/ru/oa2/lti/application/infrastructure/repository/HistoryRepository.java new file mode 100644 index 0000000..de4254b --- /dev/null +++ b/src/main/java/ru/oa2/lti/application/infrastructure/repository/HistoryRepository.java @@ -0,0 +1,16 @@ +package ru.oa2.lti.application.infrastructure.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import ru.oa2.lti.application.infrastructure.repository.entities.History; + +import java.util.List; +import java.util.UUID; + +@Repository +public interface HistoryRepository extends JpaRepository { + + List findByContentUuidOrderByCreatedDesc(UUID contentUuid); + + List findByDeploymentIdOrderByCreatedDesc(UUID deploymentId); +} diff --git a/src/main/java/ru/oa2/lti/application/infrastructure/runner/RunnerImpl.java b/src/main/java/ru/oa2/lti/application/infrastructure/runner/RunnerImpl.java index 44fab8e..3848287 100644 --- a/src/main/java/ru/oa2/lti/application/infrastructure/runner/RunnerImpl.java +++ b/src/main/java/ru/oa2/lti/application/infrastructure/runner/RunnerImpl.java @@ -2,6 +2,7 @@ package ru.oa2.lti.application.infrastructure.runner; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import ru.oa2.lti.application.service.history.HistoryService; import java.io.BufferedReader; import java.io.IOException; @@ -17,13 +18,28 @@ import java.util.UUID; public class RunnerImpl implements Runner { private final String TEMP_DIR = "./temp/"; + private final HistoryService historyService; + + public RunnerImpl(HistoryService historyService) { + this.historyService = historyService; + } @Override public boolean run(UUID userId, String script) { try { - return runScript(userId, script); + historyService.logAction(null, userId, "SCRIPT_RUN_START", "Init script execution started"); + boolean result = runScript(userId, script); + + if (result) { + historyService.logAction(null, userId, "SCRIPT_RUN_SUCCESS", "Init script completed successfully"); + } else { + historyService.logAction(null, userId, "SCRIPT_RUN_FAILED", "Init script failed with non-zero exit code"); + } + + return result; } catch (Exception ex) { log.error(ex.getMessage()); + historyService.logAction(null, userId, "SCRIPT_RUN_ERROR", "Init script error: " + ex.getMessage()); return false; } } @@ -32,9 +48,19 @@ public class RunnerImpl implements Runner { @Override public boolean check(UUID userId, String script) { try { - return runScript(userId, script); + historyService.logAction(null, userId, "SCRIPT_CHECK_START", "Verification script execution started"); + boolean result = runScript(userId, script); + + if (result) { + historyService.logAction(null, userId, "SCRIPT_CHECK_SUCCESS", "Verification script completed successfully"); + } else { + historyService.logAction(null, userId, "SCRIPT_CHECK_FAILED", "Verification script failed"); + } + + return result; } catch (Exception ex) { log.error(ex.getMessage()); + historyService.logAction(null, userId, "SCRIPT_CHECK_ERROR", "Verification script error: " + ex.getMessage()); return false; } } @@ -42,6 +68,7 @@ public class RunnerImpl implements Runner { @Override public boolean delete(UUID userId, String script) { //TODO реализовать метод удаления сущностей + historyService.logAction(null, userId, "SCRIPT_DELETE_CALLED", "Delete script called (not implemented)"); return false; } diff --git a/src/main/java/ru/oa2/lti/application/service/history/HistoryService.java b/src/main/java/ru/oa2/lti/application/service/history/HistoryService.java new file mode 100644 index 0000000..5e3868c --- /dev/null +++ b/src/main/java/ru/oa2/lti/application/service/history/HistoryService.java @@ -0,0 +1,25 @@ +package ru.oa2.lti.application.service.history; + +import java.util.UUID; + +public interface HistoryService { + + /** + * Логирование события + * + * @param deploymentId ID деплоймента LMS + * @param contentUuid UUID контента + * @param message Сообщение о событии + */ + void logEvent(UUID deploymentId, UUID contentUuid, String message); + + /** + * Логирование события с автоматической меткой времени + * + * @param deploymentId ID деплоймента LMS + * @param contentUuid UUID контента + * @param action Действие (например, "LOGIN", "TASK_LOADED") + * @param details Детали события + */ + void logAction(UUID deploymentId, UUID contentUuid, String action, String details); +} diff --git a/src/main/java/ru/oa2/lti/application/service/history/HistoryServiceImpl.java b/src/main/java/ru/oa2/lti/application/service/history/HistoryServiceImpl.java new file mode 100644 index 0000000..d0e32cc --- /dev/null +++ b/src/main/java/ru/oa2/lti/application/service/history/HistoryServiceImpl.java @@ -0,0 +1,61 @@ +package ru.oa2.lti.application.service.history; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import ru.oa2.lti.application.infrastructure.repository.HistoryRepository; +import ru.oa2.lti.application.infrastructure.repository.entities.History; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Slf4j +@Service +public class HistoryServiceImpl implements HistoryService { + + private final HistoryRepository historyRepository; + + public HistoryServiceImpl(HistoryRepository historyRepository) { + this.historyRepository = historyRepository; + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void logEvent(UUID deploymentId, UUID contentUuid, String message) { + try { + History history = new History(); + history.setDeploymentId(deploymentId); + history.setContentUuid(contentUuid); + history.setCreated(LocalDateTime.now()); + history.setMessage(truncateMessage(message)); + + historyRepository.save(history); + log.debug("Event logged: deploymentId={}, contentUuid={}, message={}", + deploymentId, contentUuid, message); + } catch (Exception e) { + log.error("Failed to log event: deploymentId={}, contentUuid={}, message={}", + deploymentId, contentUuid, message, e); + } + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void logAction(UUID deploymentId, UUID contentUuid, String action, String details) { + String message = String.format("[%s] %s", action, details); + logEvent(deploymentId, contentUuid, message); + } + + /** + * Обрезка сообщения до максимальной длины (2048 символов) + */ + private String truncateMessage(String message) { + if (message == null) { + return ""; + } + if (message.length() > 2048) { + return message.substring(0, 2045) + "..."; + } + return message; + } +} diff --git a/src/main/java/ru/oa2/lti/application/service/task/TaskServiceImpl.java b/src/main/java/ru/oa2/lti/application/service/task/TaskServiceImpl.java index 40e6a4c..4c2e8e8 100644 --- a/src/main/java/ru/oa2/lti/application/service/task/TaskServiceImpl.java +++ b/src/main/java/ru/oa2/lti/application/service/task/TaskServiceImpl.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import ru.oa2.lti.application.infrastructure.lms.LMSService; import ru.oa2.lti.application.infrastructure.repository.LMSContentRepository; +import ru.oa2.lti.application.service.history.HistoryService; import ru.oa2.lti.application.infrastructure.repository.entities.LMSContent; import ru.oa2.lti.application.infrastructure.repository.entities.Task; import ru.oa2.lti.application.infrastructure.runner.Runner; @@ -23,6 +24,7 @@ public class TaskServiceImpl implements TaskService { private final LMSContentRepository lmsContentRepository; private final Runner runner; + private final HistoryService historyService; //TODO объединить в один? final ResultService resultService; @@ -31,12 +33,14 @@ public class TaskServiceImpl implements TaskService { public TaskServiceImpl(LMSContentRepository lmsContentRepository, Runner runner, ResultService resultService, - LMSService lmsService) { + LMSService lmsService, + HistoryService historyService) { this.lmsContentRepository = lmsContentRepository; this.runner = runner; this.resultService = resultService; this.lmsService = lmsService; + this.historyService = historyService; } @Override @@ -88,9 +92,32 @@ public class TaskServiceImpl implements TaskService { Task task = content.getTask(); if (task != null) { + String oldName = task.getName(); updateTaskFromData(task, requestUpdateTask.getData()); lmsContentRepository.save(content); + + historyService.logAction( + content.getDeploymentId(), + requestUpdateTask.getContextId(), + "TASK_SAVED", + String.format("Task saved: %s (was: %s)", + requestUpdateTask.getData().getName(), oldName) + ); + } else { + historyService.logAction( + content.getDeploymentId(), + requestUpdateTask.getContextId(), + "TASK_SAVE_ERROR", + "Task is null, cannot save" + ); } + } else { + historyService.logAction( + null, + requestUpdateTask.getContextId(), + "TASK_SAVE_ERROR", + "Content not found" + ); } //TODO обработка exception - failed / success