diff --git a/src/main/java/org/ezcode/codetest/application/draft/dto/request/DraftSaveRequest.java b/src/main/java/org/ezcode/codetest/application/draft/dto/request/DraftSaveRequest.java new file mode 100644 index 00000000..4776e632 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/draft/dto/request/DraftSaveRequest.java @@ -0,0 +1,19 @@ +package org.ezcode.codetest.application.draft.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record DraftSaveRequest( + @NotNull(message = "문제 ID는 필수입니다.") + Long problemId, + + @NotNull(message = "언어 ID는 필수입니다.") + Long languageId, + + @NotNull(message = "코드는 필수입니다.") + @Size(max = 100000, message = "코드는 최대 100,000자까지 입력 가능합니다.") + String code, + + Long version // 새 엔티티는 null일 수 있으므로 @NotNull 제외 +) { +} diff --git a/src/main/java/org/ezcode/codetest/application/draft/dto/response/DraftResponse.java b/src/main/java/org/ezcode/codetest/application/draft/dto/response/DraftResponse.java new file mode 100644 index 00000000..25c4f055 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/draft/dto/response/DraftResponse.java @@ -0,0 +1,22 @@ +package org.ezcode.codetest.application.draft.dto.response; + +import org.ezcode.codetest.domain.draft.model.entity.Draft; + +import lombok.Builder; + +@Builder +public record DraftResponse( + Long problemId, + Long languageId, + String code, + Long version +) { + public static DraftResponse from(Draft draft) { + return DraftResponse.builder() + .problemId(draft.getProblem().getId()) + .languageId(draft.getLanguage().getId()) + .code(draft.getCode()) + .version(draft.getVersion()) + .build(); + } +} diff --git a/src/main/java/org/ezcode/codetest/application/draft/service/DraftService.java b/src/main/java/org/ezcode/codetest/application/draft/service/DraftService.java new file mode 100644 index 00000000..01d7407a --- /dev/null +++ b/src/main/java/org/ezcode/codetest/application/draft/service/DraftService.java @@ -0,0 +1,68 @@ +package org.ezcode.codetest.application.draft.service; + +import java.util.Optional; + +import org.ezcode.codetest.application.draft.dto.request.DraftSaveRequest; +import org.ezcode.codetest.application.draft.dto.response.DraftResponse; +import org.ezcode.codetest.domain.draft.exception.DraftException; +import org.ezcode.codetest.domain.draft.exception.DraftExceptionCode; +import org.ezcode.codetest.domain.draft.model.entity.Draft; +import org.ezcode.codetest.domain.draft.service.DraftDomainService; +import org.ezcode.codetest.domain.language.model.entity.Language; +import org.ezcode.codetest.domain.language.service.LanguageDomainService; +import org.ezcode.codetest.domain.problem.model.entity.Problem; +import org.ezcode.codetest.domain.problem.service.ProblemDomainService; +import org.ezcode.codetest.domain.user.model.entity.User; +import org.ezcode.codetest.domain.user.service.UserDomainService; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DraftService { + + private final DraftDomainService draftDomainService; + + private final UserDomainService userDomainService; + private final ProblemDomainService problemDomainService; + private final LanguageDomainService languageDomainService; + + @Transactional + public DraftResponse autoSave(Long userId, DraftSaveRequest request) { + + User user = userDomainService.getUserById(userId); + Problem problem = problemDomainService.getProblem(request.problemId()); + Language language = languageDomainService.getLanguage(request.languageId()); + + Draft draft; + try { + draft = draftDomainService.autoSave( + user, + problem, + language, + request.code(), + request.version() + ); + } catch (ObjectOptimisticLockingFailureException e) { + throw new DraftException(DraftExceptionCode.DRAFT_VERSION_CONFLICT); + } + + return DraftResponse.from(draft); + } + + @Transactional(readOnly = true) + public Optional getDraft(Long userId, Long problemId, Long languageId) { + + User user = userDomainService.getUserById(userId); + Problem problem = problemDomainService.getProblem(problemId); + Language language = languageDomainService.getLanguage(languageId); + + return draftDomainService.getDraft(user, problem, language) + .map(DraftResponse::from); + } +} diff --git a/src/main/java/org/ezcode/codetest/domain/draft/exception/DraftException.java b/src/main/java/org/ezcode/codetest/domain/draft/exception/DraftException.java new file mode 100644 index 00000000..ab5a0c6b --- /dev/null +++ b/src/main/java/org/ezcode/codetest/domain/draft/exception/DraftException.java @@ -0,0 +1,21 @@ +package org.ezcode.codetest.domain.draft.exception; + +import org.ezcode.codetest.common.base.exception.BaseException; +import org.ezcode.codetest.common.base.exception.ResponseCode; +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public class DraftException extends BaseException { + + private final ResponseCode responseCode; + private final HttpStatus httpStatus; + private final String message; + + public DraftException(ResponseCode responseCode) { + this.responseCode = responseCode; + this.httpStatus = responseCode.getStatus(); + this.message = responseCode.getMessage(); + } +} diff --git a/src/main/java/org/ezcode/codetest/domain/draft/exception/DraftExceptionCode.java b/src/main/java/org/ezcode/codetest/domain/draft/exception/DraftExceptionCode.java new file mode 100644 index 00000000..7dd6f87e --- /dev/null +++ b/src/main/java/org/ezcode/codetest/domain/draft/exception/DraftExceptionCode.java @@ -0,0 +1,25 @@ +package org.ezcode.codetest.domain.draft.exception; + +import org.ezcode.codetest.common.base.exception.ResponseCode; +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum DraftExceptionCode implements ResponseCode { + + DRAFT_NOT_FOUND(false, HttpStatus.NOT_FOUND, "해당 임시 저장 내역이 존재하지 않습니다."), + + // 409 Conflict: 낙관적 락 충돌 발생 시 사용 + DRAFT_VERSION_CONFLICT(false, HttpStatus.CONFLICT, "다른 탭에서 저장된 최신 코드가 존재합니다. 새로고침이 필요합니다."), + + // 본인의 Draft가 아닌데 조회/수정하려 할 때 (URL 조작 등) + DRAFT_ACCESS_DENIED(false, HttpStatus.FORBIDDEN, "해당 임시 저장 내역에 접근할 권한이 없습니다."), + ; + + private final boolean success; + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/org/ezcode/codetest/domain/draft/model/entity/Draft.java b/src/main/java/org/ezcode/codetest/domain/draft/model/entity/Draft.java new file mode 100644 index 00000000..cb4a53e4 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/domain/draft/model/entity/Draft.java @@ -0,0 +1,72 @@ +package org.ezcode.codetest.domain.draft.model.entity; + +import org.ezcode.codetest.domain.language.model.entity.Language; +import org.ezcode.codetest.domain.problem.model.entity.Problem; +import org.ezcode.codetest.domain.user.model.entity.User; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.Lob; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "draft", + uniqueConstraints = { + // 언어별 저장 지원 + @UniqueConstraint( + name = "uk_draft_user_problem_language", + columnNames = {"user_id", "problem_id", "language_id"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Draft { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "problem_id", nullable = false) + private Problem problem; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "language_id", nullable = false) + private Language language; + + @Lob + private String code; + + @Version // 낙관적 락을 위한 버전 관리 + private Long version; + + public void updateCode(String newCode) { + this.code = newCode; + } + + @Builder + public Draft(User user, Problem problem, Language language, String code, Long version) { + this.user = user; + this.problem = problem; + this.language = language; + this.code = code; + this.version = version; + } +} diff --git a/src/main/java/org/ezcode/codetest/domain/draft/repository/DraftRepository.java b/src/main/java/org/ezcode/codetest/domain/draft/repository/DraftRepository.java new file mode 100644 index 00000000..476e067a --- /dev/null +++ b/src/main/java/org/ezcode/codetest/domain/draft/repository/DraftRepository.java @@ -0,0 +1,18 @@ +package org.ezcode.codetest.domain.draft.repository; + +import java.util.Optional; + +import org.ezcode.codetest.domain.draft.model.entity.Draft; +import org.ezcode.codetest.domain.language.model.entity.Language; +import org.ezcode.codetest.domain.problem.model.entity.Problem; +import org.ezcode.codetest.domain.user.model.entity.User; + +public interface DraftRepository { + + Draft save(Draft draft); + + Optional findByUserAndProblemAndLanguage(User user, Problem problem, Language language); + + Draft saveAndFlush(Draft draft); + +} diff --git a/src/main/java/org/ezcode/codetest/domain/draft/service/DraftDomainService.java b/src/main/java/org/ezcode/codetest/domain/draft/service/DraftDomainService.java new file mode 100644 index 00000000..077bf463 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/domain/draft/service/DraftDomainService.java @@ -0,0 +1,58 @@ +package org.ezcode.codetest.domain.draft.service; + +import java.util.Objects; +import java.util.Optional; + +import org.ezcode.codetest.domain.draft.exception.DraftException; +import org.ezcode.codetest.domain.draft.exception.DraftExceptionCode; +import org.ezcode.codetest.domain.draft.model.entity.Draft; +import org.ezcode.codetest.domain.draft.repository.DraftRepository; +import org.ezcode.codetest.domain.language.model.entity.Language; +import org.ezcode.codetest.domain.problem.model.entity.Problem; +import org.ezcode.codetest.domain.user.model.entity.User; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DraftDomainService { + + private final DraftRepository draftRepository; + + public Draft autoSave(User user, Problem problem, Language language, String code, Long version) { + + Optional existingDraftOpt = draftRepository.findByUserAndProblemAndLanguage(user, problem, language); + + if (existingDraftOpt.isPresent()) { + Draft draft = existingDraftOpt.get(); + + // 명시적 버전 검증: 클라이언트가 보낸 version과 DB의 version이 일치해야 함 + if (version != null && !Objects.equals(version, draft.getVersion())) { + throw new DraftException(DraftExceptionCode.DRAFT_VERSION_CONFLICT); + } + + draft.updateCode(code); + draftRepository.saveAndFlush(draft); + + return draft; + } else { + // 새 엔티티인 경우 + Draft draft = Draft.builder() + .user(user) + .problem(problem) + .language(language) + .code(code) + .build(); + + return draftRepository.saveAndFlush(draft); + } + } + + public Optional getDraft(User user, Problem problem, Language language) { + + return draftRepository.findByUserAndProblemAndLanguage(user, problem, language); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/draft/DraftJpaRepository.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/draft/DraftJpaRepository.java new file mode 100644 index 00000000..a79264f8 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/draft/DraftJpaRepository.java @@ -0,0 +1,14 @@ +package org.ezcode.codetest.infrastructure.persistence.repository.draft; + +import java.util.Optional; + +import org.ezcode.codetest.domain.draft.model.entity.Draft; +import org.ezcode.codetest.domain.language.model.entity.Language; +import org.ezcode.codetest.domain.problem.model.entity.Problem; +import org.ezcode.codetest.domain.user.model.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface DraftJpaRepository extends JpaRepository { + + Optional findByUserAndProblemAndLanguage(User user, Problem problem, Language language); +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/draft/DraftRepositoryImpl.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/draft/DraftRepositoryImpl.java new file mode 100644 index 00000000..ff4985db --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/draft/DraftRepositoryImpl.java @@ -0,0 +1,37 @@ +package org.ezcode.codetest.infrastructure.persistence.repository.draft; + +import java.util.Optional; + +import org.ezcode.codetest.domain.draft.model.entity.Draft; +import org.ezcode.codetest.domain.draft.repository.DraftRepository; +import org.ezcode.codetest.domain.language.model.entity.Language; +import org.ezcode.codetest.domain.problem.model.entity.Problem; +import org.ezcode.codetest.domain.user.model.entity.User; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class DraftRepositoryImpl implements DraftRepository { + + private final DraftJpaRepository draftJpaRepository; + + @Override + public Draft save(Draft draft) { + + return draftJpaRepository.save(draft); + } + + @Override + public Optional findByUserAndProblemAndLanguage(User user, Problem problem, Language language) { + + return draftJpaRepository.findByUserAndProblemAndLanguage(user, problem, language); + } + + @Override + public Draft saveAndFlush(Draft draft) { + + return draftJpaRepository.saveAndFlush(draft); + } +} diff --git a/src/main/java/org/ezcode/codetest/presentation/draft/DraftController.java b/src/main/java/org/ezcode/codetest/presentation/draft/DraftController.java new file mode 100644 index 00000000..39014243 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/presentation/draft/DraftController.java @@ -0,0 +1,77 @@ +package org.ezcode.codetest.presentation.draft; + +import java.util.Optional; + +import org.ezcode.codetest.application.draft.dto.request.DraftSaveRequest; +import org.ezcode.codetest.application.draft.dto.response.DraftResponse; +import org.ezcode.codetest.application.draft.service.DraftService; +import org.ezcode.codetest.domain.user.model.entity.AuthUser; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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 org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/drafts") +@RequiredArgsConstructor +@Tag(name = "Drafts", description = "코드 자동 저장 API") +public class DraftController { + + private final DraftService draftService; + + @Operation( + summary = "코드 자동 저장", + description = "작성 중인 코드를 자동으로 임시 저장합니다. 동일한 문제와 언어에 대한 기존 저장 내역이 있으면 업데이트하고, 없으면 새로 생성합니다. 버전 충돌 시 409 에러가 발생합니다." + ) + @ApiResponse(responseCode = "200", description = "코드 저장 성공") + @ApiResponse(responseCode = "400", description = "유효하지 않은 요청 데이터") + @ApiResponse(responseCode = "409", description = "다른 탭에서 저장된 최신 코드가 존재합니다. 새로고침이 필요합니다.") + @PostMapping + public ResponseEntity saveDraft( + @Valid @RequestBody DraftSaveRequest request, + @AuthenticationPrincipal AuthUser authUser + ) { + + DraftResponse response = draftService.autoSave(authUser.getId(), request); + + return ResponseEntity.ok(response); + } + + @Operation( + summary = "저장된 코드 조회", + description = "특정 문제와 언어에 대해 저장된 임시 저장 코드를 조회합니다. 저장된 데이터가 없으면 result가 null로 반환됩니다.", + parameters = { + @Parameter(name = "problemId", description = "문제 ID", required = true), + @Parameter(name = "languageId", description = "언어 ID", required = true) + } + ) + @ApiResponse(responseCode = "200", description = "저장된 코드 조회 성공 (데이터가 없을 경우 result는 null)") + @GetMapping + public ResponseEntity getDraft( + @RequestParam Long problemId, + @RequestParam Long languageId, + @AuthenticationPrincipal AuthUser authUser + ) { + + Optional response = draftService.getDraft( + authUser.getId(), + problemId, + languageId + ); + + return response + .map(ResponseEntity::ok) + .orElse(ResponseEntity.ok().body(null)); + } +}