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
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,9 @@
public class AdminController {

private final AdminService adminService;
private final UserMapper userMapper; // To fetch author details for response if needed
private final UserMapper userMapper; // 응답용 작성자 정보 fetch

// Check Admin Role helper (Optional, assuming Spring Security handles it via separate config or PreAuthorize)
// But since we didn't add @PreAuthorize, we might want to check here or rely on SecurityContext
// We already added User.role field, but SecurityConfig might need update to secure /api/admin/**
// 관리자 권한 확인 (SecurityConfig 또는 @PreAuthorize 처리 필요)

@GetMapping("/quizzes")
public PageResponse<QuizResponse> getAdminQuizzes(
Expand All @@ -45,11 +43,10 @@ public PageResponse<QuizResponse> getAdminQuizzes(
int totalPages = (int) Math.ceil((double) totalElements / size);

// Convert to QuizResponse
// Note: This matches QuizService logic slightly but without complex like/follow checks for efficiency or reuse logic
// For Admin view, basic info + isHidden is key.
// Admin 뷰를 위한 기본 정보 및 숨김 상태 조회 (효율성을 위해 좋아요/팔로우 체크 제외)

List<QuizResponse> content = quizzes.stream().map(quiz -> {
// Need author info?
// 작성자 정보 매핑
UserResponse author = userMapper.findById(quiz.getUserId())
.map(u -> UserResponse.builder()
.id(u.getId())
Expand Down Expand Up @@ -94,7 +91,7 @@ public ResponseEntity<Void> createChallenge(@Valid @RequestBody ChallengeCreateR
return ResponseEntity.status(HttpStatus.CREATED).build();
}

// --- Custom Item Management ---
// --- 커스텀 아이템 관리 ---

@Autowired
private com.problemio.item.service.CustomItemService customItemService;
Expand Down Expand Up @@ -149,21 +146,20 @@ public ResponseEntity<String> uploadItemImage(@RequestParam("file") org.springfr
}

try {
// Determine directory based on type
// 타입별 디렉토리 결정
String subDir = "theme";
if ("POPOVER".equalsIgnoreCase(type)) subDir = "popover";
else if ("AVATAR".equalsIgnoreCase(type)) subDir = "avatar";

// Format: public/{subDir}/{timestamp}_{originalName}
// 형식: public/{subDir}/{timestamp}_{originalName}
String originalFilename = org.springframework.util.StringUtils.cleanPath(file.getOriginalFilename());
String filename = System.currentTimeMillis() + "_" + originalFilename;
String s3Key = "public/" + subDir + "/" + filename;

// Upload to S3
// S3 업로드
String uploadedPath = s3Service.upload(file, s3Key);

// Return path (s3Key) to be stored in config
// Frontend resolveImageUrl handles "public/..." path by prepending S3 Base URL
// DB 저장용 경로(s3Key) 반환 (프론트엔드에서 Base URL 연결)
return ResponseEntity.ok(uploadedPath);

} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,16 @@ public void toggleQuizVisibility(Long quizId) {
Quiz quiz = quizMapper.findById(quizId)
.orElseThrow(() -> new BusinessException(ErrorCode.QUIZ_NOT_FOUND));

// Toggle isHidden
// 숨김 상태 토글
quiz.setHidden(!quiz.isHidden());

// Update quiz
// Note: updateQuiz updates all fields. Ensure mapping is correct.
// If we want to update ONLY isHidden, we might want a specific update method,
// but using updateQuiz is fine if object is fully populated.
// 퀴즈 정보 업데이트
quizMapper.updateQuiz(quiz);
}

@Transactional
public void createChallenge(ChallengeCreateRequest request) {
// Validation: Check if quiz exists
// 유효성 검사: 퀴즈 존재 여부
if (quizMapper.findById(request.getTargetQuizId()).isEmpty()) {
throw new BusinessException(ErrorCode.QUIZ_NOT_FOUND);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public AiThumbnailConfirmResponse confirmCandidate(String candidateId, Long user
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
}

// DEBUG LOG
// 디버그 로그
System.out.println("Confirming Candidate ID: " + candidateId);

byte[] bytes = candidateCache.get(candidateId);
Expand All @@ -77,7 +77,7 @@ private AiThumbnailCandidateResponse.Candidate createCandidate(
byte[] bytes = gmsGeminiClient.generatePngBytes(title, description, styleHint);
String candidateId = UUID.randomUUID().toString();

// DEBUG LOG
// 디버그 로그
System.out.println(
"Generated Candidate ID: " + candidateId + ", bytes: " + (bytes != null ? bytes.length : "null"));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ private byte[] requestImage(String prompt) {
log.info("GMS Gemini request baseUrl={} model={}", baseUrl, model);
String endpoint = buildEndpoint();

// Gemini 2.0 Flash Image Generation Payload
// Gemini 2.0 Flash 이미지 생성 페이로드
// https://gms.ssafy.io/gmsapi/generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp-image-generation:generateContent
Map<String, Object> payload = Map.of(
"contents", List.of(
Expand All @@ -60,7 +60,7 @@ private byte[] requestImage(String prompt) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

// GMS uses query param 'key' for Gemini models
// GMS: Gemini 모델은 쿼리 파라미터 'key' 사용
String url = UriComponentsBuilder.fromHttpUrl(endpoint)
.queryParam("key", apiKey)
.toUriString();
Expand Down Expand Up @@ -92,9 +92,9 @@ private byte[] requestImage(String prompt) {
private byte[] extractImageBytes(String responseBody) throws Exception {
JsonNode root = objectMapper.readTree(responseBody);

// Gemini Response Structure:
// Gemini 응답 구조:
// candidates[0].content.parts[0].inlineData.data (Base64)
// OR candidates[0].content.parts[0].inline_data.data (Base64)
// 또는 candidates[0].content.parts[0].inline_data.data (Base64)

JsonNode candidates = root.get("candidates");
if (candidates != null && candidates.isArray() && !candidates.isEmpty()) {
Expand All @@ -121,20 +121,17 @@ private byte[] extractImageBytes(String responseBody) throws Exception {
}

private String buildEndpoint() {
// gms.base-url might already contain the full path
// e.g.,
// https://gms.ssafy.io/gmsapi/generativelanguage.googleapis.com/v1beta/models/
// gms.base-url에 전체 경로가 포함될 수 있음 (예: .../models/)

String base = baseUrl.endsWith("/") ? baseUrl : baseUrl + "/";
String targetPath = "generativelanguage.googleapis.com/v1beta/models/";

if (base.contains(targetPath)) {
// If base url already contains the target path, just append the model and
// method
// Base URL에 타겟 경로가 포함된 경우 모델과 메서드만 추가
return base + model + ":generateContent";
}

// Otherwise append the full path
// 그렇지 않으면 전체 경로 추가
return base + targetPath + model + ":generateContent";
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ private String requestImageBase64(String prompt) {
log.warn("GMS response missing base64. contentPreview={}", truncate(content));
} catch (Exception e) {
log.warn("GMS request attempt {} failed: {}", attempt + 1, e.toString());
// Fall through to retry once
// 재시도를 위해 계속 진행
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ public class ChallengeServiceImpl implements ChallengeService {

private final ChallengeMapper challengeMapper;
private final SubmissionService submissionService;
private final SubmissionMapper submissionMapper; // For general submission checks
private final SubmissionMapper submissionMapper; // 제출 로직 전반 검증용
private final QuestionMapper questionMapper;
private final ChallengeRankingMapper challengeRankingMapper; // For challenge specific logic
private final ChallengeRankingMapper challengeRankingMapper; // 챌린지 랭킹 전용

@Override
@Transactional(readOnly = true)
Expand Down Expand Up @@ -94,14 +94,14 @@ public QuizAnswerResponse submitAnswer(Long userId, Long challengeId, QuizSubmis
Challenge challenge = challengeMapper.findById(challengeId)
.orElseThrow(() -> new BusinessException(ErrorCode.QUIZ_NOT_FOUND));

// Time Attack Verification
// 타임어택 검증
if ("TIME_ATTACK".equals(challenge.getChallengeType()) && request.getSubmissionId() != null) {
Submission submission = submissionMapper.findById(request.getSubmissionId())
.orElseThrow(() -> new BusinessException(ErrorCode.ACCESS_DENIED));

if (submission.getSubmittedAt() != null) {
long diffSeconds = java.time.Duration.between(submission.getSubmittedAt(), TimeUtils.now()).getSeconds();
// 5s buffer for network latency
// 네트워크 지연 고려 5초 버퍼
if (diffSeconds > challenge.getTimeLimit() + 5) {
throw new BusinessException(ErrorCode.ACCESS_DENIED);
}
Expand Down Expand Up @@ -145,23 +145,22 @@ public ChallengeResultResponse getChallengeResult(Long userId, Long challengeId)
@Override
@Transactional
public void finalizeChallenge(Long challengeId) {
// 1. Get all submissions
// 1. 전체 제출 내역 조회
List<Submission> submissions = challengeRankingMapper.findSubmissionsByChallengeId(challengeId);

if (submissions.isEmpty()) {
return;
}

// 2. Filter Best Submission per User
// Use a Map to keep the best one: Key=UserId
// 2. 사용자별 최고 기록 필터링 (Map 사용)
java.util.Map<Long, Submission> bestSubmissionsMap = new java.util.HashMap<>();

for (Submission s : submissions) {
if (!bestSubmissionsMap.containsKey(s.getUserId())) {
bestSubmissionsMap.put(s.getUserId(), s);
} else {
Submission existing = bestSubmissionsMap.get(s.getUserId());
// Compare: Correct DESC, PlayTime ASC, SubmittedAt ASC
// 비교: 정답수 내림차순, 소요시간 오름차순, 제출일시 오름차순
boolean isBetter = false;
if (s.getCorrectCount() > existing.getCorrectCount()) {
isBetter = true;
Expand All @@ -183,7 +182,7 @@ public void finalizeChallenge(Long challengeId) {

List<Submission> bestSubmissions = new java.util.ArrayList<>(bestSubmissionsMap.values());

// 3. Sort Best Submissions
// 3. 최고 기록 정렬
bestSubmissions.sort((s1, s2) -> {
if (s1.getCorrectCount() != s2.getCorrectCount()) {
return s2.getCorrectCount() - s1.getCorrectCount();
Expand All @@ -194,7 +193,7 @@ public void finalizeChallenge(Long challengeId) {
return s1.getSubmittedAt().compareTo(s2.getSubmittedAt());
});

// 3. Prepare Ranking Entities
// 4. 랭킹 엔티티 생성
List<ChallengeRanking> rankings = new java.util.ArrayList<>();
for (int i = 0; i < bestSubmissions.size(); i++) {
Submission s = bestSubmissions.get(i);
Expand All @@ -210,16 +209,16 @@ public void finalizeChallenge(Long challengeId) {
rankings.add(ranking);
}

// 4. Reset and Insert
// 5. 기존 랭킹 초기화 및 저장
challengeRankingMapper.deleteRankingsByChallengeId(challengeId);
challengeRankingMapper.insertRankings(rankings);
}

@Override
@Transactional // removed readOnly=true because it might trigger lazy finalization (write)
@Transactional // 지연 확정(쓰기) 발생 가능하므로 readOnly 제외
@Cacheable(value = "leaderboard", key = "#challengeId")
public List<ChallengeRankingResponse> getTopRankings(Long challengeId) {
// Enriched DTOs with challengeType
// DTO에 챌린지 타입 포함
List<ChallengeRankingResponse> topRankings = resolveTopRankings(challengeId);

String type = challengeMapper.findById(challengeId).map(Challenge::getChallengeType).orElse("UNKNOWN");
Expand All @@ -229,25 +228,25 @@ public List<ChallengeRankingResponse> getTopRankings(Long challengeId) {
}

@Override
@Transactional // removed readOnly=true
@Transactional // readOnly 제외
public LeaderboardResponse getLeaderboard(Long challengeId, Long userId) {
// 1. Top Rankings (Lazy Finalization if needed)
// 1. 상위 랭킹 조회 (필요 시 지연 확정)
List<ChallengeRankingResponse> topRankings = resolveTopRankings(challengeId);

String type = challengeMapper.findById(challengeId).map(Challenge::getChallengeType).orElse("UNKNOWN");
topRankings.forEach(r -> r.setChallengeType(type));

// 2. My Ranking
// 2. 내 랭킹 조회
ChallengeRankingResponse myRanking = null;
if (userId != null) {
myRanking = findMyBestRanking(userId, challengeId, type);
}

// Return 0-score ranking if user has no record (User request: "10등 + 내 기록 0점")
// 기록 없음 시 0점 반환 ("10등 + 내 기록 0점" 요청 반영)
if (myRanking == null) {
myRanking = ChallengeRankingResponse.builder()
.challengeId(challengeId)
.userId(userId) // Can be null if guest
.userId(userId) // 게스트인 경우 null
.ranking(0)
.score(0.0)
.playTime(0.0)
Expand All @@ -262,26 +261,21 @@ public LeaderboardResponse getLeaderboard(Long challengeId, Long userId) {
.build();
}

// Unifies logic for finding "My Ranking" whether Active or Archived
// 진행중/종료 상태 무관하게 내 최고 랭킹 조회
private ChallengeRankingResponse findMyBestRanking(Long userId, Long challengeId, String challengeType) {
boolean isExpired = isChallengeExpired(challengeId);

if (isExpired) {
// 1. Try Archive
// 1. 아카이브(종료됨) 확인
ChallengeRankingResponse ranking = challengeRankingMapper.loginUserRanking(userId, challengeId);
if (ranking != null) {
ranking.setChallengeType(challengeType);
return ranking;
}
// If not in archive, fallback to Live logic below?
// Logic: If finalizeChallenge ran, it used live submissions.
// If "lazy finalization" happens in resolveTopRankings just before this, key submissions are in archive.
// If user was duplicate or somehow missed, checking live is safer fallback?
// Actually if finalized, Archive IS the source of truth.
// But for safety/robustness, if Archive is empty for user, maybe they are not in valid set.
// 아카이브에 없으면 안전장치로 라이브 데이터 확인
}

// 2. Try Live (Active or Fallback)
// 2. 라이브 데이터 확인 (진행중 또는 폴백)
Submission s = challengeRankingMapper.findSubmissionByUserIdAndChallengeId(userId, challengeId);
if (s != null) {
int rank = challengeRankingMapper.getLiveRanking(
Expand All @@ -305,23 +299,23 @@ private ChallengeRankingResponse findMyBestRanking(Long userId, Long challengeId
return null;
}

// Helper method for Lazy Finalization
// 지연 확정을 위한 헬퍼 메서드
private List<ChallengeRankingResponse> resolveTopRankings(Long challengeId) {
Challenge challenge = challengeMapper.findById(challengeId)
.orElseThrow(() -> new BusinessException(ErrorCode.QUIZ_NOT_FOUND));

boolean isExpired = challenge.getEndAt() != null && TimeUtils.now().isAfter(challenge.getEndAt());

if (isExpired) {
// Check if archived
// 아카이브 여부 확인
if (!challengeRankingMapper.existsByChallengeId(challengeId)) {
// Lazy Finalization
// 지연 확정 실행
finalizeChallenge(challengeId);
}
// Return from Archive
// 아카이브 데이터 반환
return challengeRankingMapper.challengeTotalRanking(challengeId, 10);
} else {
// Return from Live
// 라이브 데이터 반환
return challengeRankingMapper.findLiveTopRankingsByChallengeId(challengeId, 10);
}
}
Expand All @@ -334,7 +328,7 @@ private boolean isChallengeExpired(Long challengeId) {
private final com.problemio.quiz.mapper.QuizMapper quizMapper;

private ChallengeDto toDto(Challenge challenge) {
// Fetch target quiz
// 타겟 퀴즈 조회
com.problemio.quiz.domain.Quiz quiz = quizMapper.findById(challenge.getTargetQuizId()).orElse(null);
com.problemio.quiz.dto.QuizResponse quizResponse = null;

Expand All @@ -343,7 +337,7 @@ private ChallengeDto toDto(Challenge challenge) {
.id(quiz.getId())
.title(quiz.getTitle())
.thumbnailUrl(quiz.getThumbnailUrl())
// Basic fields enough for thumbnail display
// 썸네일 표시에 필요한 기본 필드만 포함
.build();
}

Expand Down
Loading