From f10f3a2bb50c14b0486fda0853e2c4429683c553 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 28 Mar 2026 18:16:15 +0900 Subject: [PATCH 1/7] =?UTF-8?q?#64=20[Feat]=20S3=20Presigned=20URL=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S3 버킷 프라이빗 전환에 따라 Presigned URL 기반 파일 접근 방식 도입 - S3Presigner Bean 등록 (S3Config) - S3PresignedUrlService: presigned GET URL 생성 (기존 풀 URL 하위 호환) - S3UploadServiceImpl: 업로드 후 S3 키만 반환하도록 변경 - FileUploadController: 응답에 s3Key + presignedUrl 포함 - application.yml: presigned URL 만료 시간 설정 추가 (6시간) Co-Authored-By: Claude Opus 4.6 --- .../com/swyp/app/global/config/S3Config.java | 27 ++++++++ .../s3/controller/FileUploadController.java | 14 ++-- .../infra/s3/dto/FileUploadResponse.java | 4 ++ .../s3/service/S3PresignedUrlService.java | 65 +++++++++++++++++++ .../infra/s3/service/S3UploadServiceImpl.java | 8 +-- src/main/resources/application.yml | 5 +- 6 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/swyp/app/global/config/S3Config.java create mode 100644 src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java create mode 100644 src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java diff --git a/src/main/java/com/swyp/app/global/config/S3Config.java b/src/main/java/com/swyp/app/global/config/S3Config.java new file mode 100644 index 0000000..f2ae86f --- /dev/null +++ b/src/main/java/com/swyp/app/global/config/S3Config.java @@ -0,0 +1,27 @@ +package com.swyp.app.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) +@Configuration +public class S3Config { + + @Bean + public S3Presigner s3Presigner( + @Value("${spring.cloud.aws.region.static}") String region, + @Value("${spring.cloud.aws.credentials.access-key}") String accessKey, + @Value("${spring.cloud.aws.credentials.secret-key}") String secretKey) { + + return S3Presigner.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey))) + .build(); + } +} diff --git a/src/main/java/com/swyp/app/global/infra/s3/controller/FileUploadController.java b/src/main/java/com/swyp/app/global/infra/s3/controller/FileUploadController.java index 14b2859..c17c9b4 100644 --- a/src/main/java/com/swyp/app/global/infra/s3/controller/FileUploadController.java +++ b/src/main/java/com/swyp/app/global/infra/s3/controller/FileUploadController.java @@ -1,7 +1,9 @@ package com.swyp.app.global.infra.s3.controller; import com.swyp.app.global.common.response.ApiResponse; +import com.swyp.app.global.infra.s3.dto.FileUploadResponse; import com.swyp.app.global.infra.s3.enums.FileCategory; +import com.swyp.app.global.infra.s3.service.S3PresignedUrlService; import com.swyp.app.global.infra.s3.service.S3UploadService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -26,10 +28,11 @@ public class FileUploadController { private final S3UploadService s3UploadService; + private final S3PresignedUrlService s3PresignedUrlService; @Operation(summary = "S3 파일 업로드", description = "도메인 카테고리(PHILOSOPHER, BATTLE, SCENARIO)에 맞춰 파일을 업로드합니다.") @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - public ApiResponse uploadFile( + public ApiResponse uploadFile( @Parameter(description = "업로드할 파일", content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) @RequestParam("file") MultipartFile multipartFile, @@ -42,10 +45,13 @@ public ApiResponse uploadFile( // 2. 경로 생성 (예: images/battles/UUID_thumb.png) String fileName = category.getPath() + "/" + UUID.randomUUID() + "_" + multipartFile.getOriginalFilename(); - // 3. S3 업로드 - String s3Url = s3UploadService.uploadFile(fileName, tempFile); + // 3. S3 업로드 (S3 키 반환) + String s3Key = s3UploadService.uploadFile(fileName, tempFile); - return ApiResponse.onSuccess(s3Url); + // 4. 미리보기용 Presigned URL 생성 + String presignedUrl = s3PresignedUrlService.generatePresignedUrl(s3Key); + + return ApiResponse.onSuccess(new FileUploadResponse(s3Key, presignedUrl)); } private File convertMultiPartToFile(MultipartFile file) throws IOException { diff --git a/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java b/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java new file mode 100644 index 0000000..beaf366 --- /dev/null +++ b/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java @@ -0,0 +1,4 @@ +package com.swyp.app.global.infra.s3.dto; + +// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) +public record FileUploadResponse(String s3Key, String presignedUrl) {} diff --git a/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java b/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java new file mode 100644 index 0000000..ca1fe2e --- /dev/null +++ b/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java @@ -0,0 +1,65 @@ +package com.swyp.app.global.infra.s3.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; +import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest; + +import java.time.Duration; +import java.util.Map; +import java.util.stream.Collectors; + +// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) +@Service +@RequiredArgsConstructor +public class S3PresignedUrlService { + + private final S3Presigner s3Presigner; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucketName; + + @Value("${app.s3.presigned-url.expiration-hours:6}") + private int expirationHours; + + public String generatePresignedUrl(String s3KeyOrUrl) { + if (s3KeyOrUrl == null || s3KeyOrUrl.isBlank()) { + return null; + } + + String key = extractKey(s3KeyOrUrl); + + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofHours(expirationHours)) + .getObjectRequest(getObjectRequest) + .build(); + + return s3Presigner.presignGetObject(presignRequest).url().toString(); + } + + public Map generatePresignedUrls(Map keyMap) { + if (keyMap == null || keyMap.isEmpty()) { + return keyMap; + } + return keyMap.entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + e -> generatePresignedUrl(e.getValue()) + )); + } + + private String extractKey(String input) { + if (input.startsWith("https://") && input.contains(".s3.") && input.contains(".amazonaws.com/")) { + int idx = input.indexOf(".amazonaws.com/") + ".amazonaws.com/".length(); + return input.substring(idx); + } + return input; + } +} diff --git a/src/main/java/com/swyp/app/global/infra/s3/service/S3UploadServiceImpl.java b/src/main/java/com/swyp/app/global/infra/s3/service/S3UploadServiceImpl.java index b11a733..9151a59 100644 --- a/src/main/java/com/swyp/app/global/infra/s3/service/S3UploadServiceImpl.java +++ b/src/main/java/com/swyp/app/global/infra/s3/service/S3UploadServiceImpl.java @@ -24,9 +24,6 @@ public class S3UploadServiceImpl implements S3UploadService { @Value("${spring.cloud.aws.s3.bucket}") private String bucketName; - @Value("${spring.cloud.aws.region.static}") - private String region; - @Override public String uploadFile(String key, File file) { if (file == null || !file.exists()) { @@ -49,10 +46,9 @@ public String uploadFile(String key, File file) { s3Client.putObject(putObjectRequest, RequestBody.fromFile(file)); - String fileUrl = String.format("https://%s.s3.%s.amazonaws.com/%s", bucketName, region, key); - log.info("[AWS S3] 업로드 완료! 실제 URL: {}, Content-Type: {}", fileUrl, contentType); + log.info("[AWS S3] 업로드 완료! 키: {}, Content-Type: {}", key, contentType); - return fileUrl; + return key; } catch (Exception e) { log.error("[AWS S3] 파일 업로드 실패 - 키: {}", key, e); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2199e27..0a5bf3b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -95,4 +95,7 @@ jwt: refresh-token-expiration: 1209600000 # 14일 app: - baseUrl: http://localhost:8080 \ No newline at end of file + baseUrl: http://localhost:8080 + s3: + presigned-url: + expiration-hours: 6 \ No newline at end of file From ba9e1214e9495352933170fac1b04ab936021f2d Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 28 Mar 2026 18:52:31 +0900 Subject: [PATCH 2/7] =?UTF-8?q?#64=20[Feat]=20=ED=99=88=20API=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=EB=B3=84=20=EC=A0=84=EC=9A=A9=20DTO=EB=A1=9C=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 통합 HomeBattleResponse를 섹션별 전용 DTO로 분리 (EditorPick, Trending, BestBattle, TodayQuiz, TodayVote, NewBattle) - 퀴즈(O/X)와 투표(4지선다)를 별도 응답 타입으로 분리 - Best배틀/새로운배틀 철학자 이름을 태그(PHILOSOPHER)에서 추출 - 새로운배틀에 철학자 이미지 URL 추가 - TodayBattleResponse에 퀴즈·투표 전용 필드 추가 Co-Authored-By: Claude Opus 4.6 --- .../battle/converter/BattleConverter.java | 8 +- .../dto/response/TodayBattleResponse.java | 9 +- .../dto/response/HomeBestBattleResponse.java | 15 ++ .../dto/response/HomeEditorPickResponse.java | 16 ++ ...sponse.java => HomeNewBattleResponse.java} | 19 ++- .../home/dto/response/HomeResponse.java | 14 +- .../dto/response/HomeTodayQuizResponse.java | 12 ++ ....java => HomeTodayVoteOptionResponse.java} | 7 +- .../dto/response/HomeTodayVoteResponse.java | 12 ++ .../dto/response/HomeTrendingResponse.java | 14 ++ .../app/domain/home/service/HomeService.java | 139 +++++++++++++----- .../domain/home/service/HomeServiceTest.java | 82 ++++++----- 12 files changed, 246 insertions(+), 101 deletions(-) create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeBestBattleResponse.java create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeEditorPickResponse.java rename src/main/java/com/swyp/app/domain/home/dto/response/{HomeBattleResponse.java => HomeNewBattleResponse.java} (57%) create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayQuizResponse.java rename src/main/java/com/swyp/app/domain/home/dto/response/{HomeBattleOptionResponse.java => HomeTodayVoteOptionResponse.java} (67%) create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteResponse.java create mode 100644 src/main/java/com/swyp/app/domain/home/dto/response/HomeTrendingResponse.java diff --git a/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java index 3c7d63a..2cf1dd9 100644 --- a/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java +++ b/src/main/java/com/swyp/app/domain/battle/converter/BattleConverter.java @@ -54,7 +54,13 @@ public TodayBattleResponse toTodayResponse(Battle b, List tags, List tags, // 상단 태그 리스트 - List options // 중앙 세로형 대결 카드 데이터 + List options, // 중앙 세로형 대결 카드 데이터 + // 퀴즈·투표 전용 필드 + String titlePrefix, // 투표 접두사 (예: "도덕의 기준은") + String titleSuffix, // 투표 접미사 (예: "이다") + String itemA, // 퀴즈 O 선택지 + String itemADesc, // 퀴즈 O 설명 + String itemB, // 퀴즈 X 선택지 + String itemBDesc // 퀴즈 X 설명 ) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBestBattleResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBestBattleResponse.java new file mode 100644 index 0000000..8568898 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeBestBattleResponse.java @@ -0,0 +1,15 @@ +package com.swyp.app.domain.home.dto.response; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeBestBattleResponse( + Long battleId, + String philosopherA, + String philosopherB, + String title, + List tags, + Integer audioDuration, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeEditorPickResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeEditorPickResponse.java new file mode 100644 index 0000000..24862c7 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeEditorPickResponse.java @@ -0,0 +1,16 @@ +package com.swyp.app.domain.home.dto.response; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeEditorPickResponse( + Long battleId, + String thumbnailUrl, + String optionATitle, + String optionBTitle, + String title, + String summary, + List tags, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeNewBattleResponse.java similarity index 57% rename from src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java rename to src/main/java/com/swyp/app/domain/home/dto/response/HomeNewBattleResponse.java index c00da9d..52e883b 100644 --- a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleResponse.java +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeNewBattleResponse.java @@ -1,20 +1,19 @@ package com.swyp.app.domain.home.dto.response; import com.swyp.app.domain.battle.dto.response.BattleTagResponse; -import com.swyp.app.domain.battle.enums.BattleType; import java.util.List; -public record HomeBattleResponse( +public record HomeNewBattleResponse( Long battleId, + String thumbnailUrl, String title, String summary, - String thumbnailUrl, - BattleType type, - Integer viewCount, - Long participantsCount, - Integer audioDuration, + String philosopherA, + String philosopherAImageUrl, + String philosopherB, + String philosopherBImageUrl, List tags, - List options -) { -} + Integer audioDuration, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java index 525680a..8aa1b67 100644 --- a/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeResponse.java @@ -4,10 +4,10 @@ public record HomeResponse( boolean newNotice, - List editorPicks, - List trendingBattles, - List bestBattles, - List todayPicks, - List newBattles -) { -} + List editorPicks, + List trendingBattles, + List bestBattles, + List todayQuizzes, + List todayVotes, + List newBattles +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayQuizResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayQuizResponse.java new file mode 100644 index 0000000..00eb2b1 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayQuizResponse.java @@ -0,0 +1,12 @@ +package com.swyp.app.domain.home.dto.response; + +public record HomeTodayQuizResponse( + Long battleId, + String title, + String summary, + Long participantsCount, + String itemA, + String itemADesc, + String itemB, + String itemBDesc +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteOptionResponse.java similarity index 67% rename from src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java rename to src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteOptionResponse.java index b2ce088..0c3f73d 100644 --- a/src/main/java/com/swyp/app/domain/home/dto/response/HomeBattleOptionResponse.java +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteOptionResponse.java @@ -2,8 +2,7 @@ import com.swyp.app.domain.battle.enums.BattleOptionLabel; -public record HomeBattleOptionResponse( +public record HomeTodayVoteOptionResponse( BattleOptionLabel label, - String text -) { -} + String title +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteResponse.java new file mode 100644 index 0000000..33fd5b1 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTodayVoteResponse.java @@ -0,0 +1,12 @@ +package com.swyp.app.domain.home.dto.response; + +import java.util.List; + +public record HomeTodayVoteResponse( + Long battleId, + String titlePrefix, + String titleSuffix, + String summary, + Long participantsCount, + List options +) {} diff --git a/src/main/java/com/swyp/app/domain/home/dto/response/HomeTrendingResponse.java b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTrendingResponse.java new file mode 100644 index 0000000..30d1a2a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/home/dto/response/HomeTrendingResponse.java @@ -0,0 +1,14 @@ +package com.swyp.app.domain.home.dto.response; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; + +import java.util.List; + +public record HomeTrendingResponse( + Long battleId, + String thumbnailUrl, + String title, + List tags, + Integer audioDuration, + Integer viewCount +) {} diff --git a/src/main/java/com/swyp/app/domain/home/service/HomeService.java b/src/main/java/com/swyp/app/domain/home/service/HomeService.java index 4919897..ee31bb8 100644 --- a/src/main/java/com/swyp/app/domain/home/service/HomeService.java +++ b/src/main/java/com/swyp/app/domain/home/service/HomeService.java @@ -1,20 +1,21 @@ package com.swyp.app.domain.home.service; +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; import com.swyp.app.domain.battle.dto.response.TodayBattleResponse; import com.swyp.app.domain.battle.dto.response.TodayOptionResponse; +import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.enums.BattleType; +import com.swyp.app.domain.tag.enums.TagType; import com.swyp.app.domain.battle.service.BattleService; -import com.swyp.app.domain.home.dto.response.HomeBattleOptionResponse; -import com.swyp.app.domain.home.dto.response.HomeBattleResponse; -import com.swyp.app.domain.home.dto.response.HomeResponse; +import com.swyp.app.domain.home.dto.response.*; import com.swyp.app.domain.notice.enums.NoticePlacement; import com.swyp.app.domain.notice.service.NoticeService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -29,59 +30,119 @@ public class HomeService { public HomeResponse getHome() { boolean newNotice = !noticeService.getActiveNotices(NoticePlacement.HOME_TOP, null, NOTICE_EXISTS_LIMIT).isEmpty(); - List editorPicks = toHomeBattles(battleService.getEditorPicks()); - List trendingBattles = toHomeBattles(battleService.getTrendingBattles()); - List bestBattles = toHomeBattles(battleService.getBestBattles()); + List editorPickRaw = battleService.getEditorPicks(); + List trendingRaw = battleService.getTrendingBattles(); + List bestRaw = battleService.getBestBattles(); + List voteRaw = battleService.getTodayPicks(BattleType.VOTE); + List quizRaw = battleService.getTodayPicks(BattleType.QUIZ); - List todayPicks = new ArrayList<>(); - todayPicks.addAll(toHomeBattles(battleService.getTodayPicks(BattleType.VOTE))); - todayPicks.addAll(toHomeBattles(battleService.getTodayPicks(BattleType.QUIZ))); - - List excludeIds = collectBattleIds(editorPicks, trendingBattles, bestBattles, todayPicks); - List newBattles = toHomeBattles(battleService.getNewBattles(excludeIds)); + List excludeIds = collectBattleIds(editorPickRaw, trendingRaw, bestRaw, voteRaw, quizRaw); + List newRaw = battleService.getNewBattles(excludeIds); return new HomeResponse( newNotice, - editorPicks, - trendingBattles, - bestBattles, - todayPicks, - newBattles + editorPickRaw.stream().map(this::toEditorPick).toList(), + trendingRaw.stream().map(this::toTrending).toList(), + bestRaw.stream().map(this::toBestBattle).toList(), + quizRaw.stream().map(this::toTodayQuiz).toList(), + voteRaw.stream().map(this::toTodayVote).toList(), + newRaw.stream().map(this::toNewBattle).toList() + ); + } + + private HomeEditorPickResponse toEditorPick(TodayBattleResponse b) { + String optionA = findOptionTitle(b.options(), BattleOptionLabel.A); + String optionB = findOptionTitle(b.options(), BattleOptionLabel.B); + return new HomeEditorPickResponse( + b.battleId(), b.thumbnailUrl(), + optionA, optionB, + b.title(), b.summary(), + b.tags(), b.viewCount() + ); + } + + private HomeTrendingResponse toTrending(TodayBattleResponse b) { + return new HomeTrendingResponse( + b.battleId(), b.thumbnailUrl(), + b.title(), b.tags(), + b.audioDuration(), b.viewCount() + ); + } + + private HomeBestBattleResponse toBestBattle(TodayBattleResponse b) { + List philosophers = findPhilosopherNames(b.tags()); + String philoA = philosophers.size() > 0 ? philosophers.get(0) : null; + String philoB = philosophers.size() > 1 ? philosophers.get(1) : null; + return new HomeBestBattleResponse( + b.battleId(), + philoA, philoB, + b.title(), b.tags(), + b.audioDuration(), b.viewCount() ); } - private List toHomeBattles(List battles) { - return battles.stream() - .map(this::toHomeBattle) + private HomeTodayQuizResponse toTodayQuiz(TodayBattleResponse b) { + return new HomeTodayQuizResponse( + b.battleId(), b.title(), b.summary(), + b.participantsCount(), + b.itemA(), b.itemADesc(), + b.itemB(), b.itemBDesc() + ); + } + + private HomeTodayVoteResponse toTodayVote(TodayBattleResponse b) { + List options = b.options().stream() + .map(o -> new HomeTodayVoteOptionResponse(o.label(), o.title())) .toList(); + return new HomeTodayVoteResponse( + b.battleId(), + b.titlePrefix(), b.titleSuffix(), + b.summary(), b.participantsCount(), + options + ); } - private HomeBattleResponse toHomeBattle(TodayBattleResponse battle) { - return new HomeBattleResponse( - battle.battleId(), - battle.title(), - battle.summary(), - battle.thumbnailUrl(), - battle.type(), - battle.viewCount(), - battle.participantsCount(), - battle.audioDuration(), - battle.tags(), - battle.options().stream() - .map(this::toHomeOption) - .toList() + private HomeNewBattleResponse toNewBattle(TodayBattleResponse b) { + List philosophers = findPhilosopherNames(b.tags()); + String philoA = philosophers.size() > 0 ? philosophers.get(0) : null; + String philoB = philosophers.size() > 1 ? philosophers.get(1) : null; + String imageA = findOptionImageUrl(b.options(), BattleOptionLabel.A); + String imageB = findOptionImageUrl(b.options(), BattleOptionLabel.B); + return new HomeNewBattleResponse( + b.battleId(), b.thumbnailUrl(), + b.title(), b.summary(), + philoA, imageA, + philoB, imageB, + b.tags(), b.audioDuration(), b.viewCount() ); } - private HomeBattleOptionResponse toHomeOption(TodayOptionResponse option) { - return new HomeBattleOptionResponse(option.label(), option.title()); + private String findOptionTitle(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(o -> o.label() == label) + .map(TodayOptionResponse::title) + .findFirst().orElse(null); + } + + private List findPhilosopherNames(List tags) { + return Optional.ofNullable(tags).orElse(List.of()).stream() + .filter(t -> t.type() == TagType.PHILOSOPHER) + .map(BattleTagResponse::name) + .toList(); + } + + private String findOptionImageUrl(List options, BattleOptionLabel label) { + return Optional.ofNullable(options).orElse(List.of()).stream() + .filter(o -> o.label() == label) + .map(TodayOptionResponse::imageUrl) + .findFirst().orElse(null); } @SafeVarargs - private List collectBattleIds(List... groups) { + private List collectBattleIds(List... groups) { return List.of(groups).stream() .flatMap(List::stream) - .map(HomeBattleResponse::battleId) + .map(TodayBattleResponse::battleId) .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 e7e3403..254a194 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 @@ -5,7 +5,7 @@ import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.battle.enums.BattleType; import com.swyp.app.domain.battle.service.BattleService; -import com.swyp.app.domain.home.dto.response.HomeBattleResponse; +import com.swyp.app.domain.home.dto.response.*; import com.swyp.app.domain.notice.dto.response.NoticeSummaryResponse; import com.swyp.app.domain.notice.enums.NoticePlacement; import com.swyp.app.domain.notice.service.NoticeService; @@ -51,8 +51,8 @@ void getHome_aggregates_sections_by_spec() { TodayBattleResponse editorPick = battle("editor-id", BATTLE); TodayBattleResponse trendingBattle = battle("trending-id", BATTLE); TodayBattleResponse bestBattle = battle("best-id", BATTLE); - TodayBattleResponse todayVotePick = battle("today-vote-id", VOTE); - TodayBattleResponse quizBattle = quiz("quiz-id"); + TodayBattleResponse todayVote = vote("vote-id"); + TodayBattleResponse todayQuiz = quiz("quiz-id"); TodayBattleResponse newBattle = battle("new-id", BATTLE); NoticeSummaryResponse notice = new NoticeSummaryResponse( @@ -70,33 +70,36 @@ void getHome_aggregates_sections_by_spec() { when(battleService.getEditorPicks()).thenReturn(List.of(editorPick)); when(battleService.getTrendingBattles()).thenReturn(List.of(trendingBattle)); when(battleService.getBestBattles()).thenReturn(List.of(bestBattle)); - when(battleService.getTodayPicks(VOTE)).thenReturn(List.of(todayVotePick)); - when(battleService.getTodayPicks(QUIZ)).thenReturn(List.of(quizBattle)); + when(battleService.getTodayPicks(VOTE)).thenReturn(List.of(todayVote)); + when(battleService.getTodayPicks(QUIZ)).thenReturn(List.of(todayQuiz)); when(battleService.getNewBattles(List.of( editorPick.battleId(), trendingBattle.battleId(), bestBattle.battleId(), - todayVotePick.battleId(), - quizBattle.battleId() + todayVote.battleId(), + todayQuiz.battleId() ))).thenReturn(List.of(newBattle)); var response = homeService.getHome(); assertThat(response.newNotice()).isTrue(); - assertThat(response.editorPicks()).extracting(HomeBattleResponse::title).containsExactly("editor-id"); - assertThat(response.trendingBattles()).extracting(HomeBattleResponse::title).containsExactly("trending-id"); - assertThat(response.bestBattles()).extracting(HomeBattleResponse::title).containsExactly("best-id"); - assertThat(response.todayPicks()).extracting(HomeBattleResponse::title).containsExactly("today-vote-id", "quiz-id"); - assertThat(response.newBattles()).extracting(HomeBattleResponse::title).containsExactly("new-id"); - assertThat(response.todayPicks().get(0).options()).extracting(option -> option.text()).containsExactly("A", "B"); - assertThat(response.todayPicks().get(1).options()).extracting(option -> option.text()).containsExactly("A", "B", "C", "D"); + assertThat(response.editorPicks()).extracting(HomeEditorPickResponse::title).containsExactly("editor-id"); + assertThat(response.trendingBattles()).extracting(HomeTrendingResponse::title).containsExactly("trending-id"); + assertThat(response.bestBattles()).extracting(HomeBestBattleResponse::title).containsExactly("best-id"); + assertThat(response.todayQuizzes()).extracting(HomeTodayQuizResponse::title).containsExactly("quiz-id"); + assertThat(response.todayVotes()).hasSize(1); + assertThat(response.todayVotes().get(0).titlePrefix()).isEqualTo("도덕의 기준은"); + assertThat(response.todayVotes().get(0).options()).extracting(HomeTodayVoteOptionResponse::title) + .containsExactly("결과", "의도", "규칙", "덕"); + assertThat(response.todayQuizzes().get(0).itemA()).isEqualTo("정답"); + assertThat(response.newBattles()).extracting(HomeNewBattleResponse::title).containsExactly("new-id"); verify(battleService).getNewBattles(argThat(ids -> ids.equals(List.of( editorPick.battleId(), trendingBattle.battleId(), bestBattle.battleId(), - todayVotePick.battleId(), - quizBattle.battleId() + todayVote.battleId(), + todayQuiz.battleId() )))); } @@ -117,7 +120,8 @@ void getHome_returns_false_and_empty_lists_when_no_data() { assertThat(response.editorPicks()).isEmpty(); assertThat(response.trendingBattles()).isEmpty(); assertThat(response.bestBattles()).isEmpty(); - assertThat(response.todayPicks()).isEmpty(); + assertThat(response.todayQuizzes()).isEmpty(); + assertThat(response.todayVotes()).isEmpty(); assertThat(response.newBattles()).isEmpty(); } @@ -162,39 +166,39 @@ void getHome_newNotice_true_with_multiple_notices() { private TodayBattleResponse battle(String title, BattleType type) { return new TodayBattleResponse( - generateId(), - title, - "summary", - "thumbnail", - type, - 10, - 20L, - 90, + generateId(), title, "summary", "thumbnail", type, + 10, 20L, 90, List.of(), List.of( new TodayOptionResponse(generateId(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a"), new TodayOptionResponse(generateId(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b") - ) + ), + null, null, null, null, null, null ); } private TodayBattleResponse quiz(String title) { return new TodayBattleResponse( - generateId(), - title, - "summary", - "thumbnail", - QUIZ, - 30, - 40L, - 60, + generateId(), title, "summary", "thumbnail", QUIZ, + 30, 40L, 60, + List.of(), + List.of(), + null, null, "정답", "정답 설명", "오답", "오답 설명" + ); + } + + private TodayBattleResponse vote(String title) { + return new TodayBattleResponse( + generateId(), title, "summary", "thumbnail", VOTE, + 50, 60L, 0, List.of(), List.of( - new TodayOptionResponse(generateId(), BattleOptionLabel.A, "A", "rep-a", "stance-a", "image-a"), - new TodayOptionResponse(generateId(), BattleOptionLabel.B, "B", "rep-b", "stance-b", "image-b"), - new TodayOptionResponse(generateId(), BattleOptionLabel.C, "C", "rep-c", "stance-c", "image-c"), - new TodayOptionResponse(generateId(), BattleOptionLabel.D, "D", "rep-d", "stance-d", "image-d") - ) + new TodayOptionResponse(generateId(), BattleOptionLabel.A, "결과", null, null, null), + new TodayOptionResponse(generateId(), BattleOptionLabel.B, "의도", null, null, null), + new TodayOptionResponse(generateId(), BattleOptionLabel.C, "규칙", null, null, null), + new TodayOptionResponse(generateId(), BattleOptionLabel.D, "덕", null, null, null) + ), + "도덕의 기준은", "이다", null, null, null, null ); } } From f4a8c22d910cee6e12939bd1b6718da33d3493ee Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 28 Mar 2026 19:20:57 +0900 Subject: [PATCH 3/7] =?UTF-8?q?#64=20[Feat]=20=EC=B2=A0=ED=95=99=EC=9E=90?= =?UTF-8?q?=C2=B7=EC=BA=90=EB=A6=AD=ED=84=B0=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EB=A7=A4=ED=95=91=20=EB=B0=8F=20enum=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PhilosopherType, CharacterType enum에 label(한글)과 imageKey(S3 경로) 추가 - JSON 직렬화를 enum name(대문자) 통일, 한글은 별도 label 필드로 전달 - CharacterTypeConverter를 enum name 기반으로 변경 - MypageResponse, RecapResponse에 이미지 presigned URL 및 label 필드 추가 - MypageService에 S3PresignedUrlService 연동 - FileCategory에 CHARACTER 카테고리 추가 Co-Authored-By: Claude Opus 4.6 --- .../user/dto/response/MypageResponse.java | 6 ++- .../user/dto/response/RecapResponse.java | 4 +- .../app/domain/user/entity/CharacterType.java | 40 +++++++++---------- .../user/entity/CharacterTypeConverter.java | 2 +- .../domain/user/entity/PhilosopherType.java | 31 +++++++++----- .../domain/user/service/MypageService.java | 29 +++++++++++--- .../global/infra/s3/enums/FileCategory.java | 1 + .../user/service/MypageServiceTest.java | 7 ++++ 8 files changed, 80 insertions(+), 40 deletions(-) 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 index 9804cf3..592c927 100644 --- 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 @@ -16,12 +16,16 @@ public record ProfileInfo( String userTag, String nickname, CharacterType characterType, + String characterLabel, + String characterImageUrl, BigDecimal mannerTemperature ) { } public record PhilosopherInfo( - PhilosopherType philosopherType + PhilosopherType philosopherType, + String philosopherLabel, + String imageUrl ) { } 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 index 7d7f245..4229108 100644 --- 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 @@ -13,7 +13,9 @@ public record RecapResponse( ) { public record PhilosopherCard( - PhilosopherType philosopherType + PhilosopherType philosopherType, + String philosopherLabel, + String imageUrl ) { } diff --git a/src/main/java/com/swyp/app/domain/user/entity/CharacterType.java b/src/main/java/com/swyp/app/domain/user/entity/CharacterType.java index e26e5b6..4bc8cf0 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/CharacterType.java +++ b/src/main/java/com/swyp/app/domain/user/entity/CharacterType.java @@ -1,36 +1,32 @@ package com.swyp.app.domain.user.entity; -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; import java.util.Arrays; +@Getter public enum CharacterType { - OWL("owl"), - FOX("fox"), - WOLF("wolf"), - LION("lion"), - PENGUIN("penguin"), - BEAR("bear"), - RABBIT("rabbit"), - CAT("cat"); + OWL("부엉이", "images/characters/owl.png"), + FOX("여우", "images/characters/fox.png"), + WOLF("늑대", "images/characters/wolf.png"), + LION("사자", "images/characters/lion.png"), + PENGUIN("펭귄", "images/characters/penguin.png"), + BEAR("곰", "images/characters/bear.png"), + RABBIT("토끼", "images/characters/rabbit.png"), + CAT("고양이", "images/characters/cat.png"); - private final String value; + private final String label; + private final String imageKey; - CharacterType(String value) { - this.value = value; + CharacterType(String label, String imageKey) { + this.label = label; + this.imageKey = imageKey; } - @JsonValue - public String getValue() { - return value; - } - - @JsonCreator - public static CharacterType from(String value) { + public static CharacterType from(String input) { return Arrays.stream(values()) - .filter(type -> type.value.equalsIgnoreCase(value)) + .filter(type -> type.name().equalsIgnoreCase(input) || type.label.equals(input)) .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown character type: " + value)); + .orElseThrow(() -> new IllegalArgumentException("Unknown character type: " + input)); } } diff --git a/src/main/java/com/swyp/app/domain/user/entity/CharacterTypeConverter.java b/src/main/java/com/swyp/app/domain/user/entity/CharacterTypeConverter.java index 287a520..b4e84db 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/CharacterTypeConverter.java +++ b/src/main/java/com/swyp/app/domain/user/entity/CharacterTypeConverter.java @@ -8,7 +8,7 @@ public class CharacterTypeConverter implements AttributeConverter Date: Sat, 28 Mar 2026 19:47:51 +0900 Subject: [PATCH 4/7] =?UTF-8?q?#64=20[Feat]=20=EC=B2=A0=ED=95=99=EC=9E=90?= =?UTF-8?q?=20=EC=9C=A0=ED=98=95=20=EC=82=B0=EC=B6=9C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EB=B0=8F=20PhilosopherType=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PhilosopherType에 typeName, description, bestMatch, worstMatch, 6축 고정 점수 추가 - UserProfile에 philosopherType 필드 추가 (nullable, @Enumerated STRING) - 최초 5회 투표 기반 PHILOSOPHER 태그 최다 빈도로 철학자 유형 자동 산출 후 저장 - Recap 성향 점수를 UserTendencyScore 대신 PhilosopherType 고정값으로 변경 - bestMatch/worstMatch도 PhilosopherType에서 자동 결정 Co-Authored-By: Claude Opus 4.6 --- .../battle/service/BattleQueryService.java | 19 ++++ .../user/dto/response/MypageResponse.java | 2 + .../user/dto/response/RecapResponse.java | 2 + .../domain/user/entity/PhilosopherType.java | 93 ++++++++++++++++--- .../app/domain/user/entity/UserProfile.java | 9 ++ .../domain/user/service/MypageService.java | 68 ++++++++++---- .../vote/repository/VoteRepository.java | 4 + .../domain/vote/service/VoteQueryService.java | 7 ++ .../user/service/MypageServiceTest.java | 48 ++++------ 9 files changed, 191 insertions(+), 61 deletions(-) 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 index e324144..1dc6d7b 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleQueryService.java @@ -10,8 +10,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.swyp.app.domain.tag.enums.TagType; + import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; @@ -58,4 +61,20 @@ public Map getTopTagsByBattleIds(List battleIds, int limit) java.util.LinkedHashMap::new )); } + + public Optional getTopPhilosopherTagName(List battleIds) { + if (battleIds.isEmpty()) return Optional.empty(); + + List battleTags = battleTagRepository.findByBattleIdIn(battleIds); + + return battleTags.stream() + .filter(bt -> bt.getTag().getType() == TagType.PHILOSOPHER) + .collect(Collectors.groupingBy( + bt -> bt.getTag().getName(), + Collectors.counting() + )) + .entrySet().stream() + .max(Map.Entry.comparingByValue()) + .map(Map.Entry::getKey); + } } 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 index 592c927..ab5149a 100644 --- 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 @@ -25,6 +25,8 @@ public record ProfileInfo( public record PhilosopherInfo( PhilosopherType philosopherType, String philosopherLabel, + String typeName, + String description, String imageUrl ) { } 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 index 4229108..ac76fe9 100644 --- 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 @@ -15,6 +15,8 @@ public record RecapResponse( public record PhilosopherCard( PhilosopherType philosopherType, String philosopherLabel, + String typeName, + String description, String imageUrl ) { } 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 index 9b8d2ce..3b70e6a 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/PhilosopherType.java +++ b/src/main/java/com/swyp/app/domain/user/entity/PhilosopherType.java @@ -2,24 +2,95 @@ import lombok.Getter; +import java.util.Arrays; + @Getter public enum PhilosopherType { - SOCRATES("소크라테스", "images/philosophers/socrates.png"), - PLATO("플라톤", "images/philosophers/plato.png"), - ARISTOTLE("아리스토텔레스", "images/philosophers/aristotle.png"), - KANT("칸트", "images/philosophers/kant.png"), - NIETZSCHE("니체", "images/philosophers/nietzsche.png"), - MARX("마르크스", "images/philosophers/marx.png"), - SARTRE("사르트르", "images/philosophers/sartre.png"), - CONFUCIUS("공자", "images/philosophers/confucius.png"), - LAOZI("노자", "images/philosophers/laozi.png"), - BUDDHA("붓다", "images/philosophers/buddha.png"); + SOCRATES("소크라테스", "질문형", "확신보다는 끊임없는 물음표로 진리를 찾는 탐구자", + "KANT", "ARISTOTLE", + 75, 96, 50, 58, 72, 60, + "images/philosophers/socrates.png"), + PLATO("플라톤", "이상형", "현실 너머 더 완벽하고 가치 있는 세상을 꿈꾸는 이상주의자", + "MARX", "ARISTOTLE", + 82, 65, 30, 42, 68, 95, + "images/philosophers/plato.png"), + ARISTOTLE("아리스토텔레스", "현실형", "모호한 이론보다 명확한 증거와 논리로 판단하는 실천가", + "SOCRATES", "PLATO", + 78, 92, 62, 40, 45, 25, + "images/philosophers/aristotle.png"), + KANT("칸트", "원칙형", "스스로 세운 도덕적 원칙과 보편적 가치를 지키는 원칙주의자", + "CONFUCIUS", "NIETZSCHE", + 92, 85, 72, 38, 88, 45, + "images/philosophers/kant.png"), + NIETZSCHE("니체", "돌파형", "기존의 틀을 깨고 나만의 길을 개척하는 극복의 아이콘", + "SARTRE", "KANT", + 32, 48, 95, 90, 42, 85, + "images/philosophers/nietzsche.png"), + MARX("마르크스", "구조형", "개인의 문제보다 사회의 구조와 시스템을 꿰뚫는 분석가", + "PLATO", "SARTRE", + 40, 78, 18, 94, 28, 80, + "images/philosophers/marx.png"), + SARTRE("사르트르", "자유형", "선택의 무게를 짊어지며 행동으로 존재를 증명하는 자유인", + "NIETZSCHE", "MARX", + 28, 52, 98, 86, 48, 72, + "images/philosophers/sartre.png"), + CONFUCIUS("공자", "관계형", "예의와 배려로 조화로운 인간관계를 만들어가는 평화주의자", + "KANT", "LAOZI", + 94, 65, 15, 30, 80, 50, + "images/philosophers/confucius.png"), + LAOZI("노자", "자연형", "억지로 바꾸기보다 세상의 순리와 흐름에 몸을 맡기는 유연한 영혼", + "BUDDHA", "CONFUCIUS", + 22, 38, 68, 88, 94, 70, + "images/philosophers/laozi.png"), + BUDDHA("붓다", "내면형", "외부의 소음에서 벗어나 마음속 깊은 평화와 고요를 찾는 수행자", + "LAOZI", "ARISTOTLE", + 35, 55, 42, 48, 96, 62, + "images/philosophers/buddha.png"); private final String label; + private final String typeName; + private final String description; + private final String bestMatchName; + private final String worstMatchName; + private final int principle; + private final int reason; + private final int individual; + private final int change; + private final int inner; + private final int ideal; private final String imageKey; - PhilosopherType(String label, String imageKey) { + PhilosopherType(String label, String typeName, String description, + String bestMatchName, String worstMatchName, + int principle, int reason, int individual, + int change, int inner, int ideal, + String imageKey) { this.label = label; + this.typeName = typeName; + this.description = description; + this.bestMatchName = bestMatchName; + this.worstMatchName = worstMatchName; + this.principle = principle; + this.reason = reason; + this.individual = individual; + this.change = change; + this.inner = inner; + this.ideal = ideal; this.imageKey = imageKey; } + + public PhilosopherType getBestMatch() { + return valueOf(bestMatchName); + } + + public PhilosopherType getWorstMatch() { + return valueOf(worstMatchName); + } + + public static PhilosopherType fromLabel(String label) { + return Arrays.stream(values()) + .filter(type -> type.label.equals(label)) + .findFirst() + .orElse(null); + } } diff --git a/src/main/java/com/swyp/app/domain/user/entity/UserProfile.java b/src/main/java/com/swyp/app/domain/user/entity/UserProfile.java index 7e063f5..6131b7b 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/UserProfile.java +++ b/src/main/java/com/swyp/app/domain/user/entity/UserProfile.java @@ -2,6 +2,8 @@ import com.swyp.app.global.common.BaseEntity; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; @@ -29,6 +31,9 @@ public class UserProfile extends BaseEntity { private CharacterType characterType; + @Enumerated(EnumType.STRING) + private PhilosopherType philosopherType; + private BigDecimal mannerTemperature; @Builder @@ -47,4 +52,8 @@ public void update(String nickname, CharacterType characterType) { this.characterType = characterType; } } + + public void updatePhilosopherType(PhilosopherType philosopherType) { + this.philosopherType = philosopherType; + } } diff --git a/src/main/java/com/swyp/app/domain/user/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java index d4d2a07..fa45e85 100644 --- a/src/main/java/com/swyp/app/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -28,7 +28,6 @@ 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; @@ -57,6 +56,7 @@ public class MypageService { private final PerspectiveQueryService perspectiveQueryService; private final S3PresignedUrlService s3PresignedUrlService; + @Transactional public MypageResponse getMypage() { User user = userService.findCurrentUser(); UserProfile profile = userService.findUserProfile(user.getId()); @@ -74,13 +74,15 @@ public MypageResponse getMypage() { profile.getMannerTemperature() ); - // TODO: 철학자 산출 로직 확정 후 구현, 현재는 임시로 SOCRATES 반환 - PhilosopherType philosopherType = PhilosopherType.SOCRATES; - MypageResponse.PhilosopherInfo philosopherInfo = new MypageResponse.PhilosopherInfo( - philosopherType, - philosopherType.getLabel(), - s3PresignedUrlService.generatePresignedUrl(philosopherType.getImageKey()) - ); + PhilosopherType philosopherType = resolvePhilosopherType(user.getId(), profile); + MypageResponse.PhilosopherInfo philosopherInfo = philosopherType != null + ? new MypageResponse.PhilosopherInfo( + philosopherType, + philosopherType.getLabel(), + philosopherType.getTypeName(), + philosopherType.getDescription(), + s3PresignedUrlService.generatePresignedUrl(philosopherType.getImageKey())) + : null; int currentPoint = creditService.getTotalPoints(user.getId()); TierCode tierCode = TierCode.fromPoints(currentPoint); @@ -95,20 +97,24 @@ public MypageResponse getMypage() { public RecapResponse getRecap() { User user = userService.findCurrentUser(); - UserTendencyScore score = userService.findUserTendencyScore(user.getId()); + UserProfile profile = userService.findUserProfile(user.getId()); - // TODO: 철학자 산출 로직 확정 후 구현, 현재는 임시 값 반환 - RecapResponse.PhilosopherCard myCard = toPhilosopherCard(PhilosopherType.SOCRATES); - RecapResponse.PhilosopherCard bestMatchCard = toPhilosopherCard(PhilosopherType.PLATO); - RecapResponse.PhilosopherCard worstMatchCard = toPhilosopherCard(PhilosopherType.MARX); + PhilosopherType philosopherType = profile.getPhilosopherType(); + if (philosopherType == null) { + return null; + } + + RecapResponse.PhilosopherCard myCard = toPhilosopherCard(philosopherType); + RecapResponse.PhilosopherCard bestMatchCard = toPhilosopherCard(philosopherType.getBestMatch()); + RecapResponse.PhilosopherCard worstMatchCard = toPhilosopherCard(philosopherType.getWorstMatch()); RecapResponse.Scores scores = new RecapResponse.Scores( - score.getPrinciple(), - score.getReason(), - score.getIndividual(), - score.getChange(), - score.getInner(), - score.getIdeal() + philosopherType.getPrinciple(), + philosopherType.getReason(), + philosopherType.getIndividual(), + philosopherType.getChange(), + philosopherType.getInner(), + philosopherType.getIdeal() ); RecapResponse.PreferenceReport preferenceReport = buildPreferenceReport(user.getId()); @@ -296,10 +302,34 @@ public NoticeDetailResponse getNoticeDetail(Long noticeId) { ); } + private static final int PHILOSOPHER_CALC_THRESHOLD = 5; + + private PhilosopherType resolvePhilosopherType(Long userId, UserProfile profile) { + if (profile.getPhilosopherType() != null) { + return profile.getPhilosopherType(); + } + + long totalVotes = voteQueryService.countTotalParticipation(userId); + if (totalVotes < PHILOSOPHER_CALC_THRESHOLD) { + return null; + } + + List battleIds = voteQueryService.findFirstNBattleIds(userId, PHILOSOPHER_CALC_THRESHOLD); + return battleQueryService.getTopPhilosopherTagName(battleIds) + .map(PhilosopherType::fromLabel) + .map(type -> { + profile.updatePhilosopherType(type); + return type; + }) + .orElse(null); + } + private RecapResponse.PhilosopherCard toPhilosopherCard(PhilosopherType type) { return new RecapResponse.PhilosopherCard( type, type.getLabel(), + type.getTypeName(), + type.getDescription(), s3PresignedUrlService.generatePresignedUrl(type.getImageKey()) ); } 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 086be02..da6a67e 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 @@ -55,4 +55,8 @@ List findByUserIdAndPreVoteOptionLabelOrderByCreatedAtDesc( // MypageService (recap): 사용자가 참여한 모든 투표 (배틀 목록 추출용) List findByUserId(Long userId); + + // MypageService: 철학자 유형 산출용 - 최초 N개 투표 조회 (생성순) + @Query("SELECT v FROM Vote v JOIN FETCH v.battle WHERE v.userId = :userId ORDER BY v.createdAt ASC") + List findByUserIdOrderByCreatedAtAsc(@Param("userId") Long userId, Pageable pageable); } 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 index 7250919..b7d00c7 100644 --- a/src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java +++ b/src/main/java/com/swyp/app/domain/vote/service/VoteQueryService.java @@ -68,4 +68,11 @@ public List findParticipatedBattleIds(Long userId) { .distinct() .toList(); } + + public List findFirstNBattleIds(Long userId, int n) { + return voteRepository.findByUserIdOrderByCreatedAtAsc(userId, PageRequest.of(0, n)).stream() + .map(v -> v.getBattle().getId()) + .distinct() + .toList(); + } } 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 index 060b215..5ce1473 100644 --- a/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java +++ b/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java @@ -32,7 +32,6 @@ 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; @@ -90,6 +89,7 @@ private Long generateId() { void getMypage_returns_profile_philosopher_tier() { User user = createUser(1L, "myTag"); UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + profile.updatePhilosopherType(PhilosopherType.KANT); when(userService.findCurrentUser()).thenReturn(user); when(userService.findUserProfile(1L)).thenReturn(profile); @@ -102,7 +102,9 @@ void getMypage_returns_profile_philosopher_tier() { 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.philosopher().philosopherType()).isEqualTo(PhilosopherType.KANT); + assertThat(response.philosopher().typeName()).isEqualTo("원칙형"); + assertThat(response.philosopher().description()).isNotNull(); assertThat(response.tier().tierCode()).isEqualTo(TierCode.WANDERER); assertThat(response.tier().currentPoint()).isZero(); } @@ -111,14 +113,11 @@ void getMypage_returns_profile_philosopher_tier() { @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(); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); + profile.updatePhilosopherType(PhilosopherType.KANT); when(userService.findCurrentUser()).thenReturn(user); - when(userService.findUserTendencyScore(1L)).thenReturn(score); + when(userService.findUserProfile(1L)).thenReturn(profile); when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://presigned-url"); when(voteQueryService.countTotalParticipation(1L)).thenReturn(15L); when(voteQueryService.countOpinionChanges(1L)).thenReturn(3L); @@ -134,11 +133,11 @@ void getRecap_returns_cards_scores_report() { 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.myCard().philosopherType()).isEqualTo(PhilosopherType.KANT); + assertThat(response.bestMatchCard().philosopherType()).isEqualTo(PhilosopherType.CONFUCIUS); + assertThat(response.worstMatchCard().philosopherType()).isEqualTo(PhilosopherType.NIETZSCHE); + assertThat(response.scores().principle()).isEqualTo(92); + assertThat(response.scores().ideal()).isEqualTo(45); assertThat(response.preferenceReport().totalParticipation()).isEqualTo(15); assertThat(response.preferenceReport().opinionChanges()).isEqualTo(3); assertThat(response.preferenceReport().battleWinRate()).isEqualTo(70); @@ -147,30 +146,17 @@ void getRecap_returns_cards_scores_report() { } @Test - @DisplayName("투표이력이 없으면 선호보고서가 0값이다") - void getRecap_returns_zero_report_when_no_votes() { + @DisplayName("철학자유형이 미산출이면 recap은 null이다") + void getRecap_returns_null_when_no_philosopher() { User user = createUser(1L, "tag"); - UserTendencyScore score = UserTendencyScore.builder() - .user(user) - .principle(0).reason(0).individual(0) - .change(0).inner(0).ideal(0) - .build(); + UserProfile profile = createProfile(user, "nick", CharacterType.OWL); when(userService.findCurrentUser()).thenReturn(user); - when(userService.findUserTendencyScore(1L)).thenReturn(score); - when(s3PresignedUrlService.generatePresignedUrl(anyString())).thenReturn("https://presigned-url"); - 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<>()); + when(userService.findUserProfile(1L)).thenReturn(profile); RecapResponse response = mypageService.getRecap(); - assertThat(response.preferenceReport().totalParticipation()).isZero(); - assertThat(response.preferenceReport().opinionChanges()).isZero(); - assertThat(response.preferenceReport().battleWinRate()).isZero(); - assertThat(response.preferenceReport().favoriteTopics()).isEmpty(); + assertThat(response).isNull(); } @Test From 4762ccd44ec834208e374fc032a015e3d5003b7d Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 28 Mar 2026 19:55:03 +0900 Subject: [PATCH 5/7] =?UTF-8?q?#64=20[Feat]=20=ED=83=90=EC=83=89=20?= =?UTF-8?q?=ED=83=AD=20=EB=B0=B0=ED=8B=80=20=EA=B2=80=EC=83=89=20API=20?= =?UTF-8?q?=EC=8B=A0=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - search 도메인 신설 (controller, service, dto, enum) - GET /api/v1/search/battles 엔드포인트 추가 - 카테고리 태그 필터, 인기순/최신순 정렬, offset 페이지네이션 지원 - BattleRepository에 searchAll, searchByCategory 쿼리 추가 Co-Authored-By: Claude Opus 4.6 --- .../battle/repository/BattleRepository.java | 18 ++++ .../search/controller/SearchController.java | 29 ++++++ .../response/SearchBattleListResponse.java | 25 ++++++ .../domain/search/enums/SearchSortType.java | 6 ++ .../domain/search/service/SearchService.java | 90 +++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 src/main/java/com/swyp/app/domain/search/controller/SearchController.java create mode 100644 src/main/java/com/swyp/app/domain/search/dto/response/SearchBattleListResponse.java create mode 100644 src/main/java/com/swyp/app/domain/search/enums/SearchSortType.java create mode 100644 src/main/java/com/swyp/app/domain/search/service/SearchService.java diff --git a/src/main/java/com/swyp/app/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/app/domain/battle/repository/BattleRepository.java index eef4f44..4c888e0 100644 --- a/src/main/java/com/swyp/app/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/app/domain/battle/repository/BattleRepository.java @@ -54,4 +54,22 @@ public interface BattleRepository extends JpaRepository { // 기본 조회용 List findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status); + + // 탐색 탭: 전체 배틀 검색 (정렬은 Pageable Sort로 처리) + @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + List searchAll(Pageable pageable); + + @Query("SELECT COUNT(b) FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + long countSearchAll(); + + // 탐색 탭: 카테고리 태그 필터 배틀 검색 + @Query("SELECT DISTINCT b FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + List searchByCategory(@Param("categoryName") String categoryName, Pageable pageable); + + @Query("SELECT COUNT(DISTINCT b) FROM Battle b JOIN BattleTag bt ON bt.battle = b JOIN bt.tag t " + + "WHERE t.type = 'CATEGORY' AND t.name = :categoryName " + + "AND b.status = 'PUBLISHED' AND b.deletedAt IS NULL") + long countSearchByCategory(@Param("categoryName") String categoryName); } \ No newline at end of file diff --git a/src/main/java/com/swyp/app/domain/search/controller/SearchController.java b/src/main/java/com/swyp/app/domain/search/controller/SearchController.java new file mode 100644 index 0000000..e3468ed --- /dev/null +++ b/src/main/java/com/swyp/app/domain/search/controller/SearchController.java @@ -0,0 +1,29 @@ +package com.swyp.app.domain.search.controller; + +import com.swyp.app.domain.search.dto.response.SearchBattleListResponse; +import com.swyp.app.domain.search.enums.SearchSortType; +import com.swyp.app.domain.search.service.SearchService; +import com.swyp.app.global.common.response.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/search") +public class SearchController { + + private final SearchService searchService; + + @GetMapping("/battles") + public ApiResponse searchBattles( + @RequestParam(required = false) String category, + @RequestParam(required = false) SearchSortType sort, + @RequestParam(required = false) Integer offset, + @RequestParam(required = false) Integer size + ) { + return ApiResponse.onSuccess(searchService.searchBattles(category, sort, offset, size)); + } +} diff --git a/src/main/java/com/swyp/app/domain/search/dto/response/SearchBattleListResponse.java b/src/main/java/com/swyp/app/domain/search/dto/response/SearchBattleListResponse.java new file mode 100644 index 0000000..bb5b768 --- /dev/null +++ b/src/main/java/com/swyp/app/domain/search/dto/response/SearchBattleListResponse.java @@ -0,0 +1,25 @@ +package com.swyp.app.domain.search.dto.response; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; +import com.swyp.app.domain.battle.enums.BattleType; + +import java.util.List; + +public record SearchBattleListResponse( + List items, + Integer nextOffset, + boolean hasNext +) { + + public record SearchBattleItem( + Long battleId, + String thumbnailUrl, + BattleType type, + String title, + String summary, + List tags, + Integer audioDuration, + Integer viewCount + ) { + } +} diff --git a/src/main/java/com/swyp/app/domain/search/enums/SearchSortType.java b/src/main/java/com/swyp/app/domain/search/enums/SearchSortType.java new file mode 100644 index 0000000..e0ace4a --- /dev/null +++ b/src/main/java/com/swyp/app/domain/search/enums/SearchSortType.java @@ -0,0 +1,6 @@ +package com.swyp.app.domain.search.enums; + +public enum SearchSortType { + POPULAR, + LATEST +} diff --git a/src/main/java/com/swyp/app/domain/search/service/SearchService.java b/src/main/java/com/swyp/app/domain/search/service/SearchService.java new file mode 100644 index 0000000..713442c --- /dev/null +++ b/src/main/java/com/swyp/app/domain/search/service/SearchService.java @@ -0,0 +1,90 @@ +package com.swyp.app.domain.search.service; + +import com.swyp.app.domain.battle.dto.response.BattleTagResponse; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleTag; +import com.swyp.app.domain.battle.repository.BattleRepository; +import com.swyp.app.domain.battle.repository.BattleTagRepository; +import com.swyp.app.domain.search.dto.response.SearchBattleListResponse; +import com.swyp.app.domain.search.enums.SearchSortType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SearchService { + + private static final int DEFAULT_PAGE_SIZE = 20; + + private final BattleRepository battleRepository; + private final BattleTagRepository battleTagRepository; + + public SearchBattleListResponse searchBattles(String category, SearchSortType sort, Integer offset, Integer size) { + int pageOffset = offset == null || offset < 0 ? 0 : offset; + int pageSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : size; + SearchSortType sortType = sort == null ? SearchSortType.POPULAR : sort; + + Sort pageSort = sortType == SearchSortType.LATEST + ? Sort.by(Sort.Direction.DESC, "createdAt") + : Sort.by(Sort.Direction.DESC, "viewCount"); + Pageable pageable = PageRequest.of(pageOffset / pageSize, pageSize, pageSort); + + List battles; + long totalCount; + + if (category == null || category.isBlank()) { + battles = battleRepository.searchAll(pageable); + totalCount = battleRepository.countSearchAll(); + } else { + battles = battleRepository.searchByCategory(category, pageable); + totalCount = battleRepository.countSearchByCategory(category); + } + + Map> tagMap = loadTagMap(battles); + + List items = battles.stream() + .map(battle -> new SearchBattleListResponse.SearchBattleItem( + battle.getId(), + battle.getThumbnailUrl(), + battle.getType(), + battle.getTitle(), + battle.getSummary(), + tagMap.getOrDefault(battle.getId(), List.of()), + battle.getAudioDuration(), + battle.getViewCount() + )) + .toList(); + + int nextOffset = pageOffset + pageSize; + boolean hasNext = nextOffset < totalCount; + return new SearchBattleListResponse(items, hasNext ? nextOffset : null, hasNext); + } + + private Map> loadTagMap(List battles) { + List battleIds = battles.stream().map(Battle::getId).toList(); + if (battleIds.isEmpty()) return Map.of(); + + List battleTags = battleTagRepository.findByBattleIdIn(battleIds); + return battleTags.stream() + .collect(Collectors.groupingBy( + bt -> bt.getBattle().getId(), + Collectors.mapping( + bt -> new BattleTagResponse( + bt.getTag().getId(), + bt.getTag().getName(), + bt.getTag().getType() + ), + Collectors.toList() + ) + )); + } +} From 15293696bec30bb23d87da4a1e7d750760033d6e Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 28 Mar 2026 20:18:06 +0900 Subject: [PATCH 6/7] =?UTF-8?q?#51=20[Refactor]=20Long=20userId=20?= =?UTF-8?q?=E2=86=92=20@ManyToOne=20User=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EB=B0=8F=20TODO=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Vote, Perspective, PerspectiveComment, PerspectiveLike, CreditHistory 엔티티의 Long userId를 @ManyToOne User로 전환 - Perspective의 Long battleId, optionId도 @ManyToOne Battle, BattleOption으로 전환 - 전 도메인 컨트롤러의 Long userId = 1L 하드코딩을 @AuthenticationPrincipal Long userId로 교체 - Repository 파생 쿼리 메서드명을 엔티티 ���조에 맞게 정리 - S3 임시 구현 TODO, AdMob 포인트 TODO 등 해소된 TODO 주석 제거 - 관련 서비스·테스트 일괄 수정 및 빌드 검증 완료 Co-Authored-By: Claude Opus 4.6 --- .../battle/service/BattleServiceImpl.java | 2 +- .../PerspectiveCommentController.java | 15 +++---- .../controller/PerspectiveController.java | 29 ++++++------ .../controller/PerspectiveLikeController.java | 13 +++--- .../perspective/entity/Perspective.java | 35 ++++++++------- .../entity/PerspectiveComment.java | 11 ++--- .../perspective/entity/PerspectiveLike.java | 12 ++--- .../PerspectiveCommentRepository.java | 3 +- .../repository/PerspectiveLikeRepository.java | 3 +- .../service/PerspectiveCommentService.java | 19 +++++--- .../service/PerspectiveLikeService.java | 9 +++- .../service/PerspectiveService.java | 22 ++++++---- .../service/AdMobRewardServiceImpl.java | 3 -- .../controller/ScenarioController.java | 2 +- .../scenario/service/ScenarioServiceImpl.java | 2 +- .../app/domain/user/entity/CreditHistory.java | 12 +++-- .../repository/CreditHistoryRepository.java | 2 +- .../domain/user/service/CreditService.java | 7 ++- .../domain/user/service/MypageService.java | 12 ++--- .../vote/controller/VoteController.java | 15 +++---- .../com/swyp/app/domain/vote/entity/Vote.java | 22 ++++------ .../vote/repository/VoteRepository.java | 25 ++++------- .../app/domain/vote/service/VoteService.java | 3 +- .../domain/vote/service/VoteServiceImpl.java | 27 ++++++++---- .../com/swyp/app/global/config/S3Config.java | 1 - .../infra/s3/dto/FileUploadResponse.java | 1 - .../s3/service/S3PresignedUrlService.java | 1 - .../user/service/CreditServiceTest.java | 13 +++++- .../user/service/MypageServiceTest.java | 44 +++++++++---------- 29 files changed, 192 insertions(+), 173 deletions(-) diff --git a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java index 1bba412..ea0671a 100644 --- a/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/app/domain/battle/service/BattleServiceImpl.java @@ -137,7 +137,7 @@ public BattleUserDetailResponse getBattleDetail(Long battleId) { List allTags = getTagsByBattle(battle); List options = battleOptionRepository.findByBattle(battle); - String voteStatus = voteRepository.findByBattleAndUserId(battle, 1L) + String voteStatus = voteRepository.findByBattleIdAndUserId(battleId, 1L) .map(v -> v.getPostVoteOption() != null ? v.getPostVoteOption().getLabel().name() : "NONE") .orElse("NONE"); diff --git a/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveCommentController.java b/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveCommentController.java index d869a23..f7c78c9 100644 --- a/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveCommentController.java +++ b/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveCommentController.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -33,10 +34,9 @@ public class PerspectiveCommentController { @PostMapping("/perspectives/{perspectiveId}/comments") public ApiResponse createComment( @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId, @RequestBody @Valid CreateCommentRequest request ) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; return ApiResponse.onSuccess(commentService.createComment(perspectiveId, userId, request)); } @@ -44,11 +44,10 @@ public ApiResponse createComment( @GetMapping("/perspectives/{perspectiveId}/comments") public ApiResponse getComments( @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId, @RequestParam(required = false) String cursor, @RequestParam(required = false) Integer size ) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; return ApiResponse.onSuccess(commentService.getComments(perspectiveId, userId, cursor, size)); } @@ -56,10 +55,9 @@ public ApiResponse getComments( @DeleteMapping("/perspectives/{perspectiveId}/comments/{commentId}") public ApiResponse deleteComment( @PathVariable Long perspectiveId, - @PathVariable Long commentId + @PathVariable Long commentId, + @AuthenticationPrincipal Long userId ) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; commentService.deleteComment(perspectiveId, commentId, userId); return ApiResponse.onSuccess(null); } @@ -69,10 +67,9 @@ public ApiResponse deleteComment( public ApiResponse updateComment( @PathVariable Long perspectiveId, @PathVariable Long commentId, + @AuthenticationPrincipal Long userId, @RequestBody @Valid UpdateCommentRequest request ) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; return ApiResponse.onSuccess(commentService.updateComment(perspectiveId, commentId, userId, request)); } } diff --git a/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveController.java b/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveController.java index 4ad3125..e1e4bc1 100644 --- a/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveController.java +++ b/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveController.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -30,15 +31,13 @@ public class PerspectiveController { private final PerspectiveService perspectiveService; - // TODO: Prevote 의 여부를 Vote 도메인 개발 이후 교체 @Operation(summary = "관점 생성", description = "특정 배틀에 대한 관점을 생성합니다. 사전 투표가 완료된 경우에만 가능합니다.") @PostMapping("/battles/{battleId}/perspectives") public ApiResponse createPerspective( @PathVariable Long battleId, + @AuthenticationPrincipal Long userId, @RequestBody @Valid CreatePerspectiveRequest request ) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; return ApiResponse.onSuccess(perspectiveService.createPerspective(battleId, userId, request)); } @@ -46,37 +45,36 @@ public ApiResponse createPerspective( @GetMapping("/battles/{battleId}/perspectives") public ApiResponse getPerspectives( @PathVariable Long battleId, + @AuthenticationPrincipal Long userId, @RequestParam(required = false) String cursor, @RequestParam(required = false) Integer size, @RequestParam(required = false) String optionLabel ) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; return ApiResponse.onSuccess(perspectiveService.getPerspectives(battleId, userId, cursor, size, optionLabel)); } @Operation(summary = "내 PENDING 관점 조회", description = "특정 배틀에서 내가 작성한 관점이 PENDING 상태인 경우 반환합니다. PENDING 관점이 없으면 404를 반환합니다.") @GetMapping("/battles/{battleId}/perspectives/me/pending") - public ApiResponse getMyPendingPerspective(@PathVariable Long battleId) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; + public ApiResponse getMyPendingPerspective( + @PathVariable Long battleId, + @AuthenticationPrincipal Long userId) { return ApiResponse.onSuccess(perspectiveService.getMyPendingPerspective(battleId, userId)); } @Operation(summary = "관점 삭제", description = "본인이 작성한 관점을 삭제합니다.") @DeleteMapping("/perspectives/{perspectiveId}") - public ApiResponse deletePerspective(@PathVariable Long perspectiveId) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; + public ApiResponse deletePerspective( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { perspectiveService.deletePerspective(perspectiveId, userId); return ApiResponse.onSuccess(null); } @Operation(summary = "관점 검수 재시도", description = "검수 실패(MODERATION_FAILED) 상태의 관점에 대해 GPT 검수를 다시 요청합니다.") @PostMapping("/perspectives/{perspectiveId}/moderation/retry") - public ApiResponse retryModeration(@PathVariable Long perspectiveId) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; + public ApiResponse retryModeration( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { perspectiveService.retryModeration(perspectiveId, userId); return ApiResponse.onSuccess(null); } @@ -85,10 +83,9 @@ public ApiResponse retryModeration(@PathVariable Long perspectiveId) { @PatchMapping("/perspectives/{perspectiveId}") public ApiResponse updatePerspective( @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId, @RequestBody @Valid UpdatePerspectiveRequest request ) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; return ApiResponse.onSuccess(perspectiveService.updatePerspective(perspectiveId, userId, request)); } } diff --git a/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveLikeController.java b/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveLikeController.java index c73d0f4..abe7cc7 100644 --- a/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveLikeController.java +++ b/src/main/java/com/swyp/app/domain/perspective/controller/PerspectiveLikeController.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -30,17 +31,17 @@ public ApiResponse getLikeCount(@PathVariable Long perspectiv @Operation(summary = "좋아요 등록", description = "특정 관점에 좋아요를 등록합니다.") @PostMapping("/perspectives/{perspectiveId}/likes") - public ApiResponse addLike(@PathVariable Long perspectiveId) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; + public ApiResponse addLike( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { return ApiResponse.onSuccess(likeService.addLike(perspectiveId, userId)); } @Operation(summary = "좋아요 취소", description = "특정 관점에 등록한 좋아요를 취소합니다.") @DeleteMapping("/perspectives/{perspectiveId}/likes") - public ApiResponse removeLike(@PathVariable Long perspectiveId) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; + public ApiResponse removeLike( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { return ApiResponse.onSuccess(likeService.removeLike(perspectiveId, userId)); } } diff --git a/src/main/java/com/swyp/app/domain/perspective/entity/Perspective.java b/src/main/java/com/swyp/app/domain/perspective/entity/Perspective.java index 622633e..48ece7a 100644 --- a/src/main/java/com/swyp/app/domain/perspective/entity/Perspective.java +++ b/src/main/java/com/swyp/app/domain/perspective/entity/Perspective.java @@ -1,14 +1,17 @@ package com.swyp.app.domain.perspective.entity; +import com.swyp.app.domain.battle.entity.Battle; +import com.swyp.app.domain.battle.entity.BattleOption; import com.swyp.app.domain.perspective.enums.PerspectiveStatus; +import com.swyp.app.domain.user.entity.User; import com.swyp.app.global.common.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; @@ -25,17 +28,17 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Perspective extends BaseEntity { - // TODO: Battle 엔티티 병합 후 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "battle_id") 로 교체 - @Column(name = "battle_id", nullable = false) - private Long battleId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "battle_id", nullable = false) + private Battle battle; - // TODO: User 엔티티 병합 후 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") 로 교체 - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; - // TODO: BattleOption 엔티티 병합 후 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "option_id") 로 교체 - @Column(name = "option_id", nullable = false) - private Long optionId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "option_id", nullable = false) + private BattleOption option; @Column(nullable = false, columnDefinition = "TEXT") private String content; @@ -51,10 +54,10 @@ public class Perspective extends BaseEntity { private PerspectiveStatus status; @Builder - private Perspective(Long battleId, Long userId, Long optionId, String content) { - this.battleId = battleId; - this.userId = userId; - this.optionId = optionId; + private Perspective(Battle battle, User user, BattleOption option, String content) { + this.battle = battle; + this.user = user; + this.option = option; this.content = content; this.likeCount = 0; this.commentCount = 0; diff --git a/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveComment.java b/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveComment.java index 19a940a..bf41727 100644 --- a/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveComment.java +++ b/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveComment.java @@ -1,5 +1,6 @@ package com.swyp.app.domain.perspective.entity; +import com.swyp.app.domain.user.entity.User; import com.swyp.app.global.common.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -22,17 +23,17 @@ public class PerspectiveComment extends BaseEntity { @JoinColumn(name = "perspective_id", nullable = false) private Perspective perspective; - // TODO: User 엔티티 병합 후 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") 로 교체 - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; @Column(nullable = false, columnDefinition = "TEXT") private String content; @Builder - private PerspectiveComment(Perspective perspective, Long userId, String content) { + private PerspectiveComment(Perspective perspective, User user, String content) { this.perspective = perspective; - this.userId = userId; + this.user = user; this.content = content; } diff --git a/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveLike.java b/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveLike.java index 3850c32..db399a6 100644 --- a/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveLike.java +++ b/src/main/java/com/swyp/app/domain/perspective/entity/PerspectiveLike.java @@ -1,7 +1,7 @@ package com.swyp.app.domain.perspective.entity; +import com.swyp.app.domain.user.entity.User; import com.swyp.app.global.common.BaseEntity; -import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.JoinColumn; @@ -26,13 +26,13 @@ public class PerspectiveLike extends BaseEntity { @JoinColumn(name = "perspective_id", nullable = false) private Perspective perspective; - // TODO: User 엔티티 병합 후 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") 로 교체 - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; @Builder - private PerspectiveLike(Perspective perspective, Long userId) { + private PerspectiveLike(Perspective perspective, User user) { this.perspective = perspective; - this.userId = userId; + this.user = 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 e28e877..ad44196 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 @@ -16,8 +16,7 @@ public interface PerspectiveCommentRepository extends JpaRepository 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") + @Query("SELECT c FROM PerspectiveComment c JOIN FETCH c.perspective WHERE c.user.id = :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 267a6ba..f71877c 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 @@ -18,8 +18,7 @@ public interface PerspectiveLikeRepository extends JpaRepository findByUserIdOrderByCreatedAtDesc(@Param("userId") Long userId, Pageable pageable); long countByUserId(Long userId); diff --git a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveCommentService.java b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveCommentService.java index df7fc6e..88ded3c 100644 --- a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveCommentService.java +++ b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveCommentService.java @@ -10,6 +10,8 @@ import com.swyp.app.domain.perspective.repository.PerspectiveCommentRepository; import com.swyp.app.domain.perspective.repository.PerspectiveRepository; import com.swyp.app.domain.user.dto.response.UserSummary; +import com.swyp.app.domain.user.entity.User; +import com.swyp.app.domain.user.repository.UserRepository; import com.swyp.app.domain.user.service.UserService; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; @@ -30,25 +32,28 @@ public class PerspectiveCommentService { private final PerspectiveRepository perspectiveRepository; private final PerspectiveCommentRepository commentRepository; + private final UserRepository userRepository; private final UserService userQueryService; @Transactional public CreateCommentResponse createComment(Long perspectiveId, Long userId, CreateCommentRequest request) { Perspective perspective = findPerspectiveById(perspectiveId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); PerspectiveComment comment = PerspectiveComment.builder() .perspective(perspective) - .userId(userId) + .user(user) .content(request.content()) .build(); commentRepository.save(comment); perspective.incrementCommentCount(); - UserSummary user = userQueryService.findSummaryById(userId); + UserSummary userSummary = userQueryService.findSummaryById(userId); return new CreateCommentResponse( comment.getId(), - new CreateCommentResponse.UserSummary(user.userTag(), user.nickname(), user.characterType()), + new CreateCommentResponse.UserSummary(userSummary.userTag(), userSummary.nickname(), userSummary.characterType()), comment.getContent(), comment.getCreatedAt() ); @@ -67,12 +72,12 @@ public CommentListResponse getComments(Long perspectiveId, Long userId, String c List items = comments.stream() .map(c -> { - UserSummary user = userQueryService.findSummaryById(c.getUserId()); + UserSummary author = userQueryService.findSummaryById(c.getUser().getId()); return new CommentListResponse.Item( c.getId(), - new CommentListResponse.UserSummary(user.userTag(), user.nickname(), user.characterType()), + new CommentListResponse.UserSummary(author.userTag(), author.nickname(), author.characterType()), c.getContent(), - c.getUserId().equals(userId), + c.getUser().getId().equals(userId), c.getCreatedAt() ); }) @@ -116,7 +121,7 @@ private PerspectiveComment findCommentById(Long commentId) { } private void validateOwnership(PerspectiveComment comment, Long userId) { - if (!comment.getUserId().equals(userId)) { + if (!comment.getUser().getId().equals(userId)) { throw new CustomException(ErrorCode.COMMENT_FORBIDDEN); } } diff --git a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveLikeService.java b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveLikeService.java index 3b77392..24b3e95 100644 --- a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveLikeService.java +++ b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveLikeService.java @@ -6,6 +6,8 @@ import com.swyp.app.domain.perspective.entity.PerspectiveLike; import com.swyp.app.domain.perspective.repository.PerspectiveLikeRepository; import com.swyp.app.domain.perspective.repository.PerspectiveRepository; +import com.swyp.app.domain.user.entity.User; +import com.swyp.app.domain.user.repository.UserRepository; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -19,6 +21,7 @@ public class PerspectiveLikeService { private final PerspectiveRepository perspectiveRepository; private final PerspectiveLikeRepository likeRepository; + private final UserRepository userRepository; public LikeCountResponse getLikeCount(Long perspectiveId) { Perspective perspective = findPerspectiveById(perspectiveId); @@ -29,8 +32,10 @@ public LikeCountResponse getLikeCount(Long perspectiveId) { @Transactional public LikeResponse addLike(Long perspectiveId, Long userId) { Perspective perspective = findPerspectiveById(perspectiveId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - if (perspective.getUserId().equals(userId)) { + if (perspective.getUser().getId().equals(userId)) { throw new CustomException(ErrorCode.LIKE_SELF_FORBIDDEN); } @@ -40,7 +45,7 @@ public LikeResponse addLike(Long perspectiveId, Long userId) { likeRepository.save(PerspectiveLike.builder() .perspective(perspective) - .userId(userId) + .user(user) .build()); perspective.incrementLikeCount(); diff --git a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java index 4e75278..f910766 100644 --- a/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java +++ b/src/main/java/com/swyp/app/domain/perspective/service/PerspectiveService.java @@ -1,9 +1,12 @@ package com.swyp.app.domain.perspective.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.BattleService; import com.swyp.app.domain.perspective.enums.PerspectiveStatus; +import com.swyp.app.domain.user.entity.User; +import com.swyp.app.domain.user.repository.UserRepository; import com.swyp.app.domain.perspective.dto.request.CreatePerspectiveRequest; import com.swyp.app.domain.perspective.dto.request.UpdatePerspectiveRequest; import com.swyp.app.domain.perspective.dto.response.CreatePerspectiveResponse; @@ -38,22 +41,25 @@ public class PerspectiveService { private final BattleService battleService; private final VoteService voteService; private final UserService userQueryService; + private final UserRepository userRepository; private final GptModerationService gptModerationService; @Transactional public CreatePerspectiveResponse createPerspective(Long battleId, Long userId, CreatePerspectiveRequest request) { - battleService.findById(battleId); + Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); if (perspectiveRepository.existsByBattleIdAndUserId(battleId, userId)) { throw new CustomException(ErrorCode.PERSPECTIVE_ALREADY_EXISTS); } - Long optionId = voteService.findPreVoteOptionId(battleId, userId); + BattleOption option = voteService.findPreVoteOption(battleId, userId); Perspective perspective = Perspective.builder() - .battleId(battleId) - .userId(userId) - .optionId(optionId) + .battle(battle) + .user(user) + .option(option) .content(request.content()) .build(); @@ -84,8 +90,8 @@ public PerspectiveListResponse getPerspectives(Long battleId, Long userId, Strin List items = perspectives.stream() .map(p -> { - UserSummary user = userQueryService.findSummaryById(p.getUserId()); - BattleOption option = battleService.findOptionById(p.getOptionId()); + UserSummary user = userQueryService.findSummaryById(p.getUser().getId()); + BattleOption option = p.getOption(); boolean isLiked = perspectiveLikeRepository.existsByPerspectiveAndUserId(p, userId); return new PerspectiveListResponse.Item( p.getId(), @@ -154,7 +160,7 @@ private Perspective findPerspectiveById(Long perspectiveId) { } private void validateOwnership(Perspective perspective, Long userId) { - if (!perspective.getUserId().equals(userId)) { + if (!perspective.getUser().getId().equals(userId)) { throw new CustomException(ErrorCode.PERSPECTIVE_FORBIDDEN); } } diff --git a/src/main/java/com/swyp/app/domain/reward/service/AdMobRewardServiceImpl.java b/src/main/java/com/swyp/app/domain/reward/service/AdMobRewardServiceImpl.java index 70114fa..365a6c1 100644 --- a/src/main/java/com/swyp/app/domain/reward/service/AdMobRewardServiceImpl.java +++ b/src/main/java/com/swyp/app/domain/reward/service/AdMobRewardServiceImpl.java @@ -52,9 +52,6 @@ public String processReward(AdMobRewardRequest request) { adRewardHistoryRepository.save(history); - // 6. TODO: 작업 중인 포인트 합산 로직 호출 지점 - // user.addPoint(request.reward_amount()); - log.info("보상 지급 완료: user={}, amount={}", user.getId(), request.reward_amount()); return "OK"; } diff --git a/src/main/java/com/swyp/app/domain/scenario/controller/ScenarioController.java b/src/main/java/com/swyp/app/domain/scenario/controller/ScenarioController.java index 59c7d98..926b048 100644 --- a/src/main/java/com/swyp/app/domain/scenario/controller/ScenarioController.java +++ b/src/main/java/com/swyp/app/domain/scenario/controller/ScenarioController.java @@ -3,7 +3,7 @@ import com.swyp.app.domain.scenario.dto.request.ScenarioCreateRequest; import com.swyp.app.domain.scenario.dto.request.ScenarioStatusUpdateRequest; import com.swyp.app.domain.scenario.dto.response.AdminDeleteResponse; -import com.swyp.app.domain.scenario.dto.response.AdminScenarioDetailResponse; // 🚀 추가 (상세 조회용) +import com.swyp.app.domain.scenario.dto.response.AdminScenarioDetailResponse; import com.swyp.app.domain.scenario.dto.response.AdminScenarioResponse; import com.swyp.app.domain.scenario.dto.response.UserScenarioResponse; import com.swyp.app.domain.scenario.service.ScenarioService; diff --git a/src/main/java/com/swyp/app/domain/scenario/service/ScenarioServiceImpl.java b/src/main/java/com/swyp/app/domain/scenario/service/ScenarioServiceImpl.java index 8dadc05..a5d4b4e 100644 --- a/src/main/java/com/swyp/app/domain/scenario/service/ScenarioServiceImpl.java +++ b/src/main/java/com/swyp/app/domain/scenario/service/ScenarioServiceImpl.java @@ -8,7 +8,7 @@ import com.swyp.app.domain.scenario.dto.request.ScenarioCreateRequest; import com.swyp.app.domain.scenario.dto.request.ScriptRequest; import com.swyp.app.domain.scenario.dto.response.AdminDeleteResponse; -import com.swyp.app.domain.scenario.dto.response.AdminScenarioDetailResponse; // 🚀 추가 +import com.swyp.app.domain.scenario.dto.response.AdminScenarioDetailResponse; import com.swyp.app.domain.scenario.dto.response.AdminScenarioResponse; import com.swyp.app.domain.scenario.dto.response.UserScenarioResponse; import com.swyp.app.domain.scenario.entity.InteractiveOption; diff --git a/src/main/java/com/swyp/app/domain/user/entity/CreditHistory.java b/src/main/java/com/swyp/app/domain/user/entity/CreditHistory.java index ceeea05..e3000ff 100644 --- a/src/main/java/com/swyp/app/domain/user/entity/CreditHistory.java +++ b/src/main/java/com/swyp/app/domain/user/entity/CreditHistory.java @@ -6,7 +6,10 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -22,8 +25,9 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class CreditHistory extends BaseEntity { - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; @Enumerated(EnumType.STRING) @Column(name = "credit_type", nullable = false, length = 30) @@ -36,8 +40,8 @@ public class CreditHistory extends BaseEntity { private Long referenceId; @Builder - private CreditHistory(Long userId, CreditType creditType, int amount, Long referenceId) { - this.userId = userId; + private CreditHistory(User user, CreditType creditType, int amount, Long referenceId) { + this.user = user; this.creditType = creditType; this.amount = amount; this.referenceId = referenceId; diff --git a/src/main/java/com/swyp/app/domain/user/repository/CreditHistoryRepository.java b/src/main/java/com/swyp/app/domain/user/repository/CreditHistoryRepository.java index a860eef..775b23c 100644 --- a/src/main/java/com/swyp/app/domain/user/repository/CreditHistoryRepository.java +++ b/src/main/java/com/swyp/app/domain/user/repository/CreditHistoryRepository.java @@ -8,7 +8,7 @@ public interface CreditHistoryRepository extends JpaRepository { - @Query("SELECT COALESCE(SUM(c.amount), 0) FROM CreditHistory c WHERE c.userId = :userId") + @Query("SELECT COALESCE(SUM(c.amount), 0) FROM CreditHistory c WHERE c.user.id = :userId") int sumAmountByUserId(@Param("userId") Long userId); boolean existsByUserIdAndCreditTypeAndReferenceId(Long userId, CreditType creditType, Long referenceId); diff --git a/src/main/java/com/swyp/app/domain/user/service/CreditService.java b/src/main/java/com/swyp/app/domain/user/service/CreditService.java index a9c9ce3..7704791 100644 --- a/src/main/java/com/swyp/app/domain/user/service/CreditService.java +++ b/src/main/java/com/swyp/app/domain/user/service/CreditService.java @@ -5,6 +5,7 @@ import com.swyp.app.domain.user.entity.User; import com.swyp.app.domain.user.enums.CreditType; import com.swyp.app.domain.user.repository.CreditHistoryRepository; +import com.swyp.app.domain.user.repository.UserRepository; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -18,6 +19,7 @@ public class CreditService { private final CreditHistoryRepository creditHistoryRepository; + private final UserRepository userRepository; private final UserService userService; /** @@ -50,8 +52,11 @@ public void addCredit(Long userId, CreditType creditType, Long referenceId) { public void addCredit(Long userId, CreditType creditType, int amount, Long referenceId) { validateReferenceId(referenceId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + CreditHistory history = CreditHistory.builder() - .userId(userId) + .user(user) .creditType(creditType) .amount(amount) .referenceId(referenceId) diff --git a/src/main/java/com/swyp/app/domain/user/service/MypageService.java b/src/main/java/com/swyp/app/domain/user/service/MypageService.java index fa45e85..3fcd2ba 100644 --- a/src/main/java/com/swyp/app/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/app/domain/user/service/MypageService.java @@ -193,7 +193,7 @@ private ContentActivityListResponse buildCommentActivities(User user, int pageOf .map(comment -> { Perspective p = comment.getPerspective(); return toActivityItem(comment.getId().toString(), ActivityType.COMMENT, p, - battleMap.get(p.getBattleId()), optionMap.get(p.getOptionId()), + battleMap.get(p.getBattle().getId()), optionMap.get(p.getOption().getId()), comment.getContent(), comment.getCreatedAt()); }) .toList(); @@ -215,7 +215,7 @@ private ContentActivityListResponse buildLikeActivities(User user, int pageOffse .map(like -> { Perspective p = like.getPerspective(); return toActivityItem(like.getId().toString(), ActivityType.LIKE, p, - battleMap.get(p.getBattleId()), optionMap.get(p.getOptionId()), + battleMap.get(p.getBattle().getId()), optionMap.get(p.getOption().getId()), p.getContent(), like.getCreatedAt()); }) .toList(); @@ -229,7 +229,7 @@ private ContentActivityListResponse.ContentActivityItem toActivityItem( String activityId, ActivityType activityType, Perspective perspective, Battle battle, BattleOption option, String content, LocalDateTime createdAt) { - UserSummary author = userService.findSummaryById(perspective.getUserId()); + UserSummary author = userService.findSummaryById(perspective.getUser().getId()); ContentActivityListResponse.AuthorInfo authorInfo = new ContentActivityListResponse.AuthorInfo( author.userTag(), author.nickname(), CharacterType.from(author.characterType()) ); @@ -237,7 +237,7 @@ private ContentActivityListResponse.ContentActivityItem toActivityItem( return new ContentActivityListResponse.ContentActivityItem( activityId, activityType, perspective.getId().toString(), - perspective.getBattleId().toString(), + perspective.getBattle().getId().toString(), battle != null ? battle.getTitle() : null, authorInfo, option != null ? option.getStance() : null, @@ -248,12 +248,12 @@ private ContentActivityListResponse.ContentActivityItem toActivityItem( } private Map loadBattles(List perspectives) { - List battleIds = perspectives.stream().map(Perspective::getBattleId).distinct().toList(); + List battleIds = perspectives.stream().map(p -> p.getBattle().getId()).distinct().toList(); return battleQueryService.findBattlesByIds(battleIds); } private Map loadOptions(List perspectives) { - List optionIds = perspectives.stream().map(Perspective::getOptionId).distinct().toList(); + List optionIds = perspectives.stream().map(p -> p.getOption().getId()).distinct().toList(); return battleQueryService.findOptionsByIds(optionIds); } diff --git a/src/main/java/com/swyp/app/domain/vote/controller/VoteController.java b/src/main/java/com/swyp/app/domain/vote/controller/VoteController.java index 1d703fd..4bdb9b8 100644 --- a/src/main/java/com/swyp/app/domain/vote/controller/VoteController.java +++ b/src/main/java/com/swyp/app/domain/vote/controller/VoteController.java @@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @Tag(name = "투표 (Vote)", description = "사전/사후 투표 실행 및 통계, 내 투표 내역 조회 API") @@ -23,9 +24,8 @@ public class VoteController { @PostMapping("/battles/{battleId}/votes/pre") public ApiResponse preVote( @PathVariable Long battleId, + @AuthenticationPrincipal Long userId, @RequestBody VoteRequest request) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; return ApiResponse.onSuccess(voteService.preVote(battleId, userId, request)); } @@ -33,9 +33,8 @@ public ApiResponse preVote( @PostMapping("/battles/{battleId}/votes/post") public ApiResponse postVote( @PathVariable Long battleId, + @AuthenticationPrincipal Long userId, @RequestBody VoteRequest request) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; return ApiResponse.onSuccess(voteService.postVote(battleId, userId, request)); } @@ -47,9 +46,9 @@ public ApiResponse getVoteStats(@PathVariable Long battleId) @Operation(summary = "내 투표 내역 조회", description = "특정 배틀에 대한 내 사전/사후 투표 내역과 현재 상태를 조회합니다.") @GetMapping("/battles/{battleId}/votes/me") - public ApiResponse getMyVote(@PathVariable Long battleId) { - // TODO: Security 적용 후 @AuthenticationPrincipal로 userId 교체 - Long userId = 1L; + public ApiResponse getMyVote( + @PathVariable Long battleId, + @AuthenticationPrincipal Long userId) { return ApiResponse.onSuccess(voteService.getMyVote(battleId, userId)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/swyp/app/domain/vote/entity/Vote.java b/src/main/java/com/swyp/app/domain/vote/entity/Vote.java index fe9a24b..2c469b1 100644 --- a/src/main/java/com/swyp/app/domain/vote/entity/Vote.java +++ b/src/main/java/com/swyp/app/domain/vote/entity/Vote.java @@ -2,6 +2,7 @@ import com.swyp.app.domain.battle.entity.Battle; import com.swyp.app.domain.battle.entity.BattleOption; +import com.swyp.app.domain.user.entity.User; import com.swyp.app.domain.vote.enums.VoteStatus; import com.swyp.app.global.common.BaseEntity; import jakarta.persistence.Column; @@ -9,9 +10,6 @@ import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; @@ -26,9 +24,9 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Vote extends BaseEntity { - // TODO: User 엔티티 병합 후 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") 로 교체 - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "battle_id", nullable = false) @@ -47,28 +45,26 @@ public class Vote extends BaseEntity { private VoteStatus status; @Builder - private Vote(Long userId, Battle battle, BattleOption preVoteOption, + private Vote(User user, Battle battle, BattleOption preVoteOption, BattleOption postVoteOption, VoteStatus status) { - this.userId = userId; + this.user = user; this.battle = battle; this.preVoteOption = preVoteOption; this.postVoteOption = postVoteOption; this.status = status; } - // 사전 투표 생성 팩토리 메서드 - public static Vote createPreVote(Long userId, Battle battle, BattleOption option) { + public static Vote createPreVote(User user, Battle battle, BattleOption option) { return Vote.builder() - .userId(userId) + .user(user) .battle(battle) .preVoteOption(option) .status(VoteStatus.PRE_VOTED) .build(); } - // 사후 투표 실행 상태 변경 메서드 public void doPostVote(BattleOption postOption) { this.postVoteOption = postOption; this.status = VoteStatus.POST_VOTED; } -} \ No newline at end of file +} 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 da6a67e..60b3e6b 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 @@ -5,6 +5,7 @@ import com.swyp.app.domain.battle.enums.BattleOptionLabel; import com.swyp.app.domain.user.entity.User; import com.swyp.app.domain.vote.entity.Vote; +import com.swyp.app.domain.vote.enums.VoteStatus; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -15,11 +16,9 @@ public interface VoteRepository extends JpaRepository { - // ScenarioService : Battle 엔티티 조회 없이 ID만으로 투표 내역 확인 Optional findByBattleIdAndUserId(Long battleId, Long userId); - // VoteService : 이미 조회된 Battle 엔티티를 활용하여 투표 내역 확인 - Optional findByBattleAndUserId(Battle battle, Long userId); + Optional findByBattleAndUser(Battle battle, User user); long countByBattle(Battle battle); @@ -27,36 +26,28 @@ public interface VoteRepository extends JpaRepository { 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") + "WHERE v.user.id = :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") + "WHERE v.user.id = :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") + @Query("SELECT COUNT(v) FROM Vote v WHERE v.user.id = :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); + long countByUserIdAndStatus(Long userId, VoteStatus status); - // MypageService (recap): 입장 변경 수 (사전/사후 투표 옵션이 다른 경우) - @Query("SELECT COUNT(v) FROM Vote v WHERE v.userId = :userId AND v.status = 'POST_VOTED' " + + @Query("SELECT COUNT(v) FROM Vote v WHERE v.user.id = :userId AND v.status = 'POST_VOTED' " + "AND v.preVoteOption <> v.postVoteOption") long countOpinionChangesByUserId(@Param("userId") Long userId); - // MypageService (recap): 사용자가 참여한 모든 투표 (배틀 목록 추출용) List findByUserId(Long userId); - // MypageService: 철학자 유형 산출용 - 최초 N개 투표 조회 (생성순) - @Query("SELECT v FROM Vote v JOIN FETCH v.battle WHERE v.userId = :userId ORDER BY v.createdAt ASC") + @Query("SELECT v FROM Vote v JOIN FETCH v.battle WHERE v.user.id = :userId ORDER BY v.createdAt ASC") List findByUserIdOrderByCreatedAtAsc(@Param("userId") Long userId, Pageable pageable); } diff --git a/src/main/java/com/swyp/app/domain/vote/service/VoteService.java b/src/main/java/com/swyp/app/domain/vote/service/VoteService.java index ba47ef5..70e95a7 100644 --- a/src/main/java/com/swyp/app/domain/vote/service/VoteService.java +++ b/src/main/java/com/swyp/app/domain/vote/service/VoteService.java @@ -1,5 +1,6 @@ package com.swyp.app.domain.vote.service; +import com.swyp.app.domain.battle.entity.BattleOption; import com.swyp.app.domain.vote.dto.request.VoteRequest; import com.swyp.app.domain.vote.dto.response.MyVoteResponse; import com.swyp.app.domain.vote.dto.response.VoteResultResponse; @@ -7,7 +8,7 @@ public interface VoteService { - Long findPreVoteOptionId(Long battleId, Long userId); + BattleOption findPreVoteOption(Long battleId, Long userId); VoteStatsResponse getVoteStats(Long battleId); diff --git a/src/main/java/com/swyp/app/domain/vote/service/VoteServiceImpl.java b/src/main/java/com/swyp/app/domain/vote/service/VoteServiceImpl.java index df70224..c76898b 100644 --- a/src/main/java/com/swyp/app/domain/vote/service/VoteServiceImpl.java +++ b/src/main/java/com/swyp/app/domain/vote/service/VoteServiceImpl.java @@ -4,6 +4,8 @@ import com.swyp.app.domain.battle.entity.BattleOption; import com.swyp.app.domain.battle.repository.BattleOptionRepository; import com.swyp.app.domain.battle.service.BattleService; +import com.swyp.app.domain.user.entity.User; +import com.swyp.app.domain.user.repository.UserRepository; import com.swyp.app.domain.vote.converter.VoteConverter; import com.swyp.app.domain.vote.dto.request.VoteRequest; import com.swyp.app.domain.vote.dto.response.MyVoteResponse; @@ -29,18 +31,21 @@ public class VoteServiceImpl implements VoteService { private final VoteRepository voteRepository; private final BattleService battleService; private final BattleOptionRepository battleOptionRepository; + private final UserRepository userRepository; @Override - public Long findPreVoteOptionId(Long battleId, Long userId) { + public BattleOption findPreVoteOption(Long battleId, Long userId) { Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Vote vote = voteRepository.findByBattleAndUserId(battle, userId) + Vote vote = voteRepository.findByBattleAndUser(battle, user) .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); if (vote.getPreVoteOption() == null) { throw new CustomException(ErrorCode.PRE_VOTE_REQUIRED); } - return vote.getPreVoteOption().getId(); + return vote.getPreVoteOption(); } @Override @@ -70,8 +75,10 @@ public VoteStatsResponse getVoteStats(Long battleId) { @Override public MyVoteResponse getMyVote(Long battleId, Long userId) { Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); - Vote vote = voteRepository.findByBattleAndUserId(battle, userId) + Vote vote = voteRepository.findByBattleAndUser(battle, user) .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); return VoteConverter.toMyVoteResponse(vote); @@ -81,15 +88,16 @@ public MyVoteResponse getMyVote(Long battleId, Long userId) { @Transactional public VoteResultResponse preVote(Long battleId, Long userId, VoteRequest request) { Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); BattleOption option = battleOptionRepository.findById(request.optionId()) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); - // 이미 투표 내역이 존재하는지 검증 - if (voteRepository.findByBattleAndUserId(battle, userId).isPresent()) { + if (voteRepository.findByBattleAndUser(battle, user).isPresent()) { throw new CustomException(ErrorCode.VOTE_ALREADY_SUBMITTED); } - Vote vote = Vote.createPreVote(userId, battle, option); + Vote vote = Vote.createPreVote(user, battle, option); voteRepository.save(vote); return VoteConverter.toVoteResultResponse(vote); @@ -99,13 +107,14 @@ public VoteResultResponse preVote(Long battleId, Long userId, VoteRequest reques @Transactional public VoteResultResponse postVote(Long battleId, Long userId, VoteRequest request) { Battle battle = battleService.findById(battleId); + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); BattleOption option = battleOptionRepository.findById(request.optionId()) .orElseThrow(() -> new CustomException(ErrorCode.BATTLE_OPTION_NOT_FOUND)); - Vote vote = voteRepository.findByBattleAndUserId(battle, userId) + Vote vote = voteRepository.findByBattleAndUser(battle, user) .orElseThrow(() -> new CustomException(ErrorCode.VOTE_NOT_FOUND)); - // 사전 투표 상태일 때만 사후 투표 가능 if (vote.getStatus() != VoteStatus.PRE_VOTED) { throw new CustomException(ErrorCode.INVALID_VOTE_STATUS); } diff --git a/src/main/java/com/swyp/app/global/config/S3Config.java b/src/main/java/com/swyp/app/global/config/S3Config.java index f2ae86f..cac7a59 100644 --- a/src/main/java/com/swyp/app/global/config/S3Config.java +++ b/src/main/java/com/swyp/app/global/config/S3Config.java @@ -8,7 +8,6 @@ import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.presigner.S3Presigner; -// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) @Configuration public class S3Config { diff --git a/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java b/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java index beaf366..4a510ac 100644 --- a/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java +++ b/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java @@ -1,4 +1,3 @@ package com.swyp.app.global.infra.s3.dto; -// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) public record FileUploadResponse(String s3Key, String presignedUrl) {} diff --git a/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java b/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java index ca1fe2e..fc7b0be 100644 --- a/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java +++ b/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java @@ -11,7 +11,6 @@ import java.util.Map; import java.util.stream.Collectors; -// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) @Service @RequiredArgsConstructor public class S3PresignedUrlService { diff --git a/src/test/java/com/swyp/app/domain/user/service/CreditServiceTest.java b/src/test/java/com/swyp/app/domain/user/service/CreditServiceTest.java index a82f810..2a53ae9 100644 --- a/src/test/java/com/swyp/app/domain/user/service/CreditServiceTest.java +++ b/src/test/java/com/swyp/app/domain/user/service/CreditServiceTest.java @@ -5,6 +5,7 @@ import com.swyp.app.domain.user.entity.User; import com.swyp.app.domain.user.enums.CreditType; import com.swyp.app.domain.user.repository.CreditHistoryRepository; +import com.swyp.app.domain.user.repository.UserRepository; import com.swyp.app.global.common.exception.CustomException; import com.swyp.app.global.common.exception.ErrorCode; import org.junit.jupiter.api.DisplayName; @@ -16,6 +17,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import java.util.Optional; + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -30,6 +33,9 @@ class CreditServiceTest { @Mock private CreditHistoryRepository creditHistoryRepository; + @Mock + private UserRepository userRepository; + @Mock private UserService userService; @@ -42,6 +48,7 @@ void addCredit_forCurrentUser_savesDefaultAmount() { User user = org.mockito.Mockito.mock(User.class); when(user.getId()).thenReturn(1L); when(userService.findCurrentUser()).thenReturn(user); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); creditService.addCredit(CreditType.BATTLE_VOTE, 10L); @@ -49,7 +56,7 @@ void addCredit_forCurrentUser_savesDefaultAmount() { verify(creditHistoryRepository).saveAndFlush(captor.capture()); CreditHistory saved = captor.getValue(); - assertThat(saved.getUserId()).isEqualTo(1L); + assertThat(saved.getUser().getId()).isEqualTo(1L); assertThat(saved.getCreditType()).isEqualTo(CreditType.BATTLE_VOTE); assertThat(saved.getAmount()).isEqualTo(CreditType.BATTLE_VOTE.getDefaultAmount()); assertThat(saved.getReferenceId()).isEqualTo(10L); @@ -69,6 +76,8 @@ void addCredit_withoutReferenceId_throwsException() { @Test @DisplayName("중복 적립 충돌이면 조용히 무시한다") void addCredit_duplicateInsert_ignoresConflict() { + User user = org.mockito.Mockito.mock(User.class); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) .thenThrow(new DataIntegrityViolationException("duplicate")); when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L)) @@ -82,6 +91,8 @@ void addCredit_duplicateInsert_ignoresConflict() { @Test @DisplayName("중복이 아닌 데이터 무결성 오류는 그대로 던진다") void addCredit_nonDuplicateIntegrityFailure_rethrows() { + User user = org.mockito.Mockito.mock(User.class); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) .thenThrow(new DataIntegrityViolationException("broken")); when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L)) 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 index 5ce1473..08908ac 100644 --- a/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java +++ b/src/test/java/com/swyp/app/domain/user/service/MypageServiceTest.java @@ -166,7 +166,7 @@ void getBattleRecords_returns_paginated_records() { Battle battle = createBattle("배틀 제목"); BattleOption optionA = createOption(battle, BattleOptionLabel.A); Vote vote = Vote.builder() - .userId(1L) + .user(user) .battle(battle) .preVoteOption(optionA) .build(); @@ -192,7 +192,7 @@ void getBattleRecords_returns_no_next_when_last_page() { Battle battle = createBattle("제목"); BattleOption optionA = createOption(battle, BattleOptionLabel.A); Vote vote = Vote.builder() - .userId(1L) + .user(user) .battle(battle) .preVoteOption(optionA) .build(); @@ -227,29 +227,27 @@ void getBattleRecords_applies_vote_side_filter() { @DisplayName("COMMENT 타입으로 댓글활동을 반환한다") void getContentActivities_returns_comments() { User user = createUser(1L, "tag"); - Long battleId = generateId(); - Long optionId = generateId(); + Battle battle = createBattle("배틀"); + Long battleId = battle.getId(); + BattleOption option = createOption(battle, BattleOptionLabel.A); + Long optionId = option.getId(); + Perspective perspective = Perspective.builder() - .battleId(battleId) - .userId(1L) - .optionId(optionId) + .battle(battle) + .user(user) + .option(option) .content("관점 내용") .build(); ReflectionTestUtils.setField(perspective, "id", generateId()); PerspectiveComment comment = PerspectiveComment.builder() .perspective(perspective) - .userId(1L) + .user(user) .content("댓글") .build(); ReflectionTestUtils.setField(comment, "id", generateId()); 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); @@ -268,28 +266,26 @@ void getContentActivities_returns_comments() { @DisplayName("LIKE 타입으로 좋아요활동을 반환한다") void getContentActivities_returns_likes() { User user = createUser(1L, "tag"); - Long battleId = generateId(); - Long optionId = generateId(); + Battle battle = createBattle("배틀"); + Long battleId = battle.getId(); + BattleOption option = createOption(battle, BattleOptionLabel.B); + Long optionId = option.getId(); + Perspective perspective = Perspective.builder() - .battleId(battleId) - .userId(1L) - .optionId(optionId) + .battle(battle) + .user(user) + .option(option) .content("관점 내용") .build(); ReflectionTestUtils.setField(perspective, "id", generateId()); PerspectiveLike like = PerspectiveLike.builder() .perspective(perspective) - .userId(1L) + .user(user) .build(); ReflectionTestUtils.setField(like, "id", generateId()); 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); From ae40605ce787d566524bd95b56a37393ad463d06 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 28 Mar 2026 23:20:05 +0900 Subject: [PATCH 7/7] Remove resolved S3 TODO comments --- src/main/java/com/swyp/app/global/config/S3Config.java | 1 - .../com/swyp/app/global/infra/s3/dto/FileUploadResponse.java | 1 - .../swyp/app/global/infra/s3/service/S3PresignedUrlService.java | 1 - 3 files changed, 3 deletions(-) diff --git a/src/main/java/com/swyp/app/global/config/S3Config.java b/src/main/java/com/swyp/app/global/config/S3Config.java index f2ae86f..cac7a59 100644 --- a/src/main/java/com/swyp/app/global/config/S3Config.java +++ b/src/main/java/com/swyp/app/global/config/S3Config.java @@ -8,7 +8,6 @@ import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.services.s3.presigner.S3Presigner; -// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) @Configuration public class S3Config { diff --git a/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java b/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java index beaf366..4a510ac 100644 --- a/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java +++ b/src/main/java/com/swyp/app/global/infra/s3/dto/FileUploadResponse.java @@ -1,4 +1,3 @@ package com.swyp.app.global.infra.s3.dto; -// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) public record FileUploadResponse(String s3Key, String presignedUrl) {} diff --git a/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java b/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java index ca1fe2e..fc7b0be 100644 --- a/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java +++ b/src/main/java/com/swyp/app/global/infra/s3/service/S3PresignedUrlService.java @@ -11,7 +11,6 @@ import java.util.Map; import java.util.stream.Collectors; -// TODO: S3 Presigned URL 정식 구현 시 교체 필요 (임시 구현) @Service @RequiredArgsConstructor public class S3PresignedUrlService {