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 @@ -9,6 +9,7 @@
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import jakarta.validation.ConstraintViolationException;

Expand Down Expand Up @@ -54,6 +55,16 @@ public ResponseEntity<ExceptionResponse> handleConstraintViolationException(Cons
return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException));
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ExceptionResponse> handleMethodArgumentTypeMismatchException(
MethodArgumentTypeMismatchException e) {
String specificError = String.format("%s: 올바르지 않은 값입니다", e.getName());

CommonException commonException = new CommonException(CommonErrorCode.INVALID_INPUT, specificError);
log.warn("Type mismatch: {}", specificError);
return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException));
}

@ExceptionHandler(OptimisticLockingFailureException.class)
public ResponseEntity<ExceptionResponse> handleOptimisticLockingFailureException(
OptimisticLockingFailureException e) {
Expand All @@ -69,4 +80,4 @@ public ResponseEntity<ExceptionResponse> handleGenericException(Exception e) {
return ResponseEntity.status(commonException.getStatus()).body(new ExceptionResponse(commonException));
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
import com.linglevel.api.word.dto.EssentialWordsStatsResponse;
import com.linglevel.api.word.dto.Oxford3000InitResponse;
import com.linglevel.api.word.dto.WordSearchResponse;
import com.linglevel.api.word.exception.WordsErrorCode;
import com.linglevel.api.word.exception.WordsException;
import com.linglevel.api.word.service.Oxford3000Service;
import com.linglevel.api.word.service.WordService;
import com.linglevel.api.word.validator.WordValidator;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
Expand All @@ -21,6 +23,7 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.List;
Expand All @@ -31,12 +34,15 @@
@Slf4j
@Tag(name = "Words Admin", description = "단어 관리 API (관리자 전용)")
@SecurityRequirement(name = "adminApiKey")
@PreAuthorize("hasRole('ADMIN')")
public class WordsAdminController {

private final WordService wordService;

private final Oxford3000Service oxford3000Service;

private final WordValidator wordValidator;

@Operation(summary = "단어 강제 재분석", description = """
관리자 전용: 단어를 AI로 강제 재분석합니다.

Expand Down Expand Up @@ -69,7 +75,13 @@ public ResponseEntity<WordSearchResponse> forceAnalyzeWord(
log.info("Admin force-analyze: word='{}', targetLanguage={}, overwrite={}, deleteVariants={}", word,
targetLanguage, overwrite, deleteVariants);

WordSearchResponse response = wordService.forceReanalyzeWord(word, targetLanguage, overwrite, deleteVariants);
if (targetLanguage == LanguageCode.EN) {
throw new WordsException(WordsErrorCode.SAME_SOURCE_TARGET_LANGUAGE);
}

String validatedWord = wordValidator.validateAndPreprocess(word);
WordSearchResponse response = wordService.forceReanalyzeWord(validatedWord, targetLanguage, overwrite,
deleteVariants);
return ResponseEntity.ok(response);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import com.linglevel.api.auth.jwt.JwtClaims;
import com.linglevel.api.common.dto.ExceptionResponse;
import com.linglevel.api.common.ratelimit.annotation.RateLimit;
import com.linglevel.api.common.ratelimit.annotation.RateLimit.KeyType;
import com.linglevel.api.i18n.LanguageCode;
import com.linglevel.api.word.dto.WordSearchRequest;
import com.linglevel.api.word.dto.WordSearchResponse;
import com.linglevel.api.word.entity.Word;
import com.linglevel.api.word.exception.WordsErrorCode;
import com.linglevel.api.word.exception.WordsException;
import com.linglevel.api.word.service.WordService;
Expand All @@ -21,17 +22,9 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.mongodb.core.mapping.Language;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import com.linglevel.api.common.ratelimit.annotation.RateLimit;
import com.linglevel.api.common.ratelimit.annotation.RateLimit.KeyType;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/words")
Expand Down Expand Up @@ -71,4 +64,4 @@ public ResponseEntity<ExceptionResponse> handleWordsException(WordsException e)
return ResponseEntity.status(e.getStatus()).body(new ExceptionResponse(e));
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.fasterxml.jackson.annotation.JsonProperty;
import com.linglevel.api.i18n.LanguageCode;
import com.linglevel.api.word.model.Meaning;
import com.linglevel.api.word.model.RelatedForms;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/linglevel/api/word/dto/WordResponse.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.linglevel.api.word.dto;

import com.linglevel.api.i18n.LanguageCode;
import com.linglevel.api.word.model.Meaning;
import com.linglevel.api.word.model.RelatedForms;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
Expand Down Expand Up @@ -46,4 +48,4 @@ public class WordResponse {
@Schema(description = "필수 단어 여부 (예: Oxford 3000)", example = "true")
private Boolean isEssential;

}
}
4 changes: 2 additions & 2 deletions src/main/java/com/linglevel/api/word/entity/Word.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.linglevel.api.word.entity;

import com.linglevel.api.i18n.LanguageCode;
import com.linglevel.api.word.dto.Meaning;
import com.linglevel.api.word.dto.RelatedForms;
import com.linglevel.api.word.model.Meaning;
import com.linglevel.api.word.model.RelatedForms;
import lombok.*;
import org.springframework.data.annotation.Id;
import org.springframework.data.mongodb.core.index.CompoundIndex;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.linglevel.api.word.mapper;

import com.linglevel.api.word.dto.VariantType;
import com.linglevel.api.word.dto.WordResponse;
import com.linglevel.api.word.dto.WordSearchResponse;
import com.linglevel.api.word.entity.Word;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class WordResponseMapper {

public WordResponse toWordResponse(Word word, boolean bookmarked, List<VariantType> variantTypes,
String originalForm) {
return WordResponse.builder()
.id(word.getId())
.originalForm(originalForm)
.variantTypes(variantTypes)
.sourceLanguageCode(word.getSourceLanguageCode())
.targetLanguageCode(word.getTargetLanguageCode())
.summary(word.getSummary())
.meanings(word.getMeanings())
.relatedForms(word.getRelatedForms())
.bookmarked(bookmarked)
.isEssential(word.getIsEssential())
.build();
}

public WordSearchResponse toWordSearchResponse(String searchedWord, List<WordResponse> results) {
return WordSearchResponse.builder().searchedWord(searchedWord).results(results).build();
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.linglevel.api.word.dto;
package com.linglevel.api.word.model;

import com.linglevel.api.word.dto.PartOfSpeech;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
Expand Down Expand Up @@ -31,4 +32,4 @@ public class Meaning {
@Schema(description = "예문 번역", example = "나는 매일 가게에서 그를 봅니다.")
private String exampleTranslation;

}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.linglevel.api.word.dto;
package com.linglevel.api.word.model;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.linglevel.api.word.dto.WordAnalysisResult;
import com.linglevel.api.word.exception.WordsErrorCode;
import com.linglevel.api.word.exception.WordsException;
import com.linglevel.api.word.model.Meaning;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
Expand Down Expand Up @@ -313,12 +314,12 @@ private WordAnalysisResult[] filterInvalidEnumValues(WordAnalysisResult[] result
}

// 2. 유효한 PartOfSpeech를 가진 meanings만 필터링
List<com.linglevel.api.word.dto.Meaning> originalMeanings = result.getMeanings();
List<com.linglevel.api.word.dto.Meaning> validMeanings = new ArrayList<>();
List<Meaning> originalMeanings = result.getMeanings();
List<Meaning> validMeanings = new ArrayList<>();

if (originalMeanings != null) {
int invalidCount = 0;
for (com.linglevel.api.word.dto.Meaning meaning : originalMeanings) {
for (Meaning meaning : originalMeanings) {
if (meaning.getPartOfSpeech() != null) {
validMeanings.add(meaning);
}
Expand Down Expand Up @@ -377,7 +378,7 @@ private WordAnalysisResult mergeResults(List<WordAnalysisResult> results) {
.collect(Collectors.toList());

// meanings 병합 (중복 제거 - partOfSpeech와 meaning이 같은 것은 제외)
List<com.linglevel.api.word.dto.Meaning> mergedMeanings = results.stream()
List<Meaning> mergedMeanings = results.stream()
.flatMap(r -> r.getMeanings().stream())
.collect(Collectors.toMap(m -> m.getPartOfSpeech() + ":" + m.getMeaning(), m -> m,
(existing, replacement) -> existing))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.linglevel.api.word.service;

import com.linglevel.api.i18n.LanguageCode;
import com.linglevel.api.word.dto.RelatedForms;
import com.linglevel.api.word.dto.VariantType;
import com.linglevel.api.word.dto.WordAnalysisResult;
import com.linglevel.api.word.entity.InvalidWord;
import com.linglevel.api.word.entity.Word;
import com.linglevel.api.word.entity.WordVariant;
import com.linglevel.api.word.model.RelatedForms;
import com.linglevel.api.word.repository.InvalidWordRepository;
import com.linglevel.api.word.repository.WordRepository;
import com.linglevel.api.word.repository.WordVariantRepository;
Expand Down
30 changes: 8 additions & 22 deletions src/main/java/com/linglevel/api/word/service/WordService.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.linglevel.api.word.entity.WordVariant;
import com.linglevel.api.word.exception.WordsErrorCode;
import com.linglevel.api.word.exception.WordsException;
import com.linglevel.api.word.mapper.WordResponseMapper;
import com.linglevel.api.word.repository.InvalidWordRepository;
import com.linglevel.api.word.repository.WordRepository;
import com.linglevel.api.word.repository.WordVariantRepository;
Expand Down Expand Up @@ -38,6 +39,8 @@ public class WordService {

private final WordPersistenceService wordPersistenceService;

private final WordResponseMapper wordResponseMapper;

public WordSearchResponse getOrCreateWords(String userId, String word, LanguageCode targetLanguage) {
List<WordVariant> wordVariants = getOrCreateWordEntities(word, targetLanguage);

Expand All @@ -63,13 +66,13 @@ public WordSearchResponse getOrCreateWords(String userId, String word, LanguageC

boolean isBookmarked = wordBookmarkRepository.existsByUserIdAndWord(userId, wordVariant.getOriginalForm());

WordResponse response = convertToResponse(originalWord, isBookmarked, wordVariant.getVariantTypes(),
wordVariant.getOriginalForm());
WordResponse response = wordResponseMapper.toWordResponse(originalWord, isBookmarked,
wordVariant.getVariantTypes(), wordVariant.getOriginalForm());

results.add(response);
}

return WordSearchResponse.builder().searchedWord(word).results(results).build();
return wordResponseMapper.toWordSearchResponse(word, results);
}

public List<WordVariant> getOrCreateWordEntities(String word, LanguageCode targetLanguage) {
Expand Down Expand Up @@ -137,22 +140,6 @@ private List<WordAnalysisResult> analyzeWordAndUpdateInvalidCache(String word, L
}
}

private WordResponse convertToResponse(Word word, boolean isBookmarked, List<VariantType> variantTypes,
String originalForm) {
return WordResponse.builder()
.id(word.getId())
.originalForm(originalForm)
.variantTypes(variantTypes)
.sourceLanguageCode(word.getSourceLanguageCode())
.targetLanguageCode(word.getTargetLanguageCode())
.summary(word.getSummary())
.meanings(word.getMeanings()) // Meaning을 그대로 사용
.relatedForms(word.getRelatedForms())
.bookmarked(isBookmarked)
.isEssential(word.getIsEssential())
.build();
}

/**
* 관리자 전용: 단어를 AI로 강제 재분석
* @param word 재분석할 단어
Expand Down Expand Up @@ -195,13 +182,12 @@ public WordSearchResponse forceReanalyzeWord(String word, LanguageCode targetLan
.findByWordAndTargetLanguageCode(wordVariant.getOriginalForm(), targetLanguage)
.orElseThrow(() -> new WordsException(WordsErrorCode.WORD_NOT_FOUND));

WordResponse response = convertToResponse(originalWord, false, // 어드민 API이므로
// 북마크 체크하지 않음
WordResponse response = wordResponseMapper.toWordResponse(originalWord, false,
wordVariant.getVariantTypes(), wordVariant.getOriginalForm());
results.add(response);
}

return WordSearchResponse.builder().searchedWord(word).results(results).build();
return wordResponseMapper.toWordSearchResponse(word, results);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.linglevel.api.common.handler;

import com.linglevel.api.common.dto.ExceptionResponse;
import com.linglevel.api.i18n.LanguageCode;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import static org.assertj.core.api.Assertions.assertThat;

class GlobalExceptionHandlerTest {

private final GlobalExceptionHandler globalExceptionHandler = new GlobalExceptionHandler();

@Test
@DisplayName("요청 파라미터 타입 변환 실패는 400 Bad Request로 응답")
void handleMethodArgumentTypeMismatchException_returnsBadRequest() {
MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException("INVALID",
LanguageCode.class, "targetLanguage", null, null);

ResponseEntity<ExceptionResponse> response = globalExceptionHandler
.handleMethodArgumentTypeMismatchException(exception);

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().getMessage()).isEqualTo("targetLanguage: 올바르지 않은 값입니다");
}

}
Loading
Loading