- report 테이블(회원당 대상별 1회), comment.blocked 컬럼
- POST /board/posts|comments/{id}/report, 본인 글/댓글 신고 불가
- 글: 5건이면 blocked(기존 열람제한 재사용) / 댓글: blocked → 비관리자 본문 숨김
- 삭제·탈퇴 시 신고 정리
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,8 @@ public interface WithdrawMapper {
|
||||
int deletePostsByAuthor(@Param("memberId") Long memberId);
|
||||
int deleteBoardImagesByMember(@Param("memberId") Long memberId);
|
||||
|
||||
// 포인트 / 결제 / 세션
|
||||
// 신고 / 포인트 / 결제 / 세션
|
||||
int deleteReportsByMember(@Param("memberId") Long memberId);
|
||||
int deletePointHistory(@Param("memberId") Long memberId);
|
||||
int deleteIapPurchases(@Param("memberId") Long memberId);
|
||||
int deleteAuthSessions(@Param("memberId") Long memberId);
|
||||
|
||||
@@ -324,7 +324,8 @@ public class AuthService {
|
||||
backupMapper.deleteCategories(memberId);
|
||||
backupMapper.deleteTags(memberId);
|
||||
backupMapper.deleteWallets(memberId);
|
||||
// 3) 포인트 / 결제 / 세션
|
||||
// 3) 신고 / 포인트 / 결제 / 세션
|
||||
withdrawMapper.deleteReportsByMember(memberId);
|
||||
withdrawMapper.deletePointHistory(memberId);
|
||||
withdrawMapper.deleteIapPurchases(memberId);
|
||||
withdrawMapper.deleteAuthSessions(memberId);
|
||||
|
||||
@@ -178,4 +178,22 @@ public class BoardController {
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return boardService.voteComment(commentId, req.getType(), current);
|
||||
}
|
||||
|
||||
/** 게시글 신고 (누적 5건 시 블라인드) */
|
||||
@PostMapping("/posts/{id}/report")
|
||||
public ResponseEntity<Void> reportPost(
|
||||
@PathVariable Long id,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.reportPost(id, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/** 댓글 신고 (누적 5건 시 블라인드) */
|
||||
@PostMapping("/comments/{commentId}/report")
|
||||
public ResponseEntity<Void> reportComment(
|
||||
@PathVariable Long commentId,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
boardService.reportComment(commentId, current);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,5 +24,6 @@ public class Comment implements Serializable {
|
||||
private String authorGooglePicture; // 조회 시 member JOIN (저장 안 함)
|
||||
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
|
||||
private String content;
|
||||
private Boolean blocked; // 신고 누적 블라인드
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public class CommentResponse {
|
||||
private Integer downCount; // 비추천 수
|
||||
private String myVote; // 현재 사용자의 투표: UP / DOWN / null
|
||||
private Boolean hot; // 인기 댓글(추천 50+ & 1일 이내) 상단 노출
|
||||
private Boolean blocked; // 신고 누적 블라인드(관리자만 본문 열람)
|
||||
|
||||
public static CommentResponse from(Comment c) {
|
||||
return CommentResponse.builder()
|
||||
@@ -33,6 +34,7 @@ public class CommentResponse {
|
||||
.authorGooglePicture(c.getAuthorGooglePicture())
|
||||
.authorProfileImage(c.getAuthorProfileImage())
|
||||
.content(c.getContent())
|
||||
.blocked(Boolean.TRUE.equals(c.getBlocked()))
|
||||
.createdAt(c.getCreatedAt())
|
||||
.upCount(0)
|
||||
.downCount(0)
|
||||
|
||||
@@ -18,4 +18,7 @@ public interface CommentMapper {
|
||||
int delete(@Param("id") Long id);
|
||||
|
||||
int deleteByPostId(@Param("postId") Long postId);
|
||||
|
||||
/** 신고 누적 블라인드 설정 */
|
||||
int updateBlocked(@Param("id") Long id, @Param("blocked") boolean blocked);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.sb.web.board.mapper;
|
||||
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
/**
|
||||
* 신고(report) 매퍼 — 글/댓글 신고 누적 집계.
|
||||
*/
|
||||
@Mapper
|
||||
public interface ReportMapper {
|
||||
|
||||
/** 신고 추가 (회원당 대상별 1회 — 중복은 무시) */
|
||||
int insertIgnore(@Param("targetType") String targetType,
|
||||
@Param("targetId") Long targetId,
|
||||
@Param("memberId") Long memberId,
|
||||
@Param("reason") String reason);
|
||||
|
||||
int countByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
|
||||
|
||||
int deleteByTarget(@Param("targetType") String targetType, @Param("targetId") Long targetId);
|
||||
|
||||
int deleteByMember(@Param("memberId") Long memberId);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import com.sb.web.board.dto.Recommender;
|
||||
import com.sb.web.board.dto.VoteResponse;
|
||||
import com.sb.web.board.mapper.CommentMapper;
|
||||
import com.sb.web.board.mapper.PostMapper;
|
||||
import com.sb.web.board.mapper.ReportMapper;
|
||||
import com.sb.web.board.mapper.TagMapper;
|
||||
import com.sb.web.board.mapper.VoteMapper;
|
||||
import com.sb.web.common.exception.ApiException;
|
||||
@@ -41,6 +42,7 @@ public class BoardService {
|
||||
private final TagMapper tagMapper;
|
||||
private final CommentMapper commentMapper;
|
||||
private final VoteMapper voteMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
private final PointService pointService;
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
|
||||
@@ -48,6 +50,10 @@ public class BoardService {
|
||||
private static final Duration VIEW_DEDUP_TTL = Duration.ofHours(24);
|
||||
private static final String UP = "UP";
|
||||
private static final String DOWN = "DOWN";
|
||||
private static final String T_POST = "POST";
|
||||
private static final String T_COMMENT = "COMMENT";
|
||||
/** 신고 누적 블라인드 임계 */
|
||||
private static final int REPORT_BLIND_THRESHOLD = 5;
|
||||
|
||||
// 인기 글/댓글: 등록 1일 이내 + 추천 50건 이상이면 상단에 최대 3건 노출
|
||||
private static final int HOT_WITHIN_HOURS = 24;
|
||||
@@ -185,6 +191,7 @@ public class BoardService {
|
||||
// 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조)
|
||||
voteMapper.deleteCommentVotesByPost(id);
|
||||
voteMapper.deletePostVotesByPost(id);
|
||||
reportMapper.deleteByTarget(T_POST, id);
|
||||
tagMapper.deletePostTagsByPostId(id);
|
||||
commentMapper.deleteByPostId(id);
|
||||
postMapper.delete(id);
|
||||
@@ -217,9 +224,43 @@ public class BoardService {
|
||||
}
|
||||
assertCanModify(comment.getAuthorId(), user);
|
||||
voteMapper.deleteCommentVotesByComment(commentId);
|
||||
reportMapper.deleteByTarget(T_COMMENT, commentId);
|
||||
commentMapper.delete(commentId);
|
||||
}
|
||||
|
||||
/* ===================== 신고 ===================== */
|
||||
|
||||
/** 게시글 신고 — 회원당 1회, 누적 5건이면 블라인드(관리자만 열람) */
|
||||
@Transactional
|
||||
public void reportPost(Long postId, SessionUser user) {
|
||||
Post post = mustFindPost(postId);
|
||||
if (post.getAuthorId().equals(user.getId())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 글은 신고할 수 없습니다.");
|
||||
}
|
||||
reportMapper.insertIgnore(T_POST, postId, user.getId(), null);
|
||||
if (!Boolean.TRUE.equals(post.getBlocked())
|
||||
&& reportMapper.countByTarget(T_POST, postId) >= REPORT_BLIND_THRESHOLD) {
|
||||
postMapper.updateBlocked(postId, true, "신고 누적으로 블라인드되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 댓글 신고 — 회원당 1회, 누적 5건이면 블라인드(관리자만 열람) */
|
||||
@Transactional
|
||||
public void reportComment(Long commentId, SessionUser user) {
|
||||
Comment comment = commentMapper.findById(commentId);
|
||||
if (comment == null) {
|
||||
throw new ApiException(HttpStatus.NOT_FOUND, "댓글을 찾을 수 없습니다.");
|
||||
}
|
||||
if (comment.getAuthorId().equals(user.getId())) {
|
||||
throw new ApiException(HttpStatus.BAD_REQUEST, "본인 댓글은 신고할 수 없습니다.");
|
||||
}
|
||||
reportMapper.insertIgnore(T_COMMENT, commentId, user.getId(), null);
|
||||
if (!Boolean.TRUE.equals(comment.getBlocked())
|
||||
&& reportMapper.countByTarget(T_COMMENT, commentId) >= REPORT_BLIND_THRESHOLD) {
|
||||
commentMapper.updateBlocked(commentId, true);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 추천/비추천 ===================== */
|
||||
|
||||
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
|
||||
@@ -301,6 +342,13 @@ public class BoardService {
|
||||
}
|
||||
return cr;
|
||||
}).toList());
|
||||
// 신고 누적 블라인드 댓글: 관리자가 아니면 본문 숨김
|
||||
boolean viewerAdmin = isAdmin(viewer);
|
||||
if (!viewerAdmin) {
|
||||
comments.forEach(cr -> {
|
||||
if (Boolean.TRUE.equals(cr.getBlocked())) cr.setContent(null);
|
||||
});
|
||||
}
|
||||
comments = orderWithHot(comments);
|
||||
|
||||
PostDetail detail = PostDetail.of(post, tags, comments);
|
||||
|
||||
@@ -95,6 +95,22 @@ CREATE TABLE IF NOT EXISTS comment (
|
||||
KEY idx_comment_post (post_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 댓글 신고 누적 블라인드 플래그 (멱등)
|
||||
ALTER TABLE comment ADD COLUMN IF NOT EXISTS blocked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '신고 누적 블라인드';
|
||||
|
||||
-- 신고 (글/댓글). 회원당 대상별 1회. 5건 누적 시 블라인드(관리자만 열람).
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
target_type VARCHAR(10) NOT NULL COMMENT 'POST / COMMENT',
|
||||
target_id BIGINT NOT NULL,
|
||||
member_id BIGINT NOT NULL COMMENT '신고자',
|
||||
reason VARCHAR(255) NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_report (target_type, target_id, member_id),
|
||||
KEY idx_report_target (target_type, target_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- 게시글 추천/비추천 (회원당 1표, UP/DOWN). 같은 표 재요청 시 취소, 반대면 전환.
|
||||
CREATE TABLE IF NOT EXISTS post_vote (
|
||||
post_id BIGINT NOT NULL,
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
<result property="authorGooglePicture" column="author_google_picture"/>
|
||||
<result property="authorProfileImage" column="author_profile_image"/>
|
||||
<result property="content" column="content"/>
|
||||
<result property="blocked" column="blocked"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findByPostId" parameterType="long" resultMap="commentResultMap">
|
||||
SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.created_at,
|
||||
SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.blocked, c.created_at,
|
||||
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
|
||||
FROM comment c
|
||||
LEFT JOIN member m ON m.id = c.author_id
|
||||
@@ -24,13 +25,17 @@
|
||||
</select>
|
||||
|
||||
<select id="findById" parameterType="long" resultMap="commentResultMap">
|
||||
SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.created_at,
|
||||
SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.blocked, c.created_at,
|
||||
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
|
||||
FROM comment c
|
||||
LEFT JOIN member m ON m.id = c.author_id
|
||||
WHERE c.id = #{id}
|
||||
</select>
|
||||
|
||||
<update id="updateBlocked">
|
||||
UPDATE comment SET blocked = #{blocked} WHERE id = #{id}
|
||||
</update>
|
||||
|
||||
<insert id="insert" parameterType="com.sb.web.board.domain.Comment"
|
||||
useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO comment (post_id, author_id, author_name, content, created_at)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.sb.web.board.mapper.ReportMapper">
|
||||
|
||||
<insert id="insertIgnore">
|
||||
INSERT IGNORE INTO report (target_type, target_id, member_id, reason, created_at)
|
||||
VALUES (#{targetType}, #{targetId}, #{memberId}, #{reason}, NOW())
|
||||
</insert>
|
||||
|
||||
<select id="countByTarget" resultType="int">
|
||||
SELECT COUNT(*) FROM report WHERE target_type = #{targetType} AND target_id = #{targetId}
|
||||
</select>
|
||||
|
||||
<delete id="deleteByTarget">
|
||||
DELETE FROM report WHERE target_type = #{targetType} AND target_id = #{targetId}
|
||||
</delete>
|
||||
|
||||
<delete id="deleteByMember">
|
||||
DELETE FROM report WHERE member_id = #{memberId}
|
||||
</delete>
|
||||
|
||||
</mapper>
|
||||
@@ -52,7 +52,10 @@
|
||||
DELETE FROM board_image WHERE member_id = #{memberId}
|
||||
</delete>
|
||||
|
||||
<!-- 포인트 / 결제 / 세션 -->
|
||||
<!-- 신고 / 포인트 / 결제 / 세션 -->
|
||||
<delete id="deleteReportsByMember">
|
||||
DELETE FROM report WHERE member_id = #{memberId}
|
||||
</delete>
|
||||
<delete id="deletePointHistory">
|
||||
DELETE FROM point_history WHERE member_id = #{memberId}
|
||||
</delete>
|
||||
|
||||
Reference in New Issue
Block a user