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
Expand Up @@ -26,6 +26,33 @@ include::{snippets}/admin/alcohols/search/http-response.adoc[]

'''

=== 술(Alcohol) lookup 조회 ===

- 관리자 선택 컴포넌트에서 사용할 핵심 필드 기반의 고속 조회 API입니다.
- 별점, 좋아요, 리뷰 수, 사용자 pick 여부 같은 집계성 데이터는 포함하지 않습니다.

[source]
----
GET /admin/api/v1/alcohols/lookup
----

[discrete]
==== 요청 파라미터 ====

[discrete]
include::{snippets}/admin/alcohols/lookup/query-parameters.adoc[]
include::{snippets}/admin/alcohols/lookup/curl-request.adoc[]
include::{snippets}/admin/alcohols/lookup/http-request.adoc[]

[discrete]
==== 응답 파라미터 ====

[discrete]
include::{snippets}/admin/alcohols/lookup/response-fields.adoc[]
include::{snippets}/admin/alcohols/lookup/http-response.adoc[]

'''

=== 술(Alcohol) 단건 상세 조회 ===

- 관리자용 술 단건 상세 조회 API입니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package app.bottlenote.alcohols.presentation

import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest
import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest
import app.bottlenote.alcohols.dto.request.AlcoholLookupRequest
import app.bottlenote.alcohols.service.AdminAlcoholCommandService
import app.bottlenote.alcohols.service.AlcoholLookupService
import app.bottlenote.alcohols.service.AlcoholQueryService
import app.bottlenote.global.data.response.GlobalResponse
import app.bottlenote.global.service.meta.MetaService.createMetaInfo
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*
Expand All @@ -13,8 +16,22 @@ import org.springframework.web.bind.annotation.*
@RequestMapping("/alcohols")
class AdminAlcoholsController(
private val alcoholQueryService: AlcoholQueryService,
private val adminAlcoholCommandService: AdminAlcoholCommandService
private val adminAlcoholCommandService: AdminAlcoholCommandService,
private val alcoholLookupService: AlcoholLookupService
) {
@GetMapping("/lookup")
fun getAlcoholLookups(
@ModelAttribute @Valid request: AlcoholLookupRequest
): ResponseEntity<*> {
val response = alcoholLookupService.lookup(request)
return GlobalResponse.ok(
response.items(),
createMetaInfo()
.add("searchParameters", request)
.add("pageable", response.pageable())
)
}

@GetMapping
fun searchAlcohols(
@ModelAttribute request: AdminAlcoholSearchRequest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import app.bottlenote.alcohols.constant.AdminAlcoholSortType
import app.bottlenote.alcohols.constant.AlcoholCategoryGroup
import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest
import app.bottlenote.alcohols.dto.request.AdminAlcoholUpsertRequest
import app.bottlenote.alcohols.dto.request.AlcoholLookupRequest
import app.bottlenote.alcohols.dto.response.AlcoholLookupItem
import app.bottlenote.alcohols.dto.response.CategoryPairItem
import app.bottlenote.alcohols.presentation.AdminAlcoholsController
import app.bottlenote.alcohols.service.AdminAlcoholCommandService
import app.bottlenote.alcohols.service.AlcoholLookupService
import app.bottlenote.alcohols.service.AlcoholQueryService
import app.bottlenote.global.dto.response.AdminResultResponse
import app.bottlenote.global.service.cursor.CursorResponse
import app.bottlenote.global.service.cursor.SortOrder
import app.helper.alcohols.AlcoholsHelper
import com.fasterxml.jackson.databind.ObjectMapper
Expand Down Expand Up @@ -53,6 +57,95 @@ class AdminAlcoholsControllerDocsTest {
@MockitoBean
private lateinit var adminAlcoholCommandService: AdminAlcoholCommandService

@MockitoBean
private lateinit var alcoholLookupService: AlcoholLookupService

@Test
@DisplayName("관리자용 술 lookup 목록을 조회할 수 있다")
fun lookupAlcohols() {
// given
val item = AlcoholLookupItem(
1L,
"맥캘란 12년",
"Macallan 12",
"싱글몰트",
"Single Malt",
AlcoholCategoryGroup.SINGLE_MALT,
1L,
"스페이사이드",
"Speyside",
10L,
"맥캘란",
"Macallan",
"https://example.com/alcohol.png"
)
given(alcoholLookupService.lookup(any(AlcoholLookupRequest::class.java)))
.willReturn(CursorResponse.of(listOf(item), 0L, 20))

// when & then
assertThat(
mvc.get().uri("/v1/alcohols/lookup")
.param("keyword", "macallan")
.param("category", AlcoholCategoryGroup.SINGLE_MALT.name)
.param("regionId", "1")
.param("distilleryId", "10")
.param("cursor", "0")
.param("pageSize", "20")
)
.hasStatusOk()
.apply(
document(
"admin/alcohols/lookup",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
queryParameters(
parameterWithName("keyword").optional().description("검색어 (이름, 카테고리, 지역, 증류소 대상)"),
parameterWithName("category").optional().description("카테고리 그룹 필터. ALL 또는 미전달 시 전체"),
parameterWithName("regionId").optional().description("지역 ID 필터"),
parameterWithName("distilleryId").optional().description("증류소 ID 필터"),
parameterWithName("cursor").optional().description("커서 위치 (기본값: 0)"),
parameterWithName("pageSize").optional().description("페이지 크기 (기본값: 20, 최대: 100)")
),
responseFields(
fieldWithPath("success").type(JsonFieldType.BOOLEAN).description("응답 성공 여부"),
fieldWithPath("code").type(JsonFieldType.NUMBER).description("응답 코드"),
fieldWithPath("data").type(JsonFieldType.ARRAY).description("술 lookup 목록"),
fieldWithPath("data[].alcoholId").type(JsonFieldType.NUMBER).description("술 ID"),
fieldWithPath("data[].korName").type(JsonFieldType.STRING).description("술 한글 이름"),
fieldWithPath("data[].engName").type(JsonFieldType.STRING).description("술 영문 이름"),
fieldWithPath("data[].korCategoryName").type(JsonFieldType.STRING).description("카테고리 한글명"),
fieldWithPath("data[].engCategoryName").type(JsonFieldType.STRING).description("카테고리 영문명"),
fieldWithPath("data[].categoryGroup").type(JsonFieldType.STRING).description("카테고리 그룹"),
fieldWithPath("data[].regionId").type(JsonFieldType.NUMBER).description("지역 ID"),
fieldWithPath("data[].korRegion").type(JsonFieldType.STRING).description("지역 한글명"),
fieldWithPath("data[].engRegion").type(JsonFieldType.STRING).description("지역 영문명"),
fieldWithPath("data[].distilleryId").type(JsonFieldType.NUMBER).description("증류소 ID"),
fieldWithPath("data[].korDistillery").type(JsonFieldType.STRING).description("증류소 한글명"),
fieldWithPath("data[].engDistillery").type(JsonFieldType.STRING).description("증류소 영문명"),
fieldWithPath("data[].imageUrl").type(JsonFieldType.STRING).description("술 이미지 URL"),
fieldWithPath("errors").type(JsonFieldType.ARRAY).description("에러 목록"),
fieldWithPath("meta").type(JsonFieldType.OBJECT).description("메타 정보"),
fieldWithPath("meta.pageable").type(JsonFieldType.OBJECT).description("페이징 정보"),
fieldWithPath("meta.pageable.currentCursor").type(JsonFieldType.NUMBER).description("조회 시 기준 커서"),
fieldWithPath("meta.pageable.cursor").type(JsonFieldType.NUMBER).description("다음 페이지 커서"),
fieldWithPath("meta.pageable.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"),
fieldWithPath("meta.pageable.hasNext").type(JsonFieldType.BOOLEAN).description("다음 페이지 존재 여부"),
fieldWithPath("meta.searchParameters").type(JsonFieldType.OBJECT).description("검색 조건"),
fieldWithPath("meta.searchParameters.keyword").type(JsonFieldType.STRING).description("검색어"),
fieldWithPath("meta.searchParameters.category").type(JsonFieldType.STRING).description("카테고리 그룹 필터"),
fieldWithPath("meta.searchParameters.regionId").type(JsonFieldType.NUMBER).description("지역 ID 필터"),
fieldWithPath("meta.searchParameters.distilleryId").type(JsonFieldType.NUMBER).description("증류소 ID 필터"),
fieldWithPath("meta.searchParameters.cursor").type(JsonFieldType.NUMBER).description("커서 위치"),
fieldWithPath("meta.searchParameters.pageSize").type(JsonFieldType.NUMBER).description("페이지 크기"),
fieldWithPath("meta.serverVersion").type(JsonFieldType.STRING).description("서버 버전").ignored(),
fieldWithPath("meta.serverEncoding").type(JsonFieldType.STRING).description("서버 인코딩").ignored(),
fieldWithPath("meta.serverResponseTime").type(JsonFieldType.STRING).description("서버 응답 시간").ignored(),
fieldWithPath("meta.serverPathVersion").type(JsonFieldType.STRING).description("API 경로 버전").ignored()
)
)
)
}

@Test
@DisplayName("관리자용 술 목록을 조회할 수 있다")
fun searchAdminAlcohols() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package app.bottlenote.alcohols.domain;

import app.bottlenote.alcohols.dto.response.AlcoholLookupItem;
import java.util.List;

public interface AlcoholLookupSnapshotStore {

List<AlcoholLookupItem> findAll();

void replaceAll(List<AlcoholLookupItem> items);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest;
import app.bottlenote.alcohols.dto.response.AdminAlcoholItem;
import app.bottlenote.alcohols.dto.response.AlcoholDetailItem;
import app.bottlenote.alcohols.dto.response.AlcoholLookupItem;
import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse;
import app.bottlenote.alcohols.dto.response.CategoryItem;
import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem;
Expand Down Expand Up @@ -38,6 +39,8 @@ public interface AlcoholQueryRepository {

List<CategoryItem> findAllCategoryItems();

List<AlcoholLookupItem> findAllLookupItems();

Boolean existsByAlcoholId(Long alcoholId);

Boolean existsByDistilleryId(Long distilleryId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package app.bottlenote.alcohols.dto.request;

import app.bottlenote.alcohols.constant.AlcoholCategoryGroup;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import lombok.Builder;

public record AlcoholLookupRequest(
String keyword,
String category,
Long regionId,
Long distilleryId,
@Min(0) Long cursor,
@Min(1) @Max(100) Long pageSize) {
@Builder
public AlcoholLookupRequest {
cursor = cursor != null ? cursor : 0L;
pageSize = pageSize != null ? pageSize : 20L;
}

public AlcoholCategoryGroup categoryGroup() {
if (category == null || category.isBlank() || "ALL".equalsIgnoreCase(category)) {
return null;
}
return AlcoholCategoryGroup.fromCategory(category);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package app.bottlenote.alcohols.dto.response;

import app.bottlenote.alcohols.constant.AlcoholCategoryGroup;
import java.util.Locale;
import java.util.stream.Stream;

public record AlcoholLookupItem(
Long alcoholId,
String korName,
String engName,
String korCategoryName,
String engCategoryName,
AlcoholCategoryGroup categoryGroup,
Long regionId,
String korRegion,
String engRegion,
Long distilleryId,
String korDistillery,
String engDistillery,
String imageUrl) {

public String searchText() {
return Stream.of(
korName,
engName,
korCategoryName,
engCategoryName,
categoryGroup != null ? categoryGroup.name() : null,
korRegion,
engRegion,
korDistillery,
engDistillery)
.filter(value -> value != null && !value.isBlank())
.map(value -> value.toLowerCase(Locale.ROOT))
.reduce("", (left, right) -> left + " " + right);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest;
import app.bottlenote.alcohols.dto.response.AdminAlcoholItem;
import app.bottlenote.alcohols.dto.response.AlcoholDetailItem;
import app.bottlenote.alcohols.dto.response.AlcoholLookupItem;
import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse;
import app.bottlenote.alcohols.dto.response.CategoryItem;
import app.bottlenote.alcohols.facade.payload.AlcoholSummaryItem;
Expand All @@ -19,6 +20,8 @@ public interface CustomAlcoholQueryRepository {

List<CategoryItem> findAllCategoryItems();

List<AlcoholLookupItem> findAllLookupItems();

PageResponse<AlcoholSearchResponse> searchAlcohols(AlcoholSearchCriteria criteriaDto);

AlcoholDetailItem findAlcoholDetailById(Long alcoholId, Long userId);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.bottlenote.alcohols.repository;

import static app.bottlenote.alcohols.constant.AlcoholType.WHISKY;
import static app.bottlenote.alcohols.domain.QAlcohol.alcohol;
import static app.bottlenote.alcohols.domain.QDistillery.distillery;
import static app.bottlenote.alcohols.domain.QRegion.region;
Expand All @@ -14,6 +15,7 @@
import app.bottlenote.alcohols.dto.request.AdminAlcoholSearchRequest;
import app.bottlenote.alcohols.dto.response.AdminAlcoholItem;
import app.bottlenote.alcohols.dto.response.AlcoholDetailItem;
import app.bottlenote.alcohols.dto.response.AlcoholLookupItem;
import app.bottlenote.alcohols.dto.response.AlcoholSearchResponse;
import app.bottlenote.alcohols.dto.response.AlcoholsSearchItem;
import app.bottlenote.alcohols.dto.response.CategoryItem;
Expand Down Expand Up @@ -56,6 +58,35 @@ public List<CategoryItem> findAllCategoryItems() {
.fetch();
}

@Override
public List<AlcoholLookupItem> findAllLookupItems() {
return queryFactory
.select(
Projections.constructor(
AlcoholLookupItem.class,
alcohol.id,
alcohol.korName,
alcohol.engName,
alcohol.korCategory,
alcohol.engCategory,
alcohol.categoryGroup,
region.id,
region.korName,
region.engName,
distillery.id,
distillery.korName,
distillery.engName,
alcohol.imageUrl))
.from(alcohol)
.leftJoin(region)
.on(alcohol.region.id.eq(region.id))
.leftJoin(distillery)
.on(alcohol.distillery.id.eq(distillery.id))
.where(alcohol.type.eq(WHISKY), alcohol.deletedAt.isNull())
.orderBy(alcohol.id.asc())
.fetch();
}

/** queryDSL 알코올 검색 */
@Override
public PageResponse<AlcoholSearchResponse> searchAlcohols(AlcoholSearchCriteria criteriaDto) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package app.bottlenote.alcohols.repository;

import app.bottlenote.alcohols.domain.AlcoholLookupSnapshotStore;
import app.bottlenote.alcohols.dto.response.AlcoholLookupItem;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

@Slf4j
@Repository
@RequiredArgsConstructor
public class RedisAlcoholLookupSnapshotStore implements AlcoholLookupSnapshotStore {
private static final String LOOKUP_SNAPSHOT_KEY = "alcohol:lookup:snapshot:v1";
private static final TypeReference<List<AlcoholLookupItem>> LOOKUP_ITEMS_TYPE =
new TypeReference<>() {};

private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;

@Override
public List<AlcoholLookupItem> findAll() {
Object value = redisTemplate.opsForValue().get(LOOKUP_SNAPSHOT_KEY);
if (value == null) {
return List.of();
}

try {
return objectMapper.readValue(value.toString(), LOOKUP_ITEMS_TYPE);
} catch (JsonProcessingException e) {
log.warn("Alcohol lookup snapshot 역직렬화 실패. Redis snapshot을 비어 있는 것으로 처리합니다.", e);
return List.of();
}
}

@Override
public void replaceAll(List<AlcoholLookupItem> items) {
try {
redisTemplate.opsForValue().set(LOOKUP_SNAPSHOT_KEY, objectMapper.writeValueAsString(items));
} catch (JsonProcessingException e) {
throw new IllegalStateException("Alcohol lookup snapshot 직렬화에 실패했습니다.", e);
}
}
}
Loading
Loading