From b79b54e2aab2bad565aac2ad43e87368eefaf006 Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 21 Oct 2026 19:06:19 +0900 Subject: [PATCH] =?UTF-8?q?#551=20[Feat]=20=ED=81=B4=EB=9E=98=EC=8A=A4=20P?= =?UTF-8?q?icke=20=EB=B0=B0=ED=8B=80=20=EC=B6=94=EC=B2=9C=20API=20?= =?UTF-8?q?=EB=B0=8F=20=EC=96=B4=EB=93=9C=EB=AF=BC=20=EB=B0=B0=ED=8B=80=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/erd/class.puml | 10 + .../controller/AdminBattleController.java | 12 + .../AdminBattleClassSettingsRequest.java | 23 ++ .../response/AdminBattleDetailResponse.java | 8 +- .../admin/service/AdminBattleService.java | 5 + .../battle/converter/BattleConverter.java | 6 +- .../battle/repository/BattleRepository.java | 13 + .../domain/battle/service/BattleService.java | 3 + .../battle/service/BattleServiceImpl.java | 20 ++ .../ClassBattleRecommendationController.java | 40 +++ .../ClassBattleRecommendationResponse.java | 36 +++ .../ClassBattleRecommendationService.java | 166 ++++++++++++ .../global/common/exception/ErrorCode.java | 2 + ...attleRecommendationApiIntegrationTest.java | 243 ++++++++++++++++++ 14 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleClassSettingsRequest.java create mode 100644 src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleRecommendationController.java create mode 100644 src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleRecommendationResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/classroom/service/ClassBattleRecommendationService.java create mode 100644 src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleRecommendationApiIntegrationTest.java diff --git a/docs/erd/class.puml b/docs/erd/class.puml index 0ed978c3..eea6b236 100644 --- a/docs/erd/class.puml +++ b/docs/erd/class.puml @@ -504,4 +504,14 @@ note bottom of class_debate_messages reply_to_message_id = 직전 라운드 USER 메시지 (1라운드 null) end note +note bottom of battles + 클래스 Picke 배틀 추천 (#551, GET /api/v1/class-battle-recommendations) + 후보: status PUBLISHED + deleted_at NULL + class_recommendable = true + + difficulty 일치 + category IN (선택 1~3개) + 주제 단어(쉼표·공백, 최대 5개)가 title·summary·description 에 많이 들어간 순 + → 에디터픽 → 참여자 수 → 최신 id 순, 최대 3개 (모자라면 채우지 않음) + difficulty·category·class_recommendable 는 어드민 PATCH /admin/battles/{id}/class-settings 로 설정 + 추천을 켜려면 difficulty·category 필수, 커스텀 배틀(creator_type USER)은 설정 불가 +end note + @enduml diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java index 92dc43bd..1a06d623 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.admin.controller; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleThumbnailCandidateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleThumbnailCandidatesResponse; @@ -77,6 +78,17 @@ public ApiResponse updateBattle( return ApiResponse.onSuccess(adminBattleService.updateBattle(battleId, request)); } + @Operation(summary = "배틀 클래스 설정 수정", + description = "클래스 추천 필터용 수준·카테고리, 클래스 추천 노출, 중간투표 여부를 바꿉니다. null 필드는 변경하지 않습니다. " + + "추천을 켤 때 수준·카테고리가 비어 있으면 400 CLASS_400_RECOMMEND_SETTINGS, 커스텀 배틀은 400 CLASS_400_BATTLE.") + @PatchMapping("/{battleId}/class-settings") + public ApiResponse updateClassSettings( + @PathVariable Long battleId, + @RequestBody AdminBattleClassSettingsRequest request + ) { + return ApiResponse.onSuccess(adminBattleService.updateClassSettings(battleId, request)); + } + @Operation(summary = "배틀 삭제") @DeleteMapping("/{battleId}") public ApiResponse deleteBattle( diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleClassSettingsRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleClassSettingsRequest.java new file mode 100644 index 00000000..8264f1cf --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/request/AdminBattleClassSettingsRequest.java @@ -0,0 +1,23 @@ +package com.swyp.picke.domain.admin.dto.battle.request; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import io.swagger.v3.oas.annotations.media.Schema; + +/** null 인 필드는 변경하지 않는다. 클래스 추천을 켜려면 수준·카테고리가 모두 정해져 있어야 한다. */ +@Schema(description = "배틀 클래스 설정 수정 요청 (null 필드는 변경 없음)") +public record AdminBattleClassSettingsRequest( + + @Schema(description = "대상 수준", example = "BEGINNER", nullable = true) + BattleDifficulty difficulty, + + @Schema(description = "카테고리 (철학·사회·문학·과학·예술·역사)", example = "철학", nullable = true) + BattleCategory category, + + @Schema(description = "클래스 추천 배틀로 노출할지", example = "true", nullable = true) + Boolean classRecommendable, + + @Schema(description = "중간투표 콘텐츠 여부", example = "false", nullable = true) + Boolean midVoteEnabled +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java index 9defbdf4..89d63b5a 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/battle/response/AdminBattleDetailResponse.java @@ -2,7 +2,9 @@ import com.swyp.picke.domain.battle.dto.response.BattleOptionResponse; import com.swyp.picke.domain.battle.dto.response.BattleTagResponse; +import com.swyp.picke.domain.battle.enums.BattleCategory; import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import com.swyp.picke.domain.battle.enums.BattleStatus; import java.time.LocalDate; @@ -26,5 +28,9 @@ public record AdminBattleDetailResponse( List tags, List options, LocalDateTime createdAt, - LocalDateTime updatedAt + LocalDateTime updatedAt, + BattleDifficulty difficulty, + BattleCategory category, + boolean classRecommendable, + boolean midVoteEnabled ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java index 0e082f3b..be4c136f 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.admin.service; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -33,6 +34,10 @@ public AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRe return battleService.updateBattle(battleId, request); } + public AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleClassSettingsRequest request) { + return battleService.updateClassSettings(battleId, request); + } + public AdminBattleDeleteResponse deleteBattle(Long battleId) { return battleService.deleteBattle(battleId); } diff --git a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java index cb0280df..76bd7bd4 100644 --- a/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java +++ b/src/main/java/com/swyp/picke/domain/battle/converter/BattleConverter.java @@ -86,7 +86,11 @@ public AdminBattleDetailResponse toAdminDetailResponse(Battle battle, List toTagResponses(tags, null), toOptionResponses(options, optionTagsMap), battle.getCreatedAt(), - battle.getUpdatedAt() + battle.getUpdatedAt(), + battle.getDifficulty(), + battle.getCategory(), + battle.isClassRecommendable(), + battle.isMidVoteEnabled() ); } diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java index fcf795db..647681b4 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -1,11 +1,14 @@ package com.swyp.picke.domain.battle.repository; import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.enums.BattleCategory; import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; import com.swyp.picke.domain.battle.enums.BattleStatus; import com.swyp.picke.domain.battle.repository.projection.BattleParticipationStats; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -148,4 +151,14 @@ BattleParticipationStats findParticipationStats( @Param("to") LocalDate to, @Param("totalUsers") long totalUsers ); + + /** 클래스 Picke 배틀 추천 후보: 공개·미삭제·클래스 추천이 켜진 배틀 중 수준이 같고 카테고리가 겹치는 배틀 */ + @Query("SELECT b FROM Battle b " + + "WHERE b.status = :status AND b.deletedAt IS NULL AND b.classRecommendable = true " + + "AND b.difficulty = :difficulty AND b.category IN :categories") + List findClassRecommendCandidates( + @Param("status") BattleStatus status, + @Param("difficulty") BattleDifficulty difficulty, + @Param("categories") Collection categories + ); } diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java index 87911706..776aa31d 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleService.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.battle.service; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; @@ -57,6 +58,8 @@ public interface BattleService { AdminBattleDetailResponse updateBattle(Long battleId, AdminBattleUpdateRequest request); + AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleClassSettingsRequest request); + AdminBattleDeleteResponse deleteBattle(Long battleId); void seedBattle(Long battleId, int botCount); diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index ba7443ac..72ec6a73 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.battle.service; import com.swyp.picke.domain.battle.converter.BattleConverter; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleClassSettingsRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; @@ -396,6 +397,25 @@ public AdminBattleDetailResponse getAdminBattleDetail(Long battleId) { return battleConverter.toAdminDetailResponse(battle, getTagsByBattle(battle), options, optionTagsMap); } + /** + * 클래스 추천 필터용 수준·카테고리·추천 여부·중간투표 여부 설정. null 은 변경 없음. + * 사용자가 만든 커스텀 배틀은 클래스 전용이라 바꿀 수 없고, 추천을 켜면 수준·카테고리가 모두 있어야 한다. + */ + @Override + @Transactional + @PreAuthorize("hasRole('ADMIN')") + public AdminBattleDetailResponse updateClassSettings(Long battleId, AdminBattleClassSettingsRequest request) { + Battle battle = findById(battleId); + if (battle.getCreatorType() == BattleCreatorType.USER) { + throw new CustomException(ErrorCode.CLASS_BATTLE_NOT_AVAILABLE); + } + battle.updateClassSettings(request.difficulty(), request.category(), request.classRecommendable(), request.midVoteEnabled()); + if (battle.isClassRecommendable() && (battle.getDifficulty() == null || battle.getCategory() == null)) { + throw new CustomException(ErrorCode.CLASS_RECOMMEND_SETTINGS_INCOMPLETE); + } + return getAdminBattleDetail(battleId); + } + @Override @Transactional @PreAuthorize("hasRole('ADMIN')") diff --git a/src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleRecommendationController.java b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleRecommendationController.java new file mode 100644 index 00000000..be8f9771 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/controller/ClassBattleRecommendationController.java @@ -0,0 +1,40 @@ +package com.swyp.picke.domain.classroom.controller; + +import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import com.swyp.picke.domain.classroom.dto.response.ClassBattleRecommendationResponse; +import com.swyp.picke.domain.classroom.service.ClassBattleRecommendationService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +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; + +@Tag(name = "클래스 Picke 배틀 추천 API", description = "새 클래스 만들기에서 조건에 맞는 기존 배틀(TTS 포함) 추천") +@RestController +@RequestMapping("/api/v1/class-battle-recommendations") +@RequiredArgsConstructor +public class ClassBattleRecommendationController { + + private final ClassBattleRecommendationService recommendationService; + + @Operation(summary = "조건에 맞는 Picke 배틀 추천", + description = "공개된 배틀 중 어드민이 클래스 추천을 켠 배틀에서 수준이 같고 카테고리가 겹치는 배틀을 최대 3개 돌려줍니다. " + + "조건에 맞는 배틀이 적으면 적은 대로(0개 포함) 내려갑니다. 수준 누락, 카테고리 0개·4개 이상·잘못된 값, " + + "주제 50자 초과는 400 CLASS_400_RECOMMEND_CONDITION.") + @GetMapping + public ApiResponse recommend( + @Parameter(description = "찾고 싶은 주제 (선택, 50자 이하). 쉼표·공백으로 여러 단어", example = "AI, 범죄") + @RequestParam(required = false) String keyword, + @Parameter(description = "대상 수준 (필수)", example = "BEGINNER") + @RequestParam(required = false) BattleDifficulty difficulty, + @Parameter(description = "카테고리 1~3개 (필수). 한글 이름 또는 enum 이름, 반복 또는 쉼표로 전달", example = "철학,사회") + @RequestParam(required = false) List categories + ) { + return ApiResponse.onSuccess(recommendationService.recommend(keyword, difficulty, categories)); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleRecommendationResponse.java b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleRecommendationResponse.java new file mode 100644 index 00000000..0df3c8d2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/dto/response/ClassBattleRecommendationResponse.java @@ -0,0 +1,36 @@ +package com.swyp.picke.domain.classroom.dto.response; + +import com.swyp.picke.domain.battle.enums.BattleCategory; +import com.swyp.picke.domain.battle.enums.BattleDifficulty; +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "클래스 Picke 배틀 추천 결과. 최대 3개이고 조건에 맞는 배틀이 적으면 적은 대로 내려간다") +public record ClassBattleRecommendationResponse( + + @Schema(description = "추천 배틀 (0~3개)") List items +) { + + @Schema(description = "추천 배틀 카드") + public record Item( + Long battleId, + String title, + String summary, + String thumbnailUrl, + @Schema(description = "카테고리 (철학·사회·문학·과학·예술·역사)") BattleCategory category, + @Schema(description = "대상 수준") BattleDifficulty difficulty, + @Schema(description = "소요 시간(초)") Integer audioDuration, + @Schema(description = "선택지 2개 (A, B 순서). 미리듣기 음성은 GET /api/v1/battles/{battleId}/scenario 로 받는다") List