From 2c25928b709db9767efcd96e98494a5ac522e0e7 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 12:36:34 +0900 Subject: [PATCH 01/18] =?UTF-8?q?#36=20[Docs]=20user=20API=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20mypage=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/mypage-api.md | 85 --------- docs/api-specs/user-api.md | 328 +++++++++++++++++++++-------------- 2 files changed, 199 insertions(+), 214 deletions(-) delete mode 100644 docs/api-specs/mypage-api.md diff --git a/docs/api-specs/mypage-api.md b/docs/api-specs/mypage-api.md deleted file mode 100644 index e494560..0000000 --- a/docs/api-specs/mypage-api.md +++ /dev/null @@ -1,85 +0,0 @@ -# 마이페이지 API 명세서 - -## 1. 설계 메모 - -- 마이페이지는 원천 도메인이 아니라 사용자, 리캡, 활동 이력을 묶는 조회 API 성격이 강합니다. -- 상단 요약과 상세 목록은 분리해서 조회합니다. - ---- - -## 2. 마이페이지 API - -### 2.1 `GET /api/v1/me/mypage` - -마이페이지 상단에 필요한 집계 데이터 조회. - -응답: - -```json -{ - "profile": { - "user_id": "user_001", - "nickname": "생각하는올빼미", - "avatar_emoji": "🦉", - "manner_temperature": 36.5 - }, - "recap_summary": { - "personality_title": "원칙 중심형", - "summary": "감정보다 이성과 규칙을 더 중시하는 편이에요." - }, - "activity_counts": { - "comments": 12, - "posts": 3, - "liked_contents": 8, - "changed_mind_contents": 2 - } -} -``` - -### 2.2 `GET /api/v1/me/recap` - -상세 리캡 정보 조회. - -응답: - -```json -{ - "personality_title": "원칙 중심형", - "summary": "감정보다 이성과 규칙을 더 중시하는 편이에요.", - "scores": { - "score_1": 88, - "score_2": 74, - "score_3": 62, - "score_4": 45, - "score_5": 30, - "score_6": 15 - } -} -``` - -### 2.3 `GET /api/v1/me/activities` - -사용자 행동 이력 조회. - -쿼리 파라미터: - -- `type`: `COMMENT | POST | LIKED_CONTENT | CHANGED_MIND` -- `cursor`: 선택 -- `size`: 선택 - -응답: - -```json -{ - "items": [ - { - "activity_id": "act_001", - "type": "COMMENT", - "title": "안락사 도입, 찬성 vs 반대", - "description": "자기결정권은 가장 기본적인 인권이라고 생각해요.", - "created_at": "2026-03-08T12:00:00Z" - } - ], - "next_cursor": "cursor_002" -} -``` diff --git a/docs/api-specs/user-api.md b/docs/api-specs/user-api.md index ee5898d..bf50c05 100644 --- a/docs/api-specs/user-api.md +++ b/docs/api-specs/user-api.md @@ -1,50 +1,53 @@ -# 사용자 API 명세서 +# 내 정보 / 사용자 API 명세서 ## 1. 설계 메모 -- 사용자 API는 `snake_case` 필드명을 기준으로 합니다. +- 이 문서는 사용자 프로필 수정과 `/api/v1/me/**` 계열 API를 함께 다룹니다. +- 문서 전반은 `snake_case` 필드명을 기준으로 합니다. - 외부 응답에서는 내부 PK인 `user_id`를 노출하지 않고 `user_tag`를 사용합니다. - `nickname`은 중복 허용 프로필명입니다. - `user_tag`는 고유한 공개 식별자이며 저장 시 `@` 없이 관리합니다. - `user_tag`는 prefix 없이 생성되는 8자리 이하의 랜덤 문자열입니다. - 프로필 아바타는 자유 입력 이모지가 아니라 `character_type` 선택 방식으로 관리합니다. -- `character_type`은 소문자 `snake_case` 문자열 값으로 관리합니다. -- 프로필, 설정, 성향 점수는 모두 사용자 도메인 책임입니다. -- 온보딩 완료 시 필수 약관 동의 이력은 서버에서 함께 저장합니다. -- 성향 점수는 현재값을 갱신하면서 이력도 함께 적재합니다. +- `GET /api/v1/me/mypage`는 상단 요약 조회, `GET /api/v1/me/recap`은 상세 리캡 조회에 사용합니다. +- 프론트는 `philosopher_type` 값에 따라 사전 정의된 철학자 카드를 통째로 교체 렌더링합니다. +- 그래서 백엔드는 철학자 카드용 `title`, `description`, 해시태그 문구를 내려주지 않습니다. +- 포인트(`point`)는 새 개념으로 도입하되, 이번 버전에서는 현재 DB에서 계산 가능한 항목만 부분 반영합니다. +- 현재 반영 규칙은 `완료된 사후 투표 x 10P`, `입장 변경 x 20P 보너스`입니다. +- 철학자 산출 로직은 추후 확정 예정이며, 현재는 프론트 연동을 위해 임시로 `SOCRATES`를 반환합니다. + +### 1.1 공통 프로필 응답 필드 + +| 필드 | 타입 | 설명 | +|------|------|------| +| `user_tag` | string | 외부 공개용 사용자 식별자 | +| `nickname` | string | 중복 허용 프로필명 | +| `character_type` | string | 캐릭터 enum 값 | +| `manner_temperature` | number | 사용자 매너 온도 | + +### 1.2 공통 enum 값 + +| 필드 | 가능한 값 | +|------|-----------| +| `philosopher_type` | `SOCRATES \| PLATO \| ARISTOTLE \| KANT \| NIETZSCHE \| MARX \| SARTRE \| CONFUCIUS \| LAOZI \| BUDDHA` | +| `character_type` | `OWL \| FOX \| WOLF \| LION \| PENGUIN \| BEAR \| RABBIT \| CAT` | +| `activity_type` | `COMMENT \| LIKE` | +| `vote_side` | `PRO \| CON` | --- -## 2. 첫 로그인 / 온보딩 API +## 2. 프로필 API -### 2.1 `GET /api/v1/onboarding/bootstrap` +### 2.1 `PATCH /api/v1/me/profile` -첫 로그인 화면 진입 시 필요한 초기 데이터 조회. -이모지는 8개 뿐이라 앱에서 관리하는 버전입니다. - -응답: - -```json -{ - "statusCode": 200, - "data": { - "random_nickname": "생각하는올빼미" - }, - "error": null -} -``` - -### 2.2 `POST /api/v1/onboarding/profile` - -첫 로그인 시 프로필 생성. -owl, wolf, lion 등은 추후 디자인에 따라 정의 +닉네임 및 캐릭터 수정. 요청: ```json { - "nickname": "생각하는올빼미", - "character_type": "owl" + "nickname": "생각하는펭귄", + "character_type": "PENGUIN" } ``` @@ -55,11 +58,9 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 "statusCode": 200, "data": { "user_tag": "a7k2m9q1", - "nickname": "생각하는올빼미", - "character_type": "owl", - "manner_temperature": 36.5, - "status": "ACTIVE", - "onboarding_completed": true + "nickname": "생각하는펭귄", + "character_type": "PENGUIN", + "updated_at": "2026-03-08T12:00:00Z" }, "error": null } @@ -67,11 +68,11 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 --- -## 3. 프로필 API +## 3. 마이페이지 조회 API -### 3.1 `GET /api/v1/users/{user_tag}` +### 3.1 `GET /api/v1/me/mypage` -공개 사용자 프로필 조회. +마이페이지 상단에 필요한 집계 데이터 조회. 응답: @@ -79,20 +80,28 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 { "statusCode": 200, "data": { - "user_tag": "a7k2m9q1", - "nickname": "생각하는올빼미", - "character_type": "owl", - "manner_temperature": 36.5 + "profile": { + "user_tag": "a7k2m9q1", + "nickname": "생각하는올빼미", + "character_type": "OWL", + "manner_temperature": 36.5 + }, + "philosopher": { + "philosopher_type": "SOCRATES" + }, + "tier": { + "tier_code": "WANDERER", + "tier_label": "방랑자", + "current_point": 40 + } }, "error": null } ``` ---- - -### 3.2 `GET /api/v1/me/profile` +### 3.2 `GET /api/v1/me/recap` -내 프로필 조회. +상세 리캡 정보 조회. 응답: @@ -100,30 +109,66 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 { "statusCode": 200, "data": { - "user_tag": "a7k2m9q1", - "nickname": "생각하는올빼미", - "character_type": "owl", - "manner_temperature": 36.5, - "updated_at": "2026-03-08T12:00:00Z" + "my_card": { + "philosopher_type": "SOCRATES" + }, + "best_match_card": { + "philosopher_type": "PLATO" + }, + "worst_match_card": { + "philosopher_type": "MARX" + }, + "scores": { + "principle": 88, + "reason": 74, + "individual": 62, + "change": 45, + "inner": 30, + "ideal": 15 + }, + "preference_report": { + "total_participation": 47, + "opinion_changes": 12, + "battle_win_rate": 68, + "favorite_topics": [ + { + "rank": 1, + "tag_name": "철학", + "participation_count": 20 + }, + { + "rank": 2, + "tag_name": "문학", + "participation_count": 13 + }, + { + "rank": 3, + "tag_name": "예술", + "participation_count": 8 + }, + { + "rank": 4, + "tag_name": "사회", + "participation_count": 5 + } + ] + } }, "error": null } ``` ---- +### 3.3 `GET /api/v1/me/battle-records` -### 3.3 `PATCH /api/v1/me/profile` - -닉네임 및 캐릭터 수정. +내 배틀 기록 조회. +찬성/반대 탭을 따로 나누지 않고 하나의 목록으로 반환합니다. +각 item의 `vote_side`가 실제 구분자입니다. -요청: +쿼리 파라미터: -```json -{ - "nickname": "생각하는펭귄", - "character_type": "penguin" -} -``` +- `offset`: 선택, 0-based 시작 위치 +- `size`: 선택 +- `vote_side`: 각 item의 구분자이며 가능한 값은 `PRO | CON` 응답: @@ -131,22 +176,34 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 { "statusCode": 200, "data": { - "user_tag": "a7k2m9q1", - "nickname": "생각하는펭귄", - "character_type": "penguin", - "updated_at": "2026-03-08T12:00:00Z" + "items": [ + { + "battle_id": "battle_001", + "record_id": "vote_001", + "vote_side": "PRO", + "title": "안락사 도입, 찬성 vs 반대", + "summary": "인간에게 품위 있는 죽음을 허용해야 할까?", + "created_at": "2026-03-07T18:30:00" + } + ], + "next_offset": 20, + "has_next": true }, "error": null } ``` ---- +### 3.4 `GET /api/v1/me/content-activities` -## 4. 설정 API +내 댓글/좋아요 기반 콘텐츠 활동 조회. +댓글/좋아요 탭을 따로 나누지 않고 하나의 목록으로 반환합니다. +각 item의 `activity_type`이 실제 구분자입니다. -### 4.1 `GET /api/v1/me/settings` +쿼리 파라미터: -현재 사용자 설정 조회. +- `offset`: 선택, 0-based 시작 위치 +- `size`: 선택 +- `activity_type`: 각 item의 구분자이며 가능한 값은 `COMMENT | LIKE` 응답: @@ -154,29 +211,34 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 { "statusCode": 200, "data": { - "push_enabled": false, - "email_enabled": false, - "debate_request_enabled": false, - "profile_public": false + "items": [ + { + "activity_id": "comment_001", + "activity_type": "COMMENT", + "perspective_id": "perspective_001", + "battle_id": "battle_001", + "battle_title": "안락사 도입, 찬성 vs 반대", + "author": { + "user_tag": "a7k2m9q1", + "nickname": "사색하는고양이", + "character_type": "CAT" + }, + "stance": "반대", + "content": "제도가 무서운 건, 사회적 압력이 선택을 의무로 바꿀 수 있다는 거예요.", + "like_count": 1340, + "created_at": "2026-03-08T12:00:00" + } + ], + "next_offset": 20, + "has_next": true }, "error": null } ``` ---- - -### 4.2 `PATCH /api/v1/me/settings` +### 3.5 `GET /api/v1/me/notification-settings` -사용자 설정 수정. - -요청: - -```json -{ - "push_enabled": false, - "debate_request_enabled": false -} -``` +마이페이지 알림 설정 조회. 응답: @@ -184,31 +246,27 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 { "statusCode": 200, "data": { - "updated": true + "new_battle_enabled": false, + "battle_result_enabled": true, + "comment_reply_enabled": true, + "new_comment_enabled": false, + "content_like_enabled": false, + "marketing_event_enabled": true }, "error": null } ``` ---- - -## 5. 성향 점수 API +### 3.6 `PATCH /api/v1/me/notification-settings` -### 5.1 `PUT /api/v1/me/tendency-scores` - -최신 성향 점수 수정 및 이력 저장. -!!! 기획 확정에 따라 필드명 및 규칙 변경될 예정 +마이페이지 알림 설정 부분 수정. 요청: ```json { - "score_1": 30, - "score_2": -20, - "score_3": 55, - "score_4": 10, - "score_5": -75, - "score_6": 42 + "battle_result_enabled": true, + "marketing_event_enabled": false } ``` @@ -218,30 +276,24 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 { "statusCode": 200, "data": { - "user_tag": "a7k2m9q1", - "score_1": 30, - "score_2": -20, - "score_3": 55, - "score_4": 10, - "score_5": -75, - "score_6": 42, - "updated_at": "2026-03-08T12:00:00Z", - "history_saved": true + "new_battle_enabled": false, + "battle_result_enabled": true, + "comment_reply_enabled": true, + "new_comment_enabled": false, + "content_like_enabled": false, + "marketing_event_enabled": false }, "error": null } ``` ---- +### 3.7 `GET /api/v1/me/notices` -### 5.2 `GET /api/v1/me/tendency-scores/history` - -성향 점수 변경 이력 조회. +공지/이벤트 목록 조회. 쿼리 파라미터: -- `cursor`: 선택 -- `size`: 선택 +- `type`: `NOTICE | EVENT` 응답: @@ -251,17 +303,35 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 "data": { "items": [ { - "history_id": 1, - "score_1": 30, - "score_2": -20, - "score_3": 55, - "score_4": 10, - "score_5": -75, - "score_6": 42, - "created_at": "2026-03-08T12:00:00Z" + "notice_id": "notice_001", + "type": "NOTICE", + "title": "3월 신규 딜레마 업데이트", + "body_preview": "매일 새로운 딜레마가 추가돼요.", + "is_pinned": true, + "published_at": "2026-03-01T00:00:00" } - ], - "next_cursor": null + ] + }, + "error": null +} +``` + +### 3.8 `GET /api/v1/me/notices/{noticeId}` + +공지/이벤트 상세 조회. + +응답: + +```json +{ + "statusCode": 200, + "data": { + "notice_id": "notice_001", + "type": "NOTICE", + "title": "3월 신규 딜레마 업데이트", + "body": "매일 새로운 딜레마가 추가돼요.", + "is_pinned": true, + "published_at": "2026-03-01T00:00:00" }, "error": null } @@ -269,21 +339,21 @@ owl, wolf, lion 등은 추후 디자인에 따라 정의 --- -## 6. 에러 코드 +## 4. 에러 코드 -### 6.1 공통 에러 코드 +### 4.1 공통 에러 코드 | Error Code | HTTP Status | 설명 | |------------|:-----------:|------| | `COMMON_INVALID_PARAMETER` | `400` | 요청 파라미터 오류 | | `AUTH_UNAUTHORIZED` | `401` | 인증 실패 | | `AUTH_ACCESS_TOKEN_EXPIRED` | `401` | Access Token 만료 | -| `AUTH_REFRESH_TOKEN_EXPIRED` | `401` | Refresh Token 만료 — 재로그인 필요 | +| `AUTH_REFRESH_TOKEN_EXPIRED` | `401` | Refresh Token 만료 - 재로그인 필요 | | `USER_BANNED` | `403` | 영구 제재된 사용자 | | `USER_SUSPENDED` | `403` | 일정 기간 이용 정지된 사용자 | | `INTERNAL_SERVER_ERROR` | `500` | 서버 오류 | -### 6.2 사용자 에러 코드 +### 4.2 사용자 에러 코드 | Error Code | HTTP Status | 설명 | |------------|:-----------:|------| From 8b9f51672b79d5e3702680b641b03fc08ccc61b8 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 12:40:43 +0900 Subject: [PATCH 02/18] =?UTF-8?q?#37=20[Feat]=20=ED=99=88=20API=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B3=B5=EC=A7=80=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/home-api.md | 212 ++++++++++-------- .../battle/converter/BattleConverter.java | 17 +- .../domain/battle/service/BattleService.java | 7 +- .../battle/service/BattleServiceImpl.java | 43 +++- .../home/controller/HomeController.java | 26 +++ .../response/HomeBattleOptionResponse.java | 9 + .../home/dto/response/HomeBattleResponse.java | 21 ++ .../home/dto/response/HomeResponse.java | 13 ++ .../app/domain/home/service/HomeService.java | 89 ++++++++ .../notice/controller/NoticeController.java | 43 ++++ .../dto/response/NoticeDetailResponse.java | 20 ++ .../dto/response/NoticeListResponse.java | 9 + .../dto/response/NoticeSummaryResponse.java | 19 ++ .../swyp/app/domain/notice/entity/Notice.java | 67 ++++++ .../domain/notice/entity/NoticePlacement.java | 6 + .../app/domain/notice/entity/NoticeType.java | 6 + .../notice/repository/NoticeRepository.java | 36 +++ .../domain/notice/service/NoticeService.java | 72 ++++++ .../global/common/exception/ErrorCode.java | 5 +- .../app/global/config/SecurityConfig.java | 2 + .../battle/service/BattleServiceImplTest.java | 122 ++++++++++ .../domain/home/service/HomeServiceTest.java | 152 +++++++++++++ .../notice/service/NoticeServiceTest.java | 65 ++++++ src/test/resources/application.yml | 43 ++++ 24 files changed, 1004 insertions(+), 100 deletions(-) create mode 100644 src/main/java/com/swyp/app/domain/home/controller/HomeController.java create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java create mode 100644 src/main/java/com/swyp/app/domain/home/service/HomeService.java create mode 100644 src/main/java/com/swyp/app/domain/notice/controller/NoticeController.java create mode 100644 src/main/java/com/swyp/app/domain/notice/dto/response/NoticeDetailResponse.java create mode 100644 src/main/java/com/swyp/app/domain/notice/dto/response/NoticeListResponse.java create mode 100644 src/main/java/com/swyp/app/domain/notice/dto/response/NoticeSummaryResponse.java create mode 100644 src/main/java/com/swyp/app/domain/notice/entity/Notice.java create mode 100644 src/main/java/com/swyp/app/domain/notice/entity/NoticePlacement.java create mode 100644 src/main/java/com/swyp/app/domain/notice/entity/NoticeType.java create mode 100644 src/main/java/com/swyp/app/domain/notice/repository/NoticeRepository.java create mode 100644 src/main/java/com/swyp/app/domain/notice/service/NoticeService.java create mode 100644 src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java create mode 100644 src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java create mode 100644 src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java diff --git a/docs/api-specs/home-api.md b/docs/api-specs/home-api.md index 3b83e2c..ecebb59 100644 --- a/docs/api-specs/home-api.md +++ b/docs/api-specs/home-api.md @@ -2,14 +2,11 @@ ## 1. 설계 메모 -- `Home`은 원천 도메인이 아니라 여러 도메인을 조합하는 집계 API입니다. -- 메인 화면에서 바로 응답하는 즉답형 기능은 `quiz` 도메인으로 분리합니다. -- 홈 화면은 아래 데이터를 한 번에 조합해서 반환합니다. - - HOT 배틀 - - PICK 배틀 - - 퀴즈 - - 최신 배틀 -- 공지는 홈 상단 노출 대상만 조회합니다. +- 홈은 여러 조회 결과를 한 번에 내려주는 집계 API입니다. +- 이번 문서는 `GET /api/v1/home` 하나만 정의합니다. +- 공지 목록/상세는 홈에서 직접 내려주지 않고, 마이페이지 공지 탭에서 처리합니다. +- 홈에서는 공지 내용 대신 `newNotice` boolean만 내려서 새 공지 유입 여부만 표시합니다. +- `todayPicks` 안에는 찬반형과 4지선다형이 함께 포함됩니다. --- @@ -17,112 +14,135 @@ ### 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`를 반환합니다. diff --git a/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java index 9403062..167b4f8 100644 --- a/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java +++ b/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java @@ -52,6 +52,21 @@ public TodayBattleResponse toTodayResponse(Battle b, List tags, List tags, List opts) { + return new BattleSummaryResponse( + b.getId(), + b.getTitle(), + 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), + toOptionResponses(opts) + ); + } + public AdminBattleDetailResponse toAdminDetailResponse(Battle b, List tags, List opts) { return new AdminBattleDetailResponse( b.getId(), @@ -114,4 +129,4 @@ private List toTagResponses(List tags, TagType targetTyp .map(t -> new BattleTagResponse(t.getId(), t.getName(), t.getType())) .toList(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleService.java b/src/main/java/com/swyp/app/domain/battle/service/BattleService.java index fe6fc12..6e383fe 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleService.java @@ -23,18 +23,23 @@ public interface BattleService { // 1. 에디터 픽 조회 (isEditorPick = true) List getEditorPicks(); + List getHomeEditorPicks(); // 2. 지금 뜨는 배틀 조회 (최근 24시간 투표 급증순) List getTrendingBattles(); + List getHomeTrendingBattles(); // 3. Best 배틀 조회 (누적 지표 랭킹) List getBestBattles(); + List getHomeBestBattles(); // 4. 오늘의 Pické 조회 (단일 타입 매칭) List getTodayPicks(BattleType type); + List getHomeTodayPicks(BattleType type); // 5. 새로운 배틀 조회 (중복 제외 리스트) List getNewBattles(List excludeIds); + List getHomeNewBattles(List excludeIds); // === [사용자용 - 기본 API] === @@ -59,4 +64,4 @@ public interface BattleService { // 배틀 삭제 (DB에서 지우지 않고 소프트 딜리트/상태변경을 수행합니다) AdminBattleDeleteResponse deleteBattle(UUID battleId); -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java index cf243ce..4bd43b7 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java @@ -67,6 +67,12 @@ public List getEditorPicks() { return convertToTodayResponses(battles); } + @Override + public List getHomeEditorPicks() { + List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, 10)); + return convertToSummaryResponses(battles); + } + @Override public List getTrendingBattles() { LocalDateTime yesterday = LocalDateTime.now().minusDays(1); @@ -74,18 +80,37 @@ public List getTrendingBattles() { return convertToTodayResponses(battles); } + @Override + public List getHomeTrendingBattles() { + LocalDateTime yesterday = LocalDateTime.now().minusDays(1); + List battles = battleRepository.findTrendingBattles(yesterday, PageRequest.of(0, 5)); + return convertToSummaryResponses(battles); + } + @Override public List getBestBattles() { List battles = battleRepository.findBestBattles(PageRequest.of(0, 5)); return convertToTodayResponses(battles); } + @Override + public List getHomeBestBattles() { + List battles = battleRepository.findBestBattles(PageRequest.of(0, 5)); + return convertToSummaryResponses(battles); + } + @Override public List getTodayPicks(BattleType type) { List battles = battleRepository.findTodayPicks(type, LocalDate.now()); return convertToTodayResponses(battles); } + @Override + public List getHomeTodayPicks(BattleType type) { + List battles = battleRepository.findTodayPicks(type, LocalDate.now()); + return convertToSummaryResponses(battles); + } + @Override public List getNewBattles(List excludeIds) { List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) @@ -94,6 +119,14 @@ public List getNewBattles(List excludeIds) { return convertToTodayResponses(battles); } + @Override + public List getHomeNewBattles(List excludeIds) { + List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) + ? List.of(UUID.randomUUID()) : excludeIds; + List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, 10)); + return convertToSummaryResponses(battles); + } + // [사용자용 - 기본 API] @Override @@ -217,6 +250,14 @@ private List convertToTodayResponses(List battles) }).toList(); } + private List convertToSummaryResponses(List battles) { + return battles.stream().map(battle -> { + List tags = getTagsByBattle(battle); + List options = battleOptionRepository.findByBattle(battle); + return battleConverter.toSummaryResponse(battle, tags, options); + }).toList(); + } + private List getTagsByBattle(Battle b) { return battleTagRepository.findByBattle(b).stream() .map(BattleTag::getTag) @@ -242,4 +283,4 @@ public BattleOption findOptionByBattleIdAndLabel(UUID battleId, BattleOptionLabe return battleOptionRepository.findByBattleAndLabel(b, label) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/domain/home/controller/HomeController.java b/src/main/java/com/swyp/app/domain/home/controller/HomeController.java new file mode 100644 index 0000000..8c11bb4 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/controller/HomeController.java @@ -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 getHome() { + return ApiResponse.onSuccess(homeService.getHome()); + } +} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java new file mode 100644 index 0000000..b2ce088 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java @@ -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 +) { +} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java new file mode 100644 index 0000000..713f1ce --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java @@ -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 tags, + List options +) { +} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java new file mode 100644 index 0000000..525680a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java @@ -0,0 +1,13 @@ +package com.swyp.app.domain.home.dto.response; + +import java.util.List; + +public record HomeResponse( + boolean newNotice, + List editorPicks, + List trendingBattles, + List bestBattles, + List todayPicks, + List newBattles +) { +} diff --git a/src/main/java/com/swyp/app/domain/home/service/HomeService.java b/src/main/java/com/swyp/app/domain/home/service/HomeService.java new file mode 100644 index 0000000..77de9c8 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/service/HomeService.java @@ -0,0 +1,89 @@ +package com.swyp.app.domain.home.service; + +import com.swyp.app.domain.battle.dto.response.BattleOptionResponse; +import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.battle.service.BattleService; +import com.swyp.app.domain.home.dto.response.HomeBattleOptionResponse; +import com.swyp.app.domain.home.dto.response.HomeBattleResponse; +import com.swyp.app.domain.home.dto.response.HomeResponse; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.service.NoticeService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class HomeService { + + private static final int NOTICE_EXISTS_LIMIT = 1; + + private final BattleService battleService; + private final NoticeService noticeService; + + public HomeResponse getHome() { + boolean newNotice = !noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, NOTICE_EXISTS_LIMIT).isEmpty(); + + List editorPicks = toHomeBattles(battleService.getHomeEditorPicks()); + List trendingBattles = toHomeBattles(battleService.getHomeTrendingBattles()); + List bestBattles = toHomeBattles(battleService.getHomeBestBattles()); + + List todayPicks = new ArrayList<>(); + todayPicks.addAll(toHomeBattles(battleService.getHomeTodayPicks(BattleType.VOTE))); + todayPicks.addAll(toHomeBattles(battleService.getHomeTodayPicks(BattleType.QUIZ))); + + List excludeIds = collectBattleIds(editorPicks, trendingBattles, bestBattles, todayPicks); + List newBattles = toHomeBattles(battleService.getHomeNewBattles(excludeIds)); + + return new HomeResponse( + newNotice, + editorPicks, + trendingBattles, + bestBattles, + todayPicks, + newBattles + ); + } + + private List toHomeBattles(List battles) { + return battles.stream() + .map(this::toHomeBattle) + .toList(); + } + + private HomeBattleResponse toHomeBattle(BattleSummaryResponse battle) { + return new HomeBattleResponse( + battle.battleId(), + battle.title(), + battle.summary(), + battle.thumbnailUrl(), + battle.type(), + battle.viewCount(), + battle.participantsCount(), + battle.audioDuration(), + battle.tags(), + battle.options().stream() + .map(this::toHomeOption) + .toList() + ); + } + + private HomeBattleOptionResponse toHomeOption(BattleOptionResponse option) { + return new HomeBattleOptionResponse(option.label(), option.title()); + } + + @SafeVarargs + private List collectBattleIds(List... groups) { + return List.of(groups).stream() + .flatMap(List::stream) + .map(HomeBattleResponse::battleId) + .distinct() + .toList(); + } +} diff --git a/src/main/java/com/swyp/app/domain/notice/controller/NoticeController.java b/src/main/java/com/swyp/app/domain/notice/controller/NoticeController.java new file mode 100644 index 0000000..c7e06e7 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/controller/NoticeController.java @@ -0,0 +1,43 @@ +package com.swyp.app.domain.notice.controller; + +import com.swyp.app.domain.notice.dto.response.NoticeDetailResponse; +import com.swyp.app.domain.notice.dto.response.NoticeListResponse; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import com.swyp.app.domain.notice.service.NoticeService; +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.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@Tag(name = "공지 API", description = "공지사항 조회") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/notices") +public class NoticeController { + + private final NoticeService noticeService; + + @Operation(summary = "활성 공지 목록 조회") + @GetMapping + public ApiResponse getNotices( + @RequestParam(required = false) NoticeType type, + @RequestParam(required = false) NoticePlacement placement, + @RequestParam(required = false) Integer limit + ) { + return ApiResponse.onSuccess(noticeService.getNoticeList(type, placement, limit)); + } + + @Operation(summary = "활성 공지 상세 조회") + @GetMapping("/{noticeId}") + public ApiResponse getNoticeDetail(@PathVariable UUID noticeId) { + return ApiResponse.onSuccess(noticeService.getNoticeDetail(noticeId)); + } +} diff --git a/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeDetailResponse.java b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeDetailResponse.java new file mode 100644 index 0000000..43e464f --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeDetailResponse.java @@ -0,0 +1,20 @@ +package com.swyp.app.domain.notice.dto.response; + +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record NoticeDetailResponse( + UUID noticeId, + String title, + String body, + NoticeType type, + NoticePlacement placement, + boolean pinned, + LocalDateTime startsAt, + LocalDateTime endsAt, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeListResponse.java b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeListResponse.java new file mode 100644 index 0000000..d83f91a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeListResponse.java @@ -0,0 +1,9 @@ +package com.swyp.app.domain.notice.dto.response; + +import java.util.List; + +public record NoticeListResponse( + List items, + int totalCount +) { +} diff --git a/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeSummaryResponse.java b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeSummaryResponse.java new file mode 100644 index 0000000..abe78d5 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/dto/response/NoticeSummaryResponse.java @@ -0,0 +1,19 @@ +package com.swyp.app.domain.notice.dto.response; + +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record NoticeSummaryResponse( + UUID noticeId, + String title, + String body, + NoticeType type, + NoticePlacement placement, + boolean pinned, + LocalDateTime startsAt, + LocalDateTime endsAt +) { +} diff --git a/src/main/java/com/swyp/app/domain/notice/entity/Notice.java b/src/main/java/com/swyp/app/domain/notice/entity/Notice.java new file mode 100644 index 0000000..531d297 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/entity/Notice.java @@ -0,0 +1,67 @@ +package com.swyp.app.domain.notice.entity; + +import com.swyp.app.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Entity +@Table(name = "notices") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notice extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, length = 150) + private String title; + + @Column(nullable = false, columnDefinition = "TEXT") + private String body; + + @Enumerated(EnumType.STRING) + @Column(name = "notice_type", nullable = false, length = 30) + private NoticeType type; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private NoticePlacement placement; + + @Column(name = "is_pinned", nullable = false) + private boolean pinned; + + @Column(name = "starts_at", nullable = false) + private LocalDateTime startsAt; + + @Column(name = "ends_at") + private LocalDateTime endsAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + private Notice(String title, String body, NoticeType type, NoticePlacement placement, boolean pinned, + LocalDateTime startsAt, LocalDateTime endsAt) { + this.title = title; + this.body = body; + this.type = type; + this.placement = placement; + this.pinned = pinned; + this.startsAt = startsAt; + this.endsAt = endsAt; + } +} diff --git a/src/main/java/com/swyp/app/domain/notice/entity/NoticePlacement.java b/src/main/java/com/swyp/app/domain/notice/entity/NoticePlacement.java new file mode 100644 index 0000000..180382e --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/entity/NoticePlacement.java @@ -0,0 +1,6 @@ +package com.swyp.app.domain.notice.entity; + +public enum NoticePlacement { + HOME_TOP, + NOTICE_BOARD +} diff --git a/src/main/java/com/swyp/app/domain/notice/entity/NoticeType.java b/src/main/java/com/swyp/app/domain/notice/entity/NoticeType.java new file mode 100644 index 0000000..be76097 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/entity/NoticeType.java @@ -0,0 +1,6 @@ +package com.swyp.app.domain.notice.entity; + +public enum NoticeType { + ANNOUNCEMENT, + EVENT +} diff --git a/src/main/java/com/swyp/app/domain/notice/repository/NoticeRepository.java b/src/main/java/com/swyp/app/domain/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..b7322be --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/repository/NoticeRepository.java @@ -0,0 +1,36 @@ +package com.swyp.app.domain.notice.repository; + +import com.swyp.app.domain.notice.entity.Notice; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface NoticeRepository extends JpaRepository { + + @Query("SELECT notice FROM Notice notice " + + "WHERE notice.deletedAt IS NULL " + + "AND notice.startsAt <= :now " + + "AND (notice.endsAt IS NULL OR notice.endsAt >= :now) " + + "AND (:type IS NULL OR notice.type = :type) " + + "AND (:placement IS NULL OR notice.placement = :placement) " + + "ORDER BY notice.pinned DESC, notice.startsAt DESC, notice.createdAt DESC") + List findActiveNotices(@Param("now") LocalDateTime now, + @Param("type") NoticeType type, + @Param("placement") NoticePlacement placement, + Pageable pageable); + + @Query("SELECT notice FROM Notice notice " + + "WHERE notice.id = :noticeId " + + "AND notice.deletedAt IS NULL " + + "AND notice.startsAt <= :now " + + "AND (notice.endsAt IS NULL OR notice.endsAt >= :now)") + Optional findActiveById(@Param("noticeId") UUID noticeId, @Param("now") LocalDateTime now); +} diff --git a/src/main/java/com/swyp/app/domain/notice/service/NoticeService.java b/src/main/java/com/swyp/app/domain/notice/service/NoticeService.java new file mode 100644 index 0000000..840e56c --- /dev/null +++ b/src/main/java/com/swyp/app/domain/notice/service/NoticeService.java @@ -0,0 +1,72 @@ +package com.swyp.app.domain.notice.service; + +import com.swyp.app.domain.notice.dto.response.NoticeDetailResponse; +import com.swyp.app.domain.notice.dto.response.NoticeListResponse; +import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; +import com.swyp.app.domain.notice.entity.Notice; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import com.swyp.app.domain.notice.repository.NoticeRepository; +import com.swyp.app.global.common.exception.CustomException; +import com.swyp.app.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NoticeService { + + private static final int DEFAULT_LIMIT = 20; + + private final NoticeRepository noticeRepository; + + public List getActiveNotices(NoticePlacement placement, NoticeType type, Integer limit) { + int pageSize = limit == null || limit <= 0 ? DEFAULT_LIMIT : limit; + return noticeRepository.findActiveNotices(LocalDateTime.now(), type, placement, PageRequest.of(0, pageSize)) + .stream() + .map(this::toSummaryResponse) + .toList(); + } + + public NoticeListResponse getNoticeList(NoticeType type, NoticePlacement placement, Integer limit) { + List items = getActiveNotices(placement, type, limit); + return new NoticeListResponse(items, items.size()); + } + + public NoticeDetailResponse getNoticeDetail(UUID noticeId) { + Notice notice = noticeRepository.findActiveById(noticeId, LocalDateTime.now()) + .orElseThrow(() -> new CustomException(ErrorCode.NOTICE_NOT_FOUND)); + + return new NoticeDetailResponse( + notice.getId(), + notice.getTitle(), + notice.getBody(), + notice.getType(), + notice.getPlacement(), + notice.isPinned(), + notice.getStartsAt(), + notice.getEndsAt(), + notice.getCreatedAt() + ); + } + + private NoticeSummaryResponse toSummaryResponse(Notice notice) { + return new NoticeSummaryResponse( + notice.getId(), + notice.getTitle(), + notice.getBody(), + notice.getType(), + notice.getPlacement(), + notice.isPinned(), + notice.getStartsAt(), + notice.getEndsAt() + ); + } +} diff --git a/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java b/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java index a611538..07e2ebb 100644 --- a/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java +++ b/src/main/java/com/swyp/app/global/common/exception/ErrorCode.java @@ -29,6 +29,9 @@ public enum ErrorCode { // OAuth (Social Login) INVALID_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_400_PROVIDER", "지원하지 않는 소셜 로그인 provider입니다."), + // Notice + NOTICE_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTICE_404", "존재하지 않는 공지사항입니다."), + // Battle BATTLE_NOT_FOUND (HttpStatus.NOT_FOUND, "BATTLE_404", "존재하지 않는 배틀입니다."), BATTLE_CLOSED (HttpStatus.CONFLICT, "BATTLE_409_CLS", "종료된 배틀입니다."), @@ -75,4 +78,4 @@ public enum ErrorCode { private final HttpStatus httpStatus; private final String code; private final String message; -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/global/config/SecurityConfig.java b/src/main/java/com/swyp/app/global/config/SecurityConfig.java index 8f8b4d1..d4f232d 100644 --- a/src/main/java/com/swyp/app/global/config/SecurityConfig.java +++ b/src/main/java/com/swyp/app/global/config/SecurityConfig.java @@ -34,6 +34,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/v1/auth/**", + "/api/v1/home", + "/api/v1/notices/**", "/swagger-ui/**", "/v3/api-docs/**" ).permitAll() diff --git a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java new file mode 100644 index 0000000..e66b2ef --- /dev/null +++ b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java @@ -0,0 +1,122 @@ +package com.swyp.app.domain.battle.service; + +import com.swyp.app.domain.battle.converter.BattleConverter; +import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.entity.BattleTag; +import com.swyp.app.domain.battle.enums.BattleStatus; +import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.battle.repository.BattleOptionRepository; +import com.swyp.app.domain.battle.repository.BattleOptionTagRepository; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; +import com.swyp.app.domain.tag.entity.Tag; +import com.swyp.app.domain.tag.repository.TagRepository; +import com.swyp.app.domain.user.repository.UserRepository; +import com.swyp.app.domain.vote.repository.VoteRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BattleServiceImplTest { + + @Mock + private BattleRepository battleRepository; + @Mock + private BattleOptionRepository battleOptionRepository; + @Mock + private BattleTagRepository battleTagRepository; + @Mock + private BattleOptionTagRepository battleOptionTagRepository; + @Mock + private TagRepository tagRepository; + @Mock + private UserRepository userRepository; + @Mock + private VoteRepository voteRepository; + @Mock + private BattleConverter battleConverter; + + @InjectMocks + private BattleServiceImpl battleService; + + private Battle battle; + + @BeforeEach + void setUp() { + battle = Battle.builder() + .title("battle") + .type(BattleType.BATTLE) + .targetDate(LocalDate.now()) + .status(BattleStatus.PUBLISHED) + .build(); + } + + @Test + void getHomeTrendingBattles_요약응답으로_변환한다() { + BattleSummaryResponse summary = new BattleSummaryResponse( + UUID.randomUUID(), + "trending", + "summary", + "thumbnail", + BattleType.BATTLE, + 12, + 34L, + 56, + List.of(), + List.of() + ); + + when(battleRepository.findTrendingBattles(any(LocalDateTime.class), any(Pageable.class))).thenReturn(List.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); + when(battleTagRepository.findByBattle(battle)).thenReturn(List.of()); + when(battleConverter.toSummaryResponse(eq(battle), eq(List.of()), eq(List.of()))) + .thenReturn(summary); + + var result = battleService.getHomeTrendingBattles(); + + assertThat(result).containsExactly(summary); + } + + @Test + void getHomeNewBattles_제외아이디가_비어있으면_조회용_기본값을_사용한다() { + BattleSummaryResponse summary = new BattleSummaryResponse( + UUID.randomUUID(), + "new", + "summary", + "thumbnail", + BattleType.BATTLE, + 1, + 2L, + 3, + List.of(), + List.of() + ); + + when(battleRepository.findNewBattlesExcluding(any(), any(Pageable.class))).thenReturn(List.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); + when(battleTagRepository.findByBattle(battle)).thenReturn(List.of()); + when(battleConverter.toSummaryResponse(eq(battle), eq(List.of()), eq(List.of()))) + .thenReturn(summary); + + var result = battleService.getHomeNewBattles(List.of()); + + assertThat(result).containsExactly(summary); + } +} diff --git a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java new file mode 100644 index 0000000..5e6ac99 --- /dev/null +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -0,0 +1,152 @@ +package com.swyp.app.domain.home.service; + +import com.swyp.app.domain.battle.dto.response.BattleOptionResponse; +import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +import com.swyp.app.domain.battle.enums.BattleOptionLabel; +import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.battle.service.BattleService; +import com.swyp.app.domain.home.dto.response.HomeBattleResponse; +import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.service.NoticeService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static com.swyp.app.domain.battle.enums.BattleType.BATTLE; +import static com.swyp.app.domain.battle.enums.BattleType.QUIZ; +import static com.swyp.app.domain.battle.enums.BattleType.VOTE; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HomeServiceTest { + + @Mock + private BattleService battleService; + @Mock + private NoticeService noticeService; + + @InjectMocks + private HomeService homeService; + + @Test + void getHome_명세기준으로_섹션별_데이터를_조합한다() { + BattleSummaryResponse editorPick = battle("editor-id", BATTLE); + BattleSummaryResponse trendingBattle = battle("trending-id", BATTLE); + BattleSummaryResponse bestBattle = battle("best-id", BATTLE); + BattleSummaryResponse todayVotePick = battle("today-vote-id", VOTE); + BattleSummaryResponse quizBattle = quiz("quiz-id"); + BattleSummaryResponse newBattle = battle("new-id", BATTLE); + + NoticeSummaryResponse notice = new NoticeSummaryResponse( + UUID.randomUUID(), + "notice", + "body", + null, + NoticePlacement.HOME_TOP, + true, + LocalDateTime.now().minusDays(1), + null + ); + + when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of(notice)); + when(battleService.getHomeEditorPicks()).thenReturn(List.of(editorPick)); + when(battleService.getHomeTrendingBattles()).thenReturn(List.of(trendingBattle)); + when(battleService.getHomeBestBattles()).thenReturn(List.of(bestBattle)); + when(battleService.getHomeTodayPicks(VOTE)).thenReturn(List.of(todayVotePick)); + when(battleService.getHomeTodayPicks(QUIZ)).thenReturn(List.of(quizBattle)); + when(battleService.getHomeNewBattles(List.of( + editorPick.battleId(), + trendingBattle.battleId(), + bestBattle.battleId(), + todayVotePick.battleId(), + quizBattle.battleId() + ))).thenReturn(List.of(newBattle)); + + var response = homeService.getHome(); + + assertThat(response.newNotice()).isTrue(); + assertThat(response.editorPicks()).extracting(HomeBattleResponse::title).containsExactly("editor-id"); + assertThat(response.trendingBattles()).extracting(HomeBattleResponse::title).containsExactly("trending-id"); + assertThat(response.bestBattles()).extracting(HomeBattleResponse::title).containsExactly("best-id"); + assertThat(response.todayPicks()).extracting(HomeBattleResponse::title).containsExactly("today-vote-id", "quiz-id"); + assertThat(response.newBattles()).extracting(HomeBattleResponse::title).containsExactly("new-id"); + assertThat(response.todayPicks().get(0).options()).extracting(option -> option.text()).containsExactly("A", "B"); + assertThat(response.todayPicks().get(1).options()).extracting(option -> option.text()).containsExactly("A", "B", "C", "D"); + + verify(battleService).getHomeNewBattles(argThat(ids -> ids.equals(List.of( + editorPick.battleId(), + trendingBattle.battleId(), + bestBattle.battleId(), + todayVotePick.battleId(), + quizBattle.battleId() + )))); + } + + @Test + void getHome_데이터가_없으면_false와_빈리스트를_반환한다() { + when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of()); + when(battleService.getHomeEditorPicks()).thenReturn(List.of()); + when(battleService.getHomeTrendingBattles()).thenReturn(List.of()); + when(battleService.getHomeBestBattles()).thenReturn(List.of()); + when(battleService.getHomeTodayPicks(VOTE)).thenReturn(List.of()); + when(battleService.getHomeTodayPicks(QUIZ)).thenReturn(List.of()); + when(battleService.getHomeNewBattles(List.of())).thenReturn(List.of()); + + var response = homeService.getHome(); + + assertThat(response.newNotice()).isFalse(); + assertThat(response.editorPicks()).isEmpty(); + assertThat(response.trendingBattles()).isEmpty(); + assertThat(response.bestBattles()).isEmpty(); + assertThat(response.todayPicks()).isEmpty(); + assertThat(response.newBattles()).isEmpty(); + } + + private BattleSummaryResponse battle(String title, BattleType type) { + return new BattleSummaryResponse( + UUID.randomUUID(), + title, + "summary", + "thumbnail", + type, + 10, + 20L, + 90, + List.of(), + List.of( + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "stance-a", "rep-a", "quote-a", "image-a", List.of()), + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "stance-b", "rep-b", "quote-b", "image-b", List.of()) + ) + ); + } + + private BattleSummaryResponse quiz(String title) { + return new BattleSummaryResponse( + UUID.randomUUID(), + title, + "summary", + "thumbnail", + QUIZ, + 30, + 40L, + 60, + List.of(), + List.of( + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "stance-a", "rep-a", "quote-a", "image-a", List.of()), + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "stance-b", "rep-b", "quote-b", "image-b", List.of()), + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.C, "C", "stance-c", "rep-c", "quote-c", "image-c", List.of()), + new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.D, "D", "stance-d", "rep-d", "quote-d", "image-d", List.of()) + ) + ); + } +} diff --git a/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java b/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java new file mode 100644 index 0000000..8e7be80 --- /dev/null +++ b/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java @@ -0,0 +1,65 @@ +package com.swyp.app.domain.notice.service; + +import com.swyp.app.domain.notice.entity.Notice; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import com.swyp.app.domain.notice.repository.NoticeRepository; +import com.swyp.app.global.common.exception.CustomException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NoticeServiceTest { + + @Mock + private NoticeRepository noticeRepository; + + @InjectMocks + private NoticeService noticeService; + + @Test + void getNoticeList_활성공지_목록을_개수와_함께_반환한다() { + Notice notice = Notice.builder() + .title("공지") + .body("내용") + .type(NoticeType.ANNOUNCEMENT) + .placement(NoticePlacement.HOME_TOP) + .pinned(true) + .startsAt(LocalDateTime.now().minusDays(1)) + .endsAt(LocalDateTime.now().plusDays(1)) + .build(); + + when(noticeRepository.findActiveNotices(any(LocalDateTime.class), eq(NoticeType.ANNOUNCEMENT), + eq(NoticePlacement.HOME_TOP), any(Pageable.class))).thenReturn(List.of(notice)); + + var response = noticeService.getNoticeList(NoticeType.ANNOUNCEMENT, NoticePlacement.HOME_TOP, 5); + + assertThat(response.totalCount()).isEqualTo(1); + assertThat(response.items()).hasSize(1); + assertThat(response.items().getFirst().title()).isEqualTo("공지"); + } + + @Test + void getNoticeDetail_활성공지가_없으면_예외를_던진다() { + UUID noticeId = UUID.randomUUID(); + when(noticeRepository.findActiveById(eq(noticeId), any(LocalDateTime.class))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> noticeService.getNoticeDetail(noticeId)) + .isInstanceOf(CustomException.class); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3262fa0..bbfa70b 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -11,3 +11,46 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.H2Dialect + + cloud: + gcp: + credentials: + location: /tmp/test-gcp.json + aws: + s3: + bucket: test-bucket + region: + static: ap-northeast-2 + credentials: + access-key: test-access-key + secret-key: test-secret-key + +oauth: + kakao: + client-id: test-kakao-client-id + client-secret: test-kakao-client-secret + google: + client-id: test-google-client-id + client-secret: test-google-client-secret + +jwt: + secret: dGVzdC10ZXN0LXRlc3QtdGVzdC10ZXN0LXRlc3QtdGVzdA== + access-token-expiration: 1800000 + refresh-token-expiration: 2592000000 + +openai: + api-key: test-openai-key + url: https://api.openai.com/v1/chat/completions + model: gpt-4o-mini + tts: + url: https://api.openai.com/v1/audio/speech + model: gpt-4o-mini-tts + +elevenlabs: + api-key: test-elevenlabs-key + model: test-model + voice-id: + a: test-voice-a + b: test-voice-b + user: test-voice-user + narrator: test-voice-narrator From c6ad852312904e648bd590daace5a6175abb81e6 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 12:45:46 +0900 Subject: [PATCH 03/18] =?UTF-8?q?#37=20[Fix]=20=ED=99=88=20API=20=EB=B0=B0?= =?UTF-8?q?=ED=8B=80=20=EC=BB=A8=EB=B2=84=ED=84=B0=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../battle/converter/BattleConverter.java | 15 +-- .../dto/response/TodayBattleResponse.java | 4 +- .../domain/battle/service/BattleService.java | 5 - .../battle/service/BattleServiceImpl.java | 41 ------ .../app/domain/home/service/HomeService.java | 22 ++-- .../battle/service/BattleServiceImplTest.java | 122 ------------------ .../domain/home/service/HomeServiceTest.java | 62 ++++----- 7 files changed, 46 insertions(+), 225 deletions(-) delete mode 100644 src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java diff --git a/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java index 167b4f8..9bfb5a6 100644 --- a/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java +++ b/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java @@ -41,19 +41,6 @@ public Battle toEntity(AdminBattleCreateRequest request, User admin) { public TodayBattleResponse toTodayResponse(Battle b, List tags, List opts) { return new TodayBattleResponse( - b.getId(), - b.getTitle(), - b.getSummary(), - b.getThumbnailUrl(), - b.getType(), - b.getAudioDuration() == null ? 0 : b.getAudioDuration(), - toTagResponses(tags, null), - toTodayOptionResponses(opts) - ); - } - - public BattleSummaryResponse toSummaryResponse(Battle b, List tags, List opts) { - return new BattleSummaryResponse( b.getId(), b.getTitle(), b.getSummary(), @@ -63,7 +50,7 @@ public BattleSummaryResponse toSummaryResponse(Battle b, List tags, List tags, // 상단 태그 리스트 List options // 중앙 세로형 대결 카드 데이터 -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleService.java b/src/main/java/com/swyp/app/domain/battle/service/BattleService.java index 6e383fe..dc7303f 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleService.java @@ -23,23 +23,18 @@ public interface BattleService { // 1. 에디터 픽 조회 (isEditorPick = true) List getEditorPicks(); - List getHomeEditorPicks(); // 2. 지금 뜨는 배틀 조회 (최근 24시간 투표 급증순) List getTrendingBattles(); - List getHomeTrendingBattles(); // 3. Best 배틀 조회 (누적 지표 랭킹) List getBestBattles(); - List getHomeBestBattles(); // 4. 오늘의 Pické 조회 (단일 타입 매칭) List getTodayPicks(BattleType type); - List getHomeTodayPicks(BattleType type); // 5. 새로운 배틀 조회 (중복 제외 리스트) List getNewBattles(List excludeIds); - List getHomeNewBattles(List excludeIds); // === [사용자용 - 기본 API] === diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java index 4bd43b7..9551555 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java @@ -67,12 +67,6 @@ public List getEditorPicks() { return convertToTodayResponses(battles); } - @Override - public List getHomeEditorPicks() { - List battles = battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, 10)); - return convertToSummaryResponses(battles); - } - @Override public List getTrendingBattles() { LocalDateTime yesterday = LocalDateTime.now().minusDays(1); @@ -80,37 +74,18 @@ public List getTrendingBattles() { return convertToTodayResponses(battles); } - @Override - public List getHomeTrendingBattles() { - LocalDateTime yesterday = LocalDateTime.now().minusDays(1); - List battles = battleRepository.findTrendingBattles(yesterday, PageRequest.of(0, 5)); - return convertToSummaryResponses(battles); - } - @Override public List getBestBattles() { List battles = battleRepository.findBestBattles(PageRequest.of(0, 5)); return convertToTodayResponses(battles); } - @Override - public List getHomeBestBattles() { - List battles = battleRepository.findBestBattles(PageRequest.of(0, 5)); - return convertToSummaryResponses(battles); - } - @Override public List getTodayPicks(BattleType type) { List battles = battleRepository.findTodayPicks(type, LocalDate.now()); return convertToTodayResponses(battles); } - @Override - public List getHomeTodayPicks(BattleType type) { - List battles = battleRepository.findTodayPicks(type, LocalDate.now()); - return convertToSummaryResponses(battles); - } - @Override public List getNewBattles(List excludeIds) { List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) @@ -119,14 +94,6 @@ public List getNewBattles(List excludeIds) { return convertToTodayResponses(battles); } - @Override - public List getHomeNewBattles(List excludeIds) { - List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) - ? List.of(UUID.randomUUID()) : excludeIds; - List battles = battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, 10)); - return convertToSummaryResponses(battles); - } - // [사용자용 - 기본 API] @Override @@ -250,14 +217,6 @@ private List convertToTodayResponses(List battles) }).toList(); } - private List convertToSummaryResponses(List battles) { - return battles.stream().map(battle -> { - List tags = getTagsByBattle(battle); - List options = battleOptionRepository.findByBattle(battle); - return battleConverter.toSummaryResponse(battle, tags, options); - }).toList(); - } - private List getTagsByBattle(Battle b) { return battleTagRepository.findByBattle(b).stream() .map(BattleTag::getTag) diff --git a/src/main/java/com/swyp/app/domain/home/service/HomeService.java b/src/main/java/com/swyp/app/domain/home/service/HomeService.java index 77de9c8..a2cd8e7 100644 --- a/src/main/java/com/swyp/app/domain/home/service/HomeService.java +++ b/src/main/java/com/swyp/app/domain/home/service/HomeService.java @@ -1,7 +1,7 @@ package com.swyp.app.domain.home.service; -import com.swyp.app.domain.battle.dto.response.BattleOptionResponse; -import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; +import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleType; import com.swyp.app.domain.battle.service.BattleService; import com.swyp.app.domain.home.dto.response.HomeBattleOptionResponse; @@ -30,16 +30,16 @@ public class HomeService { public HomeResponse getHome() { boolean newNotice = !noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, NOTICE_EXISTS_LIMIT).isEmpty(); - List editorPicks = toHomeBattles(battleService.getHomeEditorPicks()); - List trendingBattles = toHomeBattles(battleService.getHomeTrendingBattles()); - List bestBattles = toHomeBattles(battleService.getHomeBestBattles()); + List editorPicks = toHomeBattles(battleService.getEditorPicks()); + List trendingBattles = toHomeBattles(battleService.getTrendingBattles()); + List bestBattles = toHomeBattles(battleService.getBestBattles()); List todayPicks = new ArrayList<>(); - todayPicks.addAll(toHomeBattles(battleService.getHomeTodayPicks(BattleType.VOTE))); - todayPicks.addAll(toHomeBattles(battleService.getHomeTodayPicks(BattleType.QUIZ))); + todayPicks.addAll(toHomeBattles(battleService.getTodayPicks(BattleType.VOTE))); + todayPicks.addAll(toHomeBattles(battleService.getTodayPicks(BattleType.QUIZ))); List excludeIds = collectBattleIds(editorPicks, trendingBattles, bestBattles, todayPicks); - List newBattles = toHomeBattles(battleService.getHomeNewBattles(excludeIds)); + List newBattles = toHomeBattles(battleService.getNewBattles(excludeIds)); return new HomeResponse( newNotice, @@ -51,13 +51,13 @@ public HomeResponse getHome() { ); } - private List toHomeBattles(List battles) { + private List toHomeBattles(List battles) { return battles.stream() .map(this::toHomeBattle) .toList(); } - private HomeBattleResponse toHomeBattle(BattleSummaryResponse battle) { + private HomeBattleResponse toHomeBattle(TodayBattleResponse battle) { return new HomeBattleResponse( battle.battleId(), battle.title(), @@ -74,7 +74,7 @@ private HomeBattleResponse toHomeBattle(BattleSummaryResponse battle) { ); } - private HomeBattleOptionResponse toHomeOption(BattleOptionResponse option) { + private HomeBattleOptionResponse toHomeOption(TodayOptionResponse option) { return new HomeBattleOptionResponse(option.label(), option.title()); } diff --git a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java deleted file mode 100644 index e66b2ef..0000000 --- a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplTest.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.swyp.app.domain.battle.service; - -import com.swyp.app.domain.battle.converter.BattleConverter; -import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; -import com.swyp.app.domain.battle.entity.Battle; -import com.swyp.app.domain.battle.entity.BattleOption; -import com.swyp.app.domain.battle.entity.BattleTag; -import com.swyp.app.domain.battle.enums.BattleStatus; -import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.repository.BattleOptionRepository; -import com.swyp.app.domain.battle.repository.BattleOptionTagRepository; -import com.swyp.app.domain.battle.repository.BattleRepository; -import com.swyp.app.domain.battle.repository.BattleTagRepository; -import com.swyp.app.domain.tag.entity.Tag; -import com.swyp.app.domain.tag.repository.TagRepository; -import com.swyp.app.domain.user.repository.UserRepository; -import com.swyp.app.domain.vote.repository.VoteRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Pageable; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BattleServiceImplTest { - - @Mock - private BattleRepository battleRepository; - @Mock - private BattleOptionRepository battleOptionRepository; - @Mock - private BattleTagRepository battleTagRepository; - @Mock - private BattleOptionTagRepository battleOptionTagRepository; - @Mock - private TagRepository tagRepository; - @Mock - private UserRepository userRepository; - @Mock - private VoteRepository voteRepository; - @Mock - private BattleConverter battleConverter; - - @InjectMocks - private BattleServiceImpl battleService; - - private Battle battle; - - @BeforeEach - void setUp() { - battle = Battle.builder() - .title("battle") - .type(BattleType.BATTLE) - .targetDate(LocalDate.now()) - .status(BattleStatus.PUBLISHED) - .build(); - } - - @Test - void getHomeTrendingBattles_요약응답으로_변환한다() { - BattleSummaryResponse summary = new BattleSummaryResponse( - UUID.randomUUID(), - "trending", - "summary", - "thumbnail", - BattleType.BATTLE, - 12, - 34L, - 56, - List.of(), - List.of() - ); - - when(battleRepository.findTrendingBattles(any(LocalDateTime.class), any(Pageable.class))).thenReturn(List.of(battle)); - when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); - when(battleTagRepository.findByBattle(battle)).thenReturn(List.of()); - when(battleConverter.toSummaryResponse(eq(battle), eq(List.of()), eq(List.of()))) - .thenReturn(summary); - - var result = battleService.getHomeTrendingBattles(); - - assertThat(result).containsExactly(summary); - } - - @Test - void getHomeNewBattles_제외아이디가_비어있으면_조회용_기본값을_사용한다() { - BattleSummaryResponse summary = new BattleSummaryResponse( - UUID.randomUUID(), - "new", - "summary", - "thumbnail", - BattleType.BATTLE, - 1, - 2L, - 3, - List.of(), - List.of() - ); - - when(battleRepository.findNewBattlesExcluding(any(), any(Pageable.class))).thenReturn(List.of(battle)); - when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); - when(battleTagRepository.findByBattle(battle)).thenReturn(List.of()); - when(battleConverter.toSummaryResponse(eq(battle), eq(List.of()), eq(List.of()))) - .thenReturn(summary); - - var result = battleService.getHomeNewBattles(List.of()); - - assertThat(result).containsExactly(summary); - } -} diff --git a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java index 5e6ac99..dfbdc76 100644 --- a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -1,7 +1,7 @@ package com.swyp.app.domain.home.service; -import com.swyp.app.domain.battle.dto.response.BattleOptionResponse; -import com.swyp.app.domain.battle.dto.response.BattleSummaryResponse; +import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; +import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.enums.BattleType; import com.swyp.app.domain.battle.service.BattleService; @@ -40,12 +40,12 @@ class HomeServiceTest { @Test void getHome_명세기준으로_섹션별_데이터를_조합한다() { - BattleSummaryResponse editorPick = battle("editor-id", BATTLE); - BattleSummaryResponse trendingBattle = battle("trending-id", BATTLE); - BattleSummaryResponse bestBattle = battle("best-id", BATTLE); - BattleSummaryResponse todayVotePick = battle("today-vote-id", VOTE); - BattleSummaryResponse quizBattle = quiz("quiz-id"); - BattleSummaryResponse newBattle = battle("new-id", BATTLE); + TodayBattleResponse editorPick = battle("editor-id", BATTLE); + TodayBattleResponse trendingBattle = battle("trending-id", BATTLE); + TodayBattleResponse bestBattle = battle("best-id", BATTLE); + TodayBattleResponse todayVotePick = battle("today-vote-id", VOTE); + TodayBattleResponse quizBattle = quiz("quiz-id"); + TodayBattleResponse newBattle = battle("new-id", BATTLE); NoticeSummaryResponse notice = new NoticeSummaryResponse( UUID.randomUUID(), @@ -59,12 +59,12 @@ class HomeServiceTest { ); when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of(notice)); - when(battleService.getHomeEditorPicks()).thenReturn(List.of(editorPick)); - when(battleService.getHomeTrendingBattles()).thenReturn(List.of(trendingBattle)); - when(battleService.getHomeBestBattles()).thenReturn(List.of(bestBattle)); - when(battleService.getHomeTodayPicks(VOTE)).thenReturn(List.of(todayVotePick)); - when(battleService.getHomeTodayPicks(QUIZ)).thenReturn(List.of(quizBattle)); - when(battleService.getHomeNewBattles(List.of( + when(battleService.getEditorPicks()).thenReturn(List.of(editorPick)); + when(battleService.getTrendingBattles()).thenReturn(List.of(trendingBattle)); + when(battleService.getBestBattles()).thenReturn(List.of(bestBattle)); + when(battleService.getTodayPicks(VOTE)).thenReturn(List.of(todayVotePick)); + when(battleService.getTodayPicks(QUIZ)).thenReturn(List.of(quizBattle)); + when(battleService.getNewBattles(List.of( editorPick.battleId(), trendingBattle.battleId(), bestBattle.battleId(), @@ -83,7 +83,7 @@ class HomeServiceTest { assertThat(response.todayPicks().get(0).options()).extracting(option -> option.text()).containsExactly("A", "B"); assertThat(response.todayPicks().get(1).options()).extracting(option -> option.text()).containsExactly("A", "B", "C", "D"); - verify(battleService).getHomeNewBattles(argThat(ids -> ids.equals(List.of( + verify(battleService).getNewBattles(argThat(ids -> ids.equals(List.of( editorPick.battleId(), trendingBattle.battleId(), bestBattle.battleId(), @@ -95,12 +95,12 @@ class HomeServiceTest { @Test void getHome_데이터가_없으면_false와_빈리스트를_반환한다() { when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of()); - when(battleService.getHomeEditorPicks()).thenReturn(List.of()); - when(battleService.getHomeTrendingBattles()).thenReturn(List.of()); - when(battleService.getHomeBestBattles()).thenReturn(List.of()); - when(battleService.getHomeTodayPicks(VOTE)).thenReturn(List.of()); - when(battleService.getHomeTodayPicks(QUIZ)).thenReturn(List.of()); - when(battleService.getHomeNewBattles(List.of())).thenReturn(List.of()); + when(battleService.getEditorPicks()).thenReturn(List.of()); + when(battleService.getTrendingBattles()).thenReturn(List.of()); + when(battleService.getBestBattles()).thenReturn(List.of()); + when(battleService.getTodayPicks(VOTE)).thenReturn(List.of()); + when(battleService.getTodayPicks(QUIZ)).thenReturn(List.of()); + when(battleService.getNewBattles(List.of())).thenReturn(List.of()); var response = homeService.getHome(); @@ -112,8 +112,8 @@ class HomeServiceTest { assertThat(response.newBattles()).isEmpty(); } - private BattleSummaryResponse battle(String title, BattleType type) { - return new BattleSummaryResponse( + private TodayBattleResponse battle(String title, BattleType type) { + return new TodayBattleResponse( UUID.randomUUID(), title, "summary", @@ -124,14 +124,14 @@ private BattleSummaryResponse battle(String title, BattleType type) { 90, List.of(), List.of( - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "stance-a", "rep-a", "quote-a", "image-a", List.of()), - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "stance-b", "rep-b", "quote-b", "image-b", List.of()) + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a"), + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b") ) ); } - private BattleSummaryResponse quiz(String title) { - return new BattleSummaryResponse( + private TodayBattleResponse quiz(String title) { + return new TodayBattleResponse( UUID.randomUUID(), title, "summary", @@ -142,10 +142,10 @@ private BattleSummaryResponse quiz(String title) { 60, List.of(), List.of( - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "stance-a", "rep-a", "quote-a", "image-a", List.of()), - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "stance-b", "rep-b", "quote-b", "image-b", List.of()), - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.C, "C", "stance-c", "rep-c", "quote-c", "image-c", List.of()), - new BattleOptionResponse(UUID.randomUUID(), BattleOptionLabel.D, "D", "stance-d", "rep-d", "quote-d", "image-d", List.of()) + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a"), + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b"), + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.C, "C", "rep-c", "stance-c", "image-c"), + new TodayOptionResponse(UUID.randomUUID(), BattleOptionLabel.D, "D", "rep-d", "stance-d", "image-d") ) ); } From 284a8a1a48bd0d5136dab7bf2dd0671f3cc9ce73 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 12:59:50 +0900 Subject: [PATCH 04/18] =?UTF-8?q?#37=20[Refactor]=20=ED=99=88=20=EB=B0=B0?= =?UTF-8?q?=ED=8B=80=20=EC=A1=B0=ED=9A=8C=20V2=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BattleOptionRepository.java | 4 + .../repository/BattleTagRepository.java | 6 +- .../battle/service/BattleServiceImplV2.java | 123 ++++++++++++++++++ .../battle/service/HomeBattleService.java | 20 +++ .../app/domain/home/service/HomeService.java | 4 +- .../service/BattleServiceImplV2Test.java | 118 +++++++++++++++++ .../domain/home/service/HomeServiceTest.java | 4 +- 7 files changed, 274 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java create mode 100644 src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java create mode 100644 src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java index d00339f..cd69891 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java @@ -4,6 +4,8 @@ import com.swyp.app.domain.battle.entity.BattleOption; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -12,6 +14,8 @@ public interface BattleOptionRepository extends JpaRepository { List findByBattle(Battle battle); + @Query("SELECT battleOption FROM BattleOption battleOption WHERE battleOption.battle IN :battles ORDER BY battleOption.battle.id ASC, battleOption.label ASC") + List findByBattleInOrderByBattleIdAscLabelAsc(@Param("battles") List battles); Optional findByBattleAndLabel(Battle battle, BattleOptionLabel label); } diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java index 38a5c8a..53afda9 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java @@ -4,12 +4,16 @@ import com.swyp.app.domain.battle.entity.BattleTag; import com.swyp.app.domain.tag.entity.Tag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.UUID; public interface BattleTagRepository extends JpaRepository { List findByBattle(Battle battle); + @Query("SELECT battleTag FROM BattleTag battleTag JOIN FETCH battleTag.tag WHERE battleTag.battle IN :battles") + List findByBattleIn(@Param("battles") List battles); void deleteByBattle(Battle battle); boolean existsByTag(Tag tag); -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java new file mode 100644 index 0000000..f0918c9 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java @@ -0,0 +1,123 @@ +package com.swyp.app.domain.battle.service; + +import com.swyp.app.domain.battle.converter.BattleConverter; +import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.entity.BattleTag; +import com.swyp.app.domain.battle.enums.BattleStatus; +import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.battle.repository.BattleOptionRepository; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; +import com.swyp.app.domain.tag.entity.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BattleServiceImplV2 implements HomeBattleService { + + private final BattleRepository battleRepository; + private final BattleOptionRepository battleOptionRepository; + private final BattleTagRepository battleTagRepository; + private final BattleConverter battleConverter; + + @Override + public List getEditorPicks() { + return convertToTodayResponses( + battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, 10)) + ); + } + + @Override + public List getTrendingBattles() { + return convertToTodayResponses( + battleRepository.findTrendingBattles(LocalDateTime.now().minusDays(1), PageRequest.of(0, 5)) + ); + } + + @Override + public List getBestBattles() { + return convertToTodayResponses( + battleRepository.findBestBattles(PageRequest.of(0, 5)) + ); + } + + @Override + public List getTodayPicks(BattleType type) { + return convertToTodayResponses( + battleRepository.findTodayPicks(type, LocalDate.now()) + ); + } + + @Override + public List getNewBattles(List excludeIds) { + List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) + ? List.of(UUID.randomUUID()) + : excludeIds; + + return convertToTodayResponses( + battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, 10)) + ); + } + + private List convertToTodayResponses(List battles) { + if (battles.isEmpty()) { + return List.of(); + } + + Map> tagsByBattleId = loadTagsByBattleId(battles); + Map> optionsByBattleId = loadOptionsByBattleId(battles); + + return battles.stream() + .map(battle -> battleConverter.toTodayResponse( + battle, + tagsByBattleId.getOrDefault(battle.getId(), List.of()), + optionsByBattleId.getOrDefault(battle.getId(), List.of()) + )) + .toList(); + } + + private Map> loadTagsByBattleId(List battles) { + Map> tagsByBattleId = new HashMap<>(); + + for (BattleTag battleTag : battleTagRepository.findByBattleIn(battles)) { + Tag tag = battleTag.getTag(); + if (tag.getDeletedAt() != null) { + continue; + } + + UUID battleId = battleTag.getBattle().getId(); + tagsByBattleId.computeIfAbsent(battleId, ignored -> new ArrayList<>()).add(tag); + } + + return tagsByBattleId; + } + + private Map> loadOptionsByBattleId(List battles) { + Map> optionsByBattleId = new HashMap<>(); + + for (BattleOption option : battleOptionRepository.findByBattleInOrderByBattleIdAscLabelAsc(battles)) { + UUID battleId = option.getBattle().getId(); + optionsByBattleId.computeIfAbsent(battleId, ignored -> new ArrayList<>()).add(option); + } + + optionsByBattleId.values() + .forEach(options -> options.sort(Comparator.comparing(BattleOption::getLabel))); + + return optionsByBattleId; + } +} diff --git a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java new file mode 100644 index 0000000..d5a9958 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java @@ -0,0 +1,20 @@ +package com.swyp.app.domain.battle.service; + +import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; +import com.swyp.app.domain.battle.enums.BattleType; + +import java.util.List; +import java.util.UUID; + +public interface HomeBattleService { + + List getEditorPicks(); + + List getTrendingBattles(); + + List getBestBattles(); + + List getTodayPicks(BattleType type); + + List getNewBattles(List excludeIds); +} diff --git a/src/main/java/com/swyp/app/domain/home/service/HomeService.java b/src/main/java/com/swyp/app/domain/home/service/HomeService.java index a2cd8e7..1d068d8 100644 --- a/src/main/java/com/swyp/app/domain/home/service/HomeService.java +++ b/src/main/java/com/swyp/app/domain/home/service/HomeService.java @@ -3,7 +3,7 @@ import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.service.BattleService; +import com.swyp.app.domain.battle.service.HomeBattleService; import com.swyp.app.domain.home.dto.response.HomeBattleOptionResponse; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.home.dto.response.HomeResponse; @@ -24,7 +24,7 @@ public class HomeService { private static final int NOTICE_EXISTS_LIMIT = 1; - private final BattleService battleService; + private final HomeBattleService battleService; private final NoticeService noticeService; public HomeResponse getHome() { diff --git a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java new file mode 100644 index 0000000..f451623 --- /dev/null +++ b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java @@ -0,0 +1,118 @@ +package com.swyp.app.domain.battle.service; + +import com.swyp.app.domain.battle.converter.BattleConverter; +import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.entity.BattleTag; +import com.swyp.app.domain.battle.enums.BattleOptionLabel; +import com.swyp.app.domain.battle.enums.BattleStatus; +import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.battle.repository.BattleOptionRepository; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; +import com.swyp.app.domain.tag.entity.Tag; +import com.swyp.app.domain.tag.enums.TagType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BattleServiceImplV2Test { + + @Mock + private BattleRepository battleRepository; + @Mock + private BattleOptionRepository battleOptionRepository; + @Mock + private BattleTagRepository battleTagRepository; + @Mock + private BattleConverter battleConverter; + + @InjectMocks + private BattleServiceImplV2 battleService; + + private Battle battle; + private BattleOption optionA; + private BattleOption optionB; + private Tag tag; + + @BeforeEach + void setUp() { + battle = Battle.builder() + .title("battle") + .summary("summary") + .thumbnailUrl("thumbnail") + .type(BattleType.BATTLE) + .targetDate(LocalDate.now()) + .status(BattleStatus.PUBLISHED) + .build(); + + optionA = BattleOption.builder() + .battle(battle) + .label(BattleOptionLabel.A) + .title("A") + .stance("stance-a") + .representative("rep-a") + .imageUrl("image-a") + .build(); + + optionB = BattleOption.builder() + .battle(battle) + .label(BattleOptionLabel.B) + .title("B") + .stance("stance-b") + .representative("rep-b") + .imageUrl("image-b") + .build(); + + tag = Tag.builder() + .name("태그") + .type(TagType.CATEGORY) + .build(); + } + + @Test + void getTrendingBattles_배치조회한_태그와_옵션으로_변환한다() { + BattleTag battleTag = BattleTag.builder().battle(battle).tag(tag).build(); + TodayBattleResponse response = new TodayBattleResponse( + UUID.randomUUID(), + "title", + "summary", + "thumbnail", + BattleType.BATTLE, + 1, + 2L, + 3, + List.of(), + List.of() + ); + + when(battleRepository.findTrendingBattles(any(LocalDateTime.class), any(Pageable.class))).thenReturn(List.of(battle)); + when(battleTagRepository.findByBattleIn(List.of(battle))).thenReturn(List.of(battleTag)); + when(battleOptionRepository.findByBattleInOrderByBattleIdAscLabelAsc(List.of(battle))).thenReturn(List.of(optionA, optionB)); + when(battleConverter.toTodayResponse(eq(battle), eq(List.of(tag)), eq(List.of(optionA, optionB)))) + .thenReturn(response); + + var result = battleService.getTrendingBattles(); + + assertThat(result).containsExactly(response); + verify(battleTagRepository).findByBattleIn(List.of(battle)); + verify(battleOptionRepository).findByBattleInOrderByBattleIdAscLabelAsc(List.of(battle)); + } +} diff --git a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java index dfbdc76..bc54eed 100644 --- a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -4,7 +4,7 @@ import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.service.BattleService; +import com.swyp.app.domain.battle.service.HomeBattleService; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.entity.NoticePlacement; @@ -31,7 +31,7 @@ class HomeServiceTest { @Mock - private BattleService battleService; + private HomeBattleService battleService; @Mock private NoticeService noticeService; From 4d2fca47ff904c7edb7221d217172a50d5a7cb4a Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:05:56 +0900 Subject: [PATCH 05/18] =?UTF-8?q?#37=20[Refactor]=20=ED=99=88=20V2=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=AA=85=EC=B9=AD=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/app/domain/battle/service/BattleServiceImplV2.java | 2 +- .../service/{HomeBattleService.java => HomeServiceV2.java} | 2 +- .../java/com/swyp/app/domain/home/service/HomeService.java | 4 ++-- .../com/swyp/app/domain/home/service/HomeServiceTest.java | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/main/java/com/swyp/app/domain/battle/service/{HomeBattleService.java => HomeServiceV2.java} (93%) diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java index f0918c9..b3a87f3 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java @@ -28,7 +28,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class BattleServiceImplV2 implements HomeBattleService { +public class BattleServiceImplV2 implements HomeServiceV2 { private final BattleRepository battleRepository; private final BattleOptionRepository battleOptionRepository; diff --git a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java b/src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java similarity index 93% rename from src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java rename to src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java index d5a9958..067ac82 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java +++ b/src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.UUID; -public interface HomeBattleService { +public interface HomeServiceV2 { List getEditorPicks(); diff --git a/src/main/java/com/swyp/app/domain/home/service/HomeService.java b/src/main/java/com/swyp/app/domain/home/service/HomeService.java index 1d068d8..49d2b33 100644 --- a/src/main/java/com/swyp/app/domain/home/service/HomeService.java +++ b/src/main/java/com/swyp/app/domain/home/service/HomeService.java @@ -3,7 +3,7 @@ import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.service.HomeBattleService; +import com.swyp.app.domain.battle.service.HomeServiceV2; import com.swyp.app.domain.home.dto.response.HomeBattleOptionResponse; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.home.dto.response.HomeResponse; @@ -24,7 +24,7 @@ public class HomeService { private static final int NOTICE_EXISTS_LIMIT = 1; - private final HomeBattleService battleService; + private final HomeServiceV2 battleService; private final NoticeService noticeService; public HomeResponse getHome() { diff --git a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java index bc54eed..eb3dd8c 100644 --- a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -4,7 +4,7 @@ import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.service.HomeBattleService; +import com.swyp.app.domain.battle.service.HomeServiceV2; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.entity.NoticePlacement; @@ -31,7 +31,7 @@ class HomeServiceTest { @Mock - private HomeBattleService battleService; + private HomeServiceV2 battleService; @Mock private NoticeService noticeService; From d1c3c981a5771f3b886f93bb7e37e276979cd491 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:10:35 +0900 Subject: [PATCH 06/18] =?UTF-8?q?Revert=20"#37=20[Refactor]=20=ED=99=88=20?= =?UTF-8?q?V2=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=AA=85=EC=B9=AD=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4d2fca47ff904c7edb7221d217172a50d5a7cb4a. --- .../swyp/app/domain/battle/service/BattleServiceImplV2.java | 2 +- .../service/{HomeServiceV2.java => HomeBattleService.java} | 2 +- .../java/com/swyp/app/domain/home/service/HomeService.java | 4 ++-- .../com/swyp/app/domain/home/service/HomeServiceTest.java | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) rename src/main/java/com/swyp/app/domain/battle/service/{HomeServiceV2.java => HomeBattleService.java} (93%) diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java index b3a87f3..f0918c9 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java @@ -28,7 +28,7 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class BattleServiceImplV2 implements HomeServiceV2 { +public class BattleServiceImplV2 implements HomeBattleService { private final BattleRepository battleRepository; private final BattleOptionRepository battleOptionRepository; diff --git a/src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java similarity index 93% rename from src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java rename to src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java index 067ac82..d5a9958 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/HomeServiceV2.java +++ b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java @@ -6,7 +6,7 @@ import java.util.List; import java.util.UUID; -public interface HomeServiceV2 { +public interface HomeBattleService { List getEditorPicks(); diff --git a/src/main/java/com/swyp/app/domain/home/service/HomeService.java b/src/main/java/com/swyp/app/domain/home/service/HomeService.java index 49d2b33..1d068d8 100644 --- a/src/main/java/com/swyp/app/domain/home/service/HomeService.java +++ b/src/main/java/com/swyp/app/domain/home/service/HomeService.java @@ -3,7 +3,7 @@ import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.service.HomeServiceV2; +import com.swyp.app.domain.battle.service.HomeBattleService; import com.swyp.app.domain.home.dto.response.HomeBattleOptionResponse; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.home.dto.response.HomeResponse; @@ -24,7 +24,7 @@ public class HomeService { private static final int NOTICE_EXISTS_LIMIT = 1; - private final HomeServiceV2 battleService; + private final HomeBattleService battleService; private final NoticeService noticeService; public HomeResponse getHome() { diff --git a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java index eb3dd8c..bc54eed 100644 --- a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -4,7 +4,7 @@ import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.service.HomeServiceV2; +import com.swyp.app.domain.battle.service.HomeBattleService; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.entity.NoticePlacement; @@ -31,7 +31,7 @@ class HomeServiceTest { @Mock - private HomeServiceV2 battleService; + private HomeBattleService battleService; @Mock private NoticeService noticeService; From 980b9248fa485a5a6c584e0bb341d3cbde3c7c79 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:10:35 +0900 Subject: [PATCH 07/18] =?UTF-8?q?Revert=20"#37=20[Refactor]=20=ED=99=88=20?= =?UTF-8?q?=EB=B0=B0=ED=8B=80=20=EC=A1=B0=ED=9A=8C=20V2=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 284a8a1a48bd0d5136dab7bf2dd0671f3cc9ce73. --- .../repository/BattleOptionRepository.java | 4 - .../repository/BattleTagRepository.java | 6 +- .../battle/service/BattleServiceImplV2.java | 123 ------------------ .../battle/service/HomeBattleService.java | 20 --- .../app/domain/home/service/HomeService.java | 4 +- .../service/BattleServiceImplV2Test.java | 118 ----------------- .../domain/home/service/HomeServiceTest.java | 4 +- 7 files changed, 5 insertions(+), 274 deletions(-) delete mode 100644 src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java delete mode 100644 src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java delete mode 100644 src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java index cd69891..d00339f 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleOptionRepository.java @@ -4,8 +4,6 @@ import com.swyp.app.domain.battle.entity.BattleOption; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -14,8 +12,6 @@ public interface BattleOptionRepository extends JpaRepository { List findByBattle(Battle battle); - @Query("SELECT battleOption FROM BattleOption battleOption WHERE battleOption.battle IN :battles ORDER BY battleOption.battle.id ASC, battleOption.label ASC") - List findByBattleInOrderByBattleIdAscLabelAsc(@Param("battles") List battles); Optional findByBattleAndLabel(Battle battle, BattleOptionLabel label); } diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java index 53afda9..38a5c8a 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java @@ -4,16 +4,12 @@ import com.swyp.app.domain.battle.entity.BattleTag; import com.swyp.app.domain.tag.entity.Tag; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import java.util.List; import java.util.UUID; public interface BattleTagRepository extends JpaRepository { List findByBattle(Battle battle); - @Query("SELECT battleTag FROM BattleTag battleTag JOIN FETCH battleTag.tag WHERE battleTag.battle IN :battles") - List findByBattleIn(@Param("battles") List battles); void deleteByBattle(Battle battle); boolean existsByTag(Tag tag); -} +} \ No newline at end of file diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java deleted file mode 100644 index f0918c9..0000000 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImplV2.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.swyp.app.domain.battle.service; - -import com.swyp.app.domain.battle.converter.BattleConverter; -import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; -import com.swyp.app.domain.battle.entity.Battle; -import com.swyp.app.domain.battle.entity.BattleOption; -import com.swyp.app.domain.battle.entity.BattleTag; -import com.swyp.app.domain.battle.enums.BattleStatus; -import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.repository.BattleOptionRepository; -import com.swyp.app.domain.battle.repository.BattleRepository; -import com.swyp.app.domain.battle.repository.BattleTagRepository; -import com.swyp.app.domain.tag.entity.Tag; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class BattleServiceImplV2 implements HomeBattleService { - - private final BattleRepository battleRepository; - private final BattleOptionRepository battleOptionRepository; - private final BattleTagRepository battleTagRepository; - private final BattleConverter battleConverter; - - @Override - public List getEditorPicks() { - return convertToTodayResponses( - battleRepository.findEditorPicks(BattleStatus.PUBLISHED, PageRequest.of(0, 10)) - ); - } - - @Override - public List getTrendingBattles() { - return convertToTodayResponses( - battleRepository.findTrendingBattles(LocalDateTime.now().minusDays(1), PageRequest.of(0, 5)) - ); - } - - @Override - public List getBestBattles() { - return convertToTodayResponses( - battleRepository.findBestBattles(PageRequest.of(0, 5)) - ); - } - - @Override - public List getTodayPicks(BattleType type) { - return convertToTodayResponses( - battleRepository.findTodayPicks(type, LocalDate.now()) - ); - } - - @Override - public List getNewBattles(List excludeIds) { - List finalExcludeIds = (excludeIds == null || excludeIds.isEmpty()) - ? List.of(UUID.randomUUID()) - : excludeIds; - - return convertToTodayResponses( - battleRepository.findNewBattlesExcluding(finalExcludeIds, PageRequest.of(0, 10)) - ); - } - - private List convertToTodayResponses(List battles) { - if (battles.isEmpty()) { - return List.of(); - } - - Map> tagsByBattleId = loadTagsByBattleId(battles); - Map> optionsByBattleId = loadOptionsByBattleId(battles); - - return battles.stream() - .map(battle -> battleConverter.toTodayResponse( - battle, - tagsByBattleId.getOrDefault(battle.getId(), List.of()), - optionsByBattleId.getOrDefault(battle.getId(), List.of()) - )) - .toList(); - } - - private Map> loadTagsByBattleId(List battles) { - Map> tagsByBattleId = new HashMap<>(); - - for (BattleTag battleTag : battleTagRepository.findByBattleIn(battles)) { - Tag tag = battleTag.getTag(); - if (tag.getDeletedAt() != null) { - continue; - } - - UUID battleId = battleTag.getBattle().getId(); - tagsByBattleId.computeIfAbsent(battleId, ignored -> new ArrayList<>()).add(tag); - } - - return tagsByBattleId; - } - - private Map> loadOptionsByBattleId(List battles) { - Map> optionsByBattleId = new HashMap<>(); - - for (BattleOption option : battleOptionRepository.findByBattleInOrderByBattleIdAscLabelAsc(battles)) { - UUID battleId = option.getBattle().getId(); - optionsByBattleId.computeIfAbsent(battleId, ignored -> new ArrayList<>()).add(option); - } - - optionsByBattleId.values() - .forEach(options -> options.sort(Comparator.comparing(BattleOption::getLabel))); - - return optionsByBattleId; - } -} diff --git a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java b/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java deleted file mode 100644 index d5a9958..0000000 --- a/src/main/java/com/swyp/app/domain/battle/service/HomeBattleService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.swyp.app.domain.battle.service; - -import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; -import com.swyp.app.domain.battle.enums.BattleType; - -import java.util.List; -import java.util.UUID; - -public interface HomeBattleService { - - List getEditorPicks(); - - List getTrendingBattles(); - - List getBestBattles(); - - List getTodayPicks(BattleType type); - - List getNewBattles(List excludeIds); -} diff --git a/src/main/java/com/swyp/app/domain/home/service/HomeService.java b/src/main/java/com/swyp/app/domain/home/service/HomeService.java index 1d068d8..a2cd8e7 100644 --- a/src/main/java/com/swyp/app/domain/home/service/HomeService.java +++ b/src/main/java/com/swyp/app/domain/home/service/HomeService.java @@ -3,7 +3,7 @@ import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.service.HomeBattleService; +import com.swyp.app.domain.battle.service.BattleService; import com.swyp.app.domain.home.dto.response.HomeBattleOptionResponse; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.home.dto.response.HomeResponse; @@ -24,7 +24,7 @@ public class HomeService { private static final int NOTICE_EXISTS_LIMIT = 1; - private final HomeBattleService battleService; + private final BattleService battleService; private final NoticeService noticeService; public HomeResponse getHome() { diff --git a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java b/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java deleted file mode 100644 index f451623..0000000 --- a/src/test/java/com/swyp/app/domain/battle/service/BattleServiceImplV2Test.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.swyp.app.domain.battle.service; - -import com.swyp.app.domain.battle.converter.BattleConverter; -import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; -import com.swyp.app.domain.battle.entity.Battle; -import com.swyp.app.domain.battle.entity.BattleOption; -import com.swyp.app.domain.battle.entity.BattleTag; -import com.swyp.app.domain.battle.enums.BattleOptionLabel; -import com.swyp.app.domain.battle.enums.BattleStatus; -import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.repository.BattleOptionRepository; -import com.swyp.app.domain.battle.repository.BattleRepository; -import com.swyp.app.domain.battle.repository.BattleTagRepository; -import com.swyp.app.domain.tag.entity.Tag; -import com.swyp.app.domain.tag.enums.TagType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Pageable; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -class BattleServiceImplV2Test { - - @Mock - private BattleRepository battleRepository; - @Mock - private BattleOptionRepository battleOptionRepository; - @Mock - private BattleTagRepository battleTagRepository; - @Mock - private BattleConverter battleConverter; - - @InjectMocks - private BattleServiceImplV2 battleService; - - private Battle battle; - private BattleOption optionA; - private BattleOption optionB; - private Tag tag; - - @BeforeEach - void setUp() { - battle = Battle.builder() - .title("battle") - .summary("summary") - .thumbnailUrl("thumbnail") - .type(BattleType.BATTLE) - .targetDate(LocalDate.now()) - .status(BattleStatus.PUBLISHED) - .build(); - - optionA = BattleOption.builder() - .battle(battle) - .label(BattleOptionLabel.A) - .title("A") - .stance("stance-a") - .representative("rep-a") - .imageUrl("image-a") - .build(); - - optionB = BattleOption.builder() - .battle(battle) - .label(BattleOptionLabel.B) - .title("B") - .stance("stance-b") - .representative("rep-b") - .imageUrl("image-b") - .build(); - - tag = Tag.builder() - .name("태그") - .type(TagType.CATEGORY) - .build(); - } - - @Test - void getTrendingBattles_배치조회한_태그와_옵션으로_변환한다() { - BattleTag battleTag = BattleTag.builder().battle(battle).tag(tag).build(); - TodayBattleResponse response = new TodayBattleResponse( - UUID.randomUUID(), - "title", - "summary", - "thumbnail", - BattleType.BATTLE, - 1, - 2L, - 3, - List.of(), - List.of() - ); - - when(battleRepository.findTrendingBattles(any(LocalDateTime.class), any(Pageable.class))).thenReturn(List.of(battle)); - when(battleTagRepository.findByBattleIn(List.of(battle))).thenReturn(List.of(battleTag)); - when(battleOptionRepository.findByBattleInOrderByBattleIdAscLabelAsc(List.of(battle))).thenReturn(List.of(optionA, optionB)); - when(battleConverter.toTodayResponse(eq(battle), eq(List.of(tag)), eq(List.of(optionA, optionB)))) - .thenReturn(response); - - var result = battleService.getTrendingBattles(); - - assertThat(result).containsExactly(response); - verify(battleTagRepository).findByBattleIn(List.of(battle)); - verify(battleOptionRepository).findByBattleInOrderByBattleIdAscLabelAsc(List.of(battle)); - } -} diff --git a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java index bc54eed..dfbdc76 100644 --- a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -4,7 +4,7 @@ import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.enums.BattleType; -import com.swyp.app.domain.battle.service.HomeBattleService; +import com.swyp.app.domain.battle.service.BattleService; import com.swyp.app.domain.home.dto.response.HomeBattleResponse; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.entity.NoticePlacement; @@ -31,7 +31,7 @@ class HomeServiceTest { @Mock - private HomeBattleService battleService; + private BattleService battleService; @Mock private NoticeService noticeService; From 51e9a1af6700008b8f2924ee24b5ba509a09fcb4 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:32:09 +0900 Subject: [PATCH 08/18] =?UTF-8?q?#36=20[Refactor]=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=B0=8F=20onboarding=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserController, 불필요 DTO 11개, UserService 메서드 삭제 - User 엔티티에서 onboardingCompleted 필드/메서드 완전 제거 - AuthService 신규 유저 초기 status를 ACTIVE로 변경 Co-Authored-By: Claude Opus 4.6 --- .../app/domain/oauth/service/AuthService.java | 3 +- .../user/controller/UserController.java | 91 ------- .../CreateOnboardingProfileRequest.java | 15 -- .../request/UpdateTendencyScoreRequest.java | 20 -- .../request/UpdateUserSettingsRequest.java | 18 -- .../user/dto/response/BootstrapResponse.java | 6 - .../response/OnboardingProfileResponse.java | 16 -- .../TendencyScoreHistoryItemResponse.java | 15 -- .../TendencyScoreHistoryResponse.java | 9 - .../dto/response/TendencyScoreResponse.java | 16 -- .../dto/response/UpdateResultResponse.java | 6 - .../dto/response/UserProfileResponse.java | 13 - .../dto/response/UserSettingsResponse.java | 9 - .../com/swyp/app/domain/user/entity/User.java | 11 +- .../app/domain/user/service/UserService.java | 251 +----------------- 15 files changed, 6 insertions(+), 493 deletions(-) delete mode 100644 src/main/java/com/swyp/app/domain/user/controller/UserController.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/request/CreateOnboardingProfileRequest.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/request/UpdateTendencyScoreRequest.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/request/UpdateUserSettingsRequest.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/BootstrapResponse.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/OnboardingProfileResponse.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreHistoryItemResponse.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreHistoryResponse.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreResponse.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/UpdateResultResponse.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/UserProfileResponse.java delete mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/UserSettingsResponse.java diff --git a/src/main/java/com/swyp/app/domain/oauth/service/AuthService.java b/src/main/java/com/swyp/app/domain/oauth/service/AuthService.java index cd00057..d6be72c 100644 --- a/src/main/java/com/swyp/app/domain/oauth/service/AuthService.java +++ b/src/main/java/com/swyp/app/domain/oauth/service/AuthService.java @@ -57,8 +57,7 @@ public LoginResponse login(String provider, LoginRequest request) { user = User.builder() .userTag(generateUserTag()) .role(UserRole.USER) - .status(UserStatus.PENDING) - .onboardingCompleted(false) + .status(UserStatus.ACTIVE) .build(); userRepository.save(user); diff --git a/src/main/java/com/swyp/app/domain/user/controller/UserController.java b/src/main/java/com/swyp/app/domain/user/controller/UserController.java deleted file mode 100644 index 15c1e4e..0000000 --- a/src/main/java/com/swyp/app/domain/user/controller/UserController.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.swyp.app.domain.user.controller; - -import com.swyp.app.domain.user.dto.request.CreateOnboardingProfileRequest; -import com.swyp.app.domain.user.dto.request.UpdateTendencyScoreRequest; -import com.swyp.app.domain.user.dto.request.UpdateUserProfileRequest; -import com.swyp.app.domain.user.dto.request.UpdateUserSettingsRequest; -import com.swyp.app.domain.user.dto.response.BootstrapResponse; -import com.swyp.app.domain.user.dto.response.MyProfileResponse; -import com.swyp.app.domain.user.dto.response.OnboardingProfileResponse; -import com.swyp.app.domain.user.dto.response.TendencyScoreHistoryResponse; -import com.swyp.app.domain.user.dto.response.TendencyScoreResponse; -import com.swyp.app.domain.user.dto.response.UpdateResultResponse; -import com.swyp.app.domain.user.dto.response.UserProfileResponse; -import com.swyp.app.domain.user.dto.response.UserSettingsResponse; -import com.swyp.app.domain.user.service.UserService; -import com.swyp.app.global.common.response.ApiResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1") -public class UserController { - - private final UserService userService; - - @GetMapping("/onboarding/bootstrap") - public ApiResponse getBootstrap() { - return ApiResponse.onSuccess(userService.getBootstrap()); - } - - @PostMapping("/onboarding/profile") - public ApiResponse createOnboardingProfile( - @Valid @RequestBody CreateOnboardingProfileRequest request - ) { - return ApiResponse.onSuccess(userService.createOnboardingProfile(request)); - } - - @GetMapping("/users/{userTag}") - public ApiResponse getUserProfile(@PathVariable String userTag) { - return ApiResponse.onSuccess(userService.getUserProfile(userTag)); - } - - @GetMapping("/me/profile") - public ApiResponse getMyProfile() { - return ApiResponse.onSuccess(userService.getMyProfile()); - } - - @PatchMapping("/me/profile") - public ApiResponse updateMyProfile( - @Valid @RequestBody UpdateUserProfileRequest request - ) { - return ApiResponse.onSuccess(userService.updateMyProfile(request)); - } - - @GetMapping("/me/settings") - public ApiResponse getMySettings() { - return ApiResponse.onSuccess(userService.getMySettings()); - } - - @PatchMapping("/me/settings") - public ApiResponse updateMySettings( - @Valid @RequestBody UpdateUserSettingsRequest request - ) { - return ApiResponse.onSuccess(userService.updateMySettings(request)); - } - - @PutMapping("/me/tendency-scores") - public ApiResponse updateMyTendencyScores( - @Valid @RequestBody UpdateTendencyScoreRequest request - ) { - return ApiResponse.onSuccess(userService.updateMyTendencyScores(request)); - } - - @GetMapping("/me/tendency-scores/history") - public ApiResponse getMyTendencyScoreHistory( - @RequestParam(required = false) Long cursor, - @RequestParam(required = false) Integer size - ) { - return ApiResponse.onSuccess(userService.getMyTendencyScoreHistory(cursor, size)); - } -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/request/CreateOnboardingProfileRequest.java b/src/main/java/com/swyp/app/domain/user/dto/request/CreateOnboardingProfileRequest.java deleted file mode 100644 index f00047d..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/request/CreateOnboardingProfileRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.swyp.app.domain.user.dto.request; - -import com.swyp.app.domain.user.entity.CharacterType; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -public record CreateOnboardingProfileRequest( - @NotBlank - @Size(min = 2, max = 20) - String nickname, - @NotNull - CharacterType characterType -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/request/UpdateTendencyScoreRequest.java b/src/main/java/com/swyp/app/domain/user/dto/request/UpdateTendencyScoreRequest.java deleted file mode 100644 index 2cde0bc..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/request/UpdateTendencyScoreRequest.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.swyp.app.domain.user.dto.request; - -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; - -public record UpdateTendencyScoreRequest( - @Min(-100) @Max(100) - int score1, - @Min(-100) @Max(100) - int score2, - @Min(-100) @Max(100) - int score3, - @Min(-100) @Max(100) - int score4, - @Min(-100) @Max(100) - int score5, - @Min(-100) @Max(100) - int score6 -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/request/UpdateUserSettingsRequest.java b/src/main/java/com/swyp/app/domain/user/dto/request/UpdateUserSettingsRequest.java deleted file mode 100644 index a0a067b..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/request/UpdateUserSettingsRequest.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.swyp.app.domain.user.dto.request; - -import jakarta.validation.constraints.AssertTrue; - -public record UpdateUserSettingsRequest( - Boolean pushEnabled, - Boolean emailEnabled, - Boolean debateRequestEnabled, - Boolean profilePublic -) { - @AssertTrue(message = "적어도 하나 이상의 설정값이 필요합니다.") - public boolean hasAnySettingToUpdate() { - return pushEnabled != null - || emailEnabled != null - || debateRequestEnabled != null - || profilePublic != null; - } -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/BootstrapResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/BootstrapResponse.java deleted file mode 100644 index 60cfd4a..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/response/BootstrapResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.swyp.app.domain.user.dto.response; - -public record BootstrapResponse( - String randomNickname -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/OnboardingProfileResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/OnboardingProfileResponse.java deleted file mode 100644 index 6c67ab4..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/response/OnboardingProfileResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.swyp.app.domain.user.dto.response; - -import com.swyp.app.domain.user.entity.CharacterType; -import com.swyp.app.domain.user.entity.UserStatus; - -import java.math.BigDecimal; - -public record OnboardingProfileResponse( - String userTag, - String nickname, - CharacterType characterType, - BigDecimal mannerTemperature, - UserStatus status, - boolean onboardingCompleted -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreHistoryItemResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreHistoryItemResponse.java deleted file mode 100644 index 96aa08e..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreHistoryItemResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.swyp.app.domain.user.dto.response; - -import java.time.LocalDateTime; - -public record TendencyScoreHistoryItemResponse( - Long historyId, - int score1, - int score2, - int score3, - int score4, - int score5, - int score6, - LocalDateTime createdAt -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreHistoryResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreHistoryResponse.java deleted file mode 100644 index d125ef1..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreHistoryResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.swyp.app.domain.user.dto.response; - -import java.util.List; - -public record TendencyScoreHistoryResponse( - List items, - Long nextCursor -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreResponse.java deleted file mode 100644 index 14b697b..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/response/TendencyScoreResponse.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.swyp.app.domain.user.dto.response; - -import java.time.LocalDateTime; - -public record TendencyScoreResponse( - String userTag, - int score1, - int score2, - int score3, - int score4, - int score5, - int score6, - LocalDateTime updatedAt, - boolean historySaved -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/UpdateResultResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/UpdateResultResponse.java deleted file mode 100644 index c5ee9cb..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/response/UpdateResultResponse.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.swyp.app.domain.user.dto.response; - -public record UpdateResultResponse( - boolean updated -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/UserProfileResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/UserProfileResponse.java deleted file mode 100644 index f1bdce7..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/response/UserProfileResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.swyp.app.domain.user.dto.response; - -import com.swyp.app.domain.user.entity.CharacterType; - -import java.math.BigDecimal; - -public record UserProfileResponse( - String userTag, - String nickname, - CharacterType characterType, - BigDecimal mannerTemperature -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/UserSettingsResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/UserSettingsResponse.java deleted file mode 100644 index a1c8965..0000000 --- a/src/main/java/com/swyp/app/domain/user/dto/response/UserSettingsResponse.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.swyp.app.domain.user.dto.response; - -public record UserSettingsResponse( - boolean pushEnabled, - boolean emailEnabled, - boolean debateRequestEnabled, - boolean profilePublic -) { -} diff --git a/src/main/java/com/swyp/app/domain/user/entity/User.java b/src/main/java/com/swyp/app/domain/user/entity/User.java index ed1afe2..6bb84fd 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/User.java +++ b/src/main/java/com/swyp/app/domain/user/entity/User.java @@ -43,25 +43,16 @@ public class User extends BaseEntity { @Column(nullable = false, length = 20) private UserStatus status; - @Column(name = "onboarding_completed", nullable = false) - private boolean onboardingCompleted; - @Column(name = "deleted_at") private LocalDateTime deletedAt; @Builder - private User(String userTag, String nickname, String characterUrl, UserRole role, UserStatus status, boolean onboardingCompleted) { + private User(String userTag, String nickname, String characterUrl, UserRole role, UserStatus status) { this.userTag = userTag; this.nickname = nickname; this.characterUrl = characterUrl; this.role = role; this.status = status; - this.onboardingCompleted = onboardingCompleted; - } - - public void completeOnboarding() { - this.status = UserStatus.ACTIVE; - this.onboardingCompleted = true; } public void delete() { diff --git a/src/main/java/com/swyp/app/domain/user/service/UserService.java b/src/main/java/com/swyp/app/domain/user/service/UserService.java index b941155..6275bad 100644 --- a/src/main/java/com/swyp/app/domain/user/service/UserService.java +++ b/src/main/java/com/swyp/app/domain/user/service/UserService.java @@ -1,138 +1,31 @@ package com.swyp.app.domain.user.service; -import com.swyp.app.domain.user.dto.request.CreateOnboardingProfileRequest; -import com.swyp.app.domain.user.dto.request.UpdateTendencyScoreRequest; import com.swyp.app.domain.user.dto.request.UpdateUserProfileRequest; -import com.swyp.app.domain.user.dto.request.UpdateUserSettingsRequest; -import com.swyp.app.domain.user.dto.response.BootstrapResponse; import com.swyp.app.domain.user.dto.response.MyProfileResponse; -import com.swyp.app.domain.user.dto.response.OnboardingProfileResponse; -import com.swyp.app.domain.user.dto.response.TendencyScoreHistoryItemResponse; -import com.swyp.app.domain.user.dto.response.TendencyScoreHistoryResponse; -import com.swyp.app.domain.user.dto.response.TendencyScoreResponse; -import com.swyp.app.domain.user.dto.response.UpdateResultResponse; -import com.swyp.app.domain.user.dto.response.UserProfileResponse; -import com.swyp.app.domain.user.dto.response.UserSettingsResponse; import com.swyp.app.domain.user.dto.response.UserSummary; -import com.swyp.app.domain.user.entity.AgreementType; import com.swyp.app.domain.user.entity.User; -import com.swyp.app.domain.user.entity.UserAgreement; import com.swyp.app.domain.user.entity.UserProfile; -import com.swyp.app.domain.user.entity.UserRole; import com.swyp.app.domain.user.entity.UserSettings; -import com.swyp.app.domain.user.entity.UserStatus; import com.swyp.app.domain.user.entity.UserTendencyScore; -import com.swyp.app.domain.user.entity.UserTendencyScoreHistory; import com.swyp.app.domain.user.repository.UserProfileRepository; -import com.swyp.app.domain.user.repository.UserAgreementRepository; import com.swyp.app.domain.user.repository.UserRepository; import com.swyp.app.domain.user.repository.UserSettingsRepository; -import com.swyp.app.domain.user.repository.UserTendencyScoreHistoryRepository; import com.swyp.app.domain.user.repository.UserTendencyScoreRepository; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; -import java.util.concurrent.ThreadLocalRandom; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class UserService { - private static final String[] PREFIXES = {"생각하는", "집중하는", "차분한", "기민한", "용감한", "명확한"}; - private static final String[] SUFFIXES = {"올빼미", "여우", "늑대", "사자", "펭귄", "토끼", "고양이", "곰"}; - private static final BigDecimal DEFAULT_MANNER_TEMPERATURE = BigDecimal.valueOf(36.5); - private static final int DEFAULT_HISTORY_SIZE = 20; - private static final String DEFAULT_AGREEMENT_VERSION = "1.0"; - private static final String USER_TAG_CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789"; - private static final int USER_TAG_LENGTH = 8; - private final UserRepository userRepository; - private final UserAgreementRepository userAgreementRepository; private final UserProfileRepository userProfileRepository; private final UserSettingsRepository userSettingsRepository; private final UserTendencyScoreRepository userTendencyScoreRepository; - private final UserTendencyScoreHistoryRepository userTendencyScoreHistoryRepository; - - public BootstrapResponse getBootstrap() { - return new BootstrapResponse(generateRandomNickname()); - } - - @Transactional - public OnboardingProfileResponse createOnboardingProfile(CreateOnboardingProfileRequest request) { - User user = userRepository.findTopByOrderByIdDesc() - .orElseGet(this::createPendingUser); - - if (user.isOnboardingCompleted()) { - throw new CustomException(ErrorCode.ONBOARDING_ALREADY_COMPLETED); - } - - UserProfile profile = UserProfile.builder() - .user(user) - .nickname(request.nickname()) - .characterType(request.characterType()) - .mannerTemperature(DEFAULT_MANNER_TEMPERATURE) - .build(); - - UserSettings settings = UserSettings.builder() - .user(user) - .pushEnabled(false) - .emailEnabled(false) - .debateRequestEnabled(false) - .profilePublic(false) - .build(); - - UserTendencyScore tendencyScore = UserTendencyScore.builder() - .user(user) - .score1(0) - .score2(0) - .score3(0) - .score4(0) - .score5(0) - .score6(0) - .build(); - - userProfileRepository.save(profile); - userSettingsRepository.save(settings); - userTendencyScoreRepository.save(tendencyScore); - saveRequiredAgreements(user); - - user.completeOnboarding(); - - return new OnboardingProfileResponse( - user.getUserTag(), - profile.getNickname(), - profile.getCharacterType(), - profile.getMannerTemperature(), - user.getStatus(), - user.isOnboardingCompleted() - ); - } - - public UserProfileResponse getUserProfile(String userTag) { - User user = findUserByTag(userTag); - UserProfile profile = findUserProfile(user.getId()); - return new UserProfileResponse(user.getUserTag(), profile.getNickname(), profile.getCharacterType(), profile.getMannerTemperature()); - } - - public MyProfileResponse getMyProfile() { - User user = findCurrentUser(); - UserProfile profile = findUserProfile(user.getId()); - return new MyProfileResponse( - user.getUserTag(), - profile.getNickname(), - profile.getCharacterType(), - profile.getMannerTemperature(), - profile.getUpdatedAt() - ); - } @Transactional public MyProfileResponse updateMyProfile(UpdateUserProfileRequest request) { @@ -143,95 +36,10 @@ public MyProfileResponse updateMyProfile(UpdateUserProfileRequest request) { user.getUserTag(), profile.getNickname(), profile.getCharacterType(), - profile.getMannerTemperature(), profile.getUpdatedAt() ); } - public UserSettingsResponse getMySettings() { - UserSettings settings = findUserSettings(findCurrentUser().getId()); - return new UserSettingsResponse( - settings.isPushEnabled(), - settings.isEmailEnabled(), - settings.isDebateRequestEnabled(), - settings.isProfilePublic() - ); - } - - @Transactional - public UpdateResultResponse updateMySettings(UpdateUserSettingsRequest request) { - UserSettings settings = findUserSettings(findCurrentUser().getId()); - settings.update( - request.pushEnabled(), - request.emailEnabled(), - request.debateRequestEnabled(), - request.profilePublic() - ); - return new UpdateResultResponse(true); - } - - @Transactional - public TendencyScoreResponse updateMyTendencyScores(UpdateTendencyScoreRequest request) { - User user = findCurrentUser(); - UserTendencyScore score = findUserTendencyScore(user.getId()); - score.update( - request.score1(), - request.score2(), - request.score3(), - request.score4(), - request.score5(), - request.score6() - ); - - userTendencyScoreHistoryRepository.save(UserTendencyScoreHistory.builder() - .user(user) - .score1(request.score1()) - .score2(request.score2()) - .score3(request.score3()) - .score4(request.score4()) - .score5(request.score5()) - .score6(request.score6()) - .build()); - - return new TendencyScoreResponse( - user.getUserTag(), - score.getScore1(), - score.getScore2(), - score.getScore3(), - score.getScore4(), - score.getScore5(), - score.getScore6(), - score.getUpdatedAt(), - true - ); - } - - public TendencyScoreHistoryResponse getMyTendencyScoreHistory(Long cursor, Integer size) { - User user = findCurrentUser(); - int pageSize = size == null || size <= 0 ? DEFAULT_HISTORY_SIZE : size; - PageRequest pageable = PageRequest.of(0, pageSize); - - List histories = cursor == null - ? userTendencyScoreHistoryRepository.findByUserOrderByIdDesc(user, pageable) - : userTendencyScoreHistoryRepository.findByUserAndIdLessThanOrderByIdDesc(user, cursor, pageable); - - List items = histories.stream() - .map(history -> new TendencyScoreHistoryItemResponse( - history.getId(), - history.getScore1(), - history.getScore2(), - history.getScore3(), - history.getScore4(), - history.getScore5(), - history.getScore6(), - history.getCreatedAt() - )) - .toList(); - - Long nextCursor = histories.size() == pageSize ? histories.get(histories.size() - 1).getId() : null; - return new TendencyScoreHistoryResponse(items, nextCursor); - } - public UserSummary findSummaryById(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); @@ -239,74 +47,23 @@ public UserSummary findSummaryById(Long userId) { return new UserSummary(user.getUserTag(), profile.getNickname(), profile.getCharacterType().name()); } - private User findUserByTag(String userTag) { - return userRepository.findByUserTag(userTag) - .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - } - - private User findCurrentUser() { + public User findCurrentUser() { return userRepository.findTopByOrderByIdDesc() .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); } - private User createPendingUser() { - User user = User.builder() - .userTag(generateUserTag()) - .role(UserRole.USER) - .status(UserStatus.PENDING) - .onboardingCompleted(false) - .build(); - return userRepository.save(user); - } - - private void saveRequiredAgreements(User user) { - LocalDateTime agreedAt = LocalDateTime.now(); - userAgreementRepository.saveAll(List.of( - UserAgreement.builder() - .user(user) - .agreementType(AgreementType.TERMS_OF_SERVICE) - .version(DEFAULT_AGREEMENT_VERSION) - .agreedAt(agreedAt) - .build(), - UserAgreement.builder() - .user(user) - .agreementType(AgreementType.PRIVACY_POLICY) - .version(DEFAULT_AGREEMENT_VERSION) - .agreedAt(agreedAt) - .build() - )); - } - - private UserProfile findUserProfile(Long userId) { + public UserProfile findUserProfile(Long userId) { return userProfileRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); } - private UserSettings findUserSettings(Long userId) { + public UserSettings findUserSettings(Long userId) { return userSettingsRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); } - private UserTendencyScore findUserTendencyScore(Long userId) { + public UserTendencyScore findUserTendencyScore(Long userId) { return userTendencyScoreRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); } - - private String generateRandomNickname() { - return PREFIXES[ThreadLocalRandom.current().nextInt(PREFIXES.length)] - + SUFFIXES[ThreadLocalRandom.current().nextInt(SUFFIXES.length)]; - } - - private String generateUserTag() { - String candidate; - do { - StringBuilder builder = new StringBuilder(USER_TAG_LENGTH); - for (int i = 0; i < USER_TAG_LENGTH; i++) { - int index = ThreadLocalRandom.current().nextInt(USER_TAG_CHARACTERS.length()); - builder.append(USER_TAG_CHARACTERS.charAt(index)); - } - candidate = builder.toString(); - } while (userRepository.existsByUserTag(candidate)); - return candidate; - } } From aa7de64a4138dbdcc651a978570533d431c39ad6 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:32:17 +0900 Subject: [PATCH 09/18] =?UTF-8?q?#36=20[Refactor]=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0/DTO=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MyProfileResponse에서 mannerTemperature 제거 - UserSettings 필드 교체 (4개 → 6개 알림 설정 필드) - UserTendencyScore/History 필드 리네이밍 (score1~6 → principle/reason/individual/change/inner/ideal) Co-Authored-By: Claude Opus 4.6 --- .../user/dto/response/MyProfileResponse.java | 2 - .../app/domain/user/entity/UserSettings.java | 59 +++++++++++++------ .../domain/user/entity/UserTendencyScore.java | 54 ++++++++++------- .../user/entity/UserTendencyScoreHistory.java | 39 ++++++++---- 4 files changed, 101 insertions(+), 53 deletions(-) diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/MyProfileResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/MyProfileResponse.java index 1f7a357..9e55fff 100644 --- a/src/main/java/com/swyp/app/domain/user/dto/response/MyProfileResponse.java +++ b/src/main/java/com/swyp/app/domain/user/dto/response/MyProfileResponse.java @@ -2,14 +2,12 @@ import com.swyp.app.domain.user.entity.CharacterType; -import java.math.BigDecimal; import java.time.LocalDateTime; public record MyProfileResponse( String userTag, String nickname, CharacterType characterType, - BigDecimal mannerTemperature, LocalDateTime updatedAt ) { } diff --git a/src/main/java/com/swyp/app/domain/user/entity/UserSettings.java b/src/main/java/com/swyp/app/domain/user/entity/UserSettings.java index e141593..b2d4ba2 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/UserSettings.java +++ b/src/main/java/com/swyp/app/domain/user/entity/UserSettings.java @@ -1,6 +1,7 @@ package com.swyp.app.domain.user.entity; import com.swyp.app.global.common.BaseEntity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; @@ -27,35 +28,57 @@ public class UserSettings extends BaseEntity { @JoinColumn(name = "user_id") private User user; - private boolean pushEnabled; + @Column(name = "new_battle_enabled") + private boolean newBattleEnabled; - private boolean emailEnabled; + @Column(name = "battle_result_enabled") + private boolean battleResultEnabled; - private boolean debateRequestEnabled; + @Column(name = "comment_reply_enabled") + private boolean commentReplyEnabled; - private boolean profilePublic; + @Column(name = "new_comment_enabled") + private boolean newCommentEnabled; + + @Column(name = "content_like_enabled") + private boolean contentLikeEnabled; + + @Column(name = "marketing_event_enabled") + private boolean marketingEventEnabled; @Builder - private UserSettings(User user, boolean pushEnabled, boolean emailEnabled, boolean debateRequestEnabled, boolean profilePublic) { + private UserSettings(User user, boolean newBattleEnabled, boolean battleResultEnabled, + boolean commentReplyEnabled, boolean newCommentEnabled, + boolean contentLikeEnabled, boolean marketingEventEnabled) { this.user = user; - this.pushEnabled = pushEnabled; - this.emailEnabled = emailEnabled; - this.debateRequestEnabled = debateRequestEnabled; - this.profilePublic = profilePublic; + this.newBattleEnabled = newBattleEnabled; + this.battleResultEnabled = battleResultEnabled; + this.commentReplyEnabled = commentReplyEnabled; + this.newCommentEnabled = newCommentEnabled; + this.contentLikeEnabled = contentLikeEnabled; + this.marketingEventEnabled = marketingEventEnabled; } - public void update(Boolean pushEnabled, Boolean emailEnabled, Boolean debateRequestEnabled, Boolean profilePublic) { - if (pushEnabled != null) { - this.pushEnabled = pushEnabled; + public void update(Boolean newBattleEnabled, Boolean battleResultEnabled, + Boolean commentReplyEnabled, Boolean newCommentEnabled, + Boolean contentLikeEnabled, Boolean marketingEventEnabled) { + if (newBattleEnabled != null) { + this.newBattleEnabled = newBattleEnabled; + } + if (battleResultEnabled != null) { + this.battleResultEnabled = battleResultEnabled; + } + if (commentReplyEnabled != null) { + this.commentReplyEnabled = commentReplyEnabled; } - if (emailEnabled != null) { - this.emailEnabled = emailEnabled; + if (newCommentEnabled != null) { + this.newCommentEnabled = newCommentEnabled; } - if (debateRequestEnabled != null) { - this.debateRequestEnabled = debateRequestEnabled; + if (contentLikeEnabled != null) { + this.contentLikeEnabled = contentLikeEnabled; } - if (profilePublic != null) { - this.profilePublic = profilePublic; + if (marketingEventEnabled != null) { + this.marketingEventEnabled = marketingEventEnabled; } } } diff --git a/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScore.java b/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScore.java index 093e11e..f514bcc 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScore.java +++ b/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScore.java @@ -1,6 +1,7 @@ package com.swyp.app.domain.user.entity; import com.swyp.app.global.common.BaseEntity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; @@ -27,30 +28,43 @@ public class UserTendencyScore extends BaseEntity { @JoinColumn(name = "user_id") private User user; - private int score1; - private int score2; - private int score3; - private int score4; - private int score5; - private int score6; + @Column(name = "score1") + private int principle; + + @Column(name = "score2") + private int reason; + + @Column(name = "score3") + private int individual; + + @Column(name = "score4") + private int change; + + @Column(name = "score5") + private int inner; + + @Column(name = "score6") + private int ideal; @Builder - private UserTendencyScore(User user, int score1, int score2, int score3, int score4, int score5, int score6) { + private UserTendencyScore(User user, int principle, int reason, int individual, + int change, int inner, int ideal) { this.user = user; - this.score1 = score1; - this.score2 = score2; - this.score3 = score3; - this.score4 = score4; - this.score5 = score5; - this.score6 = score6; + this.principle = principle; + this.reason = reason; + this.individual = individual; + this.change = change; + this.inner = inner; + this.ideal = ideal; } - public void update(int score1, int score2, int score3, int score4, int score5, int score6) { - this.score1 = score1; - this.score2 = score2; - this.score3 = score3; - this.score4 = score4; - this.score5 = score5; - this.score6 = score6; + public void update(int principle, int reason, int individual, + int change, int inner, int ideal) { + this.principle = principle; + this.reason = reason; + this.individual = individual; + this.change = change; + this.inner = inner; + this.ideal = ideal; } } diff --git a/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScoreHistory.java b/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScoreHistory.java index 9cbf6de..6d45262 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScoreHistory.java +++ b/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScoreHistory.java @@ -1,6 +1,7 @@ package com.swyp.app.domain.user.entity; import com.swyp.app.global.common.BaseEntity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -28,21 +29,33 @@ public class UserTendencyScoreHistory extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - private int score1; - private int score2; - private int score3; - private int score4; - private int score5; - private int score6; + @Column(name = "score1") + private int principle; + + @Column(name = "score2") + private int reason; + + @Column(name = "score3") + private int individual; + + @Column(name = "score4") + private int change; + + @Column(name = "score5") + private int inner; + + @Column(name = "score6") + private int ideal; @Builder - private UserTendencyScoreHistory(User user, int score1, int score2, int score3, int score4, int score5, int score6) { + private UserTendencyScoreHistory(User user, int principle, int reason, int individual, + int change, int inner, int ideal) { this.user = user; - this.score1 = score1; - this.score2 = score2; - this.score3 = score3; - this.score4 = score4; - this.score5 = score5; - this.score6 = score6; + this.principle = principle; + this.reason = reason; + this.individual = individual; + this.change = change; + this.inner = inner; + this.ideal = ideal; } } From 53ecefc757e083c8a2c2fe3b3dfa769963c3439a Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:32:27 +0900 Subject: [PATCH 10/18] =?UTF-8?q?#36=20[Feat]=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20API=EC=9A=A9=20Enum,=20Notice=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0,=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PhilosopherType, TierCode, NoticeType, VoteSide, ActivityType enum 생성 - Notice 엔티티 및 NoticeRepository 생성 - 마이페이지 API Response/Request DTO 8개 생성 Co-Authored-By: Claude Opus 4.6 --- .../UpdateNotificationSettingsRequest.java | 11 ++++ .../response/BattleRecordListResponse.java | 23 ++++++++ .../response/ContentActivityListResponse.java | 35 +++++++++++ .../user/dto/response/MypageResponse.java | 34 +++++++++++ .../dto/response/NoticeDetailResponse.java | 15 +++++ .../user/dto/response/NoticeListResponse.java | 21 +++++++ .../NotificationSettingsResponse.java | 11 ++++ .../user/dto/response/RecapResponse.java | 44 ++++++++++++++ .../app/domain/user/entity/ActivityType.java | 6 ++ .../swyp/app/domain/user/entity/Notice.java | 58 +++++++++++++++++++ .../app/domain/user/entity/NoticeType.java | 6 ++ .../domain/user/entity/PhilosopherType.java | 14 +++++ .../swyp/app/domain/user/entity/TierCode.java | 15 +++++ .../swyp/app/domain/user/entity/VoteSide.java | 6 ++ .../user/repository/NoticeRepository.java | 14 +++++ 15 files changed, 313 insertions(+) create mode 100644 src/main/java/com/swyp/app/domain/user/dto/request/UpdateNotificationSettingsRequest.java create mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/BattleRecordListResponse.java create mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/ContentActivityListResponse.java create mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/MypageResponse.java create mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/NoticeDetailResponse.java create mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/NoticeListResponse.java create mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/NotificationSettingsResponse.java create mode 100644 src/main/java/com/swyp/app/domain/user/dto/response/RecapResponse.java create mode 100644 src/main/java/com/swyp/app/domain/user/entity/ActivityType.java create mode 100644 src/main/java/com/swyp/app/domain/user/entity/Notice.java create mode 100644 src/main/java/com/swyp/app/domain/user/entity/NoticeType.java create mode 100644 src/main/java/com/swyp/app/domain/user/entity/PhilosopherType.java create mode 100644 src/main/java/com/swyp/app/domain/user/entity/TierCode.java create mode 100644 src/main/java/com/swyp/app/domain/user/entity/VoteSide.java create mode 100644 src/main/java/com/swyp/app/domain/user/repository/NoticeRepository.java diff --git a/src/main/java/com/swyp/app/domain/user/dto/request/UpdateNotificationSettingsRequest.java b/src/main/java/com/swyp/app/domain/user/dto/request/UpdateNotificationSettingsRequest.java new file mode 100644 index 0000000..c2ac052 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/request/UpdateNotificationSettingsRequest.java @@ -0,0 +1,11 @@ +package com.swyp.app.domain.user.dto.request; + +public record UpdateNotificationSettingsRequest( + Boolean newBattleEnabled, + Boolean battleResultEnabled, + Boolean commentReplyEnabled, + Boolean newCommentEnabled, + Boolean contentLikeEnabled, + Boolean marketingEventEnabled +) { +} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/BattleRecordListResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/BattleRecordListResponse.java new file mode 100644 index 0000000..0436db5 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/BattleRecordListResponse.java @@ -0,0 +1,23 @@ +package com.swyp.app.domain.user.dto.response; + +import com.swyp.app.domain.user.entity.VoteSide; + +import java.time.LocalDateTime; +import java.util.List; + +public record BattleRecordListResponse( + List items, + Integer nextOffset, + boolean hasNext +) { + + public record BattleRecordItem( + String battleId, + String recordId, + VoteSide voteSide, + String title, + String summary, + LocalDateTime createdAt + ) { + } +} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/ContentActivityListResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/ContentActivityListResponse.java new file mode 100644 index 0000000..586c6a0 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/ContentActivityListResponse.java @@ -0,0 +1,35 @@ +package com.swyp.app.domain.user.dto.response; + +import com.swyp.app.domain.user.entity.ActivityType; +import com.swyp.app.domain.user.entity.CharacterType; + +import java.time.LocalDateTime; +import java.util.List; + +public record ContentActivityListResponse( + List items, + Integer nextOffset, + boolean hasNext +) { + + public record ContentActivityItem( + String activityId, + ActivityType activityType, + String perspectiveId, + String battleId, + String battleTitle, + AuthorInfo author, + String stance, + String content, + int likeCount, + LocalDateTime createdAt + ) { + } + + public record AuthorInfo( + String userTag, + String nickname, + CharacterType characterType + ) { + } +} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/MypageResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/MypageResponse.java new file mode 100644 index 0000000..9804cf3 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/MypageResponse.java @@ -0,0 +1,34 @@ +package com.swyp.app.domain.user.dto.response; + +import com.swyp.app.domain.user.entity.CharacterType; +import com.swyp.app.domain.user.entity.PhilosopherType; +import com.swyp.app.domain.user.entity.TierCode; + +import java.math.BigDecimal; + +public record MypageResponse( + ProfileInfo profile, + PhilosopherInfo philosopher, + TierInfo tier +) { + + public record ProfileInfo( + String userTag, + String nickname, + CharacterType characterType, + BigDecimal mannerTemperature + ) { + } + + public record PhilosopherInfo( + PhilosopherType philosopherType + ) { + } + + public record TierInfo( + TierCode tierCode, + String tierLabel, + int currentPoint + ) { + } +} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/NoticeDetailResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeDetailResponse.java new file mode 100644 index 0000000..dd499c9 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeDetailResponse.java @@ -0,0 +1,15 @@ +package com.swyp.app.domain.user.dto.response; + +import com.swyp.app.domain.user.entity.NoticeType; + +import java.time.LocalDateTime; + +public record NoticeDetailResponse( + Long noticeId, + NoticeType type, + String title, + String body, + boolean isPinned, + LocalDateTime publishedAt +) { +} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/NoticeListResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeListResponse.java new file mode 100644 index 0000000..86cd823 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeListResponse.java @@ -0,0 +1,21 @@ +package com.swyp.app.domain.user.dto.response; + +import com.swyp.app.domain.user.entity.NoticeType; + +import java.time.LocalDateTime; +import java.util.List; + +public record NoticeListResponse( + List items +) { + + public record NoticeItem( + Long noticeId, + NoticeType type, + String title, + String bodyPreview, + boolean isPinned, + LocalDateTime publishedAt + ) { + } +} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/NotificationSettingsResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/NotificationSettingsResponse.java new file mode 100644 index 0000000..cc2a5fb --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/NotificationSettingsResponse.java @@ -0,0 +1,11 @@ +package com.swyp.app.domain.user.dto.response; + +public record NotificationSettingsResponse( + boolean newBattleEnabled, + boolean battleResultEnabled, + boolean commentReplyEnabled, + boolean newCommentEnabled, + boolean contentLikeEnabled, + boolean marketingEventEnabled +) { +} diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/RecapResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/RecapResponse.java new file mode 100644 index 0000000..7d7f245 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/RecapResponse.java @@ -0,0 +1,44 @@ +package com.swyp.app.domain.user.dto.response; + +import com.swyp.app.domain.user.entity.PhilosopherType; + +import java.util.List; + +public record RecapResponse( + PhilosopherCard myCard, + PhilosopherCard bestMatchCard, + PhilosopherCard worstMatchCard, + Scores scores, + PreferenceReport preferenceReport +) { + + public record PhilosopherCard( + PhilosopherType philosopherType + ) { + } + + public record Scores( + int principle, + int reason, + int individual, + int change, + int inner, + int ideal + ) { + } + + public record PreferenceReport( + int totalParticipation, + int opinionChanges, + int battleWinRate, + List favoriteTopics + ) { + } + + public record FavoriteTopic( + int rank, + String tagName, + int participationCount + ) { + } +} diff --git a/src/main/java/com/swyp/app/domain/user/entity/ActivityType.java b/src/main/java/com/swyp/app/domain/user/entity/ActivityType.java new file mode 100644 index 0000000..c6f47e8 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/entity/ActivityType.java @@ -0,0 +1,6 @@ +package com.swyp.app.domain.user.entity; + +public enum ActivityType { + COMMENT, + LIKE +} diff --git a/src/main/java/com/swyp/app/domain/user/entity/Notice.java b/src/main/java/com/swyp/app/domain/user/entity/Notice.java new file mode 100644 index 0000000..54da21d --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/entity/Notice.java @@ -0,0 +1,58 @@ +package com.swyp.app.domain.user.entity; + +import com.swyp.app.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "notices") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notice extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private NoticeType type; + + @Column(nullable = false, length = 200) + private String title; + + @Column(columnDefinition = "TEXT") + private String body; + + @Column(name = "body_preview", length = 300) + private String bodyPreview; + + @Column(name = "is_pinned", nullable = false) + private boolean isPinned; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + @Builder + private Notice(NoticeType type, String title, String body, String bodyPreview, + boolean isPinned, LocalDateTime publishedAt) { + this.type = type; + this.title = title; + this.body = body; + this.bodyPreview = bodyPreview; + this.isPinned = isPinned; + this.publishedAt = publishedAt; + } +} diff --git a/src/main/java/com/swyp/app/domain/user/entity/NoticeType.java b/src/main/java/com/swyp/app/domain/user/entity/NoticeType.java new file mode 100644 index 0000000..217f993 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/entity/NoticeType.java @@ -0,0 +1,6 @@ +package com.swyp.app.domain.user.entity; + +public enum NoticeType { + NOTICE, + EVENT +} diff --git a/src/main/java/com/swyp/app/domain/user/entity/PhilosopherType.java b/src/main/java/com/swyp/app/domain/user/entity/PhilosopherType.java new file mode 100644 index 0000000..c78ad98 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/entity/PhilosopherType.java @@ -0,0 +1,14 @@ +package com.swyp.app.domain.user.entity; + +public enum PhilosopherType { + SOCRATES, + PLATO, + ARISTOTLE, + KANT, + NIETZSCHE, + MARX, + SARTRE, + CONFUCIUS, + LAOZI, + BUDDHA +} diff --git a/src/main/java/com/swyp/app/domain/user/entity/TierCode.java b/src/main/java/com/swyp/app/domain/user/entity/TierCode.java new file mode 100644 index 0000000..c43b437 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/entity/TierCode.java @@ -0,0 +1,15 @@ +package com.swyp.app.domain.user.entity; + +public enum TierCode { + WANDERER("방랑자"); + + private final String label; + + TierCode(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } +} diff --git a/src/main/java/com/swyp/app/domain/user/entity/VoteSide.java b/src/main/java/com/swyp/app/domain/user/entity/VoteSide.java new file mode 100644 index 0000000..07d3f8a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/entity/VoteSide.java @@ -0,0 +1,6 @@ +package com.swyp.app.domain.user.entity; + +public enum VoteSide { + PRO, + CON +} diff --git a/src/main/java/com/swyp/app/domain/user/repository/NoticeRepository.java b/src/main/java/com/swyp/app/domain/user/repository/NoticeRepository.java new file mode 100644 index 0000000..cb4226b --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/repository/NoticeRepository.java @@ -0,0 +1,14 @@ +package com.swyp.app.domain.user.repository; + +import com.swyp.app.domain.user.entity.Notice; +import com.swyp.app.domain.user.entity.NoticeType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface NoticeRepository extends JpaRepository { + + List findByTypeOrderByIsPinnedDescPublishedAtDesc(NoticeType type); + + List findAllByOrderByIsPinnedDescPublishedAtDesc(); +} From 3539047024183e7b0afba2a63b5af79042749837 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:32:33 +0900 Subject: [PATCH 11/18] =?UTF-8?q?#36=20[Refactor]=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0/DTO=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MyProfileResponse에서 mannerTemperature 제거 - UserSettings 필드 교체 (4개 → 6개 알림 설정 필드) - UserTendencyScore/History 필드 리네이밍 (score1~6 → principle/reason/individual/change/inner/ideal) - @Column(name) 매핑 제거, DB 컬럼명도 새 필드명으로 통일 Co-Authored-By: Claude Opus 4.6 --- .../user/controller/MypageController.java | 95 ++++++++++ .../domain/user/entity/UserTendencyScore.java | 7 - .../user/entity/UserTendencyScoreHistory.java | 7 - .../domain/user/service/MypageService.java | 165 ++++++++++++++++++ 4 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/swyp/app/domain/user/controller/MypageController.java create mode 100644 src/main/java/com/swyp/app/domain/user/service/MypageService.java diff --git a/src/main/java/com/swyp/app/domain/user/controller/MypageController.java b/src/main/java/com/swyp/app/domain/user/controller/MypageController.java new file mode 100644 index 0000000..6f1a4ad --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/controller/MypageController.java @@ -0,0 +1,95 @@ +package com.swyp.app.domain.user.controller; + +import com.swyp.app.domain.user.dto.request.UpdateNotificationSettingsRequest; +import com.swyp.app.domain.user.dto.request.UpdateUserProfileRequest; +import com.swyp.app.domain.user.dto.response.BattleRecordListResponse; +import com.swyp.app.domain.user.dto.response.ContentActivityListResponse; +import com.swyp.app.domain.user.dto.response.MypageResponse; +import com.swyp.app.domain.user.dto.response.MyProfileResponse; +import com.swyp.app.domain.user.dto.response.NoticeDetailResponse; +import com.swyp.app.domain.user.dto.response.NoticeListResponse; +import com.swyp.app.domain.user.dto.response.NotificationSettingsResponse; +import com.swyp.app.domain.user.dto.response.RecapResponse; +import com.swyp.app.domain.user.entity.ActivityType; +import com.swyp.app.domain.user.entity.NoticeType; +import com.swyp.app.domain.user.entity.VoteSide; +import com.swyp.app.domain.user.service.MypageService; +import com.swyp.app.domain.user.service.UserService; +import com.swyp.app.global.common.response.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/me") +public class MypageController { + + private final UserService userService; + private final MypageService mypageService; + + @PatchMapping("/profile") + public ApiResponse updateMyProfile( + @Valid @RequestBody UpdateUserProfileRequest request + ) { + return ApiResponse.onSuccess(userService.updateMyProfile(request)); + } + + @GetMapping("/mypage") + public ApiResponse getMypage() { + return ApiResponse.onSuccess(mypageService.getMypage()); + } + + @GetMapping("/recap") + public ApiResponse getRecap() { + return ApiResponse.onSuccess(mypageService.getRecap()); + } + + @GetMapping("/battle-records") + public ApiResponse getBattleRecords( + @RequestParam(required = false) Integer offset, + @RequestParam(required = false) Integer size, + @RequestParam(name = "vote_side", required = false) VoteSide voteSide + ) { + return ApiResponse.onSuccess(mypageService.getBattleRecords(offset, size, voteSide)); + } + + @GetMapping("/content-activities") + public ApiResponse getContentActivities( + @RequestParam(required = false) Integer offset, + @RequestParam(required = false) Integer size, + @RequestParam(name = "activity_type", required = false) ActivityType activityType + ) { + return ApiResponse.onSuccess(mypageService.getContentActivities(offset, size, activityType)); + } + + @GetMapping("/notification-settings") + public ApiResponse getNotificationSettings() { + return ApiResponse.onSuccess(mypageService.getNotificationSettings()); + } + + @PatchMapping("/notification-settings") + public ApiResponse updateNotificationSettings( + @RequestBody UpdateNotificationSettingsRequest request + ) { + return ApiResponse.onSuccess(mypageService.updateNotificationSettings(request)); + } + + @GetMapping("/notices") + public ApiResponse getNotices( + @RequestParam(required = false) NoticeType type + ) { + return ApiResponse.onSuccess(mypageService.getNotices(type)); + } + + @GetMapping("/notices/{noticeId}") + public ApiResponse getNoticeDetail(@PathVariable Long noticeId) { + return ApiResponse.onSuccess(mypageService.getNoticeDetail(noticeId)); + } +} diff --git a/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScore.java b/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScore.java index f514bcc..447c83d 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScore.java +++ b/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScore.java @@ -1,7 +1,6 @@ package com.swyp.app.domain.user.entity; import com.swyp.app.global.common.BaseEntity; -import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.Id; @@ -28,22 +27,16 @@ public class UserTendencyScore extends BaseEntity { @JoinColumn(name = "user_id") private User user; - @Column(name = "score1") private int principle; - @Column(name = "score2") private int reason; - @Column(name = "score3") private int individual; - @Column(name = "score4") private int change; - @Column(name = "score5") private int inner; - @Column(name = "score6") private int ideal; @Builder diff --git a/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScoreHistory.java b/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScoreHistory.java index 6d45262..655044d 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScoreHistory.java +++ b/src/main/java/com/swyp/app/domain/user/entity/UserTendencyScoreHistory.java @@ -1,7 +1,6 @@ package com.swyp.app.domain.user.entity; import com.swyp.app.global.common.BaseEntity; -import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -29,22 +28,16 @@ public class UserTendencyScoreHistory extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - @Column(name = "score1") private int principle; - @Column(name = "score2") private int reason; - @Column(name = "score3") private int individual; - @Column(name = "score4") private int change; - @Column(name = "score5") private int inner; - @Column(name = "score6") private int ideal; @Builder diff --git a/src/main/java/com/swyp/app/domain/user/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java new file mode 100644 index 0000000..63947e1 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -0,0 +1,165 @@ +package com.swyp.app.domain.user.service; + +import com.swyp.app.domain.user.dto.request.UpdateNotificationSettingsRequest; +import com.swyp.app.domain.user.dto.response.BattleRecordListResponse; +import com.swyp.app.domain.user.dto.response.ContentActivityListResponse; +import com.swyp.app.domain.user.dto.response.MypageResponse; +import com.swyp.app.domain.user.dto.response.NoticeDetailResponse; +import com.swyp.app.domain.user.dto.response.NoticeListResponse; +import com.swyp.app.domain.user.dto.response.NotificationSettingsResponse; +import com.swyp.app.domain.user.dto.response.RecapResponse; +import com.swyp.app.domain.user.entity.ActivityType; +import com.swyp.app.domain.user.entity.Notice; +import com.swyp.app.domain.user.entity.NoticeType; +import com.swyp.app.domain.user.entity.PhilosopherType; +import com.swyp.app.domain.user.entity.TierCode; +import com.swyp.app.domain.user.entity.User; +import com.swyp.app.domain.user.entity.UserProfile; +import com.swyp.app.domain.user.entity.UserSettings; +import com.swyp.app.domain.user.entity.UserTendencyScore; +import com.swyp.app.domain.user.entity.VoteSide; +import com.swyp.app.domain.user.repository.NoticeRepository; +import com.swyp.app.global.common.exception.CustomException; +import com.swyp.app.global.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MypageService { + + private final UserService userService; + private final NoticeRepository noticeRepository; + + public MypageResponse getMypage() { + User user = userService.findCurrentUser(); + UserProfile profile = userService.findUserProfile(user.getId()); + + MypageResponse.ProfileInfo profileInfo = new MypageResponse.ProfileInfo( + user.getUserTag(), + profile.getNickname(), + profile.getCharacterType(), + profile.getMannerTemperature() + ); + + // TODO: 철학자 산출 로직 확정 후 구현, 현재는 임시로 SOCRATES 반환 + MypageResponse.PhilosopherInfo philosopherInfo = new MypageResponse.PhilosopherInfo( + PhilosopherType.SOCRATES + ); + + // TODO: 포인트 계산 - 타 도메인(vote) 연동 필요, 현재는 WANDERER / 0P + MypageResponse.TierInfo tierInfo = new MypageResponse.TierInfo( + TierCode.WANDERER, + TierCode.WANDERER.getLabel(), + 0 + ); + + return new MypageResponse(profileInfo, philosopherInfo, tierInfo); + } + + public RecapResponse getRecap() { + User user = userService.findCurrentUser(); + UserTendencyScore score = userService.findUserTendencyScore(user.getId()); + + // TODO: 철학자 산출 로직 확정 후 구현, 현재는 임시 값 반환 + RecapResponse.PhilosopherCard myCard = new RecapResponse.PhilosopherCard(PhilosopherType.SOCRATES); + RecapResponse.PhilosopherCard bestMatchCard = new RecapResponse.PhilosopherCard(PhilosopherType.PLATO); + RecapResponse.PhilosopherCard worstMatchCard = new RecapResponse.PhilosopherCard(PhilosopherType.MARX); + + RecapResponse.Scores scores = new RecapResponse.Scores( + score.getPrinciple(), + score.getReason(), + score.getIndividual(), + score.getChange(), + score.getInner(), + score.getIdeal() + ); + + // TODO: 타 도메인(vote, perspective) 연동 필요, 현재는 0/빈값 반환 + RecapResponse.PreferenceReport preferenceReport = new RecapResponse.PreferenceReport( + 0, 0, 0, Collections.emptyList() + ); + + return new RecapResponse(myCard, bestMatchCard, worstMatchCard, scores, preferenceReport); + } + + public BattleRecordListResponse getBattleRecords(Integer offset, Integer size, VoteSide voteSide) { + // TODO: VoteRepository 연동 필요 - 사용자의 투표 기록을 조회하여 배틀 정보와 함께 반환 + return new BattleRecordListResponse(Collections.emptyList(), null, false); + } + + public ContentActivityListResponse getContentActivities(Integer offset, Integer size, ActivityType activityType) { + // TODO: PerspectiveComment/LikeRepository 연동 필요 - 사용자의 댓글/좋아요 활동 조회 + return new ContentActivityListResponse(Collections.emptyList(), null, false); + } + + public NotificationSettingsResponse getNotificationSettings() { + User user = userService.findCurrentUser(); + UserSettings settings = userService.findUserSettings(user.getId()); + return toNotificationSettingsResponse(settings); + } + + @Transactional + public NotificationSettingsResponse updateNotificationSettings(UpdateNotificationSettingsRequest request) { + User user = userService.findCurrentUser(); + UserSettings settings = userService.findUserSettings(user.getId()); + settings.update( + request.newBattleEnabled(), + request.battleResultEnabled(), + request.commentReplyEnabled(), + request.newCommentEnabled(), + request.contentLikeEnabled(), + request.marketingEventEnabled() + ); + return toNotificationSettingsResponse(settings); + } + + public NoticeListResponse getNotices(NoticeType type) { + List notices = type == null + ? noticeRepository.findAllByOrderByIsPinnedDescPublishedAtDesc() + : noticeRepository.findByTypeOrderByIsPinnedDescPublishedAtDesc(type); + + List items = notices.stream() + .map(notice -> new NoticeListResponse.NoticeItem( + notice.getId(), + notice.getType(), + notice.getTitle(), + notice.getBodyPreview(), + notice.isPinned(), + notice.getPublishedAt() + )) + .toList(); + + return new NoticeListResponse(items); + } + + public NoticeDetailResponse getNoticeDetail(Long noticeId) { + Notice notice = noticeRepository.findById(noticeId) + .orElseThrow(() -> new CustomException(ErrorCode.COMMON_INVALID_PARAMETER)); + + return new NoticeDetailResponse( + notice.getId(), + notice.getType(), + notice.getTitle(), + notice.getBody(), + notice.isPinned(), + notice.getPublishedAt() + ); + } + + private NotificationSettingsResponse toNotificationSettingsResponse(UserSettings settings) { + return new NotificationSettingsResponse( + settings.isNewBattleEnabled(), + settings.isBattleResultEnabled(), + settings.isCommentReplyEnabled(), + settings.isNewCommentEnabled(), + settings.isContentLikeEnabled(), + settings.isMarketingEventEnabled() + ); + } +} From 75cb1a9e4d8ccd3661dfffeab5fcdb06fa2bed6e Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:43:30 +0900 Subject: [PATCH 12/18] =?UTF-8?q?#36=20[Feat]=20GET=20/me/battle-records?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VoteRepository에 사용자 투표 기록 조회 쿼리 추가 (offset 페이지네이션, voteSide 필터) - MypageService.getBattleRecords() 구현 (Vote → Battle 조인, BattleOptionLabel ↔ VoteSide 매핑) Co-Authored-By: Claude Opus 4.6 --- .../domain/user/service/MypageService.java | 47 ++++++++++++++++++- .../vote/repository/VoteRepository.java | 23 +++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/app/domain/user/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java index 63947e1..0168a2c 100644 --- a/src/main/java/com/swyp/app/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -1,5 +1,6 @@ package com.swyp.app.domain.user.service; +import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.user.dto.request.UpdateNotificationSettingsRequest; import com.swyp.app.domain.user.dto.response.BattleRecordListResponse; import com.swyp.app.domain.user.dto.response.ContentActivityListResponse; @@ -19,9 +20,12 @@ import com.swyp.app.domain.user.entity.UserTendencyScore; import com.swyp.app.domain.user.entity.VoteSide; import com.swyp.app.domain.user.repository.NoticeRepository; +import com.swyp.app.domain.vote.entity.Vote; +import com.swyp.app.domain.vote.repository.VoteRepository; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,8 +37,11 @@ @Transactional(readOnly = true) public class MypageService { + private static final int DEFAULT_PAGE_SIZE = 20; + private final UserService userService; private final NoticeRepository noticeRepository; + private final VoteRepository voteRepository; public MypageResponse getMypage() { User user = userService.findCurrentUser(); @@ -89,8 +96,36 @@ public RecapResponse getRecap() { } public BattleRecordListResponse getBattleRecords(Integer offset, Integer size, VoteSide voteSide) { - // TODO: VoteRepository 연동 필요 - 사용자의 투표 기록을 조회하여 배틀 정보와 함께 반환 - return new BattleRecordListResponse(Collections.emptyList(), null, false); + User user = userService.findCurrentUser(); + int pageOffset = offset == null || offset < 0 ? 0 : offset; + int pageSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : size; + PageRequest pageable = PageRequest.of(pageOffset / pageSize, pageSize); + + BattleOptionLabel label = voteSide != null ? toOptionLabel(voteSide) : null; + + List votes = label != null + ? voteRepository.findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc(user.getId(), label, pageable) + : voteRepository.findByUserIdOrderByCreatedAtDesc(user.getId(), pageable); + + long totalCount = label != null + ? voteRepository.countByUserIdAndPreVoteOptionLabel(user.getId(), label) + : voteRepository.countByUserId(user.getId()); + + List items = votes.stream() + .map(vote -> new BattleRecordListResponse.BattleRecordItem( + vote.getBattle().getId().toString(), + vote.getId().toString(), + toVoteSide(vote.getPreVoteOption().getLabel()), + vote.getBattle().getTitle(), + vote.getBattle().getSummary(), + vote.getCreatedAt() + )) + .toList(); + + int nextOffset = pageOffset + pageSize; + boolean hasNext = nextOffset < totalCount; + + return new BattleRecordListResponse(items, hasNext ? nextOffset : null, hasNext); } public ContentActivityListResponse getContentActivities(Integer offset, Integer size, ActivityType activityType) { @@ -152,6 +187,14 @@ public NoticeDetailResponse getNoticeDetail(Long noticeId) { ); } + private VoteSide toVoteSide(BattleOptionLabel label) { + return label == BattleOptionLabel.A ? VoteSide.PRO : VoteSide.CON; + } + + private BattleOptionLabel toOptionLabel(VoteSide voteSide) { + return voteSide == VoteSide.PRO ? BattleOptionLabel.A : BattleOptionLabel.B; + } + private NotificationSettingsResponse toNotificationSettingsResponse(UserSettings settings) { return new NotificationSettingsResponse( settings.isNewBattleEnabled(), diff --git a/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java b/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java index 77e9ebd..7e506a1 100644 --- a/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java +++ b/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java @@ -2,9 +2,14 @@ import com.swyp.app.domain.battle.entity.Battle; import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.vote.entity.Vote; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -21,4 +26,22 @@ public interface VoteRepository extends JpaRepository { long countByBattleAndPreVoteOption(Battle battle, BattleOption preVoteOption); Optional findTopByBattleOrderByUpdatedAtDesc(Battle battle); + + // MypageService: 사용자 투표 기록 조회 (offset 페이지네이션) + @Query("SELECT v FROM Vote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + + "WHERE v.userId = :userId ORDER BY v.createdAt DESC") + List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + // MypageService: 사용자 투표 기록 - voteSide(PRO/CON) 필터 + @Query("SELECT v FROM Vote v JOIN FETCH v.battle JOIN FETCH v.preVoteOption " + + "WHERE v.userId = :userId AND v.preVoteOption.label = :label ORDER BY v.createdAt DESC") + List findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc( + @Param("userId") Long userId, @Param("label") BattleOptionLabel label, Pageable pageable); + + // MypageService: 사용자 투표 전체 수 + long countByUserId(Long userId); + + // MypageService: 사용자 투표 수 - voteSide 필터 + @Query("SELECT COUNT(v) FROM Vote v WHERE v.userId = :userId AND v.preVoteOption.label = :label") + long countByUserIdAndPreVoteOptionLabel(@Param("userId") Long userId, @Param("label") BattleOptionLabel label); } \ No newline at end of file From 74a6678637a6680872c9891ef0ea580c2538e38a Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:45:25 +0900 Subject: [PATCH 13/18] =?UTF-8?q?#36=20[Feat]=20GET=20/me/content-activiti?= =?UTF-8?q?es=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PerspectiveComment/LikeRepository에 사용자 활동 조회 쿼리 추가 - MypageService.getContentActivities() 구현 (COMMENT/LIKE 타입별 분기) - Battle, BattleOption 배치 조회로 N+1 방지 Co-Authored-By: Claude Opus 4.6 --- .../PerspectiveCommentRepository.java | 8 ++ .../repository/PerspectiveLikeRepository.java | 10 ++ .../domain/user/service/MypageService.java | 135 +++++++++++++++++- 3 files changed, 151 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/swyp/app/domain/perspective/repository/PerspectiveCommentRepository.java b/src/main/java/com/swyp/app/domain/perspective/repository/PerspectiveCommentRepository.java index 1b02326..0518ad0 100644 --- a/src/main/java/com/swyp/app/domain/perspective/repository/PerspectiveCommentRepository.java +++ b/src/main/java/com/swyp/app/domain/perspective/repository/PerspectiveCommentRepository.java @@ -4,6 +4,8 @@ import com.swyp.app.domain.perspective.entity.PerspectiveComment; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; @@ -14,4 +16,10 @@ public interface PerspectiveCommentRepository extends JpaRepository findByPerspectiveOrderByCreatedAtDesc(Perspective perspective, Pageable pageable); List findByPerspectiveAndCreatedAtBeforeOrderByCreatedAtDesc(Perspective perspective, LocalDateTime cursor, Pageable pageable); + + // MypageService: 사용자 댓글 활동 조회 (offset 페이지네이션) + @Query("SELECT c FROM PerspectiveComment c JOIN FETCH c.perspective WHERE c.userId = :userId ORDER BY c.createdAt DESC") + List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + long countByUserId(Long userId); } diff --git a/src/main/java/com/swyp/app/domain/perspective/repository/PerspectiveLikeRepository.java b/src/main/java/com/swyp/app/domain/perspective/repository/PerspectiveLikeRepository.java index dff34fe..a7d00a4 100644 --- a/src/main/java/com/swyp/app/domain/perspective/repository/PerspectiveLikeRepository.java +++ b/src/main/java/com/swyp/app/domain/perspective/repository/PerspectiveLikeRepository.java @@ -2,8 +2,12 @@ import com.swyp.app.domain.perspective.entity.Perspective; import com.swyp.app.domain.perspective.entity.PerspectiveLike; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -14,4 +18,10 @@ public interface PerspectiveLikeRepository extends JpaRepository findByPerspectiveAndUserId(Perspective perspective, Long userId); long countByPerspective(Perspective perspective); + + // MypageService: 사용자 좋아요 활동 조회 (offset 페이지네이션) + @Query("SELECT l FROM PerspectiveLike l JOIN FETCH l.perspective WHERE l.userId = :userId ORDER BY l.createdAt DESC") + List findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); + + long countByUserId(Long userId); } diff --git a/src/main/java/com/swyp/app/domain/user/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java index 0168a2c..e985083 100644 --- a/src/main/java/com/swyp/app/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -1,6 +1,15 @@ package com.swyp.app.domain.user.service; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; import com.swyp.app.domain.battle.enums.BattleOptionLabel; +import com.swyp.app.domain.battle.repository.BattleOptionRepository; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.perspective.entity.Perspective; +import com.swyp.app.domain.perspective.entity.PerspectiveComment; +import com.swyp.app.domain.perspective.entity.PerspectiveLike; +import com.swyp.app.domain.perspective.repository.PerspectiveCommentRepository; +import com.swyp.app.domain.perspective.repository.PerspectiveLikeRepository; import com.swyp.app.domain.user.dto.request.UpdateNotificationSettingsRequest; import com.swyp.app.domain.user.dto.response.BattleRecordListResponse; import com.swyp.app.domain.user.dto.response.ContentActivityListResponse; @@ -29,8 +38,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -42,6 +56,10 @@ public class MypageService { private final UserService userService; private final NoticeRepository noticeRepository; private final VoteRepository voteRepository; + private final BattleRepository battleRepository; + private final BattleOptionRepository battleOptionRepository; + private final PerspectiveCommentRepository perspectiveCommentRepository; + private final PerspectiveLikeRepository perspectiveLikeRepository; public MypageResponse getMypage() { User user = userService.findCurrentUser(); @@ -129,8 +147,121 @@ public BattleRecordListResponse getBattleRecords(Integer offset, Integer size, V } public ContentActivityListResponse getContentActivities(Integer offset, Integer size, ActivityType activityType) { - // TODO: PerspectiveComment/LikeRepository 연동 필요 - 사용자의 댓글/좋아요 활동 조회 - return new ContentActivityListResponse(Collections.emptyList(), null, false); + User user = userService.findCurrentUser(); + int pageOffset = offset == null || offset < 0 ? 0 : offset; + int pageSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : size; + PageRequest pageable = PageRequest.of(pageOffset / pageSize, pageSize); + + if (activityType == ActivityType.LIKE) { + return getLikeActivities(user, pageOffset, pageSize, pageable); + } else if (activityType == ActivityType.COMMENT) { + return getCommentActivities(user, pageOffset, pageSize, pageable); + } + + // activityType이 null이면 댓글 기준으로 반환 (기본값) + return getCommentActivities(user, pageOffset, pageSize, pageable); + } + + private ContentActivityListResponse getCommentActivities(User user, int pageOffset, int pageSize, PageRequest pageable) { + List comments = perspectiveCommentRepository.findByUserIdOrderByCreatedAtDesc(user.getId(), pageable); + long totalCount = perspectiveCommentRepository.countByUserId(user.getId()); + + List perspectives = comments.stream().map(PerspectiveComment::getPerspective).toList(); + Map battleMap = loadBattles(perspectives); + Map optionMap = loadOptions(perspectives); + + List items = comments.stream() + .map(comment -> { + Perspective perspective = comment.getPerspective(); + Battle battle = battleMap.get(perspective.getBattleId()); + BattleOption option = optionMap.get(perspective.getOptionId()); + return toContentActivityItem( + comment.getId().toString(), + ActivityType.COMMENT, + perspective, + battle, + option, + comment.getContent(), + comment.getCreatedAt() + ); + }) + .toList(); + + int nextOffset = pageOffset + pageSize; + boolean hasNext = nextOffset < totalCount; + return new ContentActivityListResponse(items, hasNext ? nextOffset : null, hasNext); + } + + private ContentActivityListResponse getLikeActivities(User user, int pageOffset, int pageSize, PageRequest pageable) { + List likes = perspectiveLikeRepository.findByUserIdOrderByCreatedAtDesc(user.getId(), pageable); + long totalCount = perspectiveLikeRepository.countByUserId(user.getId()); + + List perspectives = likes.stream().map(PerspectiveLike::getPerspective).toList(); + Map battleMap = loadBattles(perspectives); + Map optionMap = loadOptions(perspectives); + + List items = likes.stream() + .map(like -> { + Perspective perspective = like.getPerspective(); + Battle battle = battleMap.get(perspective.getBattleId()); + BattleOption option = optionMap.get(perspective.getOptionId()); + return toContentActivityItem( + like.getId().toString(), + ActivityType.LIKE, + perspective, + battle, + option, + perspective.getContent(), + like.getCreatedAt() + ); + }) + .toList(); + + int nextOffset = pageOffset + pageSize; + boolean hasNext = nextOffset < totalCount; + return new ContentActivityListResponse(items, hasNext ? nextOffset : null, hasNext); + } + + private ContentActivityListResponse.ContentActivityItem toContentActivityItem( + String activityId, ActivityType activityType, Perspective perspective, + Battle battle, BattleOption option, + String content, java.time.LocalDateTime createdAt) { + + com.swyp.app.domain.user.dto.response.UserSummary author = userService.findSummaryById(perspective.getUserId()); + ContentActivityListResponse.AuthorInfo authorInfo = new ContentActivityListResponse.AuthorInfo( + author.userTag(), author.nickname(), com.swyp.app.domain.user.entity.CharacterType.from(author.characterType()) + ); + + return new ContentActivityListResponse.ContentActivityItem( + activityId, + activityType, + perspective.getId().toString(), + perspective.getBattleId().toString(), + battle != null ? battle.getTitle() : null, + authorInfo, + option != null ? option.getStance() : null, + content, + perspective.getLikeCount(), + createdAt + ); + } + + private Map loadBattles(List perspectives) { + List battleIds = perspectives.stream() + .map(Perspective::getBattleId) + .distinct() + .toList(); + return battleRepository.findAllById(battleIds).stream() + .collect(Collectors.toMap(Battle::getId, Function.identity())); + } + + private Map loadOptions(List perspectives) { + List optionIds = perspectives.stream() + .map(Perspective::getOptionId) + .distinct() + .toList(); + return battleOptionRepository.findAllById(optionIds).stream() + .collect(Collectors.toMap(BattleOption::getId, Function.identity())); } public NotificationSettingsResponse getNotificationSettings() { From fa86b2b80c9b91253721f9d082c31bcc6313b189 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:46:57 +0900 Subject: [PATCH 14/18] =?UTF-8?q?#36=20[Feat]=20GET=20/me/recap=20preferen?= =?UTF-8?q?ceReport=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VoteRepository에 recap 통계용 쿼리 추가 (사후투표 수, 입장변경 수, 전체 투표 조회) - BattleTagRepository에 배치 태그 조회 쿼리 추가 - MypageService.getRecap() preferenceReport 구현 - totalParticipation: 총 투표 참여 수 - opinionChanges: 사전/사후 투표 옵션 불일치 수 - battleWinRate: 사후 투표 중 다득표 옵션 선택 비율 - favoriteTopics: 참여 배틀의 태그별 빈도 상위 4개 Co-Authored-By: Claude Opus 4.6 --- .../repository/BattleTagRepository.java | 6 ++ .../domain/user/service/MypageService.java | 90 ++++++++++++++++++- .../vote/repository/VoteRepository.java | 11 +++ 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java index 38a5c8a..5bc2b11 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleTagRepository.java @@ -4,6 +4,8 @@ import com.swyp.app.domain.battle.entity.BattleTag; import com.swyp.app.domain.tag.entity.Tag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.UUID; @@ -12,4 +14,8 @@ public interface BattleTagRepository extends JpaRepository { List findByBattle(Battle battle); void deleteByBattle(Battle battle); boolean existsByTag(Tag tag); + + // MypageService (recap): 여러 배틀의 태그를 한번에 조회 + @Query("SELECT bt FROM BattleTag bt JOIN FETCH bt.tag WHERE bt.battle.id IN :battleIds") + List findByBattleIdIn(@Param("battleIds") List battleIds); } \ No newline at end of file diff --git a/src/main/java/com/swyp/app/domain/user/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java index e985083..b03cc90 100644 --- a/src/main/java/com/swyp/app/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -2,9 +2,11 @@ import com.swyp.app.domain.battle.entity.Battle; import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.entity.BattleTag; import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.repository.BattleOptionRepository; import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; import com.swyp.app.domain.perspective.entity.Perspective; import com.swyp.app.domain.perspective.entity.PerspectiveComment; import com.swyp.app.domain.perspective.entity.PerspectiveLike; @@ -30,6 +32,7 @@ import com.swyp.app.domain.user.entity.VoteSide; import com.swyp.app.domain.user.repository.NoticeRepository; import com.swyp.app.domain.vote.entity.Vote; +import com.swyp.app.domain.vote.enums.VoteStatus; import com.swyp.app.domain.vote.repository.VoteRepository; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; @@ -58,6 +61,7 @@ public class MypageService { private final VoteRepository voteRepository; private final BattleRepository battleRepository; private final BattleOptionRepository battleOptionRepository; + private final BattleTagRepository battleTagRepository; private final PerspectiveCommentRepository perspectiveCommentRepository; private final PerspectiveLikeRepository perspectiveLikeRepository; @@ -105,14 +109,92 @@ public RecapResponse getRecap() { score.getIdeal() ); - // TODO: 타 도메인(vote, perspective) 연동 필요, 현재는 0/빈값 반환 - RecapResponse.PreferenceReport preferenceReport = new RecapResponse.PreferenceReport( - 0, 0, 0, Collections.emptyList() - ); + RecapResponse.PreferenceReport preferenceReport = buildPreferenceReport(user.getId()); return new RecapResponse(myCard, bestMatchCard, worstMatchCard, scores, preferenceReport); } + private RecapResponse.PreferenceReport buildPreferenceReport(Long userId) { + // 총 참여 수 (사전 투표 이상) + long totalParticipation = voteRepository.countByUserId(userId); + + // 입장 변경 수 + long opinionChanges = voteRepository.countOpinionChangesByUserId(userId); + + // 배틀 승률: 사후 투표 중 최종 옵션이 더 많은 득표를 받은 비율 + int battleWinRate = calculateBattleWinRate(userId); + + // 선호 주제: 참여한 배틀의 태그별 빈도 상위 4개 + List favoriteTopics = calculateFavoriteTopics(userId); + + return new RecapResponse.PreferenceReport( + (int) totalParticipation, + (int) opinionChanges, + battleWinRate, + favoriteTopics + ); + } + + private int calculateBattleWinRate(Long userId) { + List postVotes = voteRepository.findByUserId(userId).stream() + .filter(v -> v.getStatus() == VoteStatus.POST_VOTED && v.getPostVoteOption() != null) + .toList(); + + if (postVotes.isEmpty()) return 0; + + long wins = postVotes.stream() + .filter(v -> { + BattleOption myOption = v.getPostVoteOption(); + BattleOption otherOption = v.getPreVoteOption(); + // 내 최종 선택의 득표가 상대보다 높으면 승리 + if (myOption.getId().equals(otherOption.getId())) { + // 사전/사후 같은 옵션이면 해당 옵션 득표로 판단 + long totalVotes = v.getBattle().getTotalParticipantsCount(); + return myOption.getVoteCount() > totalVotes - myOption.getVoteCount(); + } + return myOption.getVoteCount() > otherOption.getVoteCount(); + }) + .count(); + + return (int) (wins * 100 / postVotes.size()); + } + + private List calculateFavoriteTopics(Long userId) { + List votes = voteRepository.findByUserId(userId); + if (votes.isEmpty()) return Collections.emptyList(); + + List battleIds = votes.stream() + .map(v -> v.getBattle().getId()) + .distinct() + .toList(); + + List battleTags = battleTagRepository.findByBattleIdIn(battleIds); + + // 태그별 참여 횟수 집계 + Map tagCounts = battleTags.stream() + .collect(Collectors.groupingBy( + bt -> bt.getTag().getName(), + Collectors.counting() + )); + + // 상위 4개 태그 추출 + List> sorted = tagCounts.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(4) + .toList(); + + List topics = new ArrayList<>(); + for (int i = 0; i < sorted.size(); i++) { + Map.Entry entry = sorted.get(i); + topics.add(new RecapResponse.FavoriteTopic( + i + 1, + entry.getKey(), + entry.getValue().intValue() + )); + } + return topics; + } + public BattleRecordListResponse getBattleRecords(Integer offset, Integer size, VoteSide voteSide) { User user = userService.findCurrentUser(); int pageOffset = offset == null || offset < 0 ? 0 : offset; diff --git a/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java b/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java index 7e506a1..3111085 100644 --- a/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java +++ b/src/main/java/com/swyp/app/domain/vote/repository/VoteRepository.java @@ -44,4 +44,15 @@ List findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc( // MypageService: 사용자 투표 수 - voteSide 필터 @Query("SELECT COUNT(v) FROM Vote v WHERE v.userId = :userId AND v.preVoteOption.label = :label") long countByUserIdAndPreVoteOptionLabel(@Param("userId") Long userId, @Param("label") BattleOptionLabel label); + + // MypageService (recap): 사후 투표 완료 수 + long countByUserIdAndStatus(Long userId, com.swyp.app.domain.vote.enums.VoteStatus status); + + // MypageService (recap): 입장 변경 수 (사전/사후 투표 옵션이 다른 경우) + @Query("SELECT COUNT(v) FROM Vote v WHERE v.userId = :userId AND v.status = 'POST_VOTED' " + + "AND v.preVoteOption <> v.postVoteOption") + long countOpinionChangesByUserId(@Param("userId") Long userId); + + // MypageService (recap): 사용자가 참여한 모든 투표 (배틀 목록 추출용) + List findByUserId(Long userId); } \ No newline at end of file From d74ab2188a242b18742669da643cf253104a8fa8 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 13:50:43 +0900 Subject: [PATCH 15/18] =?UTF-8?q?#36=20[Refactor]=20MypageService=20?= =?UTF-8?q?=ED=83=80=20=EB=8F=84=EB=A9=94=EC=9D=B8=20Repository=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=9D=98=EC=A1=B4=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VoteQueryService 생성: 투표 기록 조회, 참여 통계, 승률 계산 로직 캡슐화 - PerspectiveQueryService 생성: 댓글/좋아요 활동 조회 로직 캡슐화 - BattleQueryService 생성: 배틀/옵션 배치 조회, 태그 빈도 집계 캡슐화 - MypageService에서 타 도메인 Repository 의존 모두 제거, QueryService만 호출 Co-Authored-By: Claude Opus 4.6 --- .../battle/service/BattleQueryService.java | 62 +++++ .../service/PerspectiveQueryService.java | 39 +++ .../domain/user/service/MypageService.java | 223 +++++------------- .../domain/vote/service/VoteQueryService.java | 72 ++++++ 4 files changed, 226 insertions(+), 170 deletions(-) create mode 100644 src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java create mode 100644 src/main/java/com/swyp/app/domain/perspective/service/PerspectiveQueryService.java create mode 100644 src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java b/src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java new file mode 100644 index 0000000..1b9eff3 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java @@ -0,0 +1,62 @@ +package com.swyp.app.domain.battle.service; + +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.entity.BattleTag; +import com.swyp.app.domain.battle.repository.BattleOptionRepository; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BattleQueryService { + + private final BattleRepository battleRepository; + private final BattleOptionRepository battleOptionRepository; + private final BattleTagRepository battleTagRepository; + + public Map findBattlesByIds(List battleIds) { + return battleRepository.findAllById(battleIds).stream() + .collect(Collectors.toMap(Battle::getId, Function.identity())); + } + + public Map findOptionsByIds(List optionIds) { + return battleOptionRepository.findAllById(optionIds).stream() + .collect(Collectors.toMap(BattleOption::getId, Function.identity())); + } + + /** + * 주어진 배틀 ID 목록에 대해 태그별 빈도를 집계하여 상위 limit개를 반환한다. + * @return Map<태그명, 빈도수> (상위 limit개) + */ + public Map getTopTagsByBattleIds(List battleIds, int limit) { + if (battleIds.isEmpty()) return Map.of(); + + List battleTags = battleTagRepository.findByBattleIdIn(battleIds); + + return battleTags.stream() + .collect(Collectors.groupingBy( + bt -> bt.getTag().getName(), + Collectors.counting() + )) + .entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(limit) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (a, b) -> a, + java.util.LinkedHashMap::new + )); + } +} diff --git a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveQueryService.java b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveQueryService.java new file mode 100644 index 0000000..bc6a9f2 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveQueryService.java @@ -0,0 +1,39 @@ +package com.swyp.app.domain.perspective.service; + +import com.swyp.app.domain.perspective.entity.PerspectiveComment; +import com.swyp.app.domain.perspective.entity.PerspectiveLike; +import com.swyp.app.domain.perspective.repository.PerspectiveCommentRepository; +import com.swyp.app.domain.perspective.repository.PerspectiveLikeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PerspectiveQueryService { + + private final PerspectiveCommentRepository perspectiveCommentRepository; + private final PerspectiveLikeRepository perspectiveLikeRepository; + + public List findUserComments(Long userId, int offset, int size) { + PageRequest pageable = PageRequest.of(offset / size, size); + return perspectiveCommentRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + } + + public long countUserComments(Long userId) { + return perspectiveCommentRepository.countByUserId(userId); + } + + public List findUserLikes(Long userId, int offset, int size) { + PageRequest pageable = PageRequest.of(offset / size, size); + return perspectiveLikeRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + } + + public long countUserLikes(Long userId) { + return perspectiveLikeRepository.countByUserId(userId); + } +} diff --git a/src/main/java/com/swyp/app/domain/user/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java index b03cc90..892e4f1 100644 --- a/src/main/java/com/swyp/app/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -2,16 +2,12 @@ import com.swyp.app.domain.battle.entity.Battle; import com.swyp.app.domain.battle.entity.BattleOption; -import com.swyp.app.domain.battle.entity.BattleTag; import com.swyp.app.domain.battle.enums.BattleOptionLabel; -import com.swyp.app.domain.battle.repository.BattleOptionRepository; -import com.swyp.app.domain.battle.repository.BattleRepository; -import com.swyp.app.domain.battle.repository.BattleTagRepository; +import com.swyp.app.domain.battle.service.BattleQueryService; import com.swyp.app.domain.perspective.entity.Perspective; import com.swyp.app.domain.perspective.entity.PerspectiveComment; import com.swyp.app.domain.perspective.entity.PerspectiveLike; -import com.swyp.app.domain.perspective.repository.PerspectiveCommentRepository; -import com.swyp.app.domain.perspective.repository.PerspectiveLikeRepository; +import com.swyp.app.domain.perspective.service.PerspectiveQueryService; import com.swyp.app.domain.user.dto.request.UpdateNotificationSettingsRequest; import com.swyp.app.domain.user.dto.response.BattleRecordListResponse; import com.swyp.app.domain.user.dto.response.ContentActivityListResponse; @@ -20,7 +16,9 @@ import com.swyp.app.domain.user.dto.response.NoticeListResponse; import com.swyp.app.domain.user.dto.response.NotificationSettingsResponse; import com.swyp.app.domain.user.dto.response.RecapResponse; +import com.swyp.app.domain.user.dto.response.UserSummary; import com.swyp.app.domain.user.entity.ActivityType; +import com.swyp.app.domain.user.entity.CharacterType; import com.swyp.app.domain.user.entity.Notice; import com.swyp.app.domain.user.entity.NoticeType; import com.swyp.app.domain.user.entity.PhilosopherType; @@ -32,22 +30,19 @@ import com.swyp.app.domain.user.entity.VoteSide; import com.swyp.app.domain.user.repository.NoticeRepository; import com.swyp.app.domain.vote.entity.Vote; -import com.swyp.app.domain.vote.enums.VoteStatus; -import com.swyp.app.domain.vote.repository.VoteRepository; +import com.swyp.app.domain.vote.service.VoteQueryService; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; -import java.util.function.Function; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -58,12 +53,9 @@ public class MypageService { private final UserService userService; private final NoticeRepository noticeRepository; - private final VoteRepository voteRepository; - private final BattleRepository battleRepository; - private final BattleOptionRepository battleOptionRepository; - private final BattleTagRepository battleTagRepository; - private final PerspectiveCommentRepository perspectiveCommentRepository; - private final PerspectiveLikeRepository perspectiveLikeRepository; + private final VoteQueryService voteQueryService; + private final BattleQueryService battleQueryService; + private final PerspectiveQueryService perspectiveQueryService; public MypageResponse getMypage() { User user = userService.findCurrentUser(); @@ -115,17 +107,18 @@ public RecapResponse getRecap() { } private RecapResponse.PreferenceReport buildPreferenceReport(Long userId) { - // 총 참여 수 (사전 투표 이상) - long totalParticipation = voteRepository.countByUserId(userId); + long totalParticipation = voteQueryService.countTotalParticipation(userId); + long opinionChanges = voteQueryService.countOpinionChanges(userId); + int battleWinRate = voteQueryService.calculateBattleWinRate(userId); - // 입장 변경 수 - long opinionChanges = voteRepository.countOpinionChangesByUserId(userId); + List battleIds = voteQueryService.findParticipatedBattleIds(userId); + Map topTags = battleQueryService.getTopTagsByBattleIds(battleIds, 4); - // 배틀 승률: 사후 투표 중 최종 옵션이 더 많은 득표를 받은 비율 - int battleWinRate = calculateBattleWinRate(userId); - - // 선호 주제: 참여한 배틀의 태그별 빈도 상위 4개 - List favoriteTopics = calculateFavoriteTopics(userId); + List favoriteTopics = new ArrayList<>(); + int rank = 1; + for (Map.Entry entry : topTags.entrySet()) { + favoriteTopics.add(new RecapResponse.FavoriteTopic(rank++, entry.getKey(), entry.getValue().intValue())); + } return new RecapResponse.PreferenceReport( (int) totalParticipation, @@ -135,81 +128,15 @@ private RecapResponse.PreferenceReport buildPreferenceReport(Long userId) { ); } - private int calculateBattleWinRate(Long userId) { - List postVotes = voteRepository.findByUserId(userId).stream() - .filter(v -> v.getStatus() == VoteStatus.POST_VOTED && v.getPostVoteOption() != null) - .toList(); - - if (postVotes.isEmpty()) return 0; - - long wins = postVotes.stream() - .filter(v -> { - BattleOption myOption = v.getPostVoteOption(); - BattleOption otherOption = v.getPreVoteOption(); - // 내 최종 선택의 득표가 상대보다 높으면 승리 - if (myOption.getId().equals(otherOption.getId())) { - // 사전/사후 같은 옵션이면 해당 옵션 득표로 판단 - long totalVotes = v.getBattle().getTotalParticipantsCount(); - return myOption.getVoteCount() > totalVotes - myOption.getVoteCount(); - } - return myOption.getVoteCount() > otherOption.getVoteCount(); - }) - .count(); - - return (int) (wins * 100 / postVotes.size()); - } - - private List calculateFavoriteTopics(Long userId) { - List votes = voteRepository.findByUserId(userId); - if (votes.isEmpty()) return Collections.emptyList(); - - List battleIds = votes.stream() - .map(v -> v.getBattle().getId()) - .distinct() - .toList(); - - List battleTags = battleTagRepository.findByBattleIdIn(battleIds); - - // 태그별 참여 횟수 집계 - Map tagCounts = battleTags.stream() - .collect(Collectors.groupingBy( - bt -> bt.getTag().getName(), - Collectors.counting() - )); - - // 상위 4개 태그 추출 - List> sorted = tagCounts.entrySet().stream() - .sorted(Map.Entry.comparingByValue().reversed()) - .limit(4) - .toList(); - - List topics = new ArrayList<>(); - for (int i = 0; i < sorted.size(); i++) { - Map.Entry entry = sorted.get(i); - topics.add(new RecapResponse.FavoriteTopic( - i + 1, - entry.getKey(), - entry.getValue().intValue() - )); - } - return topics; - } - public BattleRecordListResponse getBattleRecords(Integer offset, Integer size, VoteSide voteSide) { User user = userService.findCurrentUser(); int pageOffset = offset == null || offset < 0 ? 0 : offset; int pageSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : size; - PageRequest pageable = PageRequest.of(pageOffset / pageSize, pageSize); BattleOptionLabel label = voteSide != null ? toOptionLabel(voteSide) : null; - List votes = label != null - ? voteRepository.findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc(user.getId(), label, pageable) - : voteRepository.findByUserIdOrderByCreatedAtDesc(user.getId(), pageable); - - long totalCount = label != null - ? voteRepository.countByUserIdAndPreVoteOptionLabel(user.getId(), label) - : voteRepository.countByUserId(user.getId()); + List votes = voteQueryService.findUserVotes(user.getId(), pageOffset, pageSize, label); + long totalCount = voteQueryService.countUserVotes(user.getId(), label); List items = votes.stream() .map(vote -> new BattleRecordListResponse.BattleRecordItem( @@ -224,7 +151,6 @@ public BattleRecordListResponse getBattleRecords(Integer offset, Integer size, V int nextOffset = pageOffset + pageSize; boolean hasNext = nextOffset < totalCount; - return new BattleRecordListResponse(items, hasNext ? nextOffset : null, hasNext); } @@ -232,21 +158,16 @@ public ContentActivityListResponse getContentActivities(Integer offset, Integer User user = userService.findCurrentUser(); int pageOffset = offset == null || offset < 0 ? 0 : offset; int pageSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : size; - PageRequest pageable = PageRequest.of(pageOffset / pageSize, pageSize); if (activityType == ActivityType.LIKE) { - return getLikeActivities(user, pageOffset, pageSize, pageable); - } else if (activityType == ActivityType.COMMENT) { - return getCommentActivities(user, pageOffset, pageSize, pageable); + return buildLikeActivities(user, pageOffset, pageSize); } - - // activityType이 null이면 댓글 기준으로 반환 (기본값) - return getCommentActivities(user, pageOffset, pageSize, pageable); + return buildCommentActivities(user, pageOffset, pageSize); } - private ContentActivityListResponse getCommentActivities(User user, int pageOffset, int pageSize, PageRequest pageable) { - List comments = perspectiveCommentRepository.findByUserIdOrderByCreatedAtDesc(user.getId(), pageable); - long totalCount = perspectiveCommentRepository.countByUserId(user.getId()); + private ContentActivityListResponse buildCommentActivities(User user, int pageOffset, int pageSize) { + List comments = perspectiveQueryService.findUserComments(user.getId(), pageOffset, pageSize); + long totalCount = perspectiveQueryService.countUserComments(user.getId()); List perspectives = comments.stream().map(PerspectiveComment::getPerspective).toList(); Map battleMap = loadBattles(perspectives); @@ -254,18 +175,10 @@ private ContentActivityListResponse getCommentActivities(User user, int pageOffs List items = comments.stream() .map(comment -> { - Perspective perspective = comment.getPerspective(); - Battle battle = battleMap.get(perspective.getBattleId()); - BattleOption option = optionMap.get(perspective.getOptionId()); - return toContentActivityItem( - comment.getId().toString(), - ActivityType.COMMENT, - perspective, - battle, - option, - comment.getContent(), - comment.getCreatedAt() - ); + Perspective p = comment.getPerspective(); + return toActivityItem(comment.getId().toString(), ActivityType.COMMENT, p, + battleMap.get(p.getBattleId()), optionMap.get(p.getOptionId()), + comment.getContent(), comment.getCreatedAt()); }) .toList(); @@ -274,9 +187,9 @@ private ContentActivityListResponse getCommentActivities(User user, int pageOffs return new ContentActivityListResponse(items, hasNext ? nextOffset : null, hasNext); } - private ContentActivityListResponse getLikeActivities(User user, int pageOffset, int pageSize, PageRequest pageable) { - List likes = perspectiveLikeRepository.findByUserIdOrderByCreatedAtDesc(user.getId(), pageable); - long totalCount = perspectiveLikeRepository.countByUserId(user.getId()); + private ContentActivityListResponse buildLikeActivities(User user, int pageOffset, int pageSize) { + List likes = perspectiveQueryService.findUserLikes(user.getId(), pageOffset, pageSize); + long totalCount = perspectiveQueryService.countUserLikes(user.getId()); List perspectives = likes.stream().map(PerspectiveLike::getPerspective).toList(); Map battleMap = loadBattles(perspectives); @@ -284,18 +197,10 @@ private ContentActivityListResponse getLikeActivities(User user, int pageOffset, List items = likes.stream() .map(like -> { - Perspective perspective = like.getPerspective(); - Battle battle = battleMap.get(perspective.getBattleId()); - BattleOption option = optionMap.get(perspective.getOptionId()); - return toContentActivityItem( - like.getId().toString(), - ActivityType.LIKE, - perspective, - battle, - option, - perspective.getContent(), - like.getCreatedAt() - ); + Perspective p = like.getPerspective(); + return toActivityItem(like.getId().toString(), ActivityType.LIKE, p, + battleMap.get(p.getBattleId()), optionMap.get(p.getOptionId()), + p.getContent(), like.getCreatedAt()); }) .toList(); @@ -304,19 +209,17 @@ private ContentActivityListResponse getLikeActivities(User user, int pageOffset, return new ContentActivityListResponse(items, hasNext ? nextOffset : null, hasNext); } - private ContentActivityListResponse.ContentActivityItem toContentActivityItem( + private ContentActivityListResponse.ContentActivityItem toActivityItem( String activityId, ActivityType activityType, Perspective perspective, - Battle battle, BattleOption option, - String content, java.time.LocalDateTime createdAt) { + Battle battle, BattleOption option, String content, LocalDateTime createdAt) { - com.swyp.app.domain.user.dto.response.UserSummary author = userService.findSummaryById(perspective.getUserId()); + UserSummary author = userService.findSummaryById(perspective.getUserId()); ContentActivityListResponse.AuthorInfo authorInfo = new ContentActivityListResponse.AuthorInfo( - author.userTag(), author.nickname(), com.swyp.app.domain.user.entity.CharacterType.from(author.characterType()) + author.userTag(), author.nickname(), CharacterType.from(author.characterType()) ); return new ContentActivityListResponse.ContentActivityItem( - activityId, - activityType, + activityId, activityType, perspective.getId().toString(), perspective.getBattleId().toString(), battle != null ? battle.getTitle() : null, @@ -329,21 +232,13 @@ private ContentActivityListResponse.ContentActivityItem toContentActivityItem( } private Map loadBattles(List perspectives) { - List battleIds = perspectives.stream() - .map(Perspective::getBattleId) - .distinct() - .toList(); - return battleRepository.findAllById(battleIds).stream() - .collect(Collectors.toMap(Battle::getId, Function.identity())); + List battleIds = perspectives.stream().map(Perspective::getBattleId).distinct().toList(); + return battleQueryService.findBattlesByIds(battleIds); } private Map loadOptions(List perspectives) { - List optionIds = perspectives.stream() - .map(Perspective::getOptionId) - .distinct() - .toList(); - return battleOptionRepository.findAllById(optionIds).stream() - .collect(Collectors.toMap(BattleOption::getId, Function.identity())); + List optionIds = perspectives.stream().map(Perspective::getOptionId).distinct().toList(); + return battleQueryService.findOptionsByIds(optionIds); } public NotificationSettingsResponse getNotificationSettings() { @@ -374,12 +269,8 @@ public NoticeListResponse getNotices(NoticeType type) { List items = notices.stream() .map(notice -> new NoticeListResponse.NoticeItem( - notice.getId(), - notice.getType(), - notice.getTitle(), - notice.getBodyPreview(), - notice.isPinned(), - notice.getPublishedAt() + notice.getId(), notice.getType(), notice.getTitle(), + notice.getBodyPreview(), notice.isPinned(), notice.getPublishedAt() )) .toList(); @@ -389,14 +280,9 @@ public NoticeListResponse getNotices(NoticeType type) { public NoticeDetailResponse getNoticeDetail(Long noticeId) { Notice notice = noticeRepository.findById(noticeId) .orElseThrow(() -> new CustomException(ErrorCode.COMMON_INVALID_PARAMETER)); - return new NoticeDetailResponse( - notice.getId(), - notice.getType(), - notice.getTitle(), - notice.getBody(), - notice.isPinned(), - notice.getPublishedAt() + notice.getId(), notice.getType(), notice.getTitle(), + notice.getBody(), notice.isPinned(), notice.getPublishedAt() ); } @@ -410,12 +296,9 @@ private BattleOptionLabel toOptionLabel(VoteSide voteSide) { private NotificationSettingsResponse toNotificationSettingsResponse(UserSettings settings) { return new NotificationSettingsResponse( - settings.isNewBattleEnabled(), - settings.isBattleResultEnabled(), - settings.isCommentReplyEnabled(), - settings.isNewCommentEnabled(), - settings.isContentLikeEnabled(), - settings.isMarketingEventEnabled() + settings.isNewBattleEnabled(), settings.isBattleResultEnabled(), + settings.isCommentReplyEnabled(), settings.isNewCommentEnabled(), + settings.isContentLikeEnabled(), settings.isMarketingEventEnabled() ); } } diff --git a/src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java b/src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java new file mode 100644 index 0000000..b79af7f --- /dev/null +++ b/src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java @@ -0,0 +1,72 @@ +package com.swyp.app.domain.vote.service; + +import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.enums.BattleOptionLabel; +import com.swyp.app.domain.vote.entity.Vote; +import com.swyp.app.domain.vote.enums.VoteStatus; +import com.swyp.app.domain.vote.repository.VoteRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VoteQueryService { + + private final VoteRepository voteRepository; + + public List findUserVotes(Long userId, int offset, int size, BattleOptionLabel label) { + PageRequest pageable = PageRequest.of(offset / size, size); + return label != null + ? voteRepository.findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc(userId, label, pageable) + : voteRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + } + + public long countUserVotes(Long userId, BattleOptionLabel label) { + return label != null + ? voteRepository.countByUserIdAndPreVoteOptionLabel(userId, label) + : voteRepository.countByUserId(userId); + } + + public long countTotalParticipation(Long userId) { + return voteRepository.countByUserId(userId); + } + + public long countOpinionChanges(Long userId) { + return voteRepository.countOpinionChangesByUserId(userId); + } + + public int calculateBattleWinRate(Long userId) { + List postVotes = voteRepository.findByUserId(userId).stream() + .filter(v -> v.getStatus() == VoteStatus.POST_VOTED && v.getPostVoteOption() != null) + .toList(); + + if (postVotes.isEmpty()) return 0; + + long wins = postVotes.stream() + .filter(v -> { + BattleOption myOption = v.getPostVoteOption(); + BattleOption otherOption = v.getPreVoteOption(); + if (myOption.getId().equals(otherOption.getId())) { + long totalVotes = v.getBattle().getTotalParticipantsCount(); + return myOption.getVoteCount() > totalVotes - myOption.getVoteCount(); + } + return myOption.getVoteCount() > otherOption.getVoteCount(); + }) + .count(); + + return (int) (wins * 100 / postVotes.size()); + } + + public List findParticipatedBattleIds(Long userId) { + return voteRepository.findByUserId(userId).stream() + .map(v -> v.getBattle().getId()) + .distinct() + .toList(); + } +} From debd283bb7dea427e2779e12dc7683e5be37f22a Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 14:23:34 +0900 Subject: [PATCH 16/18] =?UTF-8?q?#36=20[Refactor]=20Notice=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=86=B5=ED=95=A9=20-=20user=20=EB=82=B4?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit user/entity/Notice, NoticeType, NoticeRepository를 삭제하고 notice 도메인의 NoticeService를 호출하도록 변경. noticeId 타입을 Long → UUID로 통일. Co-Authored-By: Claude Opus 4.6 --- .../user/controller/MypageController.java | 6 +- .../dto/response/NoticeDetailResponse.java | 5 +- .../user/dto/response/NoticeListResponse.java | 5 +- .../swyp/app/domain/user/entity/Notice.java | 58 ------------------- .../app/domain/user/entity/NoticeType.java | 6 -- .../user/repository/NoticeRepository.java | 14 ----- .../domain/user/service/MypageService.java | 32 +++++----- 7 files changed, 25 insertions(+), 101 deletions(-) delete mode 100644 src/main/java/com/swyp/app/domain/user/entity/Notice.java delete mode 100644 src/main/java/com/swyp/app/domain/user/entity/NoticeType.java delete mode 100644 src/main/java/com/swyp/app/domain/user/repository/NoticeRepository.java diff --git a/src/main/java/com/swyp/app/domain/user/controller/MypageController.java b/src/main/java/com/swyp/app/domain/user/controller/MypageController.java index 6f1a4ad..095c27e 100644 --- a/src/main/java/com/swyp/app/domain/user/controller/MypageController.java +++ b/src/main/java/com/swyp/app/domain/user/controller/MypageController.java @@ -10,9 +10,11 @@ import com.swyp.app.domain.user.dto.response.NoticeListResponse; import com.swyp.app.domain.user.dto.response.NotificationSettingsResponse; import com.swyp.app.domain.user.dto.response.RecapResponse; +import com.swyp.app.domain.notice.entity.NoticeType; import com.swyp.app.domain.user.entity.ActivityType; -import com.swyp.app.domain.user.entity.NoticeType; import com.swyp.app.domain.user.entity.VoteSide; + +import java.util.UUID; import com.swyp.app.domain.user.service.MypageService; import com.swyp.app.domain.user.service.UserService; import com.swyp.app.global.common.response.ApiResponse; @@ -89,7 +91,7 @@ public ApiResponse getNotices( } @GetMapping("/notices/{noticeId}") - public ApiResponse getNoticeDetail(@PathVariable Long noticeId) { + public ApiResponse getNoticeDetail(@PathVariable UUID noticeId) { return ApiResponse.onSuccess(mypageService.getNoticeDetail(noticeId)); } } diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/NoticeDetailResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeDetailResponse.java index dd499c9..dbf02be 100644 --- a/src/main/java/com/swyp/app/domain/user/dto/response/NoticeDetailResponse.java +++ b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeDetailResponse.java @@ -1,11 +1,12 @@ package com.swyp.app.domain.user.dto.response; -import com.swyp.app.domain.user.entity.NoticeType; +import com.swyp.app.domain.notice.entity.NoticeType; import java.time.LocalDateTime; +import java.util.UUID; public record NoticeDetailResponse( - Long noticeId, + UUID noticeId, NoticeType type, String title, String body, diff --git a/src/main/java/com/swyp/app/domain/user/dto/response/NoticeListResponse.java b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeListResponse.java index 86cd823..ac3172d 100644 --- a/src/main/java/com/swyp/app/domain/user/dto/response/NoticeListResponse.java +++ b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeListResponse.java @@ -1,16 +1,17 @@ package com.swyp.app.domain.user.dto.response; -import com.swyp.app.domain.user.entity.NoticeType; +import com.swyp.app.domain.notice.entity.NoticeType; import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; public record NoticeListResponse( List items ) { public record NoticeItem( - Long noticeId, + UUID noticeId, NoticeType type, String title, String bodyPreview, diff --git a/src/main/java/com/swyp/app/domain/user/entity/Notice.java b/src/main/java/com/swyp/app/domain/user/entity/Notice.java deleted file mode 100644 index 54da21d..0000000 --- a/src/main/java/com/swyp/app/domain/user/entity/Notice.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.swyp.app.domain.user.entity; - -import com.swyp.app.global.common.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; - -@Getter -@Entity -@Table(name = "notices") -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Notice extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 20) - private NoticeType type; - - @Column(nullable = false, length = 200) - private String title; - - @Column(columnDefinition = "TEXT") - private String body; - - @Column(name = "body_preview", length = 300) - private String bodyPreview; - - @Column(name = "is_pinned", nullable = false) - private boolean isPinned; - - @Column(name = "published_at") - private LocalDateTime publishedAt; - - @Builder - private Notice(NoticeType type, String title, String body, String bodyPreview, - boolean isPinned, LocalDateTime publishedAt) { - this.type = type; - this.title = title; - this.body = body; - this.bodyPreview = bodyPreview; - this.isPinned = isPinned; - this.publishedAt = publishedAt; - } -} diff --git a/src/main/java/com/swyp/app/domain/user/entity/NoticeType.java b/src/main/java/com/swyp/app/domain/user/entity/NoticeType.java deleted file mode 100644 index 217f993..0000000 --- a/src/main/java/com/swyp/app/domain/user/entity/NoticeType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.swyp.app.domain.user.entity; - -public enum NoticeType { - NOTICE, - EVENT -} diff --git a/src/main/java/com/swyp/app/domain/user/repository/NoticeRepository.java b/src/main/java/com/swyp/app/domain/user/repository/NoticeRepository.java deleted file mode 100644 index cb4226b..0000000 --- a/src/main/java/com/swyp/app/domain/user/repository/NoticeRepository.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.swyp.app.domain.user.repository; - -import com.swyp.app.domain.user.entity.Notice; -import com.swyp.app.domain.user.entity.NoticeType; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.List; - -public interface NoticeRepository extends JpaRepository { - - List findByTypeOrderByIsPinnedDescPublishedAtDesc(NoticeType type); - - List findAllByOrderByIsPinnedDescPublishedAtDesc(); -} diff --git a/src/main/java/com/swyp/app/domain/user/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java index 892e4f1..59eba00 100644 --- a/src/main/java/com/swyp/app/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -12,6 +12,10 @@ import com.swyp.app.domain.user.dto.response.BattleRecordListResponse; import com.swyp.app.domain.user.dto.response.ContentActivityListResponse; import com.swyp.app.domain.user.dto.response.MypageResponse; +import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import com.swyp.app.domain.notice.service.NoticeService; import com.swyp.app.domain.user.dto.response.NoticeDetailResponse; import com.swyp.app.domain.user.dto.response.NoticeListResponse; import com.swyp.app.domain.user.dto.response.NotificationSettingsResponse; @@ -19,8 +23,6 @@ import com.swyp.app.domain.user.dto.response.UserSummary; import com.swyp.app.domain.user.entity.ActivityType; import com.swyp.app.domain.user.entity.CharacterType; -import com.swyp.app.domain.user.entity.Notice; -import com.swyp.app.domain.user.entity.NoticeType; import com.swyp.app.domain.user.entity.PhilosopherType; import com.swyp.app.domain.user.entity.TierCode; import com.swyp.app.domain.user.entity.User; @@ -28,18 +30,14 @@ import com.swyp.app.domain.user.entity.UserSettings; import com.swyp.app.domain.user.entity.UserTendencyScore; import com.swyp.app.domain.user.entity.VoteSide; -import com.swyp.app.domain.user.repository.NoticeRepository; import com.swyp.app.domain.vote.entity.Vote; import com.swyp.app.domain.vote.service.VoteQueryService; -import com.swyp.app.global.common.exception.CustomException; -import com.swyp.app.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; @@ -52,7 +50,7 @@ public class MypageService { private static final int DEFAULT_PAGE_SIZE = 20; private final UserService userService; - private final NoticeRepository noticeRepository; + private final NoticeService noticeService; private final VoteQueryService voteQueryService; private final BattleQueryService battleQueryService; private final PerspectiveQueryService perspectiveQueryService; @@ -263,26 +261,26 @@ public NotificationSettingsResponse updateNotificationSettings(UpdateNotificatio } public NoticeListResponse getNotices(NoticeType type) { - List notices = type == null - ? noticeRepository.findAllByOrderByIsPinnedDescPublishedAtDesc() - : noticeRepository.findByTypeOrderByIsPinnedDescPublishedAtDesc(type); + List notices = noticeService.getActiveNotices( + NoticePlacement.NOTICE_BOARD, type, null + ); List items = notices.stream() .map(notice -> new NoticeListResponse.NoticeItem( - notice.getId(), notice.getType(), notice.getTitle(), - notice.getBodyPreview(), notice.isPinned(), notice.getPublishedAt() + notice.noticeId(), notice.type(), notice.title(), + notice.body(), notice.pinned(), notice.startsAt() )) .toList(); return new NoticeListResponse(items); } - public NoticeDetailResponse getNoticeDetail(Long noticeId) { - Notice notice = noticeRepository.findById(noticeId) - .orElseThrow(() -> new CustomException(ErrorCode.COMMON_INVALID_PARAMETER)); + public NoticeDetailResponse getNoticeDetail(UUID noticeId) { + com.swyp.app.domain.notice.dto.response.NoticeDetailResponse notice = + noticeService.getNoticeDetail(noticeId); return new NoticeDetailResponse( - notice.getId(), notice.getType(), notice.getTitle(), - notice.getBody(), notice.isPinned(), notice.getPublishedAt() + notice.noticeId(), notice.type(), notice.title(), + notice.body(), notice.pinned(), notice.startsAt() ); } From bf32094c8d9bc26668d3195a629e276c6ef4395a Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 18:23:06 +0900 Subject: [PATCH 17/18] =?UTF-8?q?#40=20[Chore]=20USER=20ERD=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=ED=99=94=20=EB=B0=8F=20USER/HOME=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - user.puml: onboarding_completed 제거, nickname/character_url 추가, SUSPENDED 상태 추가, tendency score 필드명 변경 - user-ops.puml: SUSPENDED 상태 추가, user_settings 필드 실제 엔티티와 일치하도록 교체 - UserServiceTest 8건, MypageServiceTest 12건 신규 추가 - HomeServiceTest 2건 추가 Co-Authored-By: Claude Opus 4.6 --- docs/erd/user-ops.puml | 12 +- docs/erd/user.puml | 29 +- .../domain/home/service/HomeServiceTest.java | 40 ++ .../user/service/MypageServiceTest.java | 441 ++++++++++++++++++ .../domain/user/service/UserServiceTest.java | 191 ++++++++ 5 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java create mode 100644 src/test/java/com/swyp/app/domain/user/service/UserServiceTest.java diff --git a/docs/erd/user-ops.puml b/docs/erd/user-ops.puml index 8cf8d1f..db748bc 100644 --- a/docs/erd/user-ops.puml +++ b/docs/erd/user-ops.puml @@ -7,7 +7,7 @@ entity "USERS\n서비스 사용자" as users { * id : BIGINT <> -- user_tag : VARCHAR(30) <> - status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') + status : ENUM('PENDING', 'ACTIVE', 'SUSPENDED', 'BANNED', 'DELETED') created_at : timestamp updated_at : timestamp } @@ -15,10 +15,12 @@ entity "USERS\n서비스 사용자" as users { entity "USER_SETTINGS\n사용자 설정" as user_settings { * user_id : BIGINT <> -- - push_enabled : boolean - email_enabled : boolean - debate_request_enabled : boolean - profile_public : boolean + new_battle_enabled : boolean + battle_result_enabled : boolean + comment_reply_enabled : boolean + new_comment_enabled : boolean + content_like_enabled : boolean + marketing_event_enabled : boolean updated_at : timestamp } diff --git a/docs/erd/user.puml b/docs/erd/user.puml index c057281..be8614f 100644 --- a/docs/erd/user.puml +++ b/docs/erd/user.puml @@ -7,9 +7,10 @@ entity "USERS\n서비스 사용자" as users { * id : BIGINT <> -- user_tag : VARCHAR(30) <> + nickname : VARCHAR(50) + character_url : TEXT role : ENUM('USER', 'ADMIN') - status : ENUM('PENDING', 'ACTIVE', 'DELETED', 'BANNED') - onboarding_completed : boolean + status : ENUM('PENDING', 'ACTIVE', 'SUSPENDED', 'BANNED', 'DELETED') created_at : timestamp updated_at : timestamp deleted_at : timestamp (nullable) @@ -27,12 +28,12 @@ entity "USER_PROFILES\n사용자 프로필" as user_profiles { entity "USER_TENDENCY_SCORES\n사용자 성향 점수 현재값" as user_tendency_scores { * user_id : BIGINT <> -- - score_1 : int - score_2 : int - score_3 : int - score_4 : int - score_5 : int - score_6 : int + principle : int + reason : int + individual : int + change : int + inner : int + ideal : int updated_at : timestamp } @@ -40,12 +41,12 @@ entity "USER_TENDENCY_SCORE_HISTORIES\n사용자 성향 점수 변경 이력" as * id : BIGINT <> -- user_id : BIGINT <> - score_1 : int - score_2 : int - score_3 : int - score_4 : int - score_5 : int - score_6 : int + principle : int + reason : int + individual : int + change : int + inner : int + ideal : int created_at : timestamp } diff --git a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java index dfbdc76..4100c7e 100644 --- a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -9,6 +9,7 @@ import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.entity.NoticePlacement; import com.swyp.app.domain.notice.service.NoticeService; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -112,6 +113,45 @@ class HomeServiceTest { assertThat(response.newBattles()).isEmpty(); } + @Test + @DisplayName("에디터픽만 있을때 제외목록이 정확하다") + void getHome_excludes_only_editor_pick_ids() { + TodayBattleResponse editorPick = battle("editor-only", BATTLE); + + when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of()); + when(battleService.getEditorPicks()).thenReturn(List.of(editorPick)); + when(battleService.getTrendingBattles()).thenReturn(List.of()); + when(battleService.getBestBattles()).thenReturn(List.of()); + when(battleService.getTodayPicks(VOTE)).thenReturn(List.of()); + when(battleService.getTodayPicks(QUIZ)).thenReturn(List.of()); + when(battleService.getNewBattles(List.of(editorPick.battleId()))).thenReturn(List.of()); + + homeService.getHome(); + + verify(battleService).getNewBattles(List.of(editorPick.battleId())); + } + + @Test + @DisplayName("공지가 여러개여도 newNotice는 true이다") + void getHome_newNotice_true_with_multiple_notices() { + NoticeSummaryResponse notice1 = new NoticeSummaryResponse( + UUID.randomUUID(), "notice1", "body1", null, + NoticePlacement.HOME_TOP, true, LocalDateTime.now().minusDays(1), null + ); + + when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of(notice1)); + when(battleService.getEditorPicks()).thenReturn(List.of()); + when(battleService.getTrendingBattles()).thenReturn(List.of()); + when(battleService.getBestBattles()).thenReturn(List.of()); + when(battleService.getTodayPicks(VOTE)).thenReturn(List.of()); + when(battleService.getTodayPicks(QUIZ)).thenReturn(List.of()); + when(battleService.getNewBattles(List.of())).thenReturn(List.of()); + + var response = homeService.getHome(); + + assertThat(response.newNotice()).isTrue(); + } + private TodayBattleResponse battle(String title, BattleType type) { return new TodayBattleResponse( UUID.randomUUID(), diff --git a/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java b/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java new file mode 100644 index 0000000..d1a34e6 --- /dev/null +++ b/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java @@ -0,0 +1,441 @@ +package com.swyp.app.domain.user.service; + +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.battle.enums.BattleOptionLabel; +import com.swyp.app.domain.battle.enums.BattleStatus; +import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.battle.service.BattleQueryService; +import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; +import com.swyp.app.domain.notice.entity.NoticePlacement; +import com.swyp.app.domain.notice.entity.NoticeType; +import com.swyp.app.domain.notice.service.NoticeService; +import com.swyp.app.domain.perspective.entity.Perspective; +import com.swyp.app.domain.perspective.entity.PerspectiveComment; +import com.swyp.app.domain.perspective.entity.PerspectiveLike; +import com.swyp.app.domain.perspective.service.PerspectiveQueryService; +import com.swyp.app.domain.user.dto.request.UpdateNotificationSettingsRequest; +import com.swyp.app.domain.user.dto.response.BattleRecordListResponse; +import com.swyp.app.domain.user.dto.response.ContentActivityListResponse; +import com.swyp.app.domain.user.dto.response.MypageResponse; +import com.swyp.app.domain.user.dto.response.NoticeDetailResponse; +import com.swyp.app.domain.user.dto.response.NoticeListResponse; +import com.swyp.app.domain.user.dto.response.NotificationSettingsResponse; +import com.swyp.app.domain.user.dto.response.RecapResponse; +import com.swyp.app.domain.user.dto.response.UserSummary; +import com.swyp.app.domain.user.entity.ActivityType; +import com.swyp.app.domain.user.entity.CharacterType; +import com.swyp.app.domain.user.entity.PhilosopherType; +import com.swyp.app.domain.user.entity.TierCode; +import com.swyp.app.domain.user.entity.User; +import com.swyp.app.domain.user.entity.UserProfile; +import com.swyp.app.domain.user.entity.UserRole; +import com.swyp.app.domain.user.entity.UserSettings; +import com.swyp.app.domain.user.entity.UserStatus; +import com.swyp.app.domain.user.entity.UserTendencyScore; +import com.swyp.app.domain.user.entity.VoteSide; +import com.swyp.app.domain.vote.entity.Vote; +import com.swyp.app.domain.vote.service.VoteQueryService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MypageServiceTest { + + @Mock + private UserService userService; + @Mock + private NoticeService noticeService; + @Mock + private VoteQueryService voteQueryService; + @Mock + private BattleQueryService battleQueryService; + @Mock + private PerspectiveQueryService perspectiveQueryService; + + @InjectMocks + private MypageService mypageService; + + @Test + @DisplayName("프로필, 철학자, 티어 정보를 반환한다") + void getMypage_returns_profile_philosopher_tier() { + User user = createUser(1L, "myTag"); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserProfile(1L)).thenReturn(profile); + + MypageResponse response = mypageService.getMypage(); + + assertThat(response.profile().userTag()).isEqualTo("myTag"); + assertThat(response.profile().nickname()).isEqualTo("nick"); + assertThat(response.profile().characterType()).isEqualTo(CharacterType.OWL); + assertThat(response.profile().mannerTemperature()).isEqualByComparingTo(BigDecimal.valueOf(36.5)); + assertThat(response.philosopher().philosopherType()).isEqualTo(PhilosopherType.SOCRATES); + assertThat(response.tier().tierCode()).isEqualTo(TierCode.WANDERER); + assertThat(response.tier().currentPoint()).isZero(); + } + + @Test + @DisplayName("철학자카드와 성향점수와 선호보고서를 반환한다") + void getRecap_returns_cards_scores_report() { + User user = createUser(1L, "tag"); + UserTendencyScore score = UserTendencyScore.builder() + .user(user) + .principle(10).reason(20).individual(30) + .change(40).inner(50).ideal(60) + .build(); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserTendencyScore(1L)).thenReturn(score); + when(voteQueryService.countTotalParticipation(1L)).thenReturn(15L); + when(voteQueryService.countOpinionChanges(1L)).thenReturn(3L); + when(voteQueryService.calculateBattleWinRate(1L)).thenReturn(70); + + List battleIds = List.of(UUID.randomUUID()); + when(voteQueryService.findParticipatedBattleIds(1L)).thenReturn(battleIds); + + LinkedHashMap topTags = new LinkedHashMap<>(); + topTags.put("정치", 5L); + topTags.put("경제", 3L); + when(battleQueryService.getTopTagsByBattleIds(battleIds, 4)).thenReturn(topTags); + + RecapResponse response = mypageService.getRecap(); + + assertThat(response.myCard().philosopherType()).isEqualTo(PhilosopherType.SOCRATES); + assertThat(response.bestMatchCard().philosopherType()).isEqualTo(PhilosopherType.PLATO); + assertThat(response.worstMatchCard().philosopherType()).isEqualTo(PhilosopherType.MARX); + assertThat(response.scores().principle()).isEqualTo(10); + assertThat(response.scores().ideal()).isEqualTo(60); + assertThat(response.preferenceReport().totalParticipation()).isEqualTo(15); + assertThat(response.preferenceReport().opinionChanges()).isEqualTo(3); + assertThat(response.preferenceReport().battleWinRate()).isEqualTo(70); + assertThat(response.preferenceReport().favoriteTopics()).hasSize(2); + assertThat(response.preferenceReport().favoriteTopics().get(0).tagName()).isEqualTo("정치"); + } + + @Test + @DisplayName("투표이력이 없으면 선호보고서가 0값이다") + void getRecap_returns_zero_report_when_no_votes() { + User user = createUser(1L, "tag"); + UserTendencyScore score = UserTendencyScore.builder() + .user(user) + .principle(0).reason(0).individual(0) + .change(0).inner(0).ideal(0) + .build(); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserTendencyScore(1L)).thenReturn(score); + when(voteQueryService.countTotalParticipation(1L)).thenReturn(0L); + when(voteQueryService.countOpinionChanges(1L)).thenReturn(0L); + when(voteQueryService.calculateBattleWinRate(1L)).thenReturn(0); + when(voteQueryService.findParticipatedBattleIds(1L)).thenReturn(List.of()); + when(battleQueryService.getTopTagsByBattleIds(List.of(), 4)).thenReturn(new LinkedHashMap<>()); + + RecapResponse response = mypageService.getRecap(); + + assertThat(response.preferenceReport().totalParticipation()).isZero(); + assertThat(response.preferenceReport().opinionChanges()).isZero(); + assertThat(response.preferenceReport().battleWinRate()).isZero(); + assertThat(response.preferenceReport().favoriteTopics()).isEmpty(); + } + + @Test + @DisplayName("투표기록을 페이지네이션하여 반환한다") + void getBattleRecords_returns_paginated_records() { + User user = createUser(1L, "tag"); + Battle battle = createBattle("배틀 제목"); + BattleOption optionA = createOption(battle, BattleOptionLabel.A); + Vote vote = Vote.builder() + .userId(1L) + .battle(battle) + .preVoteOption(optionA) + .build(); + ReflectionTestUtils.setField(vote, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(vote, "createdAt", LocalDateTime.now()); + + when(userService.findCurrentUser()).thenReturn(user); + when(voteQueryService.findUserVotes(1L, 0, 2, null)).thenReturn(List.of(vote)); + when(voteQueryService.countUserVotes(1L, null)).thenReturn(5L); + + BattleRecordListResponse response = mypageService.getBattleRecords(0, 2, null); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).voteSide()).isEqualTo(VoteSide.PRO); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextOffset()).isEqualTo(2); + } + + @Test + @DisplayName("다음페이지가 없으면 hasNext가 false이다") + void getBattleRecords_returns_no_next_when_last_page() { + User user = createUser(1L, "tag"); + Battle battle = createBattle("제목"); + BattleOption optionA = createOption(battle, BattleOptionLabel.A); + Vote vote = Vote.builder() + .userId(1L) + .battle(battle) + .preVoteOption(optionA) + .build(); + ReflectionTestUtils.setField(vote, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(vote, "createdAt", LocalDateTime.now()); + + when(userService.findCurrentUser()).thenReturn(user); + when(voteQueryService.findUserVotes(1L, 0, 20, null)).thenReturn(List.of(vote)); + when(voteQueryService.countUserVotes(1L, null)).thenReturn(1L); + + BattleRecordListResponse response = mypageService.getBattleRecords(null, null, null); + + assertThat(response.hasNext()).isFalse(); + assertThat(response.nextOffset()).isNull(); + } + + @Test + @DisplayName("voteSide 필터가 적용된다") + void getBattleRecords_applies_vote_side_filter() { + User user = createUser(1L, "tag"); + + when(userService.findCurrentUser()).thenReturn(user); + when(voteQueryService.findUserVotes(1L, 0, 20, BattleOptionLabel.A)).thenReturn(List.of()); + when(voteQueryService.countUserVotes(1L, BattleOptionLabel.A)).thenReturn(0L); + + mypageService.getBattleRecords(null, null, VoteSide.PRO); + + verify(voteQueryService).findUserVotes(eq(1L), eq(0), eq(20), eq(BattleOptionLabel.A)); + } + + @Test + @DisplayName("COMMENT 타입으로 댓글활동을 반환한다") + void getContentActivities_returns_comments() { + User user = createUser(1L, "tag"); + UUID battleId = UUID.randomUUID(); + UUID optionId = UUID.randomUUID(); + Perspective perspective = Perspective.builder() + .battleId(battleId) + .userId(1L) + .optionId(optionId) + .content("관점 내용") + .build(); + ReflectionTestUtils.setField(perspective, "id", UUID.randomUUID()); + + PerspectiveComment comment = PerspectiveComment.builder() + .perspective(perspective) + .userId(1L) + .content("댓글") + .build(); + ReflectionTestUtils.setField(comment, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(comment, "createdAt", LocalDateTime.now()); + + Battle battle = createBattle("배틀"); + ReflectionTestUtils.setField(battle, "id", battleId); + BattleOption option = createOption(battle, BattleOptionLabel.A); + ReflectionTestUtils.setField(option, "id", optionId); + + when(userService.findCurrentUser()).thenReturn(user); + when(perspectiveQueryService.findUserComments(1L, 0, 20)).thenReturn(List.of(comment)); + when(perspectiveQueryService.countUserComments(1L)).thenReturn(1L); + when(battleQueryService.findBattlesByIds(List.of(battleId))).thenReturn(Map.of(battleId, battle)); + when(battleQueryService.findOptionsByIds(List.of(optionId))).thenReturn(Map.of(optionId, option)); + when(userService.findSummaryById(1L)).thenReturn(new UserSummary("tag", "nick", "OWL")); + + ContentActivityListResponse response = mypageService.getContentActivities(null, null, ActivityType.COMMENT); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).activityType()).isEqualTo(ActivityType.COMMENT); + assertThat(response.items().get(0).content()).isEqualTo("댓글"); + } + + @Test + @DisplayName("LIKE 타입으로 좋아요활동을 반환한다") + void getContentActivities_returns_likes() { + User user = createUser(1L, "tag"); + UUID battleId = UUID.randomUUID(); + UUID optionId = UUID.randomUUID(); + Perspective perspective = Perspective.builder() + .battleId(battleId) + .userId(1L) + .optionId(optionId) + .content("관점 내용") + .build(); + ReflectionTestUtils.setField(perspective, "id", UUID.randomUUID()); + + PerspectiveLike like = PerspectiveLike.builder() + .perspective(perspective) + .userId(1L) + .build(); + ReflectionTestUtils.setField(like, "id", UUID.randomUUID()); + ReflectionTestUtils.setField(like, "createdAt", LocalDateTime.now()); + + Battle battle = createBattle("배틀"); + ReflectionTestUtils.setField(battle, "id", battleId); + BattleOption option = createOption(battle, BattleOptionLabel.B); + ReflectionTestUtils.setField(option, "id", optionId); + + when(userService.findCurrentUser()).thenReturn(user); + when(perspectiveQueryService.findUserLikes(1L, 0, 20)).thenReturn(List.of(like)); + when(perspectiveQueryService.countUserLikes(1L)).thenReturn(1L); + when(battleQueryService.findBattlesByIds(List.of(battleId))).thenReturn(Map.of(battleId, battle)); + when(battleQueryService.findOptionsByIds(List.of(optionId))).thenReturn(Map.of(optionId, option)); + when(userService.findSummaryById(1L)).thenReturn(new UserSummary("tag", "nick", "OWL")); + + ContentActivityListResponse response = mypageService.getContentActivities(null, null, ActivityType.LIKE); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).activityType()).isEqualTo(ActivityType.LIKE); + } + + @Test + @DisplayName("알림설정을 반환한다") + void getNotificationSettings_returns_settings() { + User user = createUser(1L, "tag"); + UserSettings settings = UserSettings.builder() + .user(user) + .newBattleEnabled(true) + .battleResultEnabled(false) + .commentReplyEnabled(true) + .newCommentEnabled(true) + .contentLikeEnabled(false) + .marketingEventEnabled(false) + .build(); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserSettings(1L)).thenReturn(settings); + + NotificationSettingsResponse response = mypageService.getNotificationSettings(); + + assertThat(response.newBattleEnabled()).isTrue(); + assertThat(response.battleResultEnabled()).isFalse(); + assertThat(response.commentReplyEnabled()).isTrue(); + assertThat(response.newCommentEnabled()).isTrue(); + assertThat(response.contentLikeEnabled()).isFalse(); + assertThat(response.marketingEventEnabled()).isFalse(); + } + + @Test + @DisplayName("설정을 업데이트하고 반환한다") + void updateNotificationSettings_updates_and_returns() { + User user = createUser(1L, "tag"); + UserSettings settings = UserSettings.builder() + .user(user) + .newBattleEnabled(false) + .battleResultEnabled(false) + .commentReplyEnabled(false) + .newCommentEnabled(false) + .contentLikeEnabled(false) + .marketingEventEnabled(false) + .build(); + + when(userService.findCurrentUser()).thenReturn(user); + when(userService.findUserSettings(1L)).thenReturn(settings); + + UpdateNotificationSettingsRequest request = new UpdateNotificationSettingsRequest( + true, null, true, null, null, true + ); + + NotificationSettingsResponse response = mypageService.updateNotificationSettings(request); + + assertThat(response.newBattleEnabled()).isTrue(); + assertThat(response.battleResultEnabled()).isFalse(); + assertThat(response.commentReplyEnabled()).isTrue(); + assertThat(response.marketingEventEnabled()).isTrue(); + } + + @Test + @DisplayName("공지사항 목록을 반환한다") + void getNotices_returns_notice_list() { + NoticeSummaryResponse notice = new NoticeSummaryResponse( + UUID.randomUUID(), "공지 제목", "본문", + NoticeType.ANNOUNCEMENT, NoticePlacement.NOTICE_BOARD, + true, LocalDateTime.now().minusDays(1), null + ); + + when(noticeService.getActiveNotices(NoticePlacement.NOTICE_BOARD, NoticeType.ANNOUNCEMENT, null)) + .thenReturn(List.of(notice)); + + NoticeListResponse response = mypageService.getNotices(NoticeType.ANNOUNCEMENT); + + assertThat(response.items()).hasSize(1); + assertThat(response.items().get(0).title()).isEqualTo("공지 제목"); + assertThat(response.items().get(0).isPinned()).isTrue(); + } + + @Test + @DisplayName("공지사항 상세를 반환한다") + void getNoticeDetail_returns_notice_detail() { + UUID noticeId = UUID.randomUUID(); + com.swyp.app.domain.notice.dto.response.NoticeDetailResponse noticeDetail = + new com.swyp.app.domain.notice.dto.response.NoticeDetailResponse( + noticeId, "상세 제목", "상세 본문", + NoticeType.EVENT, NoticePlacement.NOTICE_BOARD, + false, LocalDateTime.now(), null, LocalDateTime.now() + ); + + when(noticeService.getNoticeDetail(noticeId)).thenReturn(noticeDetail); + + NoticeDetailResponse response = mypageService.getNoticeDetail(noticeId); + + assertThat(response.noticeId()).isEqualTo(noticeId); + assertThat(response.title()).isEqualTo("상세 제목"); + assertThat(response.type()).isEqualTo(NoticeType.EVENT); + assertThat(response.isPinned()).isFalse(); + } + + private User createUser(Long id, String userTag) { + User user = User.builder() + .userTag(userTag) + .nickname("nickname") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private UserProfile createProfile(User user, String nickname, CharacterType characterType) { + return UserProfile.builder() + .user(user) + .nickname(nickname) + .characterType(characterType) + .mannerTemperature(BigDecimal.valueOf(36.5)) + .build(); + } + + private Battle createBattle(String title) { + Battle battle = Battle.builder() + .title(title) + .summary("summary") + .type(BattleType.BATTLE) + .status(BattleStatus.PUBLISHED) + .build(); + ReflectionTestUtils.setField(battle, "id", UUID.randomUUID()); + return battle; + } + + private BattleOption createOption(Battle battle, BattleOptionLabel label) { + BattleOption option = BattleOption.builder() + .battle(battle) + .label(label) + .title(label.name()) + .stance("stance-" + label.name()) + .build(); + ReflectionTestUtils.setField(option, "id", UUID.randomUUID()); + return option; + } +} diff --git a/src/test/java/com/swyp/app/domain/user/service/UserServiceTest.java b/src/test/java/com/swyp/app/domain/user/service/UserServiceTest.java new file mode 100644 index 0000000..56d530f --- /dev/null +++ b/src/test/java/com/swyp/app/domain/user/service/UserServiceTest.java @@ -0,0 +1,191 @@ +package com.swyp.app.domain.user.service; + +import com.swyp.app.domain.user.dto.request.UpdateUserProfileRequest; +import com.swyp.app.domain.user.dto.response.MyProfileResponse; +import com.swyp.app.domain.user.dto.response.UserSummary; +import com.swyp.app.domain.user.entity.CharacterType; +import com.swyp.app.domain.user.entity.User; +import com.swyp.app.domain.user.entity.UserProfile; +import com.swyp.app.domain.user.entity.UserRole; +import com.swyp.app.domain.user.entity.UserSettings; +import com.swyp.app.domain.user.entity.UserStatus; +import com.swyp.app.domain.user.entity.UserTendencyScore; +import com.swyp.app.domain.user.repository.UserProfileRepository; +import com.swyp.app.domain.user.repository.UserRepository; +import com.swyp.app.domain.user.repository.UserSettingsRepository; +import com.swyp.app.domain.user.repository.UserTendencyScoreRepository; +import com.swyp.app.global.common.exception.CustomException; +import com.swyp.app.global.common.exception.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + @Mock + private UserProfileRepository userProfileRepository; + @Mock + private UserSettingsRepository userSettingsRepository; + @Mock + private UserTendencyScoreRepository userTendencyScoreRepository; + + @InjectMocks + private UserService userService; + + @Test + @DisplayName("가장 최근 사용자를 반환한다") + void findCurrentUser_returns_latest_user() { + User user = createUser(1L, "testTag"); + when(userRepository.findTopByOrderByIdDesc()).thenReturn(Optional.of(user)); + + User result = userService.findCurrentUser(); + + assertThat(result.getUserTag()).isEqualTo("testTag"); + } + + @Test + @DisplayName("사용자가 없으면 예외를 던진다") + void findCurrentUser_throws_when_no_user() { + when(userRepository.findTopByOrderByIdDesc()).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.findCurrentUser()) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND)); + } + + @Test + @DisplayName("사용자 요약정보를 반환한다") + void findSummaryById_returns_user_summary() { + User user = createUser(1L, "summaryTag"); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userProfileRepository.findById(1L)).thenReturn(Optional.of(profile)); + + UserSummary summary = userService.findSummaryById(1L); + + assertThat(summary.userTag()).isEqualTo("summaryTag"); + assertThat(summary.nickname()).isEqualTo("nick"); + assertThat(summary.characterType()).isEqualTo("OWL"); + } + + @Test + @DisplayName("존재하지 않는 사용자의 요약정보 조회 시 예외를 던진다") + void findSummaryById_throws_when_not_found() { + when(userRepository.findById(999L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.findSummaryById(999L)) + .isInstanceOf(CustomException.class) + .satisfies(ex -> assertThat(((CustomException) ex).getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND)); + } + + @Test + @DisplayName("닉네임과 캐릭터를 수정한다") + void updateMyProfile_updates_nickname_and_character() { + User user = createUser(1L, "myTag"); + UserProfile profile = createProfile(user, "oldNick", CharacterType.OWL); + + when(userRepository.findTopByOrderByIdDesc()).thenReturn(Optional.of(user)); + when(userProfileRepository.findById(1L)).thenReturn(Optional.of(profile)); + + UpdateUserProfileRequest request = new UpdateUserProfileRequest("newNick", CharacterType.FOX); + MyProfileResponse response = userService.updateMyProfile(request); + + assertThat(response.userTag()).isEqualTo("myTag"); + assertThat(response.nickname()).isEqualTo("newNick"); + assertThat(response.characterType()).isEqualTo(CharacterType.FOX); + } + + @Test + @DisplayName("프로필을 반환한다") + void findUserProfile_returns_profile() { + User user = createUser(1L, "tag"); + UserProfile profile = createProfile(user, "nick", CharacterType.BEAR); + + when(userProfileRepository.findById(1L)).thenReturn(Optional.of(profile)); + + UserProfile result = userService.findUserProfile(1L); + + assertThat(result.getNickname()).isEqualTo("nick"); + assertThat(result.getCharacterType()).isEqualTo(CharacterType.BEAR); + } + + @Test + @DisplayName("설정을 반환한다") + void findUserSettings_returns_settings() { + User user = createUser(1L, "tag"); + UserSettings settings = UserSettings.builder() + .user(user) + .newBattleEnabled(true) + .battleResultEnabled(false) + .commentReplyEnabled(true) + .newCommentEnabled(false) + .contentLikeEnabled(true) + .marketingEventEnabled(false) + .build(); + + when(userSettingsRepository.findById(1L)).thenReturn(Optional.of(settings)); + + UserSettings result = userService.findUserSettings(1L); + + assertThat(result.isNewBattleEnabled()).isTrue(); + assertThat(result.isBattleResultEnabled()).isFalse(); + } + + @Test + @DisplayName("성향점수를 반환한다") + void findUserTendencyScore_returns_score() { + User user = createUser(1L, "tag"); + UserTendencyScore score = UserTendencyScore.builder() + .user(user) + .principle(10) + .reason(20) + .individual(30) + .change(40) + .inner(50) + .ideal(60) + .build(); + + when(userTendencyScoreRepository.findById(1L)).thenReturn(Optional.of(score)); + + UserTendencyScore result = userService.findUserTendencyScore(1L); + + assertThat(result.getPrinciple()).isEqualTo(10); + assertThat(result.getReason()).isEqualTo(20); + assertThat(result.getIdeal()).isEqualTo(60); + } + + private User createUser(Long id, String userTag) { + User user = User.builder() + .userTag(userTag) + .nickname("nickname") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private UserProfile createProfile(User user, String nickname, CharacterType characterType) { + return UserProfile.builder() + .user(user) + .nickname(nickname) + .characterType(characterType) + .mannerTemperature(BigDecimal.valueOf(36.5)) + .build(); + } +} From 25ec85744d8243029ee65762c8d263494e213733 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 21 Mar 2026 18:25:38 +0900 Subject: [PATCH 18/18] =?UTF-8?q?#40=20[Chore]=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20@DisplayName?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeServiceTest: 한글 메서드명 → 영문 + @DisplayName - NoticeServiceTest: 한글 메서드명 → 영문 + @DisplayName Co-Authored-By: Claude Opus 4.6 --- .../com/swyp/app/domain/home/service/HomeServiceTest.java | 6 ++++-- .../swyp/app/domain/notice/service/NoticeServiceTest.java | 7 +++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java index 4100c7e..7bfdf00 100644 --- a/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java +++ b/src/test/java/com/swyp/app/domain/home/service/HomeServiceTest.java @@ -40,7 +40,8 @@ class HomeServiceTest { private HomeService homeService; @Test - void getHome_명세기준으로_섹션별_데이터를_조합한다() { + @DisplayName("명세기준으로 섹션별 데이터를 조합한다") + void getHome_aggregates_sections_by_spec() { TodayBattleResponse editorPick = battle("editor-id", BATTLE); TodayBattleResponse trendingBattle = battle("trending-id", BATTLE); TodayBattleResponse bestBattle = battle("best-id", BATTLE); @@ -94,7 +95,8 @@ class HomeServiceTest { } @Test - void getHome_데이터가_없으면_false와_빈리스트를_반환한다() { + @DisplayName("데이터가 없으면 false와 빈리스트를 반환한다") + void getHome_returns_false_and_empty_lists_when_no_data() { when(noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, 1)).thenReturn(List.of()); when(battleService.getEditorPicks()).thenReturn(List.of()); when(battleService.getTrendingBattles()).thenReturn(List.of()); diff --git a/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java b/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java index 8e7be80..efb48fd 100644 --- a/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java +++ b/src/test/java/com/swyp/app/domain/notice/service/NoticeServiceTest.java @@ -5,6 +5,7 @@ import com.swyp.app.domain.notice.entity.NoticeType; import com.swyp.app.domain.notice.repository.NoticeRepository; import com.swyp.app.global.common.exception.CustomException; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -33,7 +34,8 @@ class NoticeServiceTest { private NoticeService noticeService; @Test - void getNoticeList_활성공지_목록을_개수와_함께_반환한다() { + @DisplayName("활성공지 목록을 개수와 함께 반환한다") + void getNoticeList_returns_active_notices_with_count() { Notice notice = Notice.builder() .title("공지") .body("내용") @@ -55,7 +57,8 @@ class NoticeServiceTest { } @Test - void getNoticeDetail_활성공지가_없으면_예외를_던진다() { + @DisplayName("활성공지가 없으면 예외를 던진다") + void getNoticeDetail_throws_when_no_active_notice() { UUID noticeId = UUID.randomUUID(); when(noticeRepository.findActiveById(eq(noticeId), any(LocalDateTime.class))).thenReturn(Optional.empty());