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
50 changes: 44 additions & 6 deletions docs/specs/api-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
| 7 | `GET` | `/categories` | 필터칩 | 카테고리 목록 |
| 8 | `GET` | `/pois/{id}` | 장소 상세 | 장소 정보 |
| 9 | `POST/GET` | `/courses` | 내 코스 | 저장·조회 |
| 10 | `GET` | `/regions` | 지역 목록(더보기) | 89곳 페이지 조회·카테고리 필터 |

---

Expand Down Expand Up @@ -115,9 +116,9 @@
{
"user": { "name": "게스트", "remainingLeaveDays": 13 },
"filters": [
{ "key": "ALL", "label": "전체" }, { "key": "SIGHT", "label": "관광지" },
{ "key": "STAY", "label": "숙박" }, { "key": "EXPERIENCE", "label": "체험" },
{ "key": "FOOD", "label": "맛집" }
{ "key": "ALL", "label": "전체", "regionCount": 89 }, { "key": "SIGHT", "label": "관광지", "regionCount": 61 },
{ "key": "STAY", "label": "숙박", "regionCount": 34 }, { "key": "EXPERIENCE", "label": "체험", "regionCount": 12 },
{ "key": "FOOD", "label": "맛집", "regionCount": 47 }
Comment thread
sevineleven marked this conversation as resolved.
],
"recommendedRegions": [
{
Expand Down Expand Up @@ -237,13 +238,16 @@

> 🎯 필터칩 목록 · **기능 F6** · 서버 내부에서 `SIGHT`→lclsSystm(NA+HS+VE+LS+EV) 등 매핑

- `regionCount` 는 **적재된 지역 콘텐츠로 그때그때 세는 값**이라 아래 숫자는 예시다. `ALL`(89)만 고시로 고정이고 나머지는 적재 상태에 따라 달라진다.
- `regionCount` = **그 칩으로 좁혔을 때 나오는 지역 수**(#266). `GET /regions?category={key}` 의 `pageResponse.totalElements` 와 같은 값이고, `ALL` 은 전체 지역 수다. 화면이 개수를 지어내거나("전부 1건") 빈 칩을 그리지 않게 하려는 것.

**응답 `data`**

```json
{ "categories": [
{ "key": "ALL", "label": "전체" }, { "key": "SIGHT", "label": "관광지" },
{ "key": "STAY", "label": "숙박" }, { "key": "EXPERIENCE", "label": "체험" },
{ "key": "FOOD", "label": "맛집" }
{ "key": "ALL", "label": "전체", "regionCount": 89 }, { "key": "SIGHT", "label": "관광지", "regionCount": 61 },
{ "key": "STAY", "label": "숙박", "regionCount": 34 }, { "key": "EXPERIENCE", "label": "체험", "regionCount": 12 },
{ "key": "FOOD", "label": "맛집", "regionCount": 47 }
] }
```

Expand Down Expand Up @@ -282,12 +286,46 @@

---

### 🔟 지역 목록(더보기) · `GET /api/v1/regions`

> 🎯 "이번달 추천 여행지 더보기" · **기능 F3·F6** · **데이터** region89 · 관광빅데이터 · 지역 콘텐츠 · 관광사진 · **구현** #266

**쿼리** `?category=SIGHT&page=0&size=20`

- 홈은 랭킹 상위 **6곳**만 준다. 이 엔드포인트가 89곳 전부를 페이지로 끊어 준다.
- 정렬은 **방문자 랭킹 내림차순 하나뿐**이라 `sort` 파라미터가 없다. 도달시간 순은 출발지 좌표가 있어야 정의되고, 그건 `POST /regions/recommendations` 가 소유한다.
- `page`(기본 0) · `size`(기본 20, 최대 100). **잘못된 값은 거절하지 않고 자른다** — 음수 page 는 0, 상한 초과 size 는 100.
- 페이지 메타는 `data` 가 아니라 **공통 래퍼의 `pageResponse`** 에 실린다.
- **외부 API 호출이 없다.** 재료가 전부 적재된 값이라 관광 API 한도가 소진돼도 목록은 나간다.

**응답**

```json
{
"status": 200, "code": "OK", "detail": "요청이 정상 처리되었습니다.",
"data": { "regions": [
{
"regionId": 51, "name": "정선군 · 강원특별자치도",
"crowdLevel": "LOW",
"imageUrl": "http://tong.visitkorea.or.kr/cms/resource/83/1234583_image2_1.jpg",
"contentCount": 128,
"categories": [ { "key": "SIGHT", "label": "관광지" } ],
"neighborIncluded": false
}
] },
"pageResponse": { "page": 0, "size": 20, "totalElements": 89, "totalPages": 5 }
}
```

---

## 🖥️ 화면 ↔ API 매핑

| 화면 | 엔드포인트 | 기능 |
| --- | --- | --- |
| 연차 입력 | `POST /leave/available-time` | F1 |
| 홈 | `GET /home` · `GET /categories` | F3·F6 |
| 홈 → 추천 여행지 더보기 | `GET /regions` · `GET /categories` | F3·F6 |
| 샌드위치 | `GET /leave/sandwich` | F2 |
| 추천 플로우 → 후보지역 | `POST /regions/recommend` | F3 |
| 코스 확정 | `POST /courses/generate` | F4·F5 |
Expand Down
13 changes: 11 additions & 2 deletions src/main/java/com/offway/core/trip/controller/CategoryApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,16 @@
@Tag(name = "카테고리", description = "여행지 필터칩(무드/유형)")
public interface CategoryApi {

@Operation(summary = "필터칩 카테고리 목록", description = "결과 필터/재정렬에 쓰는 카테고리 칩을 노출 순서대로 반환한다.")
@ApiResponse(responseCode = "200", description = "조회 성공")
@Operation(
summary = "필터칩 카테고리 목록",
description = """
결과 필터/재정렬에 쓰는 카테고리 칩을 노출 순서대로 반환한다.

칩마다 **그 칩으로 좁혔을 때 나오는 인구감소지역 수**(`regionCount`)를 함께 준다 —
`GET /api/v1/regions?category={key}` 의 `pageResponse.totalElements` 와 같은 값이다.
`ALL` 은 전체 지역 수다. 개수는 적재된 지역 콘텐츠에서 세며 **외부 API 를 부르지 않는다**.
""")
@ApiResponse(responseCode = "200", description = "조회 성공 (콘텐츠 적재 전이면 ALL 을 제외한 개수가 0)")
@ApiResponse(responseCode = "401", description = "인증 필요")
ApiResponseBody<CategoryResponse> categories();
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,22 @@

import com.offway.core.common.response.ApiResponseBody;
import com.offway.core.trip.controller.dto.CategoryResponse;
import com.offway.core.trip.service.RegionCategoryCountProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/categories")
@RequiredArgsConstructor
public class CategoryController implements CategoryApi {

private final RegionCategoryCountProvider regionCategoryCountProvider;

@Override
@GetMapping
public ApiResponseBody<CategoryResponse> categories() {
return ApiResponseBody.ok(CategoryResponse.of());
return ApiResponseBody.ok(CategoryResponse.of(regionCategoryCountProvider.counts()));
}
}
42 changes: 42 additions & 0 deletions src/main/java/com/offway/core/trip/controller/RegionListApi.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.offway.core.trip.controller;

import com.offway.core.common.response.ApiResponseBody;
import com.offway.core.trip.controller.dto.RegionListResponse;
import com.offway.core.trip.domain.Category;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;

/** 지역 목록 API 문서 계약(#266). 매핑은 구현체({@link RegionListController})가 소유한다. */
@Tag(name = "지역 목록", description = "인구감소지역 89곳 페이지 조회")
public interface RegionListApi {

@Operation(
summary = "인구감소지역 목록",
description = """
인구감소지역 89곳을 **방문자 랭킹 내림차순**으로 페이지에 담아 준다. 홈이 주는 상위 6곳
너머를 보는 "더보기" 화면이 쓴다. 카드 재료(한산도·볼거리 수·대표 이미지·카테고리)는
홈 카드와 같다.

**외부 API 를 부르지 않는다.** 방문자 집계·지역 콘텐츠·관광사진이 모두 적재된 값이라,
관광 API 한도가 소진되거나 포털이 점검 중이어도 목록은 그대로 나간다. 아직 콘텐츠가
적재되지 않은 지역은 목록에서 빠지지 않고 볼거리 0·이미지 없음으로 나간다.

**정렬 파라미터는 없다.** 정렬이 하나뿐이기 때문이다. 도달시간 순은 출발지 좌표가 있어야
정의되는데 이 엔드포인트는 그것을 받지 않는다 — 그쪽은 `POST /api/v1/regions/recommendations`
가 소유한다.

페이지 정보(`page`·`size`·`totalElements`·`totalPages`)는 응답 본문이 아니라 공통 래퍼의
`pageResponse` 에 실린다.
""")
@ApiResponse(responseCode = "200", description = "조회 성공 (해당 카테고리에 지역이 없으면 빈 목록)")
@ApiResponse(responseCode = "400", description = "category 가 정의되지 않은 값 (ALL·SIGHT·STAY·EXPERIENCE·FOOD 외)")
@ApiResponse(responseCode = "401", description = "인증 필요")
ApiResponseBody<RegionListResponse> regions(
@Parameter(description = "필터칩으로 좁히기. 생략하거나 ALL 이면 전체. 칩별 지역 수는 GET /api/v1/categories 가 준다")
Category category,
@Parameter(description = "0부터 시작하는 페이지 번호. 기본 0. 음수는 0 으로 자른다") Integer page,
@Parameter(description = "페이지 크기. 기본 20, 최대 100. 범위를 벗어나면 잘라 준다(거절하지 않는다)")
Integer size);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.offway.core.trip.controller;

import com.offway.core.common.response.ApiResponseBody;
import com.offway.core.common.response.PageResponse;
import com.offway.core.trip.controller.dto.RegionListResponse;
import com.offway.core.trip.domain.Category;
import com.offway.core.trip.service.RegionListService;
import com.offway.core.trip.service.dto.RegionList;
import lombok.RequiredArgsConstructor;
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;

@RestController
@RequestMapping("/api/v1/regions")
@RequiredArgsConstructor
public class RegionListController implements RegionListApi {

private final RegionListService regionListService;

@Override
@GetMapping
public ApiResponseBody<RegionListResponse> regions(
@RequestParam(required = false) Category category,
@RequestParam(required = false) Integer page,
@RequestParam(required = false) Integer size) {
RegionList regions = regionListService.list(category, page, size);
return ApiResponseBody.ok(RegionListResponse.from(regions), PageResponse.of(regions));
}
}
Original file line number Diff line number Diff line change
@@ -1,32 +1,39 @@
package com.offway.core.trip.controller.dto;

import com.offway.core.trip.domain.Category;
import com.offway.core.trip.domain.CategoryCounts;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Arrays;
import java.util.List;

/**
* 필터칩 카테고리 목록 응답 — API 계약.
*
* <p>칩마다 <b>그 칩으로 좁혔을 때 나오는 지역 수</b>를 함께 낸다(#266). 없으면 화면이 개수를 지어내거나("전부 1건") 빈 칩을 그대로
* 그린다.
*
* @param categories 노출 순서대로의 카테고리 칩
*/
public record CategoryResponse(List<Item> categories) {

/** 도메인 {@link Category} 전부를 선언 순서대로 노출한다(ALL 이 맨 앞). */
public static CategoryResponse of() {
return new CategoryResponse(Arrays.stream(Category.values()).map(Item::from).toList());
public static CategoryResponse of(CategoryCounts counts) {
return new CategoryResponse(
Arrays.stream(Category.values()).map(category -> Item.from(category, counts)).toList());
}

/**
* @param key enum 식별자 (ALL·SIGHT·STAY·EXPERIENCE·FOOD)
* @param label 한글 라벨
* @param regionCount 이 칩으로 좁혔을 때 나오는 인구감소지역 수. {@code ALL} 은 전체 지역 수다
*/
public record Item(
@Schema(example = "SIGHT") String key,
@Schema(example = "관광지") String label) {
@Schema(example = "관광지") String label,
@Schema(description = "이 칩으로 좁혔을 때 나오는 지역 수 (ALL 은 전체)", example = "61") int regionCount) {

static Item from(Category category) {
return new Item(category.name(), category.label());
static Item from(Category category, CategoryCounts counts) {
return new Item(category.name(), category.label(), counts.of(category));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.offway.core.trip.controller.dto;

import com.offway.core.trip.domain.Category;
import io.swagger.v3.oas.annotations.media.Schema;

/**
* 지역 카드에 붙는 볼거리 분류 태그 — "이 지역에 이런 것이 있다".
*
* <p><b>필터칩({@link CategoryResponse.Item})과 다른 타입이다.</b> 둘 다 {@code key}·{@code label} 을 갖지만 답하는 질문이 다르다 —
* 필터칩은 "이 칩으로 좁히면 몇 곳인가"({@code regionCount})까지 답하고, 태그는 그 지역 카드의 표시일 뿐이라 개수라는 개념이 없다.
* 한 타입으로 묶으면 지역 카드마다 전체 지역 수가 따라붙어 읽는 쪽이 그것을 그 지역의 수로 오해한다.
*
* @param key enum 식별자 (SIGHT·STAY·EXPERIENCE·FOOD)
* @param label 한글 라벨
*/
public record CategoryTagResponse(
@Schema(example = "SIGHT") String key,
@Schema(example = "관광지") String label) {

public static CategoryTagResponse from(Category category) {
return new CategoryTagResponse(category.name(), category.label());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public record HomeResponse(User user, List<CategoryResponse.Item> filters, List<
public static HomeResponse from(HomeResult result) {
return new HomeResponse(
new User(GUEST_NAME, result.remainingLeaveDays()),
CategoryResponse.of().categories(),
CategoryResponse.of(result.categoryCounts()).categories(),
result.regions().stream().map(RegionCard::from).toList());
}

Expand All @@ -34,7 +34,7 @@ public record User(
* @param name 지역명 (시군구 · 시도)
* @param crowdLevel 한산도 뱃지
* @param imageUrl 대표 이미지 URL (없으면 null)
* @param categories 볼거리 카테고리
* @param categories 볼거리 카테고리 태그 (필터칩과 달리 개수가 없다 — {@link CategoryTagResponse})
* @param benefit 대표 혜택 (없으면 null)
*/
public record RegionCard(
Expand All @@ -45,7 +45,7 @@ public record RegionCard(
example = "http://tong.visitkorea.or.kr/cms/resource/83/1234583_image2_1.jpg",
nullable = true)
String imageUrl,
List<CategoryResponse.Item> categories,
List<CategoryTagResponse> categories,
@Schema(description = "대표 혜택 (없으면 null)", nullable = true) Benefit benefit) {

static RegionCard from(HomeResult.RegionCard card) {
Expand All @@ -54,7 +54,7 @@ static RegionCard from(HomeResult.RegionCard card) {
card.sigungu() + " · " + card.sido(),
card.crowdLevel(),
card.imageUrl(),
card.categories().stream().map(CategoryResponse.Item::from).toList(),
card.categories().stream().map(CategoryTagResponse::from).toList(),
card.benefit() == null ? null : Benefit.from(card.benefit()));
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.offway.core.trip.controller.dto;

import com.offway.core.common.logging.LogSummaries;
import com.offway.core.common.logging.LogSummary;
import com.offway.core.trip.domain.CrowdLevel;
import com.offway.core.trip.service.dto.RegionList;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;

/**
* 지역 목록 응답 — API 계약. 방문자 랭킹 내림차순.
*
* <p>페이지 메타({@code page}·{@code size}·{@code totalElements}·{@code totalPages})는 여기가 아니라 <b>공통 래퍼의
* {@code pageResponse}</b> 로 나간다(api-convention). 목록 API 가 전부 같은 자리에서 페이지 정보를 주게 하려는 것이다.
*
* @param regions 이 페이지의 지역
*/
public record RegionListResponse(List<Item> regions) implements LogSummary {

public static RegionListResponse from(RegionList regions) {
return new RegionListResponse(regions.regions().stream().map(Item::from).toList());
}

@Override
public String logSummary() {
return LogSummaries.count("지역", regions);
}

/**
* @param regionId 지역 ID
* @param name 지역명 (시군구 · 시도)
* @param crowdLevel 한산도 뱃지
* @param imageUrl 대표 이미지 URL (없으면 null)
* @param contentCount 볼거리 수 (인접 50km 병합 시 합산)
* @param categories 볼거리 카테고리 태그
* @param neighborIncluded 볼거리 부족으로 인접 50km 지역이 포함됐는지
*/
public record Item(
long regionId,
@Schema(example = "완도군 · 전라남도") String name,
CrowdLevel crowdLevel,
@Schema(
example = "http://tong.visitkorea.or.kr/cms/resource/83/1234583_image2_1.jpg",
nullable = true)
String imageUrl,
@Schema(example = "38") int contentCount,
List<CategoryTagResponse> categories,
@Schema(example = "false") boolean neighborIncluded) {

static Item from(RegionList.Item region) {
return new Item(
region.regionId(),
region.sigungu() + " · " + region.sido(),
region.crowdLevel(),
region.imageUrl(),
region.contentCount(),
region.categories().stream().map(CategoryTagResponse::from).toList(),
region.neighborIncluded());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public String logSummary() {
* @param crowdLevel 한산도 뱃지
* @param imageUrl 대표 이미지 URL (없으면 null)
* @param contentCount 볼거리 수 (인접 50km 병합 시 합산)
* @param categories 볼거리 카테고리
* @param categories 볼거리 카테고리 태그 (필터칩과 달리 개수가 없다 — {@link CategoryTagResponse})
* @param neighborIncluded 볼거리 부족으로 인접 50km 지역이 포함됐는지
* @param benefits 적용 혜택 뱃지
*/
Expand All @@ -44,7 +44,7 @@ public record Item(
nullable = true)
String imageUrl,
@Schema(example = "38") int contentCount,
List<CategoryResponse.Item> categories,
List<CategoryTagResponse> categories,
@Schema(example = "false") boolean neighborIncluded,
@Schema(description = """
지역 한 줄 소개(#140). 그 지역에 실제로 있는 대표 볼거리 이름으로 만든다.
Expand All @@ -62,7 +62,7 @@ static Item from(RecommendedRegion region) {
region.crowdLevel(),
region.imageUrl(),
region.contentCount(),
region.categories().stream().map(CategoryResponse.Item::from).toList(),
region.categories().stream().map(CategoryTagResponse::from).toList(),
region.neighborIncluded(),
region.intro(),
region.benefits().stream().map(Benefit::from).toList());
Expand Down
Loading