diff --git a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java index 7b71469aa..824c531f6 100644 --- a/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java +++ b/src/main/java/com/example/solidconnection/common/exception/ErrorCode.java @@ -44,6 +44,7 @@ public enum ErrorCode { LANGUAGE_TEST_SCORE_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 어학성적입니다."), NEWS_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 소식지입니다."), MENTOR_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 멘토입니다."), + REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "존재하지 않는 신고 대상입니다."), // auth USER_ALREADY_SIGN_OUT(HttpStatus.UNAUTHORIZED.value(), "로그아웃 되었습니다."), @@ -78,7 +79,7 @@ public enum ErrorCode { // community INVALID_POST_CATEGORY(HttpStatus.BAD_REQUEST.value(), "잘못된 카테고리명입니다."), INVALID_BOARD_CODE(HttpStatus.BAD_REQUEST.value(), "잘못된 게시판 코드입니다."), - INVALID_POST_ID(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 게시글입니다."), + INVALID_POST_ID(HttpStatus.BAD_REQUEST.value(), "존재하지 않는 게시글입니다."), // todo: NOT_FOUND로 통일 필요 INVALID_POST_ACCESS(HttpStatus.BAD_REQUEST.value(), "자신의 게시글만 제어할 수 있습니다."), CAN_NOT_DELETE_OR_UPDATE_QUESTION(HttpStatus.BAD_REQUEST.value(), "질문글은 수정이나 삭제할 수 없습니다."), CAN_NOT_UPLOAD_MORE_THAN_FIVE_IMAGES(HttpStatus.BAD_REQUEST.value(), "5개 이상의 파일을 업로드할 수 없습니다."), @@ -111,6 +112,9 @@ public enum ErrorCode { UNAUTHORIZED_MENTORING(HttpStatus.FORBIDDEN.value(), "멘토링 권한이 없습니다."), MENTORING_ALREADY_CONFIRMED(HttpStatus.BAD_REQUEST.value(), "이미 승인 또는 거절된 멘토링입니다."), + // report + ALREADY_REPORTED_BY_CURRENT_USER(HttpStatus.BAD_REQUEST.value(), "이미 신고한 상태입니다."), + // database DATA_INTEGRITY_VIOLATION(HttpStatus.CONFLICT.value(), "데이터베이스 무결성 제약조건 위반이 발생했습니다."), diff --git a/src/main/java/com/example/solidconnection/report/controller/ReportController.java b/src/main/java/com/example/solidconnection/report/controller/ReportController.java new file mode 100644 index 000000000..cab986f92 --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/controller/ReportController.java @@ -0,0 +1,30 @@ +package com.example.solidconnection.report.controller; + +import com.example.solidconnection.common.resolver.AuthorizedUser; +import com.example.solidconnection.report.dto.ReportRequest; +import com.example.solidconnection.report.service.ReportService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/reports") +public class ReportController { + + private final ReportService reportService; + + @PostMapping + public ResponseEntity createReport( + @AuthorizedUser SiteUser siteUser, + @Valid @RequestBody ReportRequest reportRequest + ) { + reportService.createReport(siteUser.getId(), reportRequest); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/example/solidconnection/report/domain/Report.java b/src/main/java/com/example/solidconnection/report/domain/Report.java new file mode 100644 index 000000000..e723db281 --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/domain/Report.java @@ -0,0 +1,52 @@ +package com.example.solidconnection.report.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint( + name = "uk_report_reporter_id_target_type_target_id", + columnNames = {"reporter_id", "target_type", "target_id"} + ) +}) +public class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "reporter_id") + private long reporterId; + + @Column(name = "report_type") + @Enumerated(value = EnumType.STRING) + private ReportType reportType; + + @Column(name = "target_type") + @Enumerated(value = EnumType.STRING) + private TargetType targetType; + + @Column(name = "target_id") + private long targetId; + + public Report(long reporterId, ReportType reportType, TargetType targetType, long targetId) { + this.reportType = reportType; + this.reporterId = reporterId; + this.targetType = targetType; + this.targetId = targetId; + } +} diff --git a/src/main/java/com/example/solidconnection/report/domain/ReportType.java b/src/main/java/com/example/solidconnection/report/domain/ReportType.java new file mode 100644 index 000000000..18a5e5d9b --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/domain/ReportType.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.report.domain; + +public enum ReportType { + + ADVERTISEMENT, // 광고 + SPAM, // 낚시/도배 + PERSONAL_INFO_EXPOSURE, // 개인정보 노출 + PORNOGRAPHY, // 선정성 + COPYRIGHT_INFRINGEMENT, // 저작권 침해 + ILLEGAL_ACTIVITY, // 불법 행위 + IMPERSONATION, // 사칭/도용 + INSULT, // 욕설/비하 + ; +} diff --git a/src/main/java/com/example/solidconnection/report/domain/TargetType.java b/src/main/java/com/example/solidconnection/report/domain/TargetType.java new file mode 100644 index 000000000..c48f50ac0 --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/domain/TargetType.java @@ -0,0 +1,7 @@ +package com.example.solidconnection.report.domain; + +public enum TargetType { + + POST, + ; +} diff --git a/src/main/java/com/example/solidconnection/report/dto/ReportRequest.java b/src/main/java/com/example/solidconnection/report/dto/ReportRequest.java new file mode 100644 index 000000000..52f608015 --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/dto/ReportRequest.java @@ -0,0 +1,17 @@ +package com.example.solidconnection.report.dto; + +import com.example.solidconnection.report.domain.ReportType; +import com.example.solidconnection.report.domain.TargetType; +import jakarta.validation.constraints.NotNull; + +public record ReportRequest( + @NotNull(message = "신고 유형을 선택해주세요.") + ReportType reportType, + + @NotNull(message = "신고 대상을 포함해주세요.") + TargetType targetType, + + long targetId +) { + +} diff --git a/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java new file mode 100644 index 000000000..c32d3cd5f --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/repository/ReportRepository.java @@ -0,0 +1,10 @@ +package com.example.solidconnection.report.repository; + +import com.example.solidconnection.report.domain.Report; +import com.example.solidconnection.report.domain.TargetType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { + + boolean existsByReporterIdAndTargetTypeAndTargetId(long reporterId, TargetType targetType, long targetId); +} diff --git a/src/main/java/com/example/solidconnection/report/service/ReportService.java b/src/main/java/com/example/solidconnection/report/service/ReportService.java new file mode 100644 index 000000000..3546861ea --- /dev/null +++ b/src/main/java/com/example/solidconnection/report/service/ReportService.java @@ -0,0 +1,50 @@ +package com.example.solidconnection.report.service; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.community.post.repository.PostRepository; +import com.example.solidconnection.report.domain.Report; +import com.example.solidconnection.report.domain.TargetType; +import com.example.solidconnection.report.dto.ReportRequest; +import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private final ReportRepository reportRepository; + private final SiteUserRepository siteUserRepository; + private final PostRepository postRepository; + + @Transactional + public void createReport(long reporterId, ReportRequest request) { + validateReporterExists(reporterId); + validateTargetExists(request.targetType(), request.targetId()); + validateFirstReportByUser(reporterId, request.targetType(), request.targetId()); + + Report report = new Report(reporterId, request.reportType(), request.targetType(), request.targetId()); + reportRepository.save(report); + } + + private void validateReporterExists(long reporterId) { + if (!siteUserRepository.existsById(reporterId)) { + throw new CustomException(ErrorCode.USER_NOT_FOUND); + } + } + + private void validateTargetExists(TargetType targetType, long targetId) { + if (targetType == TargetType.POST && !postRepository.existsById(targetId)) { + throw new CustomException(ErrorCode.REPORT_TARGET_NOT_FOUND); + } + } + + private void validateFirstReportByUser(long reporterId, TargetType targetType, long targetId) { + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId(reporterId, targetType, targetId)) { + throw new CustomException(ErrorCode.ALREADY_REPORTED_BY_CURRENT_USER); + } + } +} diff --git a/src/main/resources/db/migration/V24__create_report_table.sql b/src/main/resources/db/migration/V24__create_report_table.sql new file mode 100644 index 000000000..f40b6f562 --- /dev/null +++ b/src/main/resources/db/migration/V24__create_report_table.sql @@ -0,0 +1,11 @@ +CREATE TABLE report +( + id BIGINT NOT NULL AUTO_INCREMENT, + reporter_id BIGINT NOT NULL, + target_type ENUM ('POST') NOT NULL, + target_id BIGINT NOT NULL, + report_type ENUM ('ADVERTISEMENT', 'SPAM', 'PERSONAL_INFO_EXPOSURE', 'PORNOGRAPHY', 'COPYRIGHT_INFRINGEMENT', 'ILLEGAL_ACTIVITY', 'IMPERSONATION', 'INSULT') NOT NULL, + primary key (id), + constraint fk_report_reporter_id foreign key (reporter_id) references site_user (id), + unique uk_report_reporter_id_target_type_target_id (reporter_id, target_type, target_id) +); diff --git a/src/test/java/com/example/solidconnection/community/post/fixture/PostFixture.java b/src/test/java/com/example/solidconnection/community/post/fixture/PostFixture.java index 5d6eaea55..5ddf13888 100644 --- a/src/test/java/com/example/solidconnection/community/post/fixture/PostFixture.java +++ b/src/test/java/com/example/solidconnection/community/post/fixture/PostFixture.java @@ -13,6 +13,21 @@ public class PostFixture { private final PostFixtureBuilder postFixtureBuilder; + public Post 게시글( + Board board, + SiteUser siteUser + ) { + return postFixtureBuilder + .title("제목") + .content("내용") + .isQuestion(false) + .likeCount(0L) + .postCategory(PostCategory.자유) + .board(board) + .siteUser(siteUser) + .create(); + } + public Post 게시글( String title, String content, diff --git a/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java b/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java new file mode 100644 index 000000000..91c837bf3 --- /dev/null +++ b/src/test/java/com/example/solidconnection/report/fixture/ReportFixture.java @@ -0,0 +1,21 @@ +package com.example.solidconnection.report.fixture; + +import com.example.solidconnection.report.domain.Report; +import com.example.solidconnection.report.domain.TargetType; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ReportFixture { + + private final ReportFixtureBuilder reportFixtureBuilder; + + public Report 신고(long reporterId, TargetType targetType, long targetId) { + return reportFixtureBuilder.report() + .reporterId(reporterId) + .targetType(targetType) + .targetId(targetId) + .create(); + } +} diff --git a/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java b/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java new file mode 100644 index 000000000..08d0b276c --- /dev/null +++ b/src/test/java/com/example/solidconnection/report/fixture/ReportFixtureBuilder.java @@ -0,0 +1,54 @@ +package com.example.solidconnection.report.fixture; + +import com.example.solidconnection.report.domain.Report; +import com.example.solidconnection.report.domain.ReportType; +import com.example.solidconnection.report.domain.TargetType; +import com.example.solidconnection.report.repository.ReportRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.test.context.TestComponent; + +@TestComponent +@RequiredArgsConstructor +public class ReportFixtureBuilder { + + private final ReportRepository reportRepository; + + private long reporterId; + private TargetType targetType; + private long targetId; + private ReportType reportType = ReportType.ADVERTISEMENT; + + public ReportFixtureBuilder report() { + return new ReportFixtureBuilder(reportRepository); + } + + public ReportFixtureBuilder reporterId(long reporterId) { + this.reporterId = reporterId; + return this; + } + + public ReportFixtureBuilder targetType(TargetType targetType) { + this.targetType = targetType; + return this; + } + + public ReportFixtureBuilder targetId(long targetId) { + this.targetId = targetId; + return this; + } + + public ReportFixtureBuilder reasonType(ReportType reportType) { + this.reportType = reportType; + return this; + } + + public Report create() { + Report report = new Report( + reporterId, + reportType, + targetType, + targetId + ); + return reportRepository.save(report); + } +} diff --git a/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java new file mode 100644 index 000000000..23523ae34 --- /dev/null +++ b/src/test/java/com/example/solidconnection/report/service/ReportServiceTest.java @@ -0,0 +1,99 @@ +package com.example.solidconnection.report.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.community.board.domain.Board; +import com.example.solidconnection.community.board.fixture.BoardFixture; +import com.example.solidconnection.community.post.domain.Post; +import com.example.solidconnection.community.post.fixture.PostFixture; +import com.example.solidconnection.report.domain.ReportType; +import com.example.solidconnection.report.domain.TargetType; +import com.example.solidconnection.report.dto.ReportRequest; +import com.example.solidconnection.report.fixture.ReportFixture; +import com.example.solidconnection.report.repository.ReportRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.fixture.SiteUserFixture; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayName("신고 서비스 테스트") +@TestContainerSpringBootTest +class ReportServiceTest { + + @Autowired + private ReportService reportService; + + @Autowired + private ReportRepository reportRepository; + + @Autowired + private BoardFixture boardFixture; + + @Autowired + private PostFixture postFixture; + + @Autowired + private SiteUserFixture siteUserFixture; + + @Autowired + private ReportFixture reportFixture; + + private SiteUser siteUser; + private Post post; + + @BeforeEach + void setUp() { + siteUser = siteUserFixture.사용자(); + Board board = boardFixture.자유게시판(); + post = postFixture.게시글(board, siteUser); + } + + @Nested + class 신고_생성 { + + @Test + void 정상적으로_신고한다() { + // given + ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.POST, post.getId()); + + // when + reportService.createReport(siteUser.getId(), request); + + // then + boolean isSaved = reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + siteUser.getId(), TargetType.POST, post.getId()); + assertThat(isSaved).isTrue(); + } + + @Test + void 신고_대상이_존재하지_않으면_예외가_발생한다() { + // given + long notExistingId = 999L; + ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.POST, notExistingId); + + // when & then + assertThatCode(() -> reportService.createReport(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.REPORT_TARGET_NOT_FOUND.getMessage()); + } + + @Test + void 이미_신고한_경우_예외가_발생한다() { + // given + reportFixture.신고(siteUser.getId(), TargetType.POST, post.getId()); + ReportRequest request = new ReportRequest(ReportType.INSULT, TargetType.POST, post.getId()); + + // when & then + assertThatCode(() -> reportService.createReport(siteUser.getId(), request)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.ALREADY_REPORTED_BY_CURRENT_USER.getMessage()); + } + } +}