From f10f3a2bb50c14b0486fda0853e2c4429683c553 Mon Sep 17 00:00:00 2001 From: darren Date: Sat, 28 Mar 2026 18:16:15 +0900 Subject: [PATCH 1/5] =?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/5] =?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/5] =?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/5] =?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/5] =?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() + ) + )); + } +}