Skip to content

Commit

Permalink
Profanity 도메인 분리 및 금칙어 추가(파일, 단일), 삭제, csv 다운 �api 추가 (#329)
Browse files Browse the repository at this point in the history
  • Loading branch information
juno-junho authored May 20, 2024
1 parent 8cb6e2f commit ff5ba8c
Show file tree
Hide file tree
Showing 26 changed files with 670 additions and 67 deletions.
26 changes: 25 additions & 1 deletion src/docs/asciidoc/exception-api.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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 파일 생성에 실패했습니다



|====
2 changes: 2 additions & 0 deletions src/docs/asciidoc/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,7 @@ include::board-api.adoc[]

include::notification-api.adoc[]

include::profanity-api.adoc[]

include::exception-api.adoc[]

58 changes: 58 additions & 0 deletions src/docs/asciidoc/profanity-api.adoc
Original file line number Diff line number Diff line change
@@ -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:
Original file line number Diff line number Diff line change
@@ -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<ProfanityCheck, String> {

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<String> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ProfanityCheck, String> {

private final ProfanityRepository profanityRepository;

@StopWatch
@Override
@Transactional
public boolean isValid(String text, ConstraintValidatorContext constraintValidatorContext) {
List<Profanity> banWords = profanityRepository.findAll();
List<String> 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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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<VoidValue> TRIE = new ConcurrentInvertedRadixTree<>(new SmartArrayBasedNodeFactory());

@PostConstruct
private void loadProfanityFromFile() {
try {
List<String> 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<Profanity> banWords = profanityRepository.findAll();
banWords.forEach(banWord -> TRIE.put(banWord.getBanWord(), VoidValue.SINGLETON)); // 메모리 효율을 위해 불필요한 value 설정 x
}

public List<String> profanityContained(String text) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<String> addProfanity(@RequestBody BanWordRequest request) {
profanityService.saveProfanity(request.word());
return ResponseEntity.status(HttpStatus.CREATED).build();
}

@DeleteMapping
public ResponseEntity<String> 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));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.spaceclub.global.annotation.profanity.controller.request;

import jakarta.validation.constraints.NotNull;

public record BanWordRequest(
@NotNull String word
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.spaceclub.global.annotation.profanity.controller.response;

public record UrlResponse(String url) {

}
Original file line number Diff line number Diff line change
@@ -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++;
}

}
Original file line number Diff line number Diff line change
@@ -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<Profanity> profanities);

}
Loading

0 comments on commit ff5ba8c

Please sign in to comment.