diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/common/annotation/NotBannedWord.java b/backend/streetdrop-api/src/main/java/com/depromeet/common/annotation/NotBannedWord.java new file mode 100644 index 00000000..d49e9deb --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/common/annotation/NotBannedWord.java @@ -0,0 +1,21 @@ +package com.depromeet.common.annotation; +import com.depromeet.common.annotation.validator.BannedWordValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +@Target(FIELD) +@Retention(RUNTIME) +@Constraint(validatedBy = BannedWordValidator.class) +public @interface NotBannedWord { + + String message() default "Cannot use banned word"; + + Class[] groups() default {}; + + Class[] payload() default {}; +} \ No newline at end of file diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/common/annotation/validator/BannedWordValidator.java b/backend/streetdrop-api/src/main/java/com/depromeet/common/annotation/validator/BannedWordValidator.java new file mode 100644 index 00000000..be480892 --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/common/annotation/validator/BannedWordValidator.java @@ -0,0 +1,34 @@ +package com.depromeet.common.annotation.validator; + + +import com.depromeet.common.annotation.NotBannedWord; +import com.depromeet.common.repository.BannedWordRepository; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +@RequiredArgsConstructor +public class BannedWordValidator implements ConstraintValidator { + + private final BannedWordRepository bannedWordRepository; + @Override + @Transactional(readOnly = true) + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + var contentWords = List.of(value.split(" ")); + var bannedWord = bannedWordRepository.findBannedWordsInWordList(contentWords); + + if (!bannedWord.isEmpty()) { + context.buildConstraintViolationWithTemplate("Cannot use banned word : " + bannedWord.get(0)) + .addConstraintViolation(); + return false; + } + + return true; + } +} \ No newline at end of file diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/common/error/dto/ErrorCode.java b/backend/streetdrop-api/src/main/java/com/depromeet/common/error/dto/ErrorCode.java index d258f555..a78ca6b3 100644 --- a/backend/streetdrop-api/src/main/java/com/depromeet/common/error/dto/ErrorCode.java +++ b/backend/streetdrop-api/src/main/java/com/depromeet/common/error/dto/ErrorCode.java @@ -49,7 +49,9 @@ public enum ErrorCode { CAN_NOT_BLOCK_SELF(HttpStatus.BAD_REQUEST, "C-0016", "Can not block myself"), - SEARCH_TERM_NOT_FOUND(HttpStatus.NOT_FOUND, "C-0010", "Search Term Not Found"); + SEARCH_TERM_NOT_FOUND(HttpStatus.NOT_FOUND, "C-0010", "Search Term Not Found"), + + CANNOT_USE_BANNED_WORD(HttpStatus.BAD_REQUEST, "C-0010", "Cannot Use Banned Word"); private final HttpStatus status; private final String code; diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/common/repository/BannedWordRepository.java b/backend/streetdrop-api/src/main/java/com/depromeet/common/repository/BannedWordRepository.java new file mode 100644 index 00000000..6c72dd08 --- /dev/null +++ b/backend/streetdrop-api/src/main/java/com/depromeet/common/repository/BannedWordRepository.java @@ -0,0 +1,15 @@ +package com.depromeet.common.repository; + +import com.depromeet.common.entity.BannedWord; +import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; + +public interface BannedWordRepository extends JpaRepository { + + @Query("SELECT bw.word FROM BannedWord bw WHERE bw.word IN :words") + List findBannedWordsInWordList(@Param("words") List words); + +} \ No newline at end of file diff --git a/backend/streetdrop-api/src/main/java/com/depromeet/domains/item/dto/request/ItemCreateRequestDto.java b/backend/streetdrop-api/src/main/java/com/depromeet/domains/item/dto/request/ItemCreateRequestDto.java index c05225b9..492894fc 100644 --- a/backend/streetdrop-api/src/main/java/com/depromeet/domains/item/dto/request/ItemCreateRequestDto.java +++ b/backend/streetdrop-api/src/main/java/com/depromeet/domains/item/dto/request/ItemCreateRequestDto.java @@ -1,5 +1,6 @@ package com.depromeet.domains.item.dto.request; +import com.depromeet.common.annotation.NotBannedWord; import com.depromeet.domains.music.dto.request.MusicRequestDto; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; @@ -25,5 +26,6 @@ public class ItemCreateRequestDto { @Schema(description = "콘텐츠", example = "블라블라") @NotNull(message = "Content is required") + @NotBannedWord private String content; } diff --git a/backend/streetdrop-api/src/main/resources/db/migration/v20230911__add_banned_word.sql b/backend/streetdrop-api/src/main/resources/db/migration/v20230911__add_banned_word.sql new file mode 100644 index 00000000..d5a6f22f --- /dev/null +++ b/backend/streetdrop-api/src/main/resources/db/migration/v20230911__add_banned_word.sql @@ -0,0 +1,6 @@ +CREATE TABLE banned_word ( + banned_word_id BIGINT AUTO_INCREMENT PRIMARY KEY, + word VARCHAR(20) NOT NULL +); + +CREATE INDEX idx__banned_word_word ON banned_word (word); \ No newline at end of file diff --git a/backend/streetdrop-api/src/test/java/unit/domains/item/controller/ItemControllerTest.java b/backend/streetdrop-api/src/test/java/unit/domains/item/controller/ItemControllerTest.java index a1982b48..2d0f5f1c 100644 --- a/backend/streetdrop-api/src/test/java/unit/domains/item/controller/ItemControllerTest.java +++ b/backend/streetdrop-api/src/test/java/unit/domains/item/controller/ItemControllerTest.java @@ -1,7 +1,9 @@ package unit.domains.item.controller; -import com.depromeet.domains.item.controller.ItemController; +import com.depromeet.common.annotation.validator.BannedWordValidator; import com.depromeet.common.error.GlobalExceptionHandler; +import com.depromeet.common.repository.BannedWordRepository; +import com.depromeet.domains.item.controller.ItemController; import com.depromeet.domains.item.dto.request.ItemCreateRequestDto; import com.depromeet.domains.item.dto.request.ItemLocationRequestDto; import com.depromeet.domains.item.dto.request.NearItemPointRequestDto; @@ -24,6 +26,7 @@ import org.mockito.Mock; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.context.annotation.Import; @@ -41,7 +44,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@ContextConfiguration(classes = ItemController.class) +@ContextConfiguration(classes = {ItemController.class, ValidationAutoConfiguration.class}) @WebMvcTest(controllers = {ItemController.class}, excludeAutoConfiguration = {SecurityAutoConfiguration.class}) @Import({ItemController.class, GlobalExceptionHandler.class}) @DisplayName("[API][Controller] ItemController 테스트") @@ -56,6 +59,12 @@ public class ItemControllerTest { @MockBean ItemService itemService; + @MockBean + BannedWordRepository bannedWordRepository; + + @MockBean + BannedWordValidator bannedWordValidator; + @MockBean ItemLikeService itemLikeService; @@ -76,8 +85,10 @@ void createItem_ValidRequest_ReturnsCreated() throws Exception { ItemCreateRequestDto itemRequestDto = new ItemCreateRequestDto(itemLocationRequestDto, musicRequestDto, "블라블라"); ItemResponseDto itemResponseDto = createValidItemResponseDto(); + given(bannedWordRepository.findBannedWordsInWordList(any())).willReturn(List.of()); given(itemService.create(mockUser, itemRequestDto)).willReturn(itemResponseDto); + var response = mvc.perform( post("/items") .contentType(MediaType.APPLICATION_JSON) @@ -97,6 +108,7 @@ void createItem_InvalidMusicTitle_ReturnsBadRequest() throws Exception { ItemCreateRequestDto itemRequestDto = new ItemCreateRequestDto(itemLocationRequestDto, musicRequestDto, "블라블라"); ItemResponseDto itemResponseDto = createValidItemResponseDto(); + given(bannedWordRepository.findBannedWordsInWordList(any())).willReturn(List.of()); given(itemService.create(mockUser, itemRequestDto)).willReturn(itemResponseDto); var response = mvc.perform( @@ -116,6 +128,7 @@ void createItem_InvalidMusicArtist_ReturnsBadRequest() throws Exception { ItemCreateRequestDto itemRequestDto = new ItemCreateRequestDto(itemLocationRequestDto, musicRequestDto, "블라블라"); ItemResponseDto itemResponseDto = createValidItemResponseDto(); + given(bannedWordRepository.findBannedWordsInWordList(any())).willReturn(List.of()); given(itemService.create(mockUser, itemRequestDto)).willReturn(itemResponseDto); var response = mvc.perform( @@ -135,6 +148,7 @@ void createItem_InvalidMusicAlbum_ReturnsBadRequest() throws Exception { ItemCreateRequestDto itemRequestDto = new ItemCreateRequestDto(itemLocationRequestDto, musicRequestDto, "블라블라"); ItemResponseDto itemResponseDto = createValidItemResponseDto(); + given(bannedWordRepository.findBannedWordsInWordList(any())).willReturn(List.of()); given(itemService.create(mockUser, itemRequestDto)).willReturn(itemResponseDto); var response = mvc.perform( @@ -154,6 +168,7 @@ void createItem_InvalidAlbumCover_ReturnsBadRequest() throws Exception { ItemCreateRequestDto itemRequestDto = new ItemCreateRequestDto(itemLocationRequestDto, musicRequestDto, "블라블라"); ItemResponseDto itemResponseDto = createValidItemResponseDto(); + given(bannedWordRepository.findBannedWordsInWordList(any())).willReturn(List.of()); given(itemService.create(mockUser, itemRequestDto)).willReturn(itemResponseDto); var response = mvc.perform( @@ -173,6 +188,7 @@ void createItem_InvalidLatitudeRequest_ReturnsBadRequest() throws Exception { ItemCreateRequestDto itemRequestDto = new ItemCreateRequestDto(itemLocationRequestDto, musicRequestDto, "블라블라"); ItemResponseDto itemResponseDto = createValidItemResponseDto(); + given(bannedWordRepository.findBannedWordsInWordList(any())).willReturn(List.of()); given(itemService.create(mockUser, itemRequestDto)).willReturn(itemResponseDto); var response = mvc.perform( @@ -192,6 +208,7 @@ void createItem_InvalidLogitudeRequest_ReturnsBadRequest() throws Exception { ItemCreateRequestDto itemRequestDto = new ItemCreateRequestDto(itemLocationRequestDto, musicRequestDto, "블라블라"); ItemResponseDto itemResponseDto = createValidItemResponseDto(); + given(bannedWordRepository.findBannedWordsInWordList(any())).willReturn(List.of()); given(itemService.create(mockUser, itemRequestDto)).willReturn(itemResponseDto); var response = mvc.perform( @@ -211,6 +228,7 @@ void createItem_InvalidAddressRequest_ReturnsBadRequest() throws Exception { ItemCreateRequestDto itemRequestDto = new ItemCreateRequestDto(itemLocationRequestDto, musicRequestDto, "블라블라"); ItemResponseDto itemResponseDto = createValidItemResponseDto(); + given(bannedWordRepository.findBannedWordsInWordList(any())).willReturn(List.of()); given(itemService.create(mockUser, itemRequestDto)).willReturn(itemResponseDto); var response = mvc.perform( @@ -237,8 +255,29 @@ void createItem_InvalidContentRequest_ReturnsBadRequest() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(itemRequestDto))); - response.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Content is required")); + + System.out.println(response.andReturn().getResponse().getContentAsString()); + response.andExpect(jsonPath("$.message").value("Content is required")).andExpect(status().isBadRequest()) + ; + } + + @DisplayName("컨텐츠 유효성 검사 실패 - 금칙어 사용된 경우") + @Test + void createItem_BannedWordInclude_ReturnsBadRequest() throws Exception { + MusicRequestDto musicRequestDto = new MusicRequestDto("Love Dive", "IVE", "1st EP IVE", "https://www.youtube.com/watch?v=YGieI3KoeZk", List.of("K-POP", "HipHop")); + ItemLocationRequestDto itemLocationRequestDto = new ItemLocationRequestDto(37.123456, 127.123456, "서울시 성수동 성수 1가"); + ItemCreateRequestDto itemRequestDto = new ItemCreateRequestDto(itemLocationRequestDto, musicRequestDto, "나쁜 말"); + ItemResponseDto itemResponseDto = createValidItemResponseDto(); + + given(bannedWordRepository.findBannedWordsInWordList(any())).willReturn(List.of("나쁜")); + given(itemService.create(mockUser, itemRequestDto)).willReturn(itemResponseDto); + + var response = mvc.perform( + post("/items") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(itemRequestDto))); + + response.andExpect(status().isBadRequest()); } } diff --git a/backend/streetdrop-domain/src/main/java/com/depromeet/common/entity/BannedWord.java b/backend/streetdrop-domain/src/main/java/com/depromeet/common/entity/BannedWord.java new file mode 100644 index 00000000..0e52c934 --- /dev/null +++ b/backend/streetdrop-domain/src/main/java/com/depromeet/common/entity/BannedWord.java @@ -0,0 +1,23 @@ +package com.depromeet.common.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import static jakarta.persistence.GenerationType.IDENTITY; +import static lombok.AccessLevel.PROTECTED; + +@Getter +@NoArgsConstructor(access = PROTECTED) +@Entity +@Table(indexes = { + @Index(name = "idx__banned_word_word", columnList = "word") +}) +public class BannedWord { + @Id + @GeneratedValue(strategy = IDENTITY) + @Column(name = "banned_word_id") + private Long id; + + @Column(length = 20, nullable = false) + private String word; +} \ No newline at end of file