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
Expand Up @@ -2,12 +2,12 @@

import com.heddy.adapter.in.web.style.dto.SaveStyleRequest;
import com.heddy.adapter.in.web.style.dto.SavedStyleResponse;
import com.heddy.adapter.in.web.style.dto.SavedStylesResponse;
import com.heddy.adapter.in.web.style.dto.UpdateSavedStyleRequest;
import com.heddy.domain.style.port.in.SavedStyleUseCase;
import com.heddy.global.docs.ApiDocs;
import com.heddy.global.filter.RequestIdFilter;
import com.heddy.global.response.ApiResponse;
import com.heddy.global.response.PageResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
Expand All @@ -26,13 +26,12 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.UUID;

@RestController
@RequiredArgsConstructor
@RequestMapping("/saved-styles")
@RequestMapping("/me/saved-styles")
@Tag(name = "저장한 후보 스타일", description = "AR 로 시연한 스타일을 보관하고 다시 꺼내 쓴다")
@SecurityRequirement(name = "bearerAuth")
public class SavedStyleController {
Expand All @@ -43,19 +42,15 @@ public class SavedStyleController {
@ApiDocs.Ok
@ApiDocs.Authenticated
@Operation(summary = "저장한 후보 스타일 목록",
description = "최신 저장순으로 돌려줍니다. 이미지 URL 은 저장값이 아니라 조회 시점에 "
+ "짧은 만료의 Presigned GET 으로 발급합니다.")
public ApiResponse<PageResponse<SavedStyleResponse>> list(
description = "최신 저장순으로 전부 돌려줍니다. 보관함은 최대 20개라 페이지를 "
+ "나누지 않습니다. 이미지 URL 은 저장값이 아니라 조회 시점에 짧은 만료의 "
+ "Presigned GET 으로 발급합니다.")
public ApiResponse<SavedStylesResponse> list(
@AuthenticationPrincipal UUID userId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
HttpServletRequest servletRequest
) {
SavedStyleUseCase.Page result = savedStyleUseCase.list(userId, page, size);
return ApiResponse.success(
PageResponse.of(
result.items().stream().map(SavedStyleResponse::from).toList(),
page, size, result.totalElements()),
SavedStylesResponse.from(savedStyleUseCase.list(userId)),
RequestIdFilter.get(servletRequest));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
public class SavedStyleService implements SavedStyleUseCase {

private static final int MAX_SAVED_STYLES = 20;
private static final int MAX_PAGE_SIZE = 100;

private final SavedStyleRepositoryPort savedStyleRepositoryPort;
private final HairColorRepositoryPort hairColorRepositoryPort;
Expand All @@ -49,24 +48,21 @@ public class SavedStyleService implements SavedStyleUseCase {
private final FileStoragePort fileStoragePort;
private final ShareRepositoryPort shareRepositoryPort;

/**
* 보관함은 {@link #MAX_SAVED_STYLES} 개가 상한이라 한 번에 다 내려도 부담이 없다.
* 페이지를 나누면 화면이 얻는 것 없이 계약만 복잡해져 목록을 통째로 돌려준다.
*/
@Override
public Page list(UUID requesterId, int page, int size) {
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new ApplicationException(ErrorCode.INVALID_REQUEST);
}
List<SavedStyle> all = savedStyleRepositoryPort.findAllByUserId(requesterId);
int fromIndex = (int) Math.min((long) page * size, all.size());
int toIndex = Math.min(fromIndex + size, all.size());
List<SavedStyle> savedStyles = all.subList(fromIndex, toIndex);
public List<Item> list(UUID requesterId) {
List<SavedStyle> savedStyles = savedStyleRepositoryPort.findAllByUserId(requesterId);
Map<UUID, HairColor> colors = colorsOf(savedStyles);
Map<UUID, UUID> thumbnails = thumbnailFileIdsOf(savedStyles);
List<Item> items = savedStyles.stream()
return savedStyles.stream()
.map(style -> new Item(
style,
style.colorId() == null ? null : colors.get(style.colorId()),
imageUrl(style, thumbnails)))
.toList();
return new Page(items, all.size());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
/** 저장한 후보 스타일 보관함. AR로 시연한 스타일을 담아 두고 다시 꺼내 쓰는 자리다. */
public interface SavedStyleUseCase {

Page list(UUID requesterId, int page, int size);
List<Item> list(UUID requesterId);

Item save(SaveCommand command);

Expand All @@ -21,9 +21,6 @@ public interface SavedStyleUseCase {
record Item(SavedStyle savedStyle, HairColor color, URI imageUrl) {
}

record Page(List<Item> items, long totalElements) {
}

record SaveCommand(
UUID requesterId,
UUID hairstyleId,
Expand Down
1 change: 1 addition & 0 deletions src/main/java/com/heddy/global/error/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public enum ErrorCode {
AUTHENTICATION_REQUIRED(HttpStatus.UNAUTHORIZED, "AUTHENTICATION_REQUIRED", "인증이 필요합니다."),
FORBIDDEN_RESOURCE(HttpStatus.FORBIDDEN, "FORBIDDEN_RESOURCE", "접근 권한이 없습니다."),
RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND", "리소스를 찾을 수 없습니다."),
METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "METHOD_NOT_ALLOWED", "지원하지 않는 요청 메서드입니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_SERVER_ERROR", "서버 오류가 발생했습니다."),
FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "FILE_TOO_LARGE", "허용된 파일 크기를 초과했습니다."),
FILE_CONTENT_TYPE_NOT_ALLOWED(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "FILE_CONTENT_TYPE_NOT_ALLOWED", "허용되지 않는 파일 형식입니다."),
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/com/heddy/global/error/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

import java.util.List;

Expand Down Expand Up @@ -69,6 +72,36 @@ ResponseEntity<ApiErrorResponse> handleInvalidRequest(Exception exception, HttpS
errorCode.code(), errorCode.message(), RequestIdFilter.get(request)));
}

/**
* 매칭되는 핸들러가 없는 요청. 아래 {@code Exception} 핸들러가 함께 삼키면 경로 오타가
* 500 으로 보고돼 클라이언트는 재시도할 상황인지 요청을 고칠 상황인지 구분할 수 없고,
* 오류 로그도 이 소음에 묻힌다. 서버가 아니라 요청의 문제이므로 로그를 남기지 않는다.
*
* <p>정적 리소스 핸들러가 있으면 {@code NoResourceFoundException} 이,
* {@code throw-exception-if-no-handler-found} 로 동작하면
* {@code NoHandlerFoundException} 이 올라온다. 설정에 기대지 않도록 둘 다 받는다.
*/
@ExceptionHandler({NoResourceFoundException.class, NoHandlerFoundException.class})
ResponseEntity<ApiErrorResponse> handleNotFound(Exception exception, HttpServletRequest request) {
ErrorCode errorCode = ErrorCode.RESOURCE_NOT_FOUND;
return ResponseEntity.status(errorCode.status())
.body(ApiErrorResponse.of(
errorCode.code(), errorCode.message(), RequestIdFilter.get(request)));
}

/** 경로는 있으나 메서드가 없는 경우. 스프링이 계산해 둔 Allow 헤더를 그대로 실어 보낸다. */
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
ResponseEntity<ApiErrorResponse> handleMethodNotAllowed(
HttpRequestMethodNotSupportedException exception,
HttpServletRequest request
) {
ErrorCode errorCode = ErrorCode.METHOD_NOT_ALLOWED;
return ResponseEntity.status(errorCode.status())
.headers(exception.getHeaders())
.body(ApiErrorResponse.of(
errorCode.code(), errorCode.message(), RequestIdFilter.get(request)));
}

@ExceptionHandler(Exception.class)
ResponseEntity<ApiErrorResponse> handleUnexpected(Exception exception, HttpServletRequest request) {
log.error("Unexpected error", exception);
Expand Down
Loading
Loading