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 | 설명 | |------------|:-----------:|------| 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/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/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/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/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/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/controller/MypageController.java b/src/main/java/com/swyp/app/domain/user/controller/MypageController.java new file mode 100644 index 0000000..095c27e --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/controller/MypageController.java @@ -0,0 +1,97 @@ +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.notice.entity.NoticeType; +import com.swyp.app.domain.user.entity.ActivityType; +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; +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 UUID noticeId) { + return ApiResponse.onSuccess(mypageService.getNoticeDetail(noticeId)); + } +} 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/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/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/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/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/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/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/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..dbf02be --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeDetailResponse.java @@ -0,0 +1,16 @@ +package com.swyp.app.domain.user.dto.response; + +import com.swyp.app.domain.notice.entity.NoticeType; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record NoticeDetailResponse( + UUID 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..ac3172d --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/dto/response/NoticeListResponse.java @@ -0,0 +1,22 @@ +package com.swyp.app.domain.user.dto.response; + +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( + UUID 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/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/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/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/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/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/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/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..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 @@ -27,30 +27,37 @@ 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; + private int principle; + + private int reason; + + private int individual; + + private int change; + + private int inner; + + 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..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 @@ -28,21 +28,27 @@ 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; + private int principle; + + private int reason; + + private int individual; + + private int change; + + private int inner; + + 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; } } 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/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java new file mode 100644 index 0000000..59eba00 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -0,0 +1,302 @@ +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.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.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.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; +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.UserSettings; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MypageService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final UserService userService; + private final NoticeService noticeService; + private final VoteQueryService voteQueryService; + private final BattleQueryService battleQueryService; + private final PerspectiveQueryService perspectiveQueryService; + + 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() + ); + + RecapResponse.PreferenceReport preferenceReport = buildPreferenceReport(user.getId()); + + return new RecapResponse(myCard, bestMatchCard, worstMatchCard, scores, preferenceReport); + } + + private RecapResponse.PreferenceReport buildPreferenceReport(Long userId) { + long totalParticipation = voteQueryService.countTotalParticipation(userId); + long opinionChanges = voteQueryService.countOpinionChanges(userId); + int battleWinRate = voteQueryService.calculateBattleWinRate(userId); + + List battleIds = voteQueryService.findParticipatedBattleIds(userId); + Map topTags = battleQueryService.getTopTagsByBattleIds(battleIds, 4); + + 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, + (int) opinionChanges, + battleWinRate, + favoriteTopics + ); + } + + 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; + + BattleOptionLabel label = voteSide != null ? toOptionLabel(voteSide) : null; + + 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( + 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) { + User user = userService.findCurrentUser(); + int pageOffset = offset == null || offset < 0 ? 0 : offset; + int pageSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : size; + + if (activityType == ActivityType.LIKE) { + return buildLikeActivities(user, pageOffset, pageSize); + } + return buildCommentActivities(user, pageOffset, pageSize); + } + + 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); + Map optionMap = loadOptions(perspectives); + + List items = comments.stream() + .map(comment -> { + 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(); + + int nextOffset = pageOffset + pageSize; + boolean hasNext = nextOffset < totalCount; + return new ContentActivityListResponse(items, hasNext ? nextOffset : null, hasNext); + } + + 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); + Map optionMap = loadOptions(perspectives); + + List items = likes.stream() + .map(like -> { + 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(); + + int nextOffset = pageOffset + pageSize; + boolean hasNext = nextOffset < totalCount; + return new ContentActivityListResponse(items, hasNext ? nextOffset : null, hasNext); + } + + private ContentActivityListResponse.ContentActivityItem toActivityItem( + String activityId, ActivityType activityType, Perspective perspective, + Battle battle, BattleOption option, String content, LocalDateTime createdAt) { + + UserSummary author = userService.findSummaryById(perspective.getUserId()); + ContentActivityListResponse.AuthorInfo authorInfo = new ContentActivityListResponse.AuthorInfo( + author.userTag(), author.nickname(), 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 battleQueryService.findBattlesByIds(battleIds); + } + + private Map loadOptions(List perspectives) { + List optionIds = perspectives.stream().map(Perspective::getOptionId).distinct().toList(); + return battleQueryService.findOptionsByIds(optionIds); + } + + 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 = noticeService.getActiveNotices( + NoticePlacement.NOTICE_BOARD, type, null + ); + + List items = notices.stream() + .map(notice -> new NoticeListResponse.NoticeItem( + notice.noticeId(), notice.type(), notice.title(), + notice.body(), notice.pinned(), notice.startsAt() + )) + .toList(); + + return new NoticeListResponse(items); + } + + public NoticeDetailResponse getNoticeDetail(UUID noticeId) { + com.swyp.app.domain.notice.dto.response.NoticeDetailResponse notice = + noticeService.getNoticeDetail(noticeId); + return new NoticeDetailResponse( + notice.noticeId(), notice.type(), notice.title(), + notice.body(), notice.pinned(), notice.startsAt() + ); + } + + 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(), settings.isBattleResultEnabled(), + settings.isCommentReplyEnabled(), settings.isNewCommentEnabled(), + settings.isContentLikeEnabled(), settings.isMarketingEventEnabled() + ); + } +} 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; - } } 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..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 @@ -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,33 @@ 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); + + // 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 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(); + } +} 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..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 @@ -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; @@ -39,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); @@ -93,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()); @@ -112,6 +115,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/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()); 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(); + } +}