diff --git a/docker-compose-prod.yml b/docker-compose-prod.yml index b8ec4792..34527521 100644 --- a/docker-compose-prod.yml +++ b/docker-compose-prod.yml @@ -49,6 +49,7 @@ services: DISCORD_ERROR_WEBHOOK_URL: ${DISCORD_ERROR_WEBHOOK_URL} DISCORD_REPORT_WEBHOOK_URL: ${DISCORD_REPORT_WEBHOOK_URL} + DISCORD_MODERATION_WEBHOOK_URL: ${DISCORD_MODERATION_WEBHOOK_URL} REPORT_CRON_DAILY: ${REPORT_CRON_DAILY:-0 0 9 * * *} REPORT_CRON_WEEKLY: ${REPORT_CRON_WEEKLY:-0 10 9 * * MON} depends_on: diff --git a/src/main/java/com/cotato/nextstation/domain/journal/repository/JournalRepository.java b/src/main/java/com/cotato/nextstation/domain/journal/repository/JournalRepository.java index 4f9d62f0..beaec0af 100644 --- a/src/main/java/com/cotato/nextstation/domain/journal/repository/JournalRepository.java +++ b/src/main/java/com/cotato/nextstation/domain/journal/repository/JournalRepository.java @@ -1,5 +1,6 @@ package com.cotato.nextstation.domain.journal.repository; +import com.cotato.nextstation.domain.moderation.dto.ReportTarget; import com.cotato.nextstation.domain.journal.entity.Journal; import com.cotato.nextstation.domain.station.entity.LineCode; import org.springframework.data.domain.Pageable; @@ -18,6 +19,11 @@ public interface JournalRepository extends JpaRepository { boolean existsByIdAndMember_Id(Long journalId, Long memberId); boolean existsByMemberStampId(Long memberStampId); + // 신고 대상 확인용, @SQLRestriction으로 삭제된 일지는 제외되므로 결과가 비면 신고할 수 없는 대상이다. + @Query("SELECT new com.cotato.nextstation.domain.moderation.dto.ReportTarget(j.member.id, j.title) " + + "FROM Journal j WHERE j.id = :journalId") + Optional findReportTargetById(@Param("journalId") Long journalId); + // 이미 여행일지가 작성된 memberStampId 목록 조회 @Query("SELECT j.memberStampId FROM Journal j WHERE j.member.id = :memberId") Set findCompletedMemberStampIdsByMemberId(@Param("memberId") Long memberId); diff --git a/src/main/java/com/cotato/nextstation/domain/member/repository/MemberRepository.java b/src/main/java/com/cotato/nextstation/domain/member/repository/MemberRepository.java index 1f4ef793..d6de3290 100644 --- a/src/main/java/com/cotato/nextstation/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/cotato/nextstation/domain/member/repository/MemberRepository.java @@ -2,6 +2,7 @@ import com.cotato.nextstation.domain.member.entity.Member; import com.cotato.nextstation.domain.member.entity.MemberStatus; +import com.cotato.nextstation.domain.moderation.dto.ReportTarget; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -27,6 +28,11 @@ public interface MemberRepository extends JpaRepository { */ String NOT_WITHDRAWN = "mem.status <> com.cotato.nextstation.domain.member.entity.MemberStatus.WITHDRAWN"; + // 신고 대상 확인용, 탈퇴한 회원은 조회되지 않으므로 결과가 비면 신고할 수 없는 대상이다. + @Query("SELECT new com.cotato.nextstation.domain.moderation.dto.ReportTarget(mem.id, mem.nickname) " + + "FROM Member mem WHERE mem.id = :memberId AND " + NOT_WITHDRAWN) + Optional findReportTargetById(@Param("memberId") Long memberId); + Optional findByEmail(String email); boolean existsByEmail(String email); diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/controller/ContentReportController.java b/src/main/java/com/cotato/nextstation/domain/moderation/controller/ContentReportController.java new file mode 100644 index 00000000..9367d3f5 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/controller/ContentReportController.java @@ -0,0 +1,58 @@ +package com.cotato.nextstation.domain.moderation.controller; + +import com.cotato.nextstation.domain.moderation.dto.request.ContentReportRequest; +import com.cotato.nextstation.domain.moderation.dto.response.ContentReportResponse; +import com.cotato.nextstation.domain.moderation.service.command.ContentReportCommandService; +import com.cotato.nextstation.global.common.response.CommonResponse; +import com.cotato.nextstation.global.security.AuthenticationPrincipal; +import com.cotato.nextstation.global.security.JwtPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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; + +@Tag(name = "콘텐츠 신고") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/reports") +public class ContentReportController { + + private final ContentReportCommandService contentReportCommandService; + + @Operation( + summary = "콘텐츠 신고", + description = """ + 여행일지, 장소 리뷰 또는 다른 이용자의 프로필을 신고한다. + - targetType으로 대상 종류를, targetId로 해당 대상의 id를 보낸다. + - `PROFILE`은 targetId에 신고할 회원의 memberId를 보낸다. + - 삭제됐거나 존재하지 않는 대상은 신고할 수 없다. (탈퇴한 회원 포함) + - 본인이 작성한 콘텐츠와 본인 프로필은 신고할 수 없다. + - 같은 대상을 두 번 신고하면 실패한다. + - 로그인 필수다. 토큰이 없으면 401이다. + """ + ) + @SecurityRequirement(name = "accessTokenAuth") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "신고 접수 성공"), + @ApiResponse(responseCode = "400", description = """ + 필수값 누락 또는 허용되지 않는 값 (`GlobalErrorCode.VALIDATION_ERROR`) + 또는 본인이 작성한 콘텐츠·본인 프로필을 신고 (`ReportErrorCode.SELF_REPORT_NOT_ALLOWED`)"""), + @ApiResponse(responseCode = "401", description = "accessToken이 없거나 위변조·만료 (`GlobalErrorCode.UNAUTHORIZED`, `GlobalErrorCode.INVALID_TOKEN`, `GlobalErrorCode.EXPIRED_TOKEN`)"), + @ApiResponse(responseCode = "404", description = "존재하지 않거나 삭제된 신고 대상 (`ReportErrorCode.REPORT_TARGET_NOT_FOUND`)"), + @ApiResponse(responseCode = "409", description = "이미 신고한 대상 (`ReportErrorCode.REPORT_ALREADY_EXISTS`)"), + }) + @PostMapping + public CommonResponse report( + @Parameter(hidden = true) @AuthenticationPrincipal JwtPrincipal principal, + @Valid @RequestBody ContentReportRequest request) { + return CommonResponse.success(contentReportCommandService.report(principal.memberId(), request)); + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/dto/ReportTarget.java b/src/main/java/com/cotato/nextstation/domain/moderation/dto/ReportTarget.java new file mode 100644 index 00000000..f08782d4 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/dto/ReportTarget.java @@ -0,0 +1,13 @@ +package com.cotato.nextstation.domain.moderation.dto; + +/** + * 신고 대상 조회 결과. + *

+ * 작성자 확인과 알림용 본문을 한 번의 조회로 함께 가져온다. + * 알림 시점(커밋 이후)에 다시 조회하면 그 사이 삭제된 대상을 또 처리해야 하므로 여기서 함께 읽는다. + * + * @param authorId 대상 작성자의 회원 id + * @param body 알림에 실을 본문. 리뷰는 내용이 비어 있을 수 있어 null이 올 수 있다. + */ +public record ReportTarget(Long authorId, String body) { +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/dto/request/ContentReportRequest.java b/src/main/java/com/cotato/nextstation/domain/moderation/dto/request/ContentReportRequest.java new file mode 100644 index 00000000..f7e31a4b --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/dto/request/ContentReportRequest.java @@ -0,0 +1,30 @@ +package com.cotato.nextstation.domain.moderation.dto.request; + +import com.cotato.nextstation.domain.moderation.enums.ReportReason; +import com.cotato.nextstation.domain.moderation.enums.ReportTargetType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "콘텐츠 신고 요청") +public record ContentReportRequest( + + @Schema( + description = "신고 대상 종류", example = "PLACE_REVIEW", + allowableValues = {"JOURNAL", "PLACE_REVIEW", "PROFILE"} + ) + @NotNull(message = "신고 대상 종류는 필수입니다.") + ReportTargetType targetType, + + @Schema(description = "신고 대상 id", example = "501") + @NotNull(message = "신고 대상 id는 필수입니다.") + Long targetId, + + @Schema( + description = "신고 사유", example = "SPAM_AD", + allowableValues = {"ABUSIVE_CONTENT", "SPAM_AD", "HATE_OR_OFFENSIVE", + "IRRELEVANT", "ETC"} + ) + @NotNull(message = "신고 사유는 필수입니다.") + ReportReason reason +) { +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/dto/response/ContentReportResponse.java b/src/main/java/com/cotato/nextstation/domain/moderation/dto/response/ContentReportResponse.java new file mode 100644 index 00000000..d27f2c0f --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/dto/response/ContentReportResponse.java @@ -0,0 +1,10 @@ +package com.cotato.nextstation.domain.moderation.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "콘텐츠 신고 접수 결과") +public record ContentReportResponse( + @Schema(description = "접수된 신고 id", example = "12") + Long reportId +) { +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/entity/ContentReport.java b/src/main/java/com/cotato/nextstation/domain/moderation/entity/ContentReport.java new file mode 100644 index 00000000..bfed199c --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/entity/ContentReport.java @@ -0,0 +1,53 @@ +package com.cotato.nextstation.domain.moderation.entity; + +import com.cotato.nextstation.domain.moderation.enums.ReportReason; +import com.cotato.nextstation.domain.moderation.enums.ReportTargetType; +import com.cotato.nextstation.global.entity.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "content_report", + uniqueConstraints = @UniqueConstraint( + name = "uk_content_report_reporter_target", + columnNames = {"reporter_id", "target_type", "target_id"} + ), + indexes = @Index(name = "idx_content_report_target", columnList = "target_type, target_id") +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ContentReport extends BaseTimeEntity { + + @Column(name = "reporter_id", nullable = false) + private Long reporterId; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 20) + private ReportTargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private ReportReason reason; + + @Builder + public ContentReport(Long reporterId, ReportTargetType targetType, Long targetId, + ReportReason reason) { + this.reporterId = reporterId; + this.targetType = targetType; + this.targetId = targetId; + this.reason = reason; + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/enums/ReportReason.java b/src/main/java/com/cotato/nextstation/domain/moderation/enums/ReportReason.java new file mode 100644 index 00000000..0e67a737 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/enums/ReportReason.java @@ -0,0 +1,19 @@ +package com.cotato.nextstation.domain.moderation.enums; + +public enum ReportReason { + ABUSIVE_CONTENT("욕설·비방 등 부적절한 콘텐츠"), + SPAM_AD("스팸·광고성 콘텐츠"), + HATE_OR_OFFENSIVE("혐오·불쾌감을 주는 콘텐츠"), + IRRELEVANT("서비스와 관련 없는 콘텐츠"), + ETC("기타"); + + private final String label; + + ReportReason(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/enums/ReportTargetType.java b/src/main/java/com/cotato/nextstation/domain/moderation/enums/ReportTargetType.java new file mode 100644 index 00000000..a81b313b --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/enums/ReportTargetType.java @@ -0,0 +1,33 @@ +package com.cotato.nextstation.domain.moderation.enums; + +public enum ReportTargetType { + JOURNAL("여행일지", "journalId", "제목"), + PLACE_REVIEW("장소 리뷰", "reviewId", "리뷰 내용"), + PROFILE("프로필", "memberId", null); + + // 알림 제목에 쓰는 대상 종류 + private final String label; + + // 알림에서 targetId가 어느 테이블의 id인지 드러내기 위한 표기 + private final String idLabel; + + private final String bodyLabel; + + ReportTargetType(String label, String idLabel, String bodyLabel) { + this.label = label; + this.idLabel = idLabel; + this.bodyLabel = bodyLabel; + } + + public String getLabel() { + return label; + } + + public String getIdLabel() { + return idLabel; + } + + public String getBodyLabel() { + return bodyLabel; + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/event/ContentReportedEvent.java b/src/main/java/com/cotato/nextstation/domain/moderation/event/ContentReportedEvent.java new file mode 100644 index 00000000..246c0cbf --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/event/ContentReportedEvent.java @@ -0,0 +1,19 @@ +package com.cotato.nextstation.domain.moderation.event; + +import com.cotato.nextstation.domain.moderation.enums.ReportReason; +import com.cotato.nextstation.domain.moderation.enums.ReportTargetType; + +/** + * 신고가 저장된 뒤 발행한다. + *

+ * 알림 전송을 커밋 이후로 미뤄, 웹훅 응답을 기다리는 동안 DB 커넥션을 붙잡지 않는다. + */ +public record ContentReportedEvent( + Long reportId, + Long reporterId, + ReportTargetType targetType, + Long targetId, + ReportReason reason, + String targetBody +) { +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/exception/ReportErrorCode.java b/src/main/java/com/cotato/nextstation/domain/moderation/exception/ReportErrorCode.java new file mode 100644 index 00000000..e73124d4 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/exception/ReportErrorCode.java @@ -0,0 +1,22 @@ +package com.cotato.nextstation.domain.moderation.exception; + +import com.cotato.nextstation.global.exception.error.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ReportErrorCode implements ErrorCode { + + SELF_REPORT_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "CLIENT_ERROR_400_SELF_REPORT_NOT_ALLOWED", "본인이 작성한 콘텐츠와 본인 프로필은 신고할 수 없습니다."), + + REPORT_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "CLIENT_ERROR_404_REPORT_TARGET_NOT_FOUND", "존재하지 않는 신고 대상입니다."), + + REPORT_ALREADY_EXISTS(HttpStatus.CONFLICT, "CLIENT_ERROR_409_REPORT_ALREADY_EXISTS", "이미 신고한 대상입니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/repository/ContentReportRepository.java b/src/main/java/com/cotato/nextstation/domain/moderation/repository/ContentReportRepository.java new file mode 100644 index 00000000..d8a14b32 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/repository/ContentReportRepository.java @@ -0,0 +1,7 @@ +package com.cotato.nextstation.domain.moderation.repository; + +import com.cotato.nextstation.domain.moderation.entity.ContentReport; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ContentReportRepository extends JpaRepository { +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/service/ContentReportNotifier.java b/src/main/java/com/cotato/nextstation/domain/moderation/service/ContentReportNotifier.java new file mode 100644 index 00000000..f8f45252 --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/service/ContentReportNotifier.java @@ -0,0 +1,72 @@ +package com.cotato.nextstation.domain.moderation.service; + +import com.cotato.nextstation.domain.moderation.event.ContentReportedEvent; +import com.cotato.nextstation.domain.report.client.DiscordWebhookClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +// 접수된 콘텐츠 신고를 디스코드에 연동 +@Slf4j +@Component +public class ContentReportNotifier { + + private static final int EMBED_COLOR = 0xEF4444; + + private static final int BODY_MAX_LENGTH = 100; + private static final String EMPTY_BODY = "_(내용 없음)_"; + + private static final DateTimeFormatter REPORTED_AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + private static final ZoneId REPORT_ZONE = ZoneId.of("Asia/Seoul"); + + private final DiscordWebhookClient discordWebhookClient; + private final String webhookUrl; + + public ContentReportNotifier(DiscordWebhookClient discordWebhookClient, + @Value("${moderation.discord.webhook-url:}") String webhookUrl) { + this.discordWebhookClient = discordWebhookClient; + this.webhookUrl = webhookUrl; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onContentReported(ContentReportedEvent event) { + + StringBuilder description = new StringBuilder() + .append(line("대상", "`%s=%d`".formatted(event.targetType().getIdLabel(), event.targetId()))) + .append(line("사유", event.reason().getLabel())) + .append(line("신고자", "`memberId=%d`".formatted(event.reporterId()))); + + if (event.targetType().getBodyLabel() != null) { + description.append(line(event.targetType().getBodyLabel(), truncate(event.targetBody()))); + } + + Map payload = Map.of("embeds", List.of(Map.of( + "title", "🚨 %s 신고 접수 · reportId=%d".formatted(event.targetType().getLabel(), event.reportId()), + "color", EMBED_COLOR, + "description", description.toString(), + "footer", Map.of("text", LocalDateTime.now(REPORT_ZONE).format(REPORTED_AT_FORMAT))))); + + discordWebhookClient.send(webhookUrl, payload); + } + + private String line(String name, String value) { + return "**%s** %s\n\n".formatted(name, value); + } + + private String truncate(String body) { + if (body == null || body.isBlank()) { + return EMPTY_BODY; + } + + return body.length() <= BODY_MAX_LENGTH ? body : body.substring(0, BODY_MAX_LENGTH) + "..."; + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/moderation/service/command/ContentReportCommandService.java b/src/main/java/com/cotato/nextstation/domain/moderation/service/command/ContentReportCommandService.java new file mode 100644 index 00000000..7df38f0c --- /dev/null +++ b/src/main/java/com/cotato/nextstation/domain/moderation/service/command/ContentReportCommandService.java @@ -0,0 +1,93 @@ +package com.cotato.nextstation.domain.moderation.service.command; + +import com.cotato.nextstation.domain.journal.repository.JournalRepository; +import com.cotato.nextstation.domain.member.repository.MemberRepository; +import com.cotato.nextstation.domain.moderation.dto.ReportTarget; +import com.cotato.nextstation.domain.moderation.dto.request.ContentReportRequest; +import com.cotato.nextstation.domain.moderation.dto.response.ContentReportResponse; +import com.cotato.nextstation.domain.moderation.entity.ContentReport; +import com.cotato.nextstation.domain.moderation.enums.ReportTargetType; +import com.cotato.nextstation.domain.moderation.event.ContentReportedEvent; +import com.cotato.nextstation.domain.moderation.exception.ReportErrorCode; +import com.cotato.nextstation.domain.moderation.repository.ContentReportRepository; +import com.cotato.nextstation.domain.place.repository.PlaceReviewRepository; +import com.cotato.nextstation.global.exception.CustomException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class ContentReportCommandService { + + private final ContentReportRepository contentReportRepository; + private final JournalRepository journalRepository; + private final PlaceReviewRepository placeReviewRepository; + private final MemberRepository memberRepository; + private final ApplicationEventPublisher eventPublisher; + + public ContentReportResponse report(Long reporterId, ContentReportRequest request) { + ReportTargetType targetType = request.targetType(); + Long targetId = request.targetId(); + log.info("콘텐츠 신고 접수 요청: reporterId={}, targetType={}, targetId={}, reason={}", + reporterId, targetType, targetId, request.reason()); + + ReportTarget target = findTarget(targetType, targetId); + if (target.authorId().equals(reporterId)) { + log.warn("본인 대상 신고 차단: reporterId={}, targetType={}, targetId={}", + reporterId, targetType, targetId); + throw new CustomException(ReportErrorCode.SELF_REPORT_NOT_ALLOWED); + } + + ContentReport report = ContentReport.builder() + .reporterId(reporterId) + .targetType(targetType) + .targetId(targetId) + .reason(request.reason()) + .build(); + + ContentReport saved; + try { + saved = contentReportRepository.save(report); + } catch (DataIntegrityViolationException e) { + // UNIQUE(reporter_id, target_type, target_id) 위반. 같은 대상을 다시 신고한 경우다. + log.warn("중복 신고 차단: reporterId={}, targetType={}, targetId={}", + reporterId, targetType, targetId); + throw new CustomException(ReportErrorCode.REPORT_ALREADY_EXISTS); + } + + log.info("콘텐츠 신고 접수 완료: reportId={}, reporterId={}", saved.getId(), reporterId); + + eventPublisher.publishEvent(new ContentReportedEvent( + saved.getId(), reporterId, targetType, targetId, request.reason(), target.body())); + + return new ContentReportResponse(saved.getId()); + } + + /** + * 신고 대상의 작성자와 본문을 조회한다. 대상이 없거나 삭제된 경우 신고할 수 없다. + *

+ * 일지와 리뷰는 엔티티에 {@code @SQLRestriction(is_deleted = false)}이 걸려 있어 삭제된 행이 + * 조회 단계에서 빠지고, 회원은 쿼리에서 탈퇴 회원을 걸러낸다. + * 프로필 신고는 회원 자신이 작성자이자 대상이므로 본인 신고 차단이 그대로 적용된다. + */ + private ReportTarget findTarget(ReportTargetType targetType, Long targetId) { + Optional target = switch (targetType) { + case JOURNAL -> journalRepository.findReportTargetById(targetId); + case PLACE_REVIEW -> placeReviewRepository.findReportTargetById(targetId); + case PROFILE -> memberRepository.findReportTargetById(targetId); + }; + + return target.orElseThrow(() -> { + log.warn("존재하지 않는 신고 대상: targetType={}, targetId={}", targetType, targetId); + return new CustomException(ReportErrorCode.REPORT_TARGET_NOT_FOUND); + }); + } +} diff --git a/src/main/java/com/cotato/nextstation/domain/place/repository/PlaceReviewRepository.java b/src/main/java/com/cotato/nextstation/domain/place/repository/PlaceReviewRepository.java index 5d7ff14b..3f8aeb6f 100644 --- a/src/main/java/com/cotato/nextstation/domain/place/repository/PlaceReviewRepository.java +++ b/src/main/java/com/cotato/nextstation/domain/place/repository/PlaceReviewRepository.java @@ -1,5 +1,6 @@ package com.cotato.nextstation.domain.place.repository; +import com.cotato.nextstation.domain.moderation.dto.ReportTarget; import com.cotato.nextstation.domain.place.entity.PlaceReview; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -143,6 +144,11 @@ List findByPlaceIdOrderByRecommendAfterCursor( Optional findByJournalIdAndPlaceId(Long journalId, Long placeId); + // 신고 대상 확인용, 리뷰 작성자는 리뷰가 속한 여행일지의 작성자다. + @Query("SELECT new com.cotato.nextstation.domain.moderation.dto.ReportTarget(pr.journal.member.id, pr.review) " + + "FROM PlaceReview pr WHERE pr.id = :reviewId") + Optional findReportTargetById(@Param("reviewId") Long reviewId); + interface ReviewPlaceView { Long getReviewId(); Long getPlaceId(); diff --git a/src/main/java/com/cotato/nextstation/domain/report/client/DiscordWebhookClient.java b/src/main/java/com/cotato/nextstation/domain/report/client/DiscordWebhookClient.java index 8de07b63..5dc87f21 100644 --- a/src/main/java/com/cotato/nextstation/domain/report/client/DiscordWebhookClient.java +++ b/src/main/java/com/cotato/nextstation/domain/report/client/DiscordWebhookClient.java @@ -2,7 +2,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; @@ -16,9 +15,10 @@ /** * 지표 리포트를 디스코드 웹훅으로 전송한다. * 에러 로그 알림(logback DiscordAppender)과는 채널·URL이 분리되어 있다. + *

+ * URL이 비어 있으면(local 등 웹훅을 쓰지 않는 환경) 전송을 건너뛴다. */ @Slf4j -@Profile("prod") @Component public class DiscordWebhookClient { @@ -28,7 +28,7 @@ public class DiscordWebhookClient { private final RestClient restClient; private final String webhookUrl; - public DiscordWebhookClient(@Value("${report.discord.webhook-url}") String webhookUrl) { + public DiscordWebhookClient(@Value("${report.discord.webhook-url:}") String webhookUrl) { // 기본 factory는 타임아웃이 사실상 무제한이라 웹훅이 느려지면 스케줄러 스레드가 오래 붙잡힌다. HttpClient httpClient = HttpClient.newBuilder() @@ -44,6 +44,19 @@ public DiscordWebhookClient(@Value("${report.discord.webhook-url}") String webho } public void send(Map payload) { + send(webhookUrl, payload); + } + + /** + * 채널을 지정해 전송한다. + * 전송 실패는 호출자에게 전파하지 않는다. 알림은 원래 작업의 성공 여부를 바꾸지 않는다. + */ + public void send(String webhookUrl, Map payload) { + if (webhookUrl == null || webhookUrl.isBlank()) { + log.debug("디스코드 웹훅 URL 미설정, 전송 생략"); + return; + } + try { restClient.post() .uri(webhookUrl) diff --git a/src/main/java/com/cotato/nextstation/global/config/SwaggerConfig.java b/src/main/java/com/cotato/nextstation/global/config/SwaggerConfig.java index b7cde424..fd36ef21 100644 --- a/src/main/java/com/cotato/nextstation/global/config/SwaggerConfig.java +++ b/src/main/java/com/cotato/nextstation/global/config/SwaggerConfig.java @@ -187,6 +187,16 @@ public GroupedOpenApi adminApi() { .build(); } + // 콘텐츠 신고 관련 API + @Bean + public GroupedOpenApi reportApi() { + return GroupedOpenApi.builder() + .group("Report") + .displayName("Report API") + .packagesToScan("com.cotato.nextstation.domain.moderation.controller") + .build(); + } + // 여행일지 관련 API @Bean public GroupedOpenApi journalApi() { diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 2d794b72..7f54364d 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -68,4 +68,9 @@ security: aws: s3: - bucket-name: ${AWS_S3_BUCKET_NAME:} \ No newline at end of file + bucket-name: ${AWS_S3_BUCKET_NAME:} + +# 웹훅 URL을 비워두면 전송을 건너뛴다. +moderation: + discord: + webhook-url: ${DISCORD_MODERATION_WEBHOOK_URL:} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 3c5887e3..59c334c2 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -34,6 +34,10 @@ report: discord: webhook-url: ${DISCORD_REPORT_WEBHOOK_URL} +moderation: + discord: + webhook-url: ${DISCORD_MODERATION_WEBHOOK_URL} + jwt: secret: ${JWT_SECRET} diff --git a/src/test/java/com/cotato/nextstation/domain/moderation/service/ContentReportNotifierTest.java b/src/test/java/com/cotato/nextstation/domain/moderation/service/ContentReportNotifierTest.java new file mode 100644 index 00000000..30b560f2 --- /dev/null +++ b/src/test/java/com/cotato/nextstation/domain/moderation/service/ContentReportNotifierTest.java @@ -0,0 +1,104 @@ +package com.cotato.nextstation.domain.moderation.service; + +import com.cotato.nextstation.domain.moderation.event.ContentReportedEvent; +import com.cotato.nextstation.domain.moderation.enums.ReportReason; +import com.cotato.nextstation.domain.moderation.enums.ReportTargetType; +import com.cotato.nextstation.domain.report.client.DiscordWebhookClient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.then; + +// 임베드 구성(줄 배치·자르기·빈 값 처리)을 확인한다. +@ExtendWith(MockitoExtension.class) +class ContentReportNotifierTest { + + private static final String WEBHOOK_URL = "https://discord.test/webhook"; + + @Mock + private DiscordWebhookClient discordWebhookClient; + + @Test + @DisplayName("본문이 100자를 넘으면 잘라서 보낸다") + void onContentReported_longBodyIsTruncated() { + String body = "가".repeat(150); + + String description = descriptionOf(event(body)); + + assertThat(description).contains("가".repeat(100) + "...").doesNotContain("가".repeat(101)); + } + + @Test + @DisplayName("본문이 비어 있어도 줄을 비워두지 않는다") + void onContentReported_blankBodyHasPlaceholder() { + String description = descriptionOf(event(null)); + + assertThat(lineOf(description, "리뷰 내용")).isNotBlank(); + } + + @Test + @DisplayName("대상 id를 어느 테이블의 id인지 드러나게 표기한다") + void onContentReported_targetIdHasLabel() { + String description = descriptionOf(event("리뷰 본문")); + + assertThat(lineOf(description, "대상")).contains("reviewId=501"); + } + + @Test + @DisplayName("줄마다 빈 줄을 넣어 간격을 띄운다") + void onContentReported_linesAreSeparatedByBlankLine() { + String description = descriptionOf(event("리뷰 본문")); + + assertThat(description.split("\n\n")) + .extracting(line -> line.substring(0, line.indexOf("**", 2) + 2)) + .containsExactly("**대상**", "**사유**", "**신고자**", "**리뷰 내용**"); + } + + @Test + @DisplayName("프로필 신고는 제목으로 종류를 드러내고 내용 줄을 만들지 않는다") + void onContentReported_profileHasNoBodyLine() { + Map embed = sendAndCaptureEmbed(new ContentReportedEvent( + 12L, 1L, ReportTargetType.PROFILE, 501L, ReportReason.ABUSIVE_CONTENT, "민성")); + + assertThat((String) embed.get("title")).contains("프로필 신고 접수"); + assertThat((String) embed.get("description")) + .contains("memberId=501") + .doesNotContain("민성"); + } + + private String descriptionOf(ContentReportedEvent event) { + return (String) sendAndCaptureEmbed(event).get("description"); + } + + @SuppressWarnings("unchecked") + private Map sendAndCaptureEmbed(ContentReportedEvent event) { + new ContentReportNotifier(discordWebhookClient, WEBHOOK_URL).onContentReported(event); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + then(discordWebhookClient).should().send(eq(WEBHOOK_URL), captor.capture()); + + List> embeds = (List>) captor.getValue().get("embeds"); + return embeds.get(0); + } + + private String lineOf(String description, String name) { + return java.util.Arrays.stream(description.split("\n\n")) + .filter(line -> line.startsWith("**%s**".formatted(name))) + .findFirst() + .orElseThrow(() -> new AssertionError("%s 줄이 없다".formatted(name))); + } + + private ContentReportedEvent event(String targetBody) { + return new ContentReportedEvent(12L, 1L, ReportTargetType.PLACE_REVIEW, 501L, + ReportReason.ABUSIVE_CONTENT, targetBody); + } +} diff --git a/src/test/java/com/cotato/nextstation/domain/moderation/service/command/ContentReportCommandServiceTest.java b/src/test/java/com/cotato/nextstation/domain/moderation/service/command/ContentReportCommandServiceTest.java new file mode 100644 index 00000000..5e6cbddb --- /dev/null +++ b/src/test/java/com/cotato/nextstation/domain/moderation/service/command/ContentReportCommandServiceTest.java @@ -0,0 +1,202 @@ +package com.cotato.nextstation.domain.moderation.service.command; + +import com.cotato.nextstation.domain.journal.repository.JournalRepository; +import com.cotato.nextstation.domain.member.repository.MemberRepository; +import com.cotato.nextstation.domain.moderation.dto.ReportTarget; +import com.cotato.nextstation.domain.moderation.dto.request.ContentReportRequest; +import com.cotato.nextstation.domain.moderation.dto.response.ContentReportResponse; +import com.cotato.nextstation.domain.moderation.entity.ContentReport; +import com.cotato.nextstation.domain.moderation.enums.ReportReason; +import com.cotato.nextstation.domain.moderation.enums.ReportTargetType; +import com.cotato.nextstation.domain.moderation.event.ContentReportedEvent; +import com.cotato.nextstation.domain.moderation.exception.ReportErrorCode; +import com.cotato.nextstation.domain.moderation.repository.ContentReportRepository; +import com.cotato.nextstation.domain.place.repository.PlaceReviewRepository; +import com.cotato.nextstation.global.exception.CustomException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class ContentReportCommandServiceTest { + + private static final Long REPORTER_ID = 1L; + private static final Long AUTHOR_ID = 2L; + private static final Long TARGET_ID = 501L; + private static final Long SAVED_REPORT_ID = 12L; + private static final String TARGET_BODY = "신고 대상 본문"; + + @InjectMocks + private ContentReportCommandService contentReportCommandService; + + @Mock + private ContentReportRepository contentReportRepository; + + @Mock + private JournalRepository journalRepository; + + @Mock + private PlaceReviewRepository placeReviewRepository; + + @Mock + private MemberRepository memberRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @Test + @DisplayName("여행일지를 신고하면 접수되고 알림 이벤트가 발행된다") + void report_journal() { + // given + given(journalRepository.findReportTargetById(TARGET_ID)) + .willReturn(Optional.of(new ReportTarget(AUTHOR_ID, TARGET_BODY))); + givenSavedWithId(); + + // when + ContentReportResponse response = contentReportCommandService.report( + REPORTER_ID, request(ReportTargetType.JOURNAL, ReportReason.SPAM_AD)); + + // then + assertThat(response.reportId()).isEqualTo(SAVED_REPORT_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContentReportedEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().reportId()).isEqualTo(SAVED_REPORT_ID); + assertThat(captor.getValue().targetType()).isEqualTo(ReportTargetType.JOURNAL); + assertThat(captor.getValue().targetId()).isEqualTo(TARGET_ID); + assertThat(captor.getValue().targetBody()).isEqualTo(TARGET_BODY); + } + + @Test + @DisplayName("장소 리뷰를 신고하면 리뷰 작성자를 기준으로 판단한다") + void report_placeReview() { + // given + given(placeReviewRepository.findReportTargetById(TARGET_ID)) + .willReturn(Optional.of(new ReportTarget(AUTHOR_ID, TARGET_BODY))); + givenSavedWithId(); + + // when + ContentReportResponse response = contentReportCommandService.report( + REPORTER_ID, request(ReportTargetType.PLACE_REVIEW, ReportReason.HATE_OR_OFFENSIVE)); + + // then + assertThat(response.reportId()).isEqualTo(SAVED_REPORT_ID); + then(journalRepository).should(never()).findReportTargetById(any()); + } + + @Test + @DisplayName("프로필을 신고하면 회원 본인이 작성자 기준이 된다") + void report_member() { + // given + given(memberRepository.findReportTargetById(TARGET_ID)) + .willReturn(Optional.of(new ReportTarget(TARGET_ID, "민성"))); + givenSavedWithId(); + + // when + ContentReportResponse response = contentReportCommandService.report( + REPORTER_ID, request(ReportTargetType.PROFILE, ReportReason.ABUSIVE_CONTENT)); + + // then + assertThat(response.reportId()).isEqualTo(SAVED_REPORT_ID); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ContentReportedEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().targetType()).isEqualTo(ReportTargetType.PROFILE); + assertThat(captor.getValue().targetBody()).isEqualTo("민성"); + } + + @Test + @DisplayName("본인 프로필은 신고할 수 없다") + void report_selfProfile() { + // given + given(memberRepository.findReportTargetById(REPORTER_ID)) + .willReturn(Optional.of(new ReportTarget(REPORTER_ID, "민성"))); + + // when & then + assertThatThrownBy(() -> contentReportCommandService.report(REPORTER_ID, + new ContentReportRequest(ReportTargetType.PROFILE, REPORTER_ID, ReportReason.ABUSIVE_CONTENT))) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ReportErrorCode.SELF_REPORT_NOT_ALLOWED.getMessage()); + + then(contentReportRepository).should(never()).save(any()); + } + + @Test + @DisplayName("존재하지 않거나 삭제된 대상을 신고하면 예외가 발생한다") + void report_targetNotFound() { + // given + given(journalRepository.findReportTargetById(TARGET_ID)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> contentReportCommandService.report( + REPORTER_ID, request(ReportTargetType.JOURNAL, ReportReason.SPAM_AD))) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ReportErrorCode.REPORT_TARGET_NOT_FOUND.getMessage()); + + then(contentReportRepository).should(never()).save(any()); + } + + @Test + @DisplayName("본인이 작성한 콘텐츠는 신고할 수 없다") + void report_selfReport() { + // given + given(journalRepository.findReportTargetById(TARGET_ID)) + .willReturn(Optional.of(new ReportTarget(REPORTER_ID, TARGET_BODY))); + + // when & then + assertThatThrownBy(() -> contentReportCommandService.report( + REPORTER_ID, request(ReportTargetType.JOURNAL, ReportReason.ABUSIVE_CONTENT))) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ReportErrorCode.SELF_REPORT_NOT_ALLOWED.getMessage()); + + then(contentReportRepository).should(never()).save(any()); + then(eventPublisher).should(never()).publishEvent(any(ContentReportedEvent.class)); + } + + @Test + @DisplayName("같은 대상을 다시 신고하면 UNIQUE 제약 위반이 중복 신고 예외로 변환된다") + void report_duplicate() { + // given + given(journalRepository.findReportTargetById(TARGET_ID)) + .willReturn(Optional.of(new ReportTarget(AUTHOR_ID, TARGET_BODY))); + given(contentReportRepository.save(any(ContentReport.class))) + .willThrow(new DataIntegrityViolationException("uk_content_report_reporter_target")); + + // when & then + assertThatThrownBy(() -> contentReportCommandService.report( + REPORTER_ID, request(ReportTargetType.JOURNAL, ReportReason.SPAM_AD))) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ReportErrorCode.REPORT_ALREADY_EXISTS.getMessage()); + + then(eventPublisher).should(never()).publishEvent(any(ContentReportedEvent.class)); + } + + // 저장 시 JPA가 채워주는 id를 흉내낸다. 응답과 이벤트 모두 저장된 엔티티의 id를 쓴다. + private void givenSavedWithId() { + given(contentReportRepository.save(any(ContentReport.class))).willAnswer(invocation -> { + ContentReport saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", SAVED_REPORT_ID); + return saved; + }); + } + + private ContentReportRequest request(ReportTargetType targetType, ReportReason reason) { + return new ContentReportRequest(targetType, TARGET_ID, reason); + } +} diff --git a/src/test/java/com/cotato/nextstation/domain/report/client/DiscordWebhookClientTest.java b/src/test/java/com/cotato/nextstation/domain/report/client/DiscordWebhookClientTest.java index c6e19b4a..10e9b698 100644 --- a/src/test/java/com/cotato/nextstation/domain/report/client/DiscordWebhookClientTest.java +++ b/src/test/java/com/cotato/nextstation/domain/report/client/DiscordWebhookClientTest.java @@ -20,4 +20,13 @@ void send_failureIsSwallowed() { assertThatCode(() -> client.send(Map.of("content", "리포트"))).doesNotThrowAnyException(); } + + @Test + @DisplayName("웹훅 URL이 비어 있으면 전송하지 않는다") + void send_blankUrlIsSkipped() { + // 상대 경로로 요청이 나가면 RestClient가 IllegalArgumentException을 던지므로 생략 여부를 가릴 수 있다 + DiscordWebhookClient client = new DiscordWebhookClient(""); + + assertThatCode(() -> client.send(Map.of("content", "리포트"))).doesNotThrowAnyException(); + } }