refactoring

This commit is contained in:
Anton Dzyk 2026-01-10 20:43:12 +03:00
parent 3ccb43cb6f
commit 368ba49119
26 changed files with 902 additions and 100 deletions

34
pom.xml
View File

@ -19,6 +19,7 @@
<properties>
<java.version>21</java.version>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencies>
@ -48,6 +49,12 @@
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@ -87,6 +94,33 @@
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok-mapstruct-binding</artifactId>
<version>0.2.0</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

View File

@ -2,15 +2,20 @@ 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 ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.HistoryEntity;
import java.util.List;
import java.util.UUID;
/**
* @deprecated Используйте {@link ru.oa2.lti.infrastructure.adapter.persistence.jpa.repository.HistoryJpaRepository}
* Этот интерфейс оставлен для обратной совместимости и будет удален в следующей версии.
*/
@Deprecated
@Repository
public interface HistoryRepository extends JpaRepository<History, Long> {
public interface HistoryRepository extends JpaRepository<HistoryEntity, Long> {
List<History> findByContentUuidOrderByCreatedDesc(UUID contentUuid);
List<HistoryEntity> findByContentUuidOrderByCreatedDesc(UUID contentUuid);
List<History> findByDeploymentIdOrderByCreatedDesc(UUID deploymentId);
List<HistoryEntity> findByDeploymentIdOrderByCreatedDesc(UUID deploymentId);
}

View File

@ -2,13 +2,18 @@ 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.LMSContent;
import ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.LMSContentEntity;
import java.util.Optional;
import java.util.UUID;
/**
* @deprecated Используйте {@link ru.oa2.lti.infrastructure.adapter.persistence.jpa.repository.LMSContentJpaRepository}
* Этот интерфейс оставлен для обратной совместимости и будет удален в следующей версии.
*/
@Deprecated
@Repository
public interface LMSContentRepository extends JpaRepository<LMSContent, Long> {
public interface LMSContentRepository extends JpaRepository<LMSContentEntity, Long> {
Optional<LMSContent> getLMSContentByContentUuid(UUID contentUuid);
Optional<LMSContentEntity> getLMSContentByContentUuid(UUID contextId);
}

View File

@ -7,6 +7,11 @@ import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* @deprecated Используйте {@link ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.HistoryEntity}
* Этот класс оставлен для обратной совместимости и будет удален в следующей версии.
*/
@Deprecated
@Getter
@Setter
@Entity

View File

@ -7,6 +7,11 @@ import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* @deprecated Используйте {@link ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.LMSContentEntity}
* Этот класс оставлен для обратной совместимости и будет удален в следующей версии.
*/
@Deprecated
@Getter
@Setter
@Entity
@ -29,7 +34,6 @@ public class LMSContent {
@Column(name = "created", nullable = false)
LocalDateTime created;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "task_id", nullable = false, foreignKey = @ForeignKey(name = "fk_lms_content_task"))
Task task;
@Column(name = "task_id", nullable = false)
Long taskId;
}

View File

@ -4,6 +4,11 @@ import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
/**
* @deprecated Используйте {@link ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.TaskEntity}
* Этот класс оставлен для обратной совместимости и будет удален в следующей версии.
*/
@Deprecated
@Getter
@Setter
@Entity

View File

@ -5,7 +5,7 @@ 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 ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.HistoryEntity;
import java.time.LocalDateTime;
import java.util.UUID;
@ -24,7 +24,7 @@ public class HistoryServiceImpl implements HistoryService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logEvent(UUID deploymentId, UUID contentUuid, String message) {
try {
History history = new History();
HistoryEntity history = new HistoryEntity();
history.setDeploymentId(deploymentId);
history.setContentUuid(contentUuid);
history.setCreated(LocalDateTime.now());

View File

@ -1,15 +1,17 @@
package ru.oa2.lti.application.service.task;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
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;
import ru.oa2.lti.application.service.history.HistoryService;
import ru.oa2.lti.application.service.results.ResultService;
import ru.oa2.lti.domain.exception.TaskNotFoundException;
import ru.oa2.lti.domain.model.Task;
import ru.oa2.lti.domain.repository.TaskRepository;
import ru.oa2.lti.domain.valueobject.*;
import ru.oa2.lti.domain.model.ResultRequest;
import ru.oa2.lti.domain.model.results.ResultResponse;
import ru.oa2.lti.domain.model.task.RequestUpdateTask;
@ -17,128 +19,149 @@ import ru.oa2.lti.domain.model.task.TaskData;
import java.util.UUID;
/**
* Реализация TaskService, следующая принципам Clean Architecture и DDD.
*
* Улучшения по сравнению со старой версией:
* - Использует доменную модель Task вместо JPA Entity
* - Зависит от порта TaskRepository (domain), а не от конкретной реализации
* - Бизнес-логика теперь в доменной модели Task
* - Сервис только координирует взаимодействие между компонентами
* - Явная обработка исключений
* - Транзакции только на методах записи
*/
@Slf4j
@Service
@Transactional(rollbackOn = Throwable.class)
@RequiredArgsConstructor
public class TaskServiceImpl implements TaskService {
private final LMSContentRepository lmsContentRepository;
private final TaskRepository taskRepository;
private final Runner runner;
private final HistoryService historyService;
//TODO объединить в один?
final ResultService resultService;
final LMSService lmsService;
public TaskServiceImpl(LMSContentRepository lmsContentRepository,
Runner runner,
ResultService resultService,
LMSService lmsService,
HistoryService historyService) {
this.lmsContentRepository = lmsContentRepository;
this.runner = runner;
this.resultService = resultService;
this.lmsService = lmsService;
this.historyService = historyService;
}
private final ResultService resultService;
private final LMSService lmsService;
@Override
@Transactional(readOnly = true)
public TaskData getTask(UUID contextId) {
var content = lmsContentRepository.getLMSContentByContentUuid(contextId);
log.info("Getting task for context: {}", contextId);
if(content.isPresent()) {
LMSContent lmsContent = content.get();
Task task = lmsContent.getTask();
// Используем доменный репозиторий через порт
Task task = taskRepository.findByContextId(contextId)
.orElseThrow(() -> new TaskNotFoundException(contextId));
if (task == null) {
return new TaskData("Not Page", "", "", "", "");
// Преобразуем в DTO для presentation layer
TaskData data = taskToDto(task);
// Выполняем инициализационный скрипт
Script initScript = task.getInitScript();
if (initScript.isExecutable()) {
boolean success = runner.run(contextId, initScript.content());
if (!success) {
log.error("Init script failed for context: {}", contextId);
throw new RuntimeException("Initialization script execution failed");
}
TaskData data = entityToTaskData(task);
//TODO string -> exception?
if (!runner.run(contextId, data.getInitScript())) {
return new TaskData("Init script FAILED", "", "", "", "");
}
return data;
} else {
return new TaskData("Not Page", "", "", "", "");
}
return data;
}
@Override
@Transactional
public ResultResponse checkTask(ResultRequest resultRequest) {
log.info("Checking task for clientId: {}", resultRequest.clientId());
//TODO запуск скрипта проверки
var assessToken = lmsService.exchangeForAccessToken(
resultRequest.clientId());
resultService.setResult(assessToken, resultRequest.idToken());
// Получаем access token от LMS
String accessToken = lmsService.exchangeForAccessToken(resultRequest.clientId());
//TODO обработка и запуск скрипта
// Отправляем результат
resultService.setResult(accessToken, resultRequest.idToken());
// TODO: реализовать запуск скрипта проверки
return new ResultResponse("success");
}
@Override
@Transactional
public ResultResponse saveTask(RequestUpdateTask requestUpdateTask) {
log.info("Saving task for context: {}", requestUpdateTask.getContextId());
log.info("save");
var result = lmsContentRepository.getLMSContentByContentUuid(requestUpdateTask.getContextId());
try {
// Загружаем существующую задачу
Task task = taskRepository.findByContextId(requestUpdateTask.getContextId())
.orElseThrow(() -> new TaskNotFoundException(requestUpdateTask.getContextId()));
//TODO доработать версионирование task-ов
if (result.isPresent()) {
LMSContent content = result.get();
Task task = content.getTask();
String oldName = task.getName().value();
if (task != null) {
String oldName = task.getName();
updateTaskFromData(task, requestUpdateTask.getData());
lmsContentRepository.save(content);
// Обновляем задачу через доменную модель (бизнес-логика внутри)
updateTaskFromDto(task, requestUpdateTask.getData());
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 {
// Сохраняем через порт репозитория
taskRepository.save(task);
// Логируем действие
historyService.logAction(
null, // deploymentId нужно получать из контекста
requestUpdateTask.getContextId(),
"TASK_SAVED",
String.format("Task saved: %s (was: %s)",
requestUpdateTask.getData().getName(), oldName)
);
return new ResultResponse("success");
} catch (TaskNotFoundException e) {
log.error("Task not found for context: {}", requestUpdateTask.getContextId(), e);
historyService.logAction(
null,
requestUpdateTask.getContextId(),
"TASK_SAVE_ERROR",
"Content not found"
"Task not found: " + e.getMessage()
);
throw e;
} catch (Exception e) {
log.error("Error saving task", e);
historyService.logAction(
null,
requestUpdateTask.getContextId(),
"TASK_SAVE_ERROR",
"Error: " + e.getMessage()
);
throw new RuntimeException("Failed to save task", e);
}
//TODO обработка exception - failed / success
return new ResultResponse("success");
}
private TaskData entityToTaskData(Task task) {
/**
* Преобразует доменную модель Task в DTO TaskData
*/
private TaskData taskToDto(Task task) {
return new TaskData(
task.getName(),
task.getDescription(),
task.getInitScript(),
task.getVerificationScript(),
task.getDeleteScript()
task.getName().value(),
task.getDescription().value(),
task.getInitScript().content(),
task.getVerificationScript().content(),
task.getDeleteScript().content()
);
}
private void updateTaskFromData(Task task, TaskData data) {
task.setName(data.getName());
task.setDescription(data.getDescription());
task.setInitScript(data.getInitScript());
task.setVerificationScript(data.getVerificationScript());
task.setDeleteScript(data.getDeleteScript());
/**
* Обновляет доменную модель Task из DTO TaskData
*/
private void updateTaskFromDto(Task task, TaskData data) {
// Используем Value Objects с валидацией
task.update(
TaskName.of(data.getName()),
Description.of(data.getDescription())
);
// Обновляем скрипты
if (data.getInitScript() != null) {
task.setScript(Script.of(ScriptType.INITIALIZATION, data.getInitScript()));
}
if (data.getVerificationScript() != null) {
task.setScript(Script.of(ScriptType.VERIFICATION, data.getVerificationScript()));
}
if (data.getDeleteScript() != null) {
task.setScript(Script.of(ScriptType.DELETION, data.getDeleteScript()));
}
}
}

View File

@ -0,0 +1,14 @@
package ru.oa2.lti.domain.exception;
/**
* Базовое исключение для доменного слоя
*/
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) {
super(message);
}
protected DomainException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -0,0 +1,10 @@
package ru.oa2.lti.domain.exception;
/**
* Исключение при невалидном состоянии задачи
*/
public class InvalidTaskStateException extends DomainException {
public InvalidTaskStateException(String message) {
super(message);
}
}

View File

@ -0,0 +1,18 @@
package ru.oa2.lti.domain.exception;
import ru.oa2.lti.domain.valueobject.TaskId;
import java.util.UUID;
/**
* Исключение, когда задача не найдена
*/
public class TaskNotFoundException extends DomainException {
public TaskNotFoundException(TaskId taskId) {
super("Task not found with id: " + taskId.value());
}
public TaskNotFoundException(UUID contextId) {
super("Task not found for context: " + contextId);
}
}

View File

@ -0,0 +1,140 @@
package ru.oa2.lti.domain.model;
import ru.oa2.lti.domain.exception.InvalidTaskStateException;
import ru.oa2.lti.domain.valueobject.*;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* Доменная модель задачи (Aggregate Root).
* Содержит бизнес-логику и инварианты.
* НЕ зависит от инфраструктурных деталей (JPA, Spring, etc.)
*/
public class Task {
private final TaskId id;
private TaskName name;
private Description description;
private final Map<ScriptType, Script> scripts;
// Приватный конструктор - создание только через фабричные методы
private Task(TaskId id, TaskName name, Description description) {
this.id = Objects.requireNonNull(id, "Task id cannot be null");
this.name = Objects.requireNonNull(name, "Task name cannot be null");
this.description = Objects.requireNonNull(description, "Task description cannot be null");
this.scripts = new HashMap<>();
}
/**
* Фабричный метод для создания новой задачи
*/
public static Task create(TaskName name, Description description) {
return new Task(null, name, description);
}
/**
* Фабричный метод для восстановления существующей задачи из хранилища
*/
public static Task restore(TaskId id, TaskName name, Description description) {
return new Task(id, name, description);
}
/**
* Добавляет или обновляет скрипт определенного типа
*/
public void setScript(Script script) {
Objects.requireNonNull(script, "Script cannot be null");
scripts.put(script.type(), script);
}
/**
* Получает скрипт определенного типа
*/
public Optional<Script> getScript(ScriptType type) {
return Optional.ofNullable(scripts.get(type));
}
/**
* Проверяет, может ли задача быть инициализирована
*/
public boolean canInitialize() {
Script initScript = scripts.get(ScriptType.INITIALIZATION);
return initScript != null && initScript.isExecutable();
}
/**
* Проверяет, может ли задача быть проверена
*/
public boolean canVerify() {
Script verificationScript = scripts.get(ScriptType.VERIFICATION);
return verificationScript != null && verificationScript.isExecutable();
}
/**
* Валидирует, что задача готова к выполнению
*/
public void validateForExecution() {
if (name == null || name.value().isBlank()) {
throw new InvalidTaskStateException("Task must have a name before execution");
}
if (!canInitialize()) {
throw new InvalidTaskStateException("Task must have an executable initialization script");
}
}
/**
* Обновляет информацию о задаче
*/
public void update(TaskName newName, Description newDescription) {
this.name = Objects.requireNonNull(newName, "Task name cannot be null");
this.description = Objects.requireNonNull(newDescription, "Task description cannot be null");
}
// Геттеры
public TaskId getId() {
return id;
}
public TaskName getName() {
return name;
}
public Description getDescription() {
return description;
}
public Script getInitScript() {
return scripts.getOrDefault(ScriptType.INITIALIZATION, Script.empty(ScriptType.INITIALIZATION));
}
public Script getVerificationScript() {
return scripts.getOrDefault(ScriptType.VERIFICATION, Script.empty(ScriptType.VERIFICATION));
}
public Script getDeleteScript() {
return scripts.getOrDefault(ScriptType.DELETION, Script.empty(ScriptType.DELETION));
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Task task = (Task) o;
return Objects.equals(id, task.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
@Override
public String toString() {
return "Task{" +
"id=" + id +
", name=" + name +
'}';
}
}

View File

@ -0,0 +1,54 @@
package ru.oa2.lti.domain.repository;
import ru.oa2.lti.domain.model.Task;
import ru.oa2.lti.domain.valueobject.TaskId;
import java.util.Optional;
import java.util.UUID;
/**
* Порт репозитория для работы с задачами (Domain Layer).
* Это интерфейс, который НЕ зависит от инфраструктуры.
* Реализация будет в infrastructure слое (JPA, MongoDB, etc.)
*
* Следует принципам:
* - Clean Architecture: внутренний слой определяет интерфейс
* - Hexagonal Architecture: это выходной порт (output port)
* - DDD: репозиторий работает с агрегатами
*/
public interface TaskRepository {
/**
* Находит задачу по идентификатору
* @param id идентификатор задачи
* @return Optional с задачей или пустой Optional
*/
Optional<Task> findById(TaskId id);
/**
* Находит задачу по UUID контекста LMS
* @param contextId UUID контекста
* @return Optional с задачей или пустой Optional
*/
Optional<Task> findByContextId(UUID contextId);
/**
* Сохраняет задачу (create или update)
* @param task задача для сохранения
* @return сохраненная задача с присвоенным ID
*/
Task save(Task task);
/**
* Удаляет задачу
* @param task задача для удаления
*/
void delete(Task task);
/**
* Проверяет, существует ли задача с данным ID
* @param id идентификатор задачи
* @return true если существует
*/
boolean existsById(TaskId id);
}

View File

@ -0,0 +1,24 @@
package ru.oa2.lti.domain.valueobject;
import java.util.Objects;
/**
* Value Object для описания задачи
*/
public record Description(String value) {
public Description {
Objects.requireNonNull(value, "Description cannot be null");
}
public static Description of(String value) {
return new Description(value);
}
public static Description empty() {
return new Description("");
}
public boolean isEmpty() {
return value.isBlank();
}
}

View File

@ -0,0 +1,31 @@
package ru.oa2.lti.domain.valueobject;
import java.util.Objects;
import ru.oa2.lti.domain.valueobject.ScriptType;
/**
* Value Object для скрипта с типом и содержимым
*/
public record Script(ScriptType type, String content) {
public Script {
Objects.requireNonNull(type, "Script type cannot be null");
Objects.requireNonNull(content, "Script content cannot be null");
}
public static Script of(ScriptType type, String content) {
return new Script(type, content);
}
public static Script empty(ScriptType type) {
return new Script(type, "");
}
public boolean isEmpty() {
return content.isBlank();
}
public boolean isExecutable() {
return !isEmpty();
}
}

View File

@ -0,0 +1,20 @@
package ru.oa2.lti.domain.valueobject;
/**
* Типы скриптов для задачи
*/
public enum ScriptType {
INITIALIZATION("init_script"),
VERIFICATION("verification_script"),
DELETION("delete_script");
private final String columnName;
ScriptType(String columnName) {
this.columnName = columnName;
}
public String getColumnName() {
return columnName;
}
}

View File

@ -0,0 +1,19 @@
package ru.oa2.lti.domain.valueobject;
import java.util.Objects;
/**
* Value Object для идентификатора задачи
*/
public record TaskId(Long value) {
public TaskId {
Objects.requireNonNull(value, "TaskId cannot be null");
if (value <= 0) {
throw new IllegalArgumentException("TaskId must be positive");
}
}
public static TaskId of(Long value) {
return new TaskId(value);
}
}

View File

@ -0,0 +1,26 @@
package ru.oa2.lti.domain.valueobject;
import java.util.Objects;
/**
* Value Object для названия задачи с валидацией
*/
public record TaskName(String value) {
private static final int MAX_LENGTH = 250;
public TaskName {
Objects.requireNonNull(value, "Task name cannot be null");
if (value.isBlank()) {
throw new IllegalArgumentException("Task name cannot be empty");
}
if (value.length() > MAX_LENGTH) {
throw new IllegalArgumentException(
String.format("Task name cannot exceed %d characters", MAX_LENGTH)
);
}
}
public static TaskName of(String value) {
return new TaskName(value);
}
}

View File

@ -0,0 +1,91 @@
package ru.oa2.lti.infrastructure.adapter.persistence.jpa;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import ru.oa2.lti.domain.model.Task;
import ru.oa2.lti.domain.repository.TaskRepository;
import ru.oa2.lti.domain.valueobject.TaskId;
import ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.TaskEntity;
import ru.oa2.lti.infrastructure.adapter.persistence.jpa.mapper.TaskEntityMapper;
import ru.oa2.lti.infrastructure.adapter.persistence.jpa.repository.TaskJpaRepository;
import java.util.Optional;
import java.util.UUID;
/**
* JPA адаптер для TaskRepository (Hexagonal Architecture - Output Adapter).
* Реализует порт из domain слоя, используя инфраструктурные детали (JPA).
*
* Принципы:
* - Находится в infrastructure слое
* - Реализует интерфейс из domain слоя
* - Использует JPA репозиторий и маппер
* - Преобразует между domain и infrastructure моделями
*/
@Component
@RequiredArgsConstructor
public class TaskRepositoryAdapter implements TaskRepository {
private final TaskJpaRepository jpaRepository;
private final TaskEntityMapper mapper;
@Override
public Optional<Task> findById(TaskId id) {
if (id == null) {
return Optional.empty();
}
return jpaRepository.findById(id.value())
.map(mapper::toDomain);
}
@Override
public Optional<Task> findByContextId(UUID contextId) {
if (contextId == null) {
return Optional.empty();
}
return jpaRepository.findByContextId(contextId)
.map(mapper::toDomain);
}
@Override
public Task save(Task task) {
if (task == null) {
throw new IllegalArgumentException("Task cannot be null");
}
TaskEntity entity;
if (task.getId() != null) {
// Update existing
entity = jpaRepository.findById(task.getId().value())
.orElseGet(() -> mapper.toEntity(task));
mapper.updateEntity(entity, task);
} else {
// Create new
entity = mapper.toEntity(task);
}
TaskEntity savedEntity = jpaRepository.save(entity);
return mapper.toDomain(savedEntity);
}
@Override
public void delete(Task task) {
if (task == null || task.getId() == null) {
throw new IllegalArgumentException("Task or Task ID cannot be null");
}
jpaRepository.deleteById(task.getId().value());
}
@Override
public boolean existsById(TaskId id) {
if (id == null) {
return false;
}
return jpaRepository.existsById(id.value());
}
}

View File

@ -0,0 +1,43 @@
package ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* JPA Entity для истории действий.
*/
@Getter
@Setter
@Entity
@Table(name = "history",
indexes = {
@Index(name = "idx_history_content_uuid", columnList = "content_uuid"),
@Index(name = "idx_history_deployment_id", columnList = "deployment_id"),
@Index(name = "idx_history_created", columnList = "created")
}
)
public class HistoryEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "deployment_id")
private UUID deploymentId;
@Column(name = "content_uuid")
private UUID contentUuid;
@Column(name = "created", nullable = false)
private LocalDateTime created;
@Column(name = "action_type", length = 50)
private String actionType;
@Column(name = "message", columnDefinition = "text")
private String message;
}

View File

@ -0,0 +1,38 @@
package ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* JPA Entity для контента LMS.
* Переименована из LMSContent для ясности, что это infrastructure entity.
*/
@Getter
@Setter
@Entity
@Table(name = "lms_content",
indexes = {
@Index(name = "idx_content_uuid", columnList = "content_uuid")
})
public class LMSContentEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "deployment_id", nullable = false)
private UUID deploymentId;
@Column(name = "content_uuid", nullable = false)
private UUID contentUuid;
@Column(name = "created", nullable = false)
private LocalDateTime created;
@Column(name = "task_id", nullable = false)
private Long taskId;
}

View File

@ -0,0 +1,36 @@
package ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
/**
* JPA Entity для задачи.
* Это НЕ доменная модель, а инфраструктурная деталь для персистентности.
* Находится в infrastructure слое, НЕ влияет на domain.
*/
@Getter
@Setter
@Entity
@Table(name = "task")
public class TaskEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 250)
private String name;
@Column(name = "description", nullable = false, columnDefinition = "text")
private String description;
@Column(name = "init_script", columnDefinition = "text")
private String initScript;
@Column(name = "verification_script", columnDefinition = "text")
private String verificationScript;
@Column(name = "delete_script", columnDefinition = "text")
private String deleteScript;
}

View File

@ -0,0 +1,90 @@
package ru.oa2.lti.infrastructure.adapter.persistence.jpa.mapper;
import org.mapstruct.*;
import ru.oa2.lti.domain.model.Task;
import ru.oa2.lti.domain.valueobject.*;
import ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.TaskEntity;
/**
* MapStruct маппер между доменной моделью Task и JPA entity TaskEntity.
* Отвечает за преобразование между domain и infrastructure слоями.
*
* Принципы:
* - Domain модель НЕ знает о Entity
* - Entity НЕ знает о Domain модели
* - Mapper знает об обоих и выполняет преобразование
*/
@Mapper(
componentModel = MappingConstants.ComponentModel.SPRING,
injectionStrategy = InjectionStrategy.CONSTRUCTOR
)
public abstract class TaskEntityMapper {
/**
* Преобразует JPA entity в доменную модель
*/
public Task toDomain(TaskEntity entity) {
if (entity == null) {
return null;
}
Task task = Task.restore(
TaskId.of(entity.getId()),
TaskName.of(entity.getName()),
Description.of(entity.getDescription())
);
// Добавляем скрипты
if (entity.getInitScript() != null && !entity.getInitScript().isBlank()) {
task.setScript(Script.of(ScriptType.INITIALIZATION, entity.getInitScript()));
}
if (entity.getVerificationScript() != null && !entity.getVerificationScript().isBlank()) {
task.setScript(Script.of(ScriptType.VERIFICATION, entity.getVerificationScript()));
}
if (entity.getDeleteScript() != null && !entity.getDeleteScript().isBlank()) {
task.setScript(Script.of(ScriptType.DELETION, entity.getDeleteScript()));
}
return task;
}
/**
* Преобразует доменную модель в JPA entity
*/
public TaskEntity toEntity(Task domain) {
if (domain == null) {
return null;
}
TaskEntity entity = new TaskEntity();
if (domain.getId() != null) {
entity.setId(domain.getId().value());
}
entity.setName(domain.getName().value());
entity.setDescription(domain.getDescription().value());
entity.setInitScript(domain.getInitScript().content());
entity.setVerificationScript(domain.getVerificationScript().content());
entity.setDeleteScript(domain.getDeleteScript().content());
return entity;
}
/**
* Обновляет существующую entity данными из domain модели
*/
public void updateEntity(TaskEntity entity, Task domain) {
if (entity == null || domain == null) {
return;
}
entity.setName(domain.getName().value());
entity.setDescription(domain.getDescription().value());
entity.setInitScript(domain.getInitScript().content());
entity.setVerificationScript(domain.getVerificationScript().content());
entity.setDeleteScript(domain.getDeleteScript().content());
}
}

View File

@ -0,0 +1,19 @@
package ru.oa2.lti.infrastructure.adapter.persistence.jpa.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.HistoryEntity;
import java.util.List;
import java.util.UUID;
/**
* Spring Data JPA репозиторий для HistoryEntity
*/
@Repository
public interface HistoryJpaRepository extends JpaRepository<HistoryEntity, Long> {
List<HistoryEntity> findByContentUuidOrderByCreatedDesc(UUID contentUuid);
List<HistoryEntity> findByDeploymentIdOrderByCreatedDesc(UUID deploymentId);
}

View File

@ -0,0 +1,17 @@
package ru.oa2.lti.infrastructure.adapter.persistence.jpa.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.LMSContentEntity;
import java.util.Optional;
import java.util.UUID;
/**
* Spring Data JPA репозиторий для LMSContentEntity
*/
@Repository
public interface LMSContentJpaRepository extends JpaRepository<LMSContentEntity, Long> {
Optional<LMSContentEntity> findByContentUuid(UUID contentUuid);
}

View File

@ -0,0 +1,31 @@
package ru.oa2.lti.infrastructure.adapter.persistence.jpa.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import ru.oa2.lti.infrastructure.adapter.persistence.jpa.entity.TaskEntity;
import java.util.Optional;
import java.util.UUID;
/**
* Spring Data JPA репозиторий для TaskEntity.
* Это инфраструктурная деталь, работает с JPA entities.
*/
@Repository
public interface TaskJpaRepository extends JpaRepository<TaskEntity, Long> {
/**
* Находит задачу по UUID контекста через связь с LMSContent.
* Выбирает задачу из самой последней записи LMSContent (по дате created).
*/
@Query("""
SELECT t FROM TaskEntity t
JOIN LMSContentEntity lms ON lms.taskId = t.id
WHERE lms.contentUuid = :contextId
ORDER BY lms.created DESC
LIMIT 1
""")
Optional<TaskEntity> findByContextId(@Param("contextId") UUID contextId);
}