diff --git a/CLAUDE.md b/CLAUDE.md index 3e8f3f6..6db4dc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ domain/{도메인}/ - 회원은 `User.create(email, nickname, profileImageUrl)`로 만든다. 닉네임 정리(앞뒤 공백 제거, 50자 자르기, 비면 이메일 앞부분)는 가입 서비스가 하고, `create`는 길이·필수 값만 검사한다. 테스트에서는 `UserFixture`를 쓴다 - 롬복 허용: `@Getter`, `@NoArgsConstructor(access = PROTECTED)`, `@Builder(access = PRIVATE)`(정적 팩토리 안에서만), `@RequiredArgsConstructor`, `@Slf4j` - 롬복 금지: `@Setter`, `@Data`, `@AllArgsConstructor`, 엔티티의 `@ToString`·`@EqualsAndHashCode` -- 트랜잭션: 서비스 클래스에 `@Transactional(readOnly = true)`, 쓰기 메서드에만 `@Transactional` +- 트랜잭션: 조회 서비스(`{도메인}QueryService`)는 클래스에 `@Transactional(readOnly = true)`. 명령 서비스(`{도메인}CommandService`)는 클래스에 붙이지 않고 쓰기 메서드마다 `@Transactional` (클래스에 `readOnly`를 두면 메서드에 빠뜨렸을 때 쓰기가 읽기 전용 트랜잭션으로 실행된다) - 검증: 형식 검증은 요청 객체(빈 검증), 업무 규칙 검증은 서비스 ## 응답·예외 diff --git a/docs/s1-api-spec.md b/docs/s1-api-spec.md index ecdba10..b7578f7 100644 --- a/docs/s1-api-spec.md +++ b/docs/s1-api-spec.md @@ -1798,6 +1798,7 @@ POST /api/v1/trash/templates/18/restore | --- | --- | --- | --- | | 403 | TEMPLATE403_1 | 템플릿 저장 한도에 도달했어요. | 활성 템플릿 수가 한도(50개) 이상. 한도 모달은 기획에서 삭제됨. 화면 처리 미정 (프론트·기획 논의 중) | | 409 | TEMPLATE409_2 | 사용할 수 있는 단축어 번호가 없어요. | 자동 번호 2~99가 모두 사용 중 | +| 409 | TEMPLATE409_1 | 이미 사용 중인 단축어예요. | 고른 단축어를 저장하는 사이 다른 탭의 수정과 겹친 경우 (드묾). 복구되지 않고 휴지통에 그대로 있으며, 다시 시도하면 자동 번호로 복구됨 | | 404 | TRASH404_1 | 휴지통에서 템플릿을 찾을 수 없어요. | 휴지통에 없는 템플릿(이미 복구·영구 삭제됨, 다른 사람 템플릿) | ##### 비고 @@ -1805,6 +1806,7 @@ POST /api/v1/trash/templates/18/restore - 한도(50개) 도달 시 복구하지 않고 TEMPLATE403_1. 한도 모달(BIN-07)은 기획에서 삭제됐습니다. - 자동 번호 규칙: 원본 뒤에 2부터 99까지 붙여 비어 있는 첫 번호 사용. "/" 뒤가 10자를 넘으면 원본을 뒤에서 잘라 맞춤. 예) /견적요청서작성안내문 → /견적요청서작성안내2 - 숫자로 끝나는 단축어(/견적2)는 원본 뒤에 붙입니다(/견적22). (개발팀 결정) +- 단축어는 서버가 비어 있는 것으로 고르지만, 같은 순간 다른 탭에서 다른 템플릿을 같은 단축어로 수정하면 TEMPLATE409_1이 날 수 있습니다. 사용자가 입력한 값이 없으므로 화면은 입력창 에러가 아니라 "다시 시도" 안내로 처리합니다. 다시 요청하면 자동 번호가 붙어 성공합니다 (개발팀 결정). - editedAt·최근 사용 시각·생성 시각은 삭제 전 값 그대로 돌아옵니다. 목록은 생성 순이라 원래 자리로 돌아갑니다. --- @@ -2234,6 +2236,7 @@ POST /api/v1/gallery/templates/3/save | --- | --- | --- | --- | | 403 | TEMPLATE403_1 | 템플릿 저장 한도에 도달했어요. | 활성 템플릿 수가 한도(50개) 이상. 한도 모달은 기획에서 삭제됨. 화면 처리 미정 (프론트·기획 논의 중) | | 409 | TEMPLATE409_2 | 사용할 수 있는 단축어 번호가 없어요. | 자동 번호 2~99가 모두 사용 중 | +| 409 | TEMPLATE409_1 | 이미 사용 중인 단축어예요. | 고른 단축어를 저장하는 사이 다른 탭의 수정과 겹친 경우 (드묾). 저장되지 않으며, 다시 시도하면 자동 번호로 저장됨 | | 404 | GALLERY404_1 | 갤러리 템플릿을 찾을 수 없어요. | 없거나 비공개 처리된 갤러리 템플릿 | ##### 비고 @@ -2245,6 +2248,7 @@ POST /api/v1/gallery/templates/3/save - 비로그인 사용자는 화면에서 로그인으로 유도만 합니다(가입 후 자동 저장 없음). - **같은 갤러리 템플릿을 여러 번 저장할 수 있습니다(기획 확정).** 저장할 때마다 내 템플릿이 새로 하나씩 생기고, 각각 템플릿 한도(50개)를 한 칸씩 차지합니다. - 단축어가 이미 있으면 자동 번호를 붙여 저장합니다(`/견적` → `/견적2` → `/견적3`). 2~99가 모두 쓰이면 TEMPLATE409_2. +- 같은 순간 다른 탭의 수정과 단축어가 겹치면 TEMPLATE409_1이 날 수 있습니다. 화면 처리는 복구 API(16번)와 같습니다. - 갤러리에서 가져온 템플릿은 내용만 복사하고 원본과 동기화하지 않습니다. `source_gallery_template_id`는 출처 기록·집계용입니다(원본이 삭제되면 비워짐). - 화면에 "저장됨" 표시는 없습니다. 저장 버튼은 항상 그대로 동작합니다. diff --git a/src/main/java/com/sniptext/domain/template/controller/TrashController.java b/src/main/java/com/sniptext/domain/template/controller/TrashController.java index da97640..6a1ed53 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TrashController.java +++ b/src/main/java/com/sniptext/domain/template/controller/TrashController.java @@ -2,12 +2,17 @@ import org.springframework.http.ResponseEntity; 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.RestController; import com.sniptext.domain.template.controller.dto.TrashListResponse; +import com.sniptext.domain.template.controller.dto.TrashRestoreResponse; +import com.sniptext.domain.template.service.TrashCommandService; import com.sniptext.domain.template.service.TrashQueryService; +import com.sniptext.domain.template.service.dto.command.TrashRestoreCommand; import com.sniptext.domain.template.service.dto.query.TrashListQuery; import com.sniptext.global.response.ApiResponse; import com.sniptext.global.security.LoginUser; @@ -26,6 +31,8 @@ public class TrashController { private final TrashQueryService trashQueryService; + private final TrashCommandService trashCommandService; + @GetMapping public ResponseEntity> getTrash( @LoginUser Long userId, @@ -39,4 +46,15 @@ public ResponseEntity> getTrash( return ApiResponse.success(TrashSuccessCode.TRASH_LIST_FOUND, response); } + @PostMapping("/{templateId}/restore") + public ResponseEntity> restore( + @LoginUser Long userId, + @PathVariable Long templateId) { + + TrashRestoreResponse response = TrashRestoreResponse.from( + trashCommandService.restore(new TrashRestoreCommand(userId, templateId))); + + return ApiResponse.success(TrashSuccessCode.TRASH_RESTORED, response); + } + } diff --git a/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java b/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java index f6f14a3..3f59043 100644 --- a/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java +++ b/src/main/java/com/sniptext/domain/template/controller/TrashSuccessCode.java @@ -11,7 +11,8 @@ @RequiredArgsConstructor public enum TrashSuccessCode implements ResultCode { - TRASH_LIST_FOUND(HttpStatus.OK, "TRASH200_1", "휴지통 조회에 성공했습니다."); + TRASH_LIST_FOUND(HttpStatus.OK, "TRASH200_1", "휴지통 조회에 성공했습니다."), + TRASH_RESTORED(HttpStatus.OK, "TRASH200_2", "템플릿을 복구했습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/sniptext/domain/template/controller/dto/TrashRestoreResponse.java b/src/main/java/com/sniptext/domain/template/controller/dto/TrashRestoreResponse.java new file mode 100644 index 0000000..104d007 --- /dev/null +++ b/src/main/java/com/sniptext/domain/template/controller/dto/TrashRestoreResponse.java @@ -0,0 +1,24 @@ +package com.sniptext.domain.template.controller.dto; + +import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult; + +/** 휴지통 복구 결과 (명세 16번). shortcutChanged면 화면이 자동 번호 안내 토스트를 띄운다 */ +public record TrashRestoreResponse( + Long templateId, + String title, + String shortcut, + String originalShortcut, + boolean shortcutChanged +) { + + public static TrashRestoreResponse from(TemplateShortcutAssignResult result) { + return new TrashRestoreResponse( + result.templateId(), + result.title(), + result.shortcut(), + result.originalShortcut(), + result.shortcutChanged() + ); + } + +} diff --git a/src/main/java/com/sniptext/domain/template/entity/Template.java b/src/main/java/com/sniptext/domain/template/entity/Template.java index 7417626..3d1753c 100644 --- a/src/main/java/com/sniptext/domain/template/entity/Template.java +++ b/src/main/java/com/sniptext/domain/template/entity/Template.java @@ -125,6 +125,19 @@ public void moveToTrash(LocalDateTime now) { this.deletedAt = now; } + /** + * 휴지통에서 꺼낸다. shortcut은 삭제 당시 단축어이거나, 겹쳐서 자동 번호를 붙인 단축어다 (고르는 건 서비스가 한다). + * 수정·사용 시각은 삭제 전 값 그대로 둔다. 휴지통이 아닌 템플릿에 부르면 조회 조건을 빠뜨린 코드 실수다. + */ + public void restore(Shortcut shortcut) { + require(shortcut != null, "단축어가 없다"); + if (!isInTrash()) { + throw new IllegalStateException("휴지통에 없는 템플릿은 복구할 수 없다"); + } + this.shortcut = shortcut; + this.deletedAt = null; + } + public boolean isInTrash() { return deletedAt != null; } diff --git a/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java b/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java index 2e91d14..2243f6a 100644 --- a/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java +++ b/src/main/java/com/sniptext/domain/template/exception/TrashErrorCode.java @@ -12,7 +12,9 @@ public enum TrashErrorCode implements ResultCode { /** 커서를 해석할 수 없거나 size가 1~50 밖 */ - INVALID_PAGE_REQUEST(HttpStatus.BAD_REQUEST, "TRASH400_1", "잘못된 페이지 요청이에요."); + INVALID_PAGE_REQUEST(HttpStatus.BAD_REQUEST, "TRASH400_1", "잘못된 페이지 요청이에요."), + /** 휴지통에 없는 템플릿 (없는 ID, 다른 회원의 템플릿, 이미 복구·영구 삭제됨) */ + TRASHED_TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "TRASH404_1", "휴지통에서 템플릿을 찾을 수 없어요."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java b/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java index 583a079..62c417b 100644 --- a/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java +++ b/src/main/java/com/sniptext/domain/template/repository/TemplateRepository.java @@ -8,17 +8,29 @@ import org.springframework.data.domain.Limit; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import com.sniptext.domain.template.entity.Template; import com.sniptext.domain.template.repository.dto.TrashTemplateRow; +import jakarta.persistence.LockModeType; + public interface TemplateRepository extends JpaRepository { /** 본인 소유 조회. 휴지통 템플릿도 찾으므로 내 휴지통과 다른 회원 것을 호출자가 구분할 수 있다 */ Optional