Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
package com.sniptext.domain.template.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.TrashEmptyResponse;
import com.sniptext.domain.template.controller.dto.TrashListResponse;
import com.sniptext.domain.template.controller.dto.TrashPurgeResponse;
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.TrashPurgeCommand;
import com.sniptext.domain.template.service.dto.command.TrashRestoreCommand;
import com.sniptext.domain.template.service.dto.query.TrashListQuery;
import com.sniptext.global.response.ApiResponse;
Expand Down Expand Up @@ -57,4 +61,22 @@ public ResponseEntity<ApiResponse<TrashRestoreResponse>> restore(
return ApiResponse.success(TrashSuccessCode.TRASH_RESTORED, response);
}

@DeleteMapping("/{templateId}")
public ResponseEntity<ApiResponse<TrashPurgeResponse>> purge(
@LoginUser Long userId,
@PathVariable Long templateId) {

TrashPurgeResponse response = TrashPurgeResponse.from(
trashCommandService.purge(new TrashPurgeCommand(userId, templateId)));

return ApiResponse.success(TrashSuccessCode.TRASH_PURGED, response);
}

@DeleteMapping
public ResponseEntity<ApiResponse<TrashEmptyResponse>> empty(@LoginUser Long userId) {
TrashEmptyResponse response = TrashEmptyResponse.from(trashCommandService.empty(userId));

return ApiResponse.success(TrashSuccessCode.TRASH_EMPTIED, response);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
public enum TrashSuccessCode implements ResultCode {

TRASH_LIST_FOUND(HttpStatus.OK, "TRASH200_1", "휴지통 조회에 성공했습니다."),
TRASH_RESTORED(HttpStatus.OK, "TRASH200_2", "템플릿을 복구했습니다.");
TRASH_RESTORED(HttpStatus.OK, "TRASH200_2", "템플릿을 복구했습니다."),
TRASH_PURGED(HttpStatus.OK, "TRASH200_3", "템플릿을 영구 삭제했습니다."),
TRASH_EMPTIED(HttpStatus.OK, "TRASH200_4", "휴지통을 비웠습니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.sniptext.domain.template.controller.dto;

import com.sniptext.domain.template.service.dto.result.TrashEmptyResult;

/** 휴지통 비우기 결과 (명세 18번). 비어 있었으면 0 */
public record TrashEmptyResponse(int deletedCount) {

public static TrashEmptyResponse from(TrashEmptyResult result) {
return new TrashEmptyResponse(result.deletedCount());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.sniptext.domain.template.controller.dto;

import com.sniptext.domain.template.service.dto.result.TrashPurgeResult;

/** 휴지통 템플릿 개별 영구 삭제 결과 (명세 17번) */
public record TrashPurgeResponse(Long templateId) {

public static TrashPurgeResponse from(TrashPurgeResult result) {
return new TrashPurgeResponse(result.templateId());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

Expand All @@ -24,7 +25,7 @@ public interface TemplateRepository extends JpaRepository<Template, Long> {

/**
* 본인의 휴지통 템플릿을 비관적 쓰기 잠금으로 찾는다 (SELECT ... FOR UPDATE). 복구가 쓴다.
* 회원을 잠그지 않는 정리 배치·영구 삭제가 먼저 지우고 있으면 기다렸다가 빈 값을 받는다.
* 회원을 잠그지 않는 정리 배치가 먼저 지우고 있으면 기다렸다가 빈 값을 받는다.
* 잠금 순서는 회원 행 → 템플릿 행이다. 템플릿을 먼저 잠그고 회원을 잠그는 쪽이 생기면 교착이 날 수 있다
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
Expand Down Expand Up @@ -78,4 +79,19 @@ public interface TemplateRepository extends JpaRepository<Template, Long> {
List<TrashTemplateRow> findTrashAfter(@Param("userId") Long userId, @Param("deletedAt") LocalDateTime deletedAt,
@Param("id") Long id, Limit limit);

/**
* 본인의 휴지통 템플릿 하나를 엔티티를 읽지 않고 지운다. 지운 행 수(1 또는 0)를 돌려준다.
* 엔티티를 읽으면 본문을 복호화하므로, 복호화에 실패하는 행도 지울 수 있게 삭제 쿼리로 둔다.
* 지운 행이 1차 캐시에 남아 있으면 같은 트랜잭션의 다음 조회가 이미 없는 행을 돌려주므로 실행 뒤 캐시를 비우고,
* 비우면서 아직 내보내지 않은 변경이 사라지지 않게 실행 전에 내보낸다. 호출 뒤에는 미리 읽어 둔 엔티티를 고쳐도 반영되지 않는다
*/
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("delete from Template t where t.id = :id and t.userId = :userId and t.deletedAt is not null")
int deleteTrashedByIdAndUserId(@Param("id") Long id, @Param("userId") Long userId);

/** 본인의 휴지통을 모두 지우고 지운 개수를 돌려준다. 읽지 않고 지우는 이유와 캐시 처리는 deleteTrashedByIdAndUserId와 같다 */
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("delete from Template t where t.userId = :userId and t.deletedAt is not null")
int deleteTrashedByUserId(@Param("userId") Long userId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
import com.sniptext.domain.template.entity.Template;
import com.sniptext.domain.template.exception.TrashErrorCode;
import com.sniptext.domain.template.repository.TemplateRepository;
import com.sniptext.domain.template.service.dto.command.TrashPurgeCommand;
import com.sniptext.domain.template.service.dto.command.TrashRestoreCommand;
import com.sniptext.domain.template.service.dto.result.TemplateShortcutAssignResult;
import com.sniptext.domain.template.service.dto.result.TrashEmptyResult;
import com.sniptext.domain.template.service.dto.result.TrashPurgeResult;
import com.sniptext.domain.user.service.UserCommandService;
import com.sniptext.global.exception.BusinessException;

Expand All @@ -30,7 +33,7 @@ public class TrashCommandService {
* 회원 잠금이 트랜잭션의 첫 조회여야 한다. MySQL 기본 격리 수준(REPEATABLE READ)은 첫 일반 조회 시점의 스냅샷을 끝까지 쓰므로,
* 템플릿을 먼저 읽으면 잠금을 기다리는 사이 커밋된 생성·복구를 개수 검사와 단축어 조회가 보지 못한다.
* 같은 템플릿을 동시에 복구해도 잠금으로 줄을 서므로, 뒤에 온 요청은 이미 꺼낸 템플릿을 읽어 TRASH404_1이 된다.
* 템플릿 행도 잠가 읽는다. 정리 배치·영구 삭제는 회원을 잠그지 않아, 일반 조회로 읽으면 저장 전에 행이 지워져 500이 나기 때문이다.
* 템플릿 행도 잠가 읽는다. 정리 배치는 회원을 잠그지 않아, 일반 조회로 읽으면 저장 전에 행이 지워져 500이 나기 때문이다.
* 잠금 조회는 스냅샷을 만들지 않으므로 이어지는 개수 검사가 옛 값을 보는 문제는 생기지 않는다.
* 엔티티를 읽으므로 본문 복호화에 실패하는 행은 복구도 500이다.
*/
Expand All @@ -49,4 +52,31 @@ public TemplateShortcutAssignResult restore(TrashRestoreCommand command) {
return TemplateShortcutAssignResult.of(templateCommandService.save(template), original);
}

/**
* 본인 휴지통의 템플릿 하나를 영구 삭제한다. 없는 ID, 다른 회원의 템플릿, 휴지통 밖 템플릿, 이미 지운 템플릿은 TRASH404_1.
* 엔티티를 읽지 않고 삭제 쿼리로 지우므로 본문 복호화에 실패하는 행도 지운다.
* 복구·비우기와 같이 회원 행을 먼저 잠근다. 같은 회원의 휴지통 쓰기가 줄을 서므로, 같은 템플릿을 동시에 지우거나 복구와 겹치면
* 뒤에 온 쪽은 조건에 맞는 행이 없어 TRASH404_1이 된다. 잠금 순서(회원 행 → 템플릿 행)가 같아 서로 교착하지 않는다.
*/
@Transactional
public TrashPurgeResult purge(TrashPurgeCommand command) {
userCommandService.lockUser(command.userId());
int deleted = templateRepository.deleteTrashedByIdAndUserId(command.templateId(), command.userId());
if (deleted == 0) {
throw new BusinessException(TrashErrorCode.TRASHED_TEMPLATE_NOT_FOUND);
}
return new TrashPurgeResult(command.templateId());
}

/**
* 본인 휴지통을 모두 영구 삭제하고 지운 개수를 돌려준다. 비어 있어도 성공이다. 읽지 않고 지우는 이유는 purge와 같다.
* 회원 행을 먼저 잠가야 한다. 비우기는 (user_id, deleted_at) 인덱스 → 기본 키 순서로 행을 잠그고, 복구는 기본 키를 잠근 뒤
* 저장할 때 같은 인덱스를 고치므로, 둘이 줄을 서지 않으면 교착이 나서 한쪽이 500이 된다.
*/
@Transactional
public TrashEmptyResult empty(Long userId) {
userCommandService.lockUser(userId);
return new TrashEmptyResult(templateRepository.deleteTrashedByUserId(userId));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.sniptext.domain.template.service.dto.command;

public record TrashPurgeCommand(Long userId, Long templateId) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.sniptext.domain.template.service.dto.result;

/** 휴지통 비우기로 지운 개수. 비어 있었으면 0 */
public record TrashEmptyResult(int deletedCount) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.sniptext.domain.template.service.dto.result;

/** 영구 삭제한 템플릿 ID */
public record TrashPurgeResult(Long templateId) {
}
Loading
Loading