Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/erd/class.puml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -77,6 +78,17 @@ public ApiResponse<AdminBattleDetailResponse> 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<AdminBattleDetailResponse> updateClassSettings(
@PathVariable Long battleId,
@RequestBody AdminBattleClassSettingsRequest request
) {
return ApiResponse.onSuccess(adminBattleService.updateClassSettings(battleId, request));
}

@Operation(summary = "배틀 삭제")
@DeleteMapping("/{battleId}")
public ApiResponse<AdminBattleDeleteResponse> deleteBattle(
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,5 +28,9 @@ public record AdminBattleDetailResponse(
List<BattleTagResponse> tags,
List<BattleOptionResponse> options,
LocalDateTime createdAt,
LocalDateTime updatedAt
LocalDateTime updatedAt,
BattleDifficulty difficulty,
BattleCategory category,
boolean classRecommendable,
boolean midVoteEnabled
) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,11 @@ public AdminBattleDetailResponse toAdminDetailResponse(Battle battle, List<Tag>
toTagResponses(tags, null),
toOptionResponses(options, optionTagsMap),
battle.getCreatedAt(),
battle.getUpdatedAt()
battle.getUpdatedAt(),
battle.getDifficulty(),
battle.getCategory(),
battle.isClassRecommendable(),
battle.isMidVoteEnabled()
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Battle> findClassRecommendCandidates(
@Param("status") BattleStatus status,
@Param("difficulty") BattleDifficulty difficulty,
@Param("categories") Collection<BattleCategory> categories
);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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')")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ClassBattleRecommendationResponse> 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<String> categories
) {
return ApiResponse.onSuccess(recommendationService.recommend(keyword, difficulty, categories));
}
}
Original file line number Diff line number Diff line change
@@ -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<Item> 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<Option> options
) {
}

public record Option(
Long optionId,
@Schema(description = "A / B") String position,
String title,
String stance,
@Schema(description = "대표 철학자") String representative,
String imageUrl
) {
}
}
Loading
Loading