Skip to content
Merged
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
@@ -0,0 +1,42 @@
package com.sniptext.domain.template.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
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.service.TrashQueryService;
import com.sniptext.domain.template.service.dto.query.TrashListQuery;
import com.sniptext.global.response.ApiResponse;
import com.sniptext.global.security.LoginUser;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;

/**
* 휴지통 (로그인 필요, 캐시 금지).
*/
@RestController
@RequestMapping("/api/v1/trash/templates")
@RequiredArgsConstructor
public class TrashController {

private final TrashQueryService trashQueryService;

@GetMapping
public ResponseEntity<ApiResponse<TrashListResponse>> getTrash(
@LoginUser Long userId,
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "TRASH400_1") @Max(value = 50, message = "TRASH400_1") int size) {

TrashListResponse response = TrashListResponse.from(
trashQueryService.getTrash(new TrashListQuery(userId, cursor, size)));

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

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

import org.springframework.http.HttpStatus;

import com.sniptext.global.response.ResultCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum TrashSuccessCode implements ResultCode {

TRASH_LIST_FOUND(HttpStatus.OK, "TRASH200_1", "휴지통 조회에 성공했습니다.");

private final HttpStatus httpStatus;
private final String code;
private final String message;

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

import java.util.List;

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

/** 명세 15번 휴지통 목록 */
public record TrashListResponse(long totalCount, List<TrashTemplateResponse> templates, boolean hasNext,
String nextCursor) {

public static TrashListResponse from(TrashListResult result) {
return new TrashListResponse(
result.totalCount(),
result.templates().stream().map(TrashTemplateResponse::from).toList(),
result.hasNext(),
result.nextCursor()
);
}

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

import java.time.LocalDateTime;

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

/** 명세 15번 휴지통 항목. 시각은 전역 직렬화 설정이 +09:00을 붙여 내보낸다 */
public record TrashTemplateResponse(
Long templateId,
String title,
String shortcut,
LocalDateTime deletedAt,
LocalDateTime purgeAt
) {

public static TrashTemplateResponse from(TrashTemplateResult result) {
return new TrashTemplateResponse(
result.templateId(),
result.title(),
result.shortcut(),
result.deletedAt(),
result.purgeAt()
);
}

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

import org.springframework.http.HttpStatus;

import com.sniptext.global.response.ResultCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum TrashErrorCode implements ResultCode {

/** 커서를 해석할 수 없거나 size가 1~50 밖 */
INVALID_PAGE_REQUEST(HttpStatus.BAD_REQUEST, "TRASH400_1", "잘못된 페이지 요청이에요.");

private final HttpStatus httpStatus;
private final String code;
private final String message;

}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package com.sniptext.domain.template.repository;

import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Set;

import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
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;

public interface TemplateRepository extends JpaRepository<Template, Long> {

Expand All @@ -27,4 +31,32 @@ public interface TemplateRepository extends JpaRepository<Template, Long> {
@Query("select t.activeShortcutKey from Template t where t.userId = :userId and t.activeShortcutKey in :keys")
Set<String> findActiveShortcutKeys(@Param("userId") Long userId, @Param("keys") Collection<String> keys);

/** 휴지통 개수. idx_templates_user_deleted (user_id, deleted_at)를 탄다 */
long countByUserIdAndDeletedAtIsNotNull(Long userId);

/**
* 휴지통 첫 페이지. 최근 삭제순, 같은 시각이면 ID가 큰 것부터.
* idx_templates_user_deleted는 뒤에 기본 키가 붙어 있어 이 정렬을 인덱스 역방향 스캔으로 처리한다
*/
@Query("""
select new com.sniptext.domain.template.repository.dto.TrashTemplateRow(
t.id, t.title.value, t.shortcut.value, t.deletedAt)
from Template t
where t.userId = :userId and t.deletedAt is not null
order by t.deletedAt desc, t.id desc
""")
List<TrashTemplateRow> findTrashFirstPage(@Param("userId") Long userId, Limit limit);

/** 휴지통 다음 페이지. 정렬이 같고, 커서가 가리키는 항목 바로 다음부터 읽는다. deleted_at 비교가 있어 휴지통만 걸린다 */
@Query("""
select new com.sniptext.domain.template.repository.dto.TrashTemplateRow(
t.id, t.title.value, t.shortcut.value, t.deletedAt)
from Template t
where t.userId = :userId
and (t.deletedAt < :deletedAt or (t.deletedAt = :deletedAt and t.id < :id))
order by t.deletedAt desc, t.id desc
""")
List<TrashTemplateRow> findTrashAfter(@Param("userId") Long userId, @Param("deletedAt") LocalDateTime deletedAt,
@Param("id") Long id, Limit limit);

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

import java.time.LocalDateTime;

/**
* 휴지통 목록 한 행. 성능 때문이 아니라 본문을 읽지 않기 위한 조회 전용 객체다.
* 엔티티로 읽으면 내보내지도 않는 본문을 복호화하고, 복호화에 실패한 행 하나가 목록 전체를 500으로 만든다.
*/
public record TrashTemplateRow(Long id, String title, String shortcut, LocalDateTime deletedAt) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.sniptext.domain.template.service;

import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Base64;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
* 휴지통 목록의 다음 페이지 위치. 이 페이지 마지막 항목의 (삭제 시각, ID)를 이어 붙여 Base64 URL로 인코딩한다.
* 화면은 값을 해석하지 않고 nextCursor를 그대로 돌려보낸다.
* 같은 초 안에서도 순서가 갈리므로 시각은 DATETIME(6)과 같은 마이크로초까지 넣는다.
* 해석할 때 자릿수를 고정해 DB가 받지 못하는 연도(+12026)가 쿼리까지 가지 않게 한다.
*/
record TrashCursor(LocalDateTime deletedAt, Long id) {

private static final String SEPARATOR = "_";
private static final DateTimeFormatter DELETED_AT_FORMAT = DateTimeFormatter.ofPattern(
"uuuu-MM-dd'T'HH:mm:ss.SSSSSS");
private static final Pattern FORMAT = Pattern.compile(
"(\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{6})" + SEPARATOR + "(\\d{1,19})");

public String encode() {
String raw = DELETED_AT_FORMAT.format(deletedAt) + SEPARATOR + id;
return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
}

/** 서버가 만든 형식이 아니면 비어 있다. 어떤 에러로 응답할지는 서비스가 정한다 */
public static Optional<TrashCursor> decode(String cursor) {
try {
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
Matcher matcher = FORMAT.matcher(raw);
if (!matcher.matches()) {
return Optional.empty();
}
return Optional.of(new TrashCursor(LocalDateTime.parse(matcher.group(1)), Long.valueOf(matcher.group(2))));
} catch (IllegalArgumentException | DateTimeParseException exception) {
return Optional.empty();
}
}

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

import java.util.List;

import org.springframework.data.domain.Limit;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.sniptext.domain.template.exception.TrashErrorCode;
import com.sniptext.domain.template.repository.TemplateRepository;
import com.sniptext.domain.template.repository.dto.TrashTemplateRow;
import com.sniptext.domain.template.service.dto.query.TrashListQuery;
import com.sniptext.domain.template.service.dto.result.TrashListResult;
import com.sniptext.domain.template.service.dto.result.TrashTemplateResult;
import com.sniptext.global.exception.BusinessException;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TrashQueryService {

private final TemplateRepository templateRepository;

/** 요청한 개수보다 1개 더 읽어 다음 페이지가 있는지 판단한다 */
public TrashListResult getTrash(TrashListQuery query) {
List<TrashTemplateRow> rows = findPageWithOneMore(query);
boolean hasNext = rows.size() > query.size();
List<TrashTemplateRow> page = hasNext ? rows.subList(0, query.size()) : rows;

return TrashListResult.of(
templateRepository.countByUserIdAndDeletedAtIsNotNull(query.userId()),
page.stream().map(TrashTemplateResult::from).toList(),
hasNext ? nextCursor(page.getLast()) : null
);
}

private List<TrashTemplateRow> findPageWithOneMore(TrashListQuery query) {
Limit limit = Limit.of(query.size() + 1);
if (query.cursor() == null || query.cursor().isEmpty()) {
return templateRepository.findTrashFirstPage(query.userId(), limit);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

휴지통 목록은 본문을 내보내지 않는데 엔티티를 통째로 읽어서, 최대 51개 행의 본문을 매번 복호화하고 있습니다. 비용도 있지만, 복호화가 실패하는 행이 하나라도 생기면 본문을 보여주지도 않는 휴지통 목록 전체가 500이 될 거 같아요! 필요한 컬럼만 고르는 조회(repository/dto의 행 객체)로 바꾸는 걸 고려하면 어떨까 싶습니다!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 맞네여 휴지통 목록은 본문을 안보여주니까, 말씀 주신 부분 맞습니다 감사합니당 😄

제안 해 주신 repository/dto로 가면 좋을것 같은 생각이여서 해당 부분 반영 하겠습니다 !

}
TrashCursor cursor = TrashCursor.decode(query.cursor())
.orElseThrow(() -> new BusinessException(TrashErrorCode.INVALID_PAGE_REQUEST));
return templateRepository.findTrashAfter(query.userId(), cursor.deletedAt(), cursor.id(), limit);
}

private static String nextCursor(TrashTemplateRow last) {
return new TrashCursor(last.deletedAt(), last.id()).encode();
}

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

public record TrashListQuery(Long userId, String cursor, int size) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.sniptext.domain.template.service.dto.result;

import java.util.List;

public record TrashListResult(long totalCount, List<TrashTemplateResult> templates, boolean hasNext,
String nextCursor) {

public static TrashListResult of(long totalCount, List<TrashTemplateResult> templates, String nextCursor) {
return new TrashListResult(totalCount, List.copyOf(templates), nextCursor != null, nextCursor);
}

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

import java.time.LocalDateTime;

import com.sniptext.domain.template.repository.dto.TrashTemplateRow;
import com.sniptext.domain.template.support.TrashRetention;

/** 휴지통 항목. 본문은 화면에 보이지 않아 내보내지 않는다 */
public record TrashTemplateResult(
Long templateId,
String title,
String shortcut,
LocalDateTime deletedAt,
LocalDateTime purgeAt
) {

public static TrashTemplateResult from(TrashTemplateRow row) {
return new TrashTemplateResult(
row.id(),
row.title(),
row.shortcut(),
row.deletedAt(),
TrashRetention.purgeAt(row.deletedAt())
);
}

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

import java.time.LocalDateTime;

/**
* 휴지통 보관 규칙. 삭제하고 30일이 지나면 자동 영구 삭제 대상이 된다.
* 목록의 예정 시각과 휴지통 정리 배치가 같은 기간을 쓰도록 여기 한 곳에 둔다.
*/
public final class TrashRetention {

private static final int RETENTION_DAYS = 30;

private TrashRetention() {
}

public static LocalDateTime purgeAt(LocalDateTime deletedAt) {
return deletedAt.plusDays(RETENTION_DAYS);
}

}
Loading
Loading