Skip to content
1 change: 1 addition & 0 deletions docker-compose-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -18,6 +19,11 @@ public interface JournalRepository extends JpaRepository<Journal, Long> {
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<ReportTarget> findReportTargetById(@Param("journalId") Long journalId);

// 이미 여행일지가 작성된 memberStampId 목록 조회
@Query("SELECT j.memberStampId FROM Journal j WHERE j.member.id = :memberId")
Set<Long> findCompletedMemberStampIdsByMemberId(@Param("memberId") Long memberId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +28,11 @@ public interface MemberRepository extends JpaRepository<Member, Long> {
*/
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<ReportTarget> findReportTargetById(@Param("memberId") Long memberId);

Optional<Member> findByEmail(String email);

boolean existsByEmail(String email);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ContentReportResponse> report(
@Parameter(hidden = true) @AuthenticationPrincipal JwtPrincipal principal,
@Valid @RequestBody ContentReportRequest request) {
return CommonResponse.success(contentReportCommandService.report(principal.memberId(), request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.cotato.nextstation.domain.moderation.dto;

/**
* 신고 대상 조회 결과.
* <p>
* 작성자 확인과 알림용 본문을 한 번의 조회로 함께 가져온다.
* 알림 시점(커밋 이후)에 다시 조회하면 그 사이 삭제된 대상을 또 처리해야 하므로 여기서 함께 읽는다.
*
* @param authorId 대상 작성자의 회원 id
* @param body 알림에 실을 본문. 리뷰는 내용이 비어 있을 수 있어 null이 올 수 있다.
*/
public record ReportTarget(Long authorId, String body) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 신고가 저장된 뒤 발행한다.
* <p>
* 알림 전송을 커밋 이후로 미뤄, 웹훅 응답을 기다리는 동안 DB 커넥션을 붙잡지 않는다.
*/
public record ContentReportedEvent(
Long reportId,
Long reporterId,
ReportTargetType targetType,
Long targetId,
ReportReason reason,
String targetBody
) {
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<ContentReport, Long> {
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> 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) + "...";
}
}
Loading
Loading