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
212 changes: 116 additions & 96 deletions docs/api-specs/home-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,127 +2,147 @@

## 1. 설계 메모

- `Home`은 원천 도메인이 아니라 여러 도메인을 조합하는 집계 API입니다.
- 메인 화면에서 바로 응답하는 즉답형 기능은 `quiz` 도메인으로 분리합니다.
- 홈 화면은 아래 데이터를 한 번에 조합해서 반환합니다.
- HOT 배틀
- PICK 배틀
- 퀴즈
- 최신 배틀
- 공지는 홈 상단 노출 대상만 조회합니다.
- 홈은 여러 조회 결과를 한 번에 내려주는 집계 API입니다.
- 이번 문서는 `GET /api/v1/home` 하나만 정의합니다.
- 공지 목록/상세는 홈에서 직접 내려주지 않고, 마이페이지 공지 탭에서 처리합니다.
- 홈에서는 공지 내용 대신 `newNotice` boolean만 내려서 새 공지 유입 여부만 표시합니다.
- `todayPicks` 안에는 찬반형과 4지선다형이 함께 포함됩니다.

---

## 2. 홈 API

### 2.1 `GET /api/v1/home`

홈 화면 집계 조회 API.
홈 화면 진입 시 필요한 데이터를 한 번에 조회합니다.

반환 항목:
반환 섹션:

- HOT 배틀
- PICK 배틀
- 퀴즈 2지선다
- 퀴즈 4지선다
- 최신 배틀 목록
- `newNotice`: 새 공지가 있는지 여부
- `editorPicks`: Editor Pick
- `trendingBattles`: 지금 뜨는 배틀
- `bestBattles`: Best 배틀
- `todayPicks`: 오늘의 Pické
- `newBattles`: 새로운 배틀

```json
{
"hot_battle": {
"battle_id": "battle_001",
"title": "안락사 도입, 찬성 vs 반대",
"summary": "인간에게 품위 있는 죽음을 허용해야 할까?",
"thumbnail_url": "https://cdn.example.com/battle/hot-001.png"
},
"pick_battle": {
"battle_id": "battle_002",
"title": "공리주의 vs 의무론",
"summary": "도덕 판단의 기준은 결과일까 원칙일까?",
"thumbnail_url": "https://cdn.example.com/battle/pick-002.png"
},
"quizzes": [
"newNotice": true,
"editorPicks": [
{
"quiz_id": "quiz_001",
"type": "BINARY",
"title": "AI가 만든 그림도 예술일까?",
"battleId": "7b6c8d81-40f4-4f1e-9f13-4cc2fa0a3a10",
"title": "연애 상대의 전 애인 사진, 지워달라고 말한다 vs 그냥 둔다",
"summary": "에디터가 직접 골라본 오늘의 주제",
"thumbnailUrl": "https://cdn.example.com/battle/editor-pick-001.png",
"type": "BATTLE",
"viewCount": 182,
"participantsCount": 562,
"audioDuration": 153,
"tags": [],
"options": []
}
],
"trendingBattles": [
{
"battleId": "40f4c311-0bd8-4baf-85df-58f8eaf1bf1f",
"title": "안락사 도입, 찬성 vs 반대",
"summary": "최근 24시간 참여가 급증한 배틀",
"thumbnailUrl": "https://cdn.example.com/battle/hot-001.png",
"type": "BATTLE",
"viewCount": 120,
"participantsCount": 420,
"audioDuration": 180,
"tags": [],
"options": []
}
],
"bestBattles": [
{
"battleId": "11c22d33-44e5-6789-9abc-123456789def",
"title": "반려동물 출입 가능 식당, 확대해야 한다 vs 제한해야 한다",
"summary": "누적 참여와 댓글 반응이 높은 배틀",
"thumbnailUrl": "https://cdn.example.com/battle/best-001.png",
"type": "BATTLE",
"viewCount": 348,
"participantsCount": 1103,
"audioDuration": 201,
"tags": [],
"options": []
}
],
"todayPicks": [
{
"battleId": "4e5291d2-b514-4d2a-a8fb-1258ae21a001",
"title": "배달 일회용 수저 기본 제공, 찬성 vs 반대",
"summary": "오늘의 Pické 찬반형 예시",
"thumbnailUrl": "https://cdn.example.com/battle/today-vote-001.png",
"type": "VOTE",
"viewCount": 97,
"participantsCount": 238,
"audioDuration": 96,
"tags": [],
"options": [
{ "code": "A", "label": "그렇다" },
{ "code": "B", "label": "아니다" }
{
"label": "A",
"text": "찬성"
},
{
"label": "B",
"text": "반대"
}
]
},
{
"quiz_id": "quiz_002",
"type": "MULTIPLE_CHOICE",
"title": "도덕 판단의 기준은?",
"battleId": "9f8e7d6c-5b4a-3210-9abc-7f6e5d4c3b2a",
"title": "다음 중 세계에서 가장 큰 사막은?",
"summary": "오늘의 Pické 4지선다형 예시",
"thumbnailUrl": "https://cdn.example.com/battle/today-quiz-001.png",
"type": "QUIZ",
"viewCount": 76,
"participantsCount": 191,
"audioDuration": 88,
"tags": [],
"options": [
{ "code": "A", "label": "결과" },
{ "code": "B", "label": "의도" },
{ "code": "C", "label": "규칙" },
{ "code": "D", "label": "상황" }
{
"label": "A",
"text": "사하라 사막"
},
{
"label": "B",
"text": "고비 사막"
},
{
"label": "C",
"text": "남극 대륙"
},
{
"label": "D",
"text": "아라비아 사막"
}
]
}
],
"latest_battles": [
"newBattles": [
{
"battle_id": "battle_101",
"title": "정의란 무엇인가",
"summary": "정의의 기준은 모두에게 같아야 할까?",
"thumbnail_url": "https://cdn.example.com/battle/latest-101.png"
"battleId": "aa11bb22-cc33-44dd-88ee-ff0011223344",
"title": "회사 회식은 근무의 연장이다 vs 사적인 친목이다",
"summary": "홈의 다른 섹션과 중복되지 않는 최신 배틀",
"thumbnailUrl": "https://cdn.example.com/battle/new-001.png",
"type": "BATTLE",
"viewCount": 24,
"participantsCount": 71,
"audioDuration": 142,
"tags": [],
"options": []
}
]
}
```

### 2.2 `POST /api/v1/quiz/{quizId}/responses`

홈 화면에서 퀴즈 응답 저장.
비고:

요청:

```json
{
"selected_option_code": "A"
}
```

응답:

```json
{
"quiz_id": "quiz_001",
"selected_option_code": "A",
"submitted_at": "2026-03-08T12:00:00Z"
}
```

---

## 3. 공지 API

### 3.1 `GET /api/v1/notices`

현재 노출 가능한 전체 공지 목록 조회.

쿼리 파라미터:

- `placement`: 선택, 예시 `HOME_TOP`
- `limit`: 선택

응답:

```json
{
"items": [
{
"notice_id": "notice_001",
"title": "3월 신규 딜레마 업데이트",
"body": "매일 새로운 딜레마가 추가돼요.",
"notice_type": "ANNOUNCEMENT",
"is_pinned": true,
"starts_at": "2026-03-01T00:00:00Z",
"ends_at": "2026-03-31T23:59:59Z"
}
]
}
```
- `newNotice`는 홈에서 공지 내용을 직접 노출하지 않고, 마이페이지 공지 탭으로 이동시키기 위한 신규 공지 존재 여부입니다.
- `editorPicks`, `trendingBattles`, `bestBattles`, `newBattles`는 동일한 배틀 요약 카드 구조를 사용합니다.
- `todayPicks`는 `type`으로 찬반형과 4지선다형을 구분합니다.
- `todayPicks`의 4지선다형은 별도 `quizzes` 필드로 분리하지 않고 이 배열 안에 포함합니다.
- 데이터가 없으면 리스트 섹션은 빈 배열을, `newNotice`는 `false`를 반환합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ public TodayBattleResponse toTodayResponse(Battle b, List<Tag> tags, List<Battle
b.getSummary(),
b.getThumbnailUrl(),
b.getType(),
b.getViewCount() == null ? 0 : b.getViewCount(),
b.getTotalParticipantsCount() == null ? 0L : b.getTotalParticipantsCount(),
b.getAudioDuration() == null ? 0 : b.getAudioDuration(),
toTagResponses(tags, null),
toTodayOptionResponses(opts)
Expand Down Expand Up @@ -114,4 +116,4 @@ private List<BattleTagResponse> toTagResponses(List<Tag> tags, TagType targetTyp
.map(t -> new BattleTagResponse(t.getId(), t.getName(), t.getType()))
.toList();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ public record TodayBattleResponse(
String summary, // 중간 요약 문구
String thumbnailUrl, // 풀스크린 배경 이미지 URL
BattleType type, // 타입 태그
Integer viewCount, // 조회수
Long participantsCount, // 누적 참여자 수
Integer audioDuration, // 소요 시간 (분:초 변환용 데이터)
List<BattleTagResponse> tags, // 상단 태그 리스트
List<TodayOptionResponse> options // 중앙 세로형 대결 카드 데이터
) {}
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,4 @@ public interface BattleService {

// 배틀 삭제 (DB에서 지우지 않고 소프트 딜리트/상태변경을 수행합니다)
AdminBattleDeleteResponse deleteBattle(UUID battleId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,4 @@ public BattleOption findOptionByBattleIdAndLabel(UUID battleId, BattleOptionLabe
return battleOptionRepository.findByBattleAndLabel(b, label)
.orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.swyp.app.domain.home.controller;

import com.swyp.app.domain.home.dto.response.HomeResponse;
import com.swyp.app.domain.home.service.HomeService;
import com.swyp.app.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "홈 API", description = "홈 화면 집계 조회")
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class HomeController {

private final HomeService homeService;

@Operation(summary = "홈 화면 집계 조회")
@GetMapping("/home")
public ApiResponse<HomeResponse> getHome() {
return ApiResponse.onSuccess(homeService.getHome());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.swyp.app.domain.home.dto.response;

import com.swyp.app.domain.battle.enums.BattleOptionLabel;

public record HomeBattleOptionResponse(
BattleOptionLabel label,
String text
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.swyp.app.domain.home.dto.response;

import com.swyp.app.domain.battle.dto.response.BattleTagResponse;
import com.swyp.app.domain.battle.enums.BattleType;

import java.util.List;
import java.util.UUID;

public record HomeBattleResponse(
UUID battleId,
String title,
String summary,
String thumbnailUrl,
BattleType type,
Integer viewCount,
Long participantsCount,
Integer audioDuration,
List<BattleTagResponse> tags,
List<HomeBattleOptionResponse> options
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.swyp.app.domain.home.dto.response;

import java.util.List;

public record HomeResponse(
boolean newNotice,
List<HomeBattleResponse> editorPicks,
List<HomeBattleResponse> trendingBattles,
List<HomeBattleResponse> bestBattles,
List<HomeBattleResponse> todayPicks,
List<HomeBattleResponse> newBattles
) {
}
Loading