Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] Profanity 도메인 분리 및 추가(파일, 단일), 삭제, csv 다운 기능 구현 #329

Merged
merged 7 commits into from
May 20, 2024
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
Loading