diff --git a/src/docs/asciidoc/exception-api.adoc b/src/docs/asciidoc/exception-api.adoc index 13b49cd8..d6720ee8 100644 --- a/src/docs/asciidoc/exception-api.adoc +++ b/src/docs/asciidoc/exception-api.adoc @@ -217,9 +217,33 @@ |`+KAKAO_UNLINK_FAIL+` |카카오 계정 연결 해제에 실패했습니다 + + + +|==== + +=== 금칙어 + +|==== +|코드 |코드 설명 + +|`+FAIL_BAD_WORD_SETUP+` +|비속어 목록 Trie 생성 실패 + |`+BAD_WORD_DETECTED+` |비속어가 발견 되었습니다 +|`+INVALID_EXTENSION+` +|md,txt 파일만 업로드 가능합니다 + +|`+FAILED_TO_SAVE+` +|금칙어 저장에 실패했습니다 + +|`+BAD_WORD_ALREADY_EXISTS+` +|이미 존재하는 금칙어 입니다 + +|`+FAILED_TO_CREATE_CSV+` +|csv 파일 생성에 실패했습니다 + -|==== diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 5eba2bb7..1078929f 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -28,5 +28,7 @@ include::board-api.adoc[] include::notification-api.adoc[] +include::profanity-api.adoc[] + include::exception-api.adoc[] diff --git a/src/docs/asciidoc/profanity-api.adoc b/src/docs/asciidoc/profanity-api.adoc new file mode 100644 index 00000000..5385a8d0 --- /dev/null +++ b/src/docs/asciidoc/profanity-api.adoc @@ -0,0 +1,58 @@ +:sectnums: +== 금칙어 관리 + +=== 금칙어를 파일로 등록 +:sectnums!: + +==== Request +include::{snippets}/profanity/upload-banword-file/http-request.adoc[] + +===== Request Part +include::{snippets}/profanity/upload-banword-file/request-parts.adoc[] + +==== Response +include::{snippets}/profanity/upload-banword-file/http-response.adoc[] + +:sectnums: + +=== 금칙어 정보 csv 파일로 저장 +:sectnums!: + +==== Request +include::{snippets}/profanity/download-csv-file/http-request.adoc[] + +==== Response +include::{snippets}/profanity/download-csv-file/http-response.adoc[] + +===== Response Body +include::{snippets}/profanity/download-csv-file/response-fields.adoc[] + +:sectnums: + +=== 금칙어 정보 단일 추가 +:sectnums!: + +==== Request +include::{snippets}/profanity/add-single-banword/http-request.adoc[] + +===== Request Body +include::{snippets}/profanity/add-single-banword/request-fields.adoc[] + +==== Response +include::{snippets}/profanity/add-single-banword/http-response.adoc[] + +:sectnums: + +=== 금칙어 정보 단일 삭제 +:sectnums!: + +==== Request +include::{snippets}/profanity/delete-banword/http-request.adoc[] + +===== Request Body +include::{snippets}/profanity/delete-banword/request-fields.adoc[] + +==== Response +include::{snippets}/profanity/delete-banword/http-response.adoc[] + +:sectnums: diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityCheckValidator.java b/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityCheckValidator.java index 9838cddd..a3ec1ef2 100644 --- a/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityCheckValidator.java +++ b/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityCheckValidator.java @@ -1,23 +1,42 @@ package com.spaceclub.global.annotation.profanity; +import com.spaceclub.global.annotation.profanity.domain.Profanity; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; +import com.spaceclub.global.timer.StopWatch; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; import static com.spaceclub.global.annotation.profanity.ProfanityExceptionMessage.BAD_WORD_DETECTED; +@Slf4j @Component @RequiredArgsConstructor public class ProfanityCheckValidator implements ConstraintValidator { private final ProfanityLoader profanityLoader; + private final ProfanityRepository profanityRepository; + @StopWatch @Override + @Transactional public boolean isValid(String text, ConstraintValidatorContext context) { - if (text == null || profanityLoader.profanityContained(text).isEmpty()) return true; // 비속어 없음 + if (text == null) return true; // 비속어 없음 + List detectedProfanities = profanityLoader.profanityContained(text); + if (detectedProfanities.isEmpty()) return true; // 비속어 있음 + detectedProfanities.forEach(banWord -> { + Profanity profanity = profanityRepository.findByBanWord(banWord); + profanity.increaseUseCount(); + }); + + log.debug("Detected profanities: {}", detectedProfanities); context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate(BAD_WORD_DETECTED.toString()).addConstraintViolation(); return false; diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityCheckValidatorUsingList.java b/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityCheckValidatorUsingList.java new file mode 100644 index 00000000..7588eaeb --- /dev/null +++ b/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityCheckValidatorUsingList.java @@ -0,0 +1,44 @@ +package com.spaceclub.global.annotation.profanity; + +import com.spaceclub.global.annotation.profanity.domain.Profanity; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; +import com.spaceclub.global.timer.StopWatch; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProfanityCheckValidatorUsingList implements ConstraintValidator { + + private final ProfanityRepository profanityRepository; + + @StopWatch + @Override + @Transactional + public boolean isValid(String text, ConstraintValidatorContext constraintValidatorContext) { + List banWords = profanityRepository.findAll(); + List detectedBanWords = new ArrayList<>(); + for (Profanity banWord : banWords) { + if (text.contains(banWord.getBanWord())) { + detectedBanWords.add(banWord.getBanWord()); + } + } + + detectedBanWords.forEach(banWord -> { + Profanity profanity = profanityRepository.findByBanWord(banWord); + profanity.increaseUseCount(); + }); + + log.debug("Detected profanities: {}", detectedBanWords); + return detectedBanWords.isEmpty(); + } + +} diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityLoader.java b/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityLoader.java index 346f85c0..d5225c59 100644 --- a/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityLoader.java +++ b/src/main/java/com/spaceclub/global/annotation/profanity/ProfanityLoader.java @@ -4,38 +4,29 @@ import com.googlecode.concurrenttrees.radix.node.concrete.voidvalue.VoidValue; import com.googlecode.concurrenttrees.radixinverted.ConcurrentInvertedRadixTree; import com.googlecode.concurrenttrees.radixinverted.InvertedRadixTree; -import com.spaceclub.global.config.ProfanityConfig; +import com.spaceclub.global.annotation.profanity.domain.Profanity; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; import java.util.List; import java.util.Spliterator; import java.util.stream.StreamSupport; -import static com.spaceclub.global.annotation.profanity.ProfanityExceptionMessage.FAIL_BAD_WORD_SETUP; - @Slf4j @Component @RequiredArgsConstructor public class ProfanityLoader { - private final ProfanityConfig config; + private final ProfanityRepository profanityRepository; private final InvertedRadixTree TRIE = new ConcurrentInvertedRadixTree<>(new SmartArrayBasedNodeFactory()); @PostConstruct private void loadProfanityFromFile() { - try { - List banWords = Files.readAllLines(Paths.get(config.filePath())); - banWords.forEach(banWord -> TRIE.put(banWord, VoidValue.SINGLETON)); // 메모리 효율을 위해 불필요한 value 설정 x - } catch (IOException e) { - log.error("비속어 목록 파일 읽기 실패", e); - throw new IllegalStateException(FAIL_BAD_WORD_SETUP.toString()); - } + List banWords = profanityRepository.findAll(); + banWords.forEach(banWord -> TRIE.put(banWord.getBanWord(), VoidValue.SINGLETON)); // 메모리 효율을 위해 불필요한 value 설정 x } public List profanityContained(String text) { diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/controller/ProfanityController.java b/src/main/java/com/spaceclub/global/annotation/profanity/controller/ProfanityController.java new file mode 100644 index 00000000..3f5dbde4 --- /dev/null +++ b/src/main/java/com/spaceclub/global/annotation/profanity/controller/ProfanityController.java @@ -0,0 +1,73 @@ +package com.spaceclub.global.annotation.profanity.controller; + +import com.spaceclub.global.annotation.profanity.controller.request.BanWordRequest; +import com.spaceclub.global.annotation.profanity.controller.response.UrlResponse; +import com.spaceclub.global.annotation.profanity.service.ProfanityService; +import com.spaceclub.global.config.ProfanityConfig; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import static com.spaceclub.global.annotation.profanity.ProfanityExceptionMessage.INVALID_EXTENSION; + +@Slf4j +@RestController +@RequestMapping("/api/v1/profanities") +@RequiredArgsConstructor +public class ProfanityController { + + private final ProfanityService profanityService; + private final ProfanityConfig profanityConfig; + + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadFile(@RequestPart MultipartFile file) { + log.info("금칙어 목록 파일을 업로드 합니다 : {}", file.getOriginalFilename()); + if (!isValidExtension(file)) { + throw new IllegalArgumentException(INVALID_EXTENSION.getMessage()); + } + profanityService.saveProfanitiesFromFile(file); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @GetMapping("/csv") + @ResponseStatus(HttpStatus.CREATED) + public UrlResponse createCsvFile() { + String filePath = profanityService.createCsvFile(); + return new UrlResponse(filePath); + } + + @PostMapping + public ResponseEntity addProfanity(@RequestBody BanWordRequest request) { + profanityService.saveProfanity(request.word()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @DeleteMapping + public ResponseEntity deleteProfanity(@RequestBody BanWordRequest request) { + profanityService.deleteProfanity(request.word()); + return ResponseEntity.noContent().build(); + } + + private boolean isValidExtension(MultipartFile file) { + String fileName = file.getOriginalFilename(); + if (fileName == null || !fileName.contains(".")) { + return false; // 파일 이름이 null이거나 확장자가 없는 경우 + } + + String extension = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase(); + return profanityConfig.validExtensions().stream() // 대소문자 구분 없이 확장자 비교 + .anyMatch(validExtension -> validExtension.equalsIgnoreCase(extension)); + } + +} diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/controller/request/BanWordRequest.java b/src/main/java/com/spaceclub/global/annotation/profanity/controller/request/BanWordRequest.java new file mode 100644 index 00000000..bfcabb96 --- /dev/null +++ b/src/main/java/com/spaceclub/global/annotation/profanity/controller/request/BanWordRequest.java @@ -0,0 +1,8 @@ +package com.spaceclub.global.annotation.profanity.controller.request; + +import jakarta.validation.constraints.NotNull; + +public record BanWordRequest( + @NotNull String word +) { +} diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/controller/response/UrlResponse.java b/src/main/java/com/spaceclub/global/annotation/profanity/controller/response/UrlResponse.java new file mode 100644 index 00000000..519ecd28 --- /dev/null +++ b/src/main/java/com/spaceclub/global/annotation/profanity/controller/response/UrlResponse.java @@ -0,0 +1,5 @@ +package com.spaceclub.global.annotation.profanity.controller.response; + +public record UrlResponse(String url) { + +} diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/domain/Profanity.java b/src/main/java/com/spaceclub/global/annotation/profanity/domain/Profanity.java new file mode 100644 index 00000000..732e032e --- /dev/null +++ b/src/main/java/com/spaceclub/global/annotation/profanity/domain/Profanity.java @@ -0,0 +1,36 @@ +package com.spaceclub.global.annotation.profanity.domain; + +import com.spaceclub.global.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Getter; + +@Entity +@Getter +public class Profanity extends BaseTimeEntity { + + protected Profanity() {} + + public Profanity(String banWord) { + this.banWord = banWord; + this.useCount = 0L; + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String banWord; + + @Column(nullable = false) + private long useCount; + + public void increaseUseCount() { + this.useCount++; + } + +} diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/domain/repository/ProfanityCustomRepository.java b/src/main/java/com/spaceclub/global/annotation/profanity/domain/repository/ProfanityCustomRepository.java new file mode 100644 index 00000000..052a32f7 --- /dev/null +++ b/src/main/java/com/spaceclub/global/annotation/profanity/domain/repository/ProfanityCustomRepository.java @@ -0,0 +1,11 @@ +package com.spaceclub.global.annotation.profanity.domain.repository; + +import com.spaceclub.global.annotation.profanity.domain.Profanity; + +import java.util.List; + +public interface ProfanityCustomRepository { + + void bulkInsert(List profanities); + +} diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/domain/repository/ProfanityCustomRepositoryImpl.java b/src/main/java/com/spaceclub/global/annotation/profanity/domain/repository/ProfanityCustomRepositoryImpl.java new file mode 100644 index 00000000..bef4ad16 --- /dev/null +++ b/src/main/java/com/spaceclub/global/annotation/profanity/domain/repository/ProfanityCustomRepositoryImpl.java @@ -0,0 +1,43 @@ +package com.spaceclub.global.annotation.profanity.domain.repository; + +import com.spaceclub.global.annotation.profanity.domain.Profanity; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.List; + +@RequiredArgsConstructor +public class ProfanityCustomRepositoryImpl implements ProfanityCustomRepository { + + private final JdbcTemplate jdbcTemplate; + + @Override + public void bulkInsert(List profanities) { + String sql = "INSERT INTO profanity (ban_word, use_count, created_at, last_modified_at) " + + "VALUES (?, ?, ?, ?)"; + LocalDateTime now = LocalDateTime.now(); + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + Profanity profanity = profanities.get(i); + ps.setString(1, profanity.getBanWord()); + ps.setLong(2, profanity.getUseCount()); + ps.setObject(3, now); // created date + ps.setObject(4, now); // last modified date + } + + @Override + public int getBatchSize() { + return profanities.size(); + } + + }); + } + +} diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/domain/repository/ProfanityRepository.java b/src/main/java/com/spaceclub/global/annotation/profanity/domain/repository/ProfanityRepository.java new file mode 100644 index 00000000..c655b5c9 --- /dev/null +++ b/src/main/java/com/spaceclub/global/annotation/profanity/domain/repository/ProfanityRepository.java @@ -0,0 +1,20 @@ +package com.spaceclub.global.annotation.profanity.domain.repository; + +import com.spaceclub.global.annotation.profanity.domain.Profanity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDateTime; + +public interface ProfanityRepository extends JpaRepository, ProfanityCustomRepository { + + @Query("SELECT MAX(p.lastModifiedAt) FROM Profanity p") + LocalDateTime findLatestModifiedDate(); + + boolean existsByBanWord(String word); + + Profanity findByBanWord(String banWord); + + void deleteByBanWord(String word); + +} diff --git a/src/main/java/com/spaceclub/global/annotation/profanity/service/ProfanityService.java b/src/main/java/com/spaceclub/global/annotation/profanity/service/ProfanityService.java new file mode 100644 index 00000000..eeb10f46 --- /dev/null +++ b/src/main/java/com/spaceclub/global/annotation/profanity/service/ProfanityService.java @@ -0,0 +1,99 @@ +package com.spaceclub.global.annotation.profanity.service; + +import com.spaceclub.global.annotation.profanity.domain.Profanity; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; +import com.spaceclub.global.aws.s3.S3FileUploader; +import com.spaceclub.global.timer.StopWatch; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.csv.CSVFormat; +import org.apache.commons.csv.CSVPrinter; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.StringWriter; +import java.time.LocalDateTime; +import java.util.List; + +import static com.spaceclub.global.annotation.profanity.ProfanityExceptionMessage.BAD_WORD_ALREADY_EXISTS; +import static com.spaceclub.global.annotation.profanity.ProfanityExceptionMessage.FAILED_TO_CREATE_CSV; +import static com.spaceclub.global.annotation.profanity.ProfanityExceptionMessage.FAILED_TO_SAVE; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProfanityService { + + private final ProfanityRepository profanityRepository; + private final S3FileUploader s3FileUploader; + + private String downloadUrl; + private LocalDateTime downloadDate = LocalDateTime.now(); + + @StopWatch + public void saveProfanitiesFromFile(MultipartFile file) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(file.getInputStream()))) { + List profanities = reader.lines() + .distinct() + .map(Profanity::new) + .toList(); + + profanityRepository.bulkInsert(profanities); + } catch (IOException e) { + throw new IllegalStateException(FAILED_TO_SAVE.getMessage()); + } + } + + @Transactional + public void saveProfanity(String word) { + if (profanityRepository.existsByBanWord(word)) { + throw new IllegalArgumentException(BAD_WORD_ALREADY_EXISTS.getMessage()); + } + + profanityRepository.save(new Profanity(word)); + } + + @Transactional + public void deleteProfanity(String word) { + profanityRepository.deleteByBanWord(word); + } + + public String createCsvFile() { + // 여기에 csv 파일 생성 여부 추가 + if (downloadUrl != null && profanityRepository.findLatestModifiedDate().isBefore(downloadDate)) { + return downloadUrl; + } + + List profanities = profanityRepository.findAll(); + + CSVFormat csvFormat = CSVFormat.DEFAULT.builder() + .setHeader("ID", "금칙어", "사용횟수") + .build(); + try ( + final StringWriter writer = new StringWriter(); + final CSVPrinter printer = new CSVPrinter(writer, csvFormat) + ) { + for (Profanity profanity : profanities) { + printer.printRecord(profanity.getId(), profanity.getBanWord(), profanity.getUseCount()); + } + printer.flush(); + + String filePath = s3FileUploader.uploadProfanityInfo(writer.toString()); + writeIMemory(filePath); + + return filePath; + } catch (IOException e) { + throw new IllegalStateException(FAILED_TO_CREATE_CSV.getMessage()); + } + } + + private void writeIMemory(String filePath) { + downloadDate = LocalDateTime.now(); + downloadUrl = filePath; + } + +} diff --git a/src/main/java/com/spaceclub/global/aws/s3/S3FileUploader.java b/src/main/java/com/spaceclub/global/aws/s3/S3FileUploader.java index 76731697..e19cf135 100644 --- a/src/main/java/com/spaceclub/global/aws/s3/S3FileUploader.java +++ b/src/main/java/com/spaceclub/global/aws/s3/S3FileUploader.java @@ -6,6 +6,7 @@ import com.amazonaws.services.s3.model.PutObjectRequest; import com.spaceclub.global.aws.S3Properties; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.io.ByteArrayInputStream; @@ -16,6 +17,7 @@ import static com.spaceclub.global.exception.GlobalExceptionCode.FAIL_FILE_UPLOAD; +@Slf4j @Component @RequiredArgsConstructor public class S3FileUploader { @@ -36,6 +38,7 @@ public String uploadProfanityInfo(String content) { .withCannedAcl(CannedAccessControlList.PublicRead); amazonS3.putObject(request); + log.info("csv 파일 업로드 성공, 파일 이름:{}", csvFileKey); return s3Properties.url() + csvFileKey; } catch (IOException e) { throw new IllegalStateException(FAIL_FILE_UPLOAD.toString()); diff --git a/src/main/java/com/spaceclub/global/config/InterceptorProperties.java b/src/main/java/com/spaceclub/global/config/InterceptorProperties.java index 861e9969..3da8c815 100644 --- a/src/main/java/com/spaceclub/global/config/InterceptorProperties.java +++ b/src/main/java/com/spaceclub/global/config/InterceptorProperties.java @@ -13,7 +13,7 @@ public record InterceptorProperties( public record pathMethod( String path, - HttpMethod method + List method ) { } diff --git a/src/main/java/com/spaceclub/oauth/interceptor/AuthorizationInterceptor.java b/src/main/java/com/spaceclub/oauth/interceptor/AuthorizationInterceptor.java index 35ec638b..c65c5e70 100644 --- a/src/main/java/com/spaceclub/oauth/interceptor/AuthorizationInterceptor.java +++ b/src/main/java/com/spaceclub/oauth/interceptor/AuthorizationInterceptor.java @@ -13,6 +13,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -32,13 +33,14 @@ public class AuthorizationInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { - Map pathToExclude = interceptorProperties.pathToExclude() + Map> pathToExclude = interceptorProperties.pathToExclude() .stream() .collect(Collectors.toMap(pathMethod::path, pathMethod::method)); - for (Map.Entry entry : unmodifiableSet(pathToExclude.entrySet())) { + for (Map.Entry> entry : unmodifiableSet(pathToExclude.entrySet())) { boolean matchesPathAndMethod = request.getRequestURI().contains(entry.getKey()) && - request.getMethod().equals(entry.getValue().name()); + entry.getValue().stream() + .anyMatch(httpMethod -> httpMethod.name().equals(request.getMethod())); if (matchesPathAndMethod) { return true; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f7614539..f4f99a94 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -89,6 +89,12 @@ interceptor: - path: /api/v1/mails/retry-all-failed-emails method: POST + - path: /api/v1/profanities + method: + - POST + - GET + - DELETE + web: interceptor-path-pattern: /api/v1/** diff --git a/src/test/java/com/spaceclub/board/controller/CommentControllerTest.java b/src/test/java/com/spaceclub/board/controller/CommentControllerTest.java index 8d992641..1c389ba2 100644 --- a/src/test/java/com/spaceclub/board/controller/CommentControllerTest.java +++ b/src/test/java/com/spaceclub/board/controller/CommentControllerTest.java @@ -9,6 +9,7 @@ import com.spaceclub.global.annotation.login.UserArgumentResolver; import com.spaceclub.global.annotation.profanity.ProfanityCheckValidator; import com.spaceclub.global.annotation.profanity.ProfanityLoader; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; import com.spaceclub.global.config.WebConfig; import com.spaceclub.oauth.interceptor.AuthenticationInterceptor; import com.spaceclub.oauth.interceptor.AuthorizationInterceptor; @@ -92,6 +93,9 @@ class CommentControllerTest { @MockBean private ProfanityCheckValidator profanityCheckValidator; + @MockBean + private ProfanityRepository profanityRepository; + @Test @WithMockUser void 댓글_전체_조회에_성공한다() throws Exception { diff --git a/src/test/java/com/spaceclub/board/controller/PostControllerTest.java b/src/test/java/com/spaceclub/board/controller/PostControllerTest.java index 573ad572..550b6d30 100644 --- a/src/test/java/com/spaceclub/board/controller/PostControllerTest.java +++ b/src/test/java/com/spaceclub/board/controller/PostControllerTest.java @@ -10,6 +10,7 @@ import com.spaceclub.global.annotation.login.UserArgumentResolver; import com.spaceclub.global.annotation.profanity.ProfanityCheckValidator; import com.spaceclub.global.annotation.profanity.ProfanityLoader; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; import com.spaceclub.global.aws.s3.S3ImageUploader; import com.spaceclub.global.config.WebConfig; import com.spaceclub.oauth.interceptor.AuthenticationInterceptor; @@ -102,6 +103,9 @@ class PostControllerTest { @MockBean private ProfanityCheckValidator profanityCheckValidator; + @MockBean + private ProfanityRepository profanityRepository; + @Test @WithMockUser void 게시글_전체_조회에_성공한다() throws Exception { diff --git a/src/test/java/com/spaceclub/club/controller/ClubControllerTest.java b/src/test/java/com/spaceclub/club/controller/ClubControllerTest.java index b0feae44..7db24b1d 100644 --- a/src/test/java/com/spaceclub/club/controller/ClubControllerTest.java +++ b/src/test/java/com/spaceclub/club/controller/ClubControllerTest.java @@ -9,6 +9,7 @@ import com.spaceclub.global.annotation.login.UserArgumentResolver; import com.spaceclub.global.annotation.profanity.ProfanityCheckValidator; import com.spaceclub.global.annotation.profanity.ProfanityLoader; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; import com.spaceclub.global.aws.S3Properties; import com.spaceclub.global.config.WebConfig; import com.spaceclub.oauth.interceptor.AuthenticationInterceptor; @@ -97,6 +98,9 @@ class ClubControllerTest { @MockBean private ProfanityCheckValidator profanityCheckValidator; + @MockBean + private ProfanityRepository profanityRepository; + private MockMultipartFile logoImage; private MockMultipartFile coverImage; private MockMultipartFile request; diff --git a/src/test/java/com/spaceclub/club/controller/ClubNoticeControllerTest.java b/src/test/java/com/spaceclub/club/controller/ClubNoticeControllerTest.java index ae017518..bfe0db01 100644 --- a/src/test/java/com/spaceclub/club/controller/ClubNoticeControllerTest.java +++ b/src/test/java/com/spaceclub/club/controller/ClubNoticeControllerTest.java @@ -9,6 +9,7 @@ import com.spaceclub.global.annotation.login.UserArgumentResolver; import com.spaceclub.global.annotation.profanity.ProfanityCheckValidator; import com.spaceclub.global.annotation.profanity.ProfanityLoader; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; import com.spaceclub.global.config.WebConfig; import com.spaceclub.oauth.interceptor.AuthenticationInterceptor; import com.spaceclub.oauth.interceptor.AuthorizationInterceptor; @@ -86,6 +87,9 @@ class ClubNoticeControllerTest { @MockBean private ProfanityCheckValidator profanityCheckValidator; + @MockBean + private ProfanityRepository profanityRepository; + @Test @WithMockUser void 클럽_공지사항_작성에_성공한다() throws Exception { diff --git a/src/test/java/com/spaceclub/global/annotation/profanity/controller/ProfanityControllerTest.java b/src/test/java/com/spaceclub/global/annotation/profanity/controller/ProfanityControllerTest.java new file mode 100644 index 00000000..4b46ad02 --- /dev/null +++ b/src/test/java/com/spaceclub/global/annotation/profanity/controller/ProfanityControllerTest.java @@ -0,0 +1,182 @@ +package com.spaceclub.global.annotation.profanity.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.spaceclub.SpaceClubCustomDisplayNameGenerator; +import com.spaceclub.global.annotation.login.UserArgumentResolver; +import com.spaceclub.global.annotation.profanity.controller.request.BanWordRequest; +import com.spaceclub.global.annotation.profanity.service.ProfanityService; +import com.spaceclub.global.config.ProfanityConfig; +import com.spaceclub.global.config.WebConfig; +import com.spaceclub.oauth.interceptor.AuthenticationInterceptor; +import com.spaceclub.oauth.interceptor.AuthorizationInterceptor; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.http.MediaType.MULTIPART_FORM_DATA; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.delete; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest( + value = ProfanityController.class, + excludeFilters = { + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { + WebConfig.class, + AuthorizationInterceptor.class, + AuthenticationInterceptor.class + }) + }) +@AutoConfigureRestDocs +@DisplayNameGeneration(SpaceClubCustomDisplayNameGenerator.class) +class ProfanityControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper mapper; + + @MockBean + private ProfanityService profanityService; + + @MockBean + private UserArgumentResolver userArgumentResolver; + + @MockBean + private ProfanityConfig profanityConfig; + + @Test + @WithMockUser + void 금칙어_파일을_DB에_등록하는데_성공한다() throws Exception { + // given + given(profanityConfig.validExtensions()).willReturn(List.of("txt", "md")); + + MockMultipartFile file = new MockMultipartFile( + "file", + "profanity-list.txt", + MediaType.TEXT_PLAIN_VALUE, + "content 예시".getBytes(UTF_8)); + + doNothing().when(profanityService).saveProfanitiesFromFile(eq(file)); + // when, then + mockMvc.perform(multipart("/api/v1/profanities/upload") + .file(file) + .characterEncoding(UTF_8) + .contentType(MULTIPART_FORM_DATA) + .with(csrf())) + .andExpect(status().isCreated()) + .andDo( + document("profanity/upload-banword-file", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestParts( + partWithName("file").description("금칙어 목록 파일") + ) + ) + ); + } + + @Test + @WithMockUser + void 금칙어_통계자료를_csv파일로_다운로드에_성공한다() throws Exception { + // given + String filePath = "/files/profanity_info.csv"; + given(profanityService.createCsvFile()).willReturn(filePath); + + // when, then + mockMvc.perform(get("/api/v1/profanities/csv") + .contentType(APPLICATION_JSON) + .with(csrf())) + .andExpect(status().isCreated()) + .andDo( + document("profanity/download-csv-file", + responseFields( + fieldWithPath("url").description("csv 파일 다운로드 s3 url") + ) + ) + ); + + + } + + @Test + @WithMockUser + void 금칙어_단일추가에_성공한다() throws Exception { + // given + String banWord = "바보"; + BanWordRequest request = new BanWordRequest(banWord); + doNothing().when(profanityService).saveProfanity(eq(banWord)); + + // when, then + mockMvc.perform(post("/api/v1/profanities") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isCreated()) + .andDo( + document("profanity/add-banword", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("word").description("금칙어") + ) + ) + ); + + } + + @Test + @WithMockUser + void 금칙어_단일삭제에_성공한다() throws Exception { + // given + String banWord = "바보"; + BanWordRequest request = new BanWordRequest(banWord); + doNothing().when(profanityService).deleteProfanity(eq(banWord)); + + // when, then + mockMvc.perform(delete("/api/v1/profanities") + .contentType(APPLICATION_JSON) + .content(mapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isNoContent()) + .andDo( + document("profanity/delete-banword", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + requestFields( + fieldWithPath("word").description("금칙어") + ) + ) + ); + } + +} diff --git a/src/test/java/com/spaceclub/global/profanity/ProfanityCheckValidatorTest.java b/src/test/java/com/spaceclub/global/profanity/ProfanityCheckValidatorTest.java deleted file mode 100644 index 85a903dd..00000000 --- a/src/test/java/com/spaceclub/global/profanity/ProfanityCheckValidatorTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.spaceclub.global.profanity; - -import com.spaceclub.club.controller.request.ClubCreateRequest; -import jakarta.validation.Validator; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.NullSource; -import org.junit.jupiter.params.provider.ValueSource; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -class ProfanityCheckValidatorTest { - - @Autowired - private Validator validatorFromFactory; - - @NullSource - @ParameterizedTest - @ValueSource(strings = {"비속어가 포함되어 있지 않은 텍스트"}) - void 비속어가_text에_포함되어_있지않으면_true를_반환한다(String text) { - // given - ClubCreateRequest request = new ClubCreateRequest(text, text); - - // when - var violations = validatorFromFactory.validate(request); - - // then - assertThat(violations).isEmpty(); - } - - @Test - void 비속어가_text에_포함되어_있으면_false를_반환한다() { - // given - String text = "비속어가 포함된 텍스트 시발"; - ClubCreateRequest request = new ClubCreateRequest("클럽 이름",text); - - // when - var violations = validatorFromFactory.validate(request); - - // then - assertThat(violations).isNotEmpty(); - } - -} diff --git a/src/test/java/com/spaceclub/user/controller/LoginControllerTest.java b/src/test/java/com/spaceclub/user/controller/LoginControllerTest.java index 735a0597..fcfeddb3 100644 --- a/src/test/java/com/spaceclub/user/controller/LoginControllerTest.java +++ b/src/test/java/com/spaceclub/user/controller/LoginControllerTest.java @@ -6,6 +6,7 @@ import com.spaceclub.global.annotation.login.UserArgumentResolver; import com.spaceclub.global.annotation.profanity.ProfanityCheckValidator; import com.spaceclub.global.annotation.profanity.ProfanityLoader; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; import com.spaceclub.global.config.WebConfig; import com.spaceclub.oauth.interceptor.AuthenticationInterceptor; import com.spaceclub.oauth.interceptor.AuthorizationInterceptor; @@ -87,6 +88,9 @@ class LoginControllerTest { @MockBean private ProfanityCheckValidator profanityCheckValidator; + @MockBean + private ProfanityRepository profanityRepository; + @Test @WithMockUser void 유저가_신규유저이면_빈값_토큰과_아이디반환에_성공한다() throws Exception { diff --git a/src/test/java/com/spaceclub/user/controller/UserControllerTest.java b/src/test/java/com/spaceclub/user/controller/UserControllerTest.java index 14033b79..c27e9076 100644 --- a/src/test/java/com/spaceclub/user/controller/UserControllerTest.java +++ b/src/test/java/com/spaceclub/user/controller/UserControllerTest.java @@ -5,6 +5,7 @@ import com.spaceclub.global.annotation.login.UserArgumentResolver; import com.spaceclub.global.annotation.profanity.ProfanityCheckValidator; import com.spaceclub.global.annotation.profanity.ProfanityLoader; +import com.spaceclub.global.annotation.profanity.domain.repository.ProfanityRepository; import com.spaceclub.global.config.WebConfig; import com.spaceclub.oauth.interceptor.AuthenticationInterceptor; import com.spaceclub.oauth.interceptor.AuthorizationInterceptor; @@ -97,6 +98,9 @@ class UserControllerTest { @MockBean private ProfanityCheckValidator profanityCheckValidator; + @MockBean + private ProfanityRepository profanityRepository; + @Test @WithMockUser void 유저의_프로필_조회에_성공한다() throws Exception {