- 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 deletePostsByAuthor(@Param("memberId") Long memberId);
|
||||||
int deleteBoardImagesByMember(@Param("memberId") Long memberId);
|
int deleteBoardImagesByMember(@Param("memberId") Long memberId);
|
||||||
|
|
||||||
// 포인트 / 결제 / 세션
|
// 신고 / 포인트 / 결제 / 세션
|
||||||
|
int deleteReportsByMember(@Param("memberId") Long memberId);
|
||||||
int deletePointHistory(@Param("memberId") Long memberId);
|
int deletePointHistory(@Param("memberId") Long memberId);
|
||||||
int deleteIapPurchases(@Param("memberId") Long memberId);
|
int deleteIapPurchases(@Param("memberId") Long memberId);
|
||||||
int deleteAuthSessions(@Param("memberId") Long memberId);
|
int deleteAuthSessions(@Param("memberId") Long memberId);
|
||||||
|
|||||||
@@ -324,7 +324,8 @@ public class AuthService {
|
|||||||
backupMapper.deleteCategories(memberId);
|
backupMapper.deleteCategories(memberId);
|
||||||
backupMapper.deleteTags(memberId);
|
backupMapper.deleteTags(memberId);
|
||||||
backupMapper.deleteWallets(memberId);
|
backupMapper.deleteWallets(memberId);
|
||||||
// 3) 포인트 / 결제 / 세션
|
// 3) 신고 / 포인트 / 결제 / 세션
|
||||||
|
withdrawMapper.deleteReportsByMember(memberId);
|
||||||
withdrawMapper.deletePointHistory(memberId);
|
withdrawMapper.deletePointHistory(memberId);
|
||||||
withdrawMapper.deleteIapPurchases(memberId);
|
withdrawMapper.deleteIapPurchases(memberId);
|
||||||
withdrawMapper.deleteAuthSessions(memberId);
|
withdrawMapper.deleteAuthSessions(memberId);
|
||||||
|
|||||||
@@ -178,4 +178,22 @@ public class BoardController {
|
|||||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||||
return boardService.voteComment(commentId, req.getType(), 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 authorGooglePicture; // 조회 시 member JOIN (저장 안 함)
|
||||||
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
|
private String authorProfileImage; // 조회 시 member JOIN (저장 안 함)
|
||||||
private String content;
|
private String content;
|
||||||
|
private Boolean blocked; // 신고 누적 블라인드
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class CommentResponse {
|
|||||||
private Integer downCount; // 비추천 수
|
private Integer downCount; // 비추천 수
|
||||||
private String myVote; // 현재 사용자의 투표: UP / DOWN / null
|
private String myVote; // 현재 사용자의 투표: UP / DOWN / null
|
||||||
private Boolean hot; // 인기 댓글(추천 50+ & 1일 이내) 상단 노출
|
private Boolean hot; // 인기 댓글(추천 50+ & 1일 이내) 상단 노출
|
||||||
|
private Boolean blocked; // 신고 누적 블라인드(관리자만 본문 열람)
|
||||||
|
|
||||||
public static CommentResponse from(Comment c) {
|
public static CommentResponse from(Comment c) {
|
||||||
return CommentResponse.builder()
|
return CommentResponse.builder()
|
||||||
@@ -33,6 +34,7 @@ public class CommentResponse {
|
|||||||
.authorGooglePicture(c.getAuthorGooglePicture())
|
.authorGooglePicture(c.getAuthorGooglePicture())
|
||||||
.authorProfileImage(c.getAuthorProfileImage())
|
.authorProfileImage(c.getAuthorProfileImage())
|
||||||
.content(c.getContent())
|
.content(c.getContent())
|
||||||
|
.blocked(Boolean.TRUE.equals(c.getBlocked()))
|
||||||
.createdAt(c.getCreatedAt())
|
.createdAt(c.getCreatedAt())
|
||||||
.upCount(0)
|
.upCount(0)
|
||||||
.downCount(0)
|
.downCount(0)
|
||||||
|
|||||||
@@ -18,4 +18,7 @@ public interface CommentMapper {
|
|||||||
int delete(@Param("id") Long id);
|
int delete(@Param("id") Long id);
|
||||||
|
|
||||||
int deleteByPostId(@Param("postId") Long postId);
|
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.dto.VoteResponse;
|
||||||
import com.sb.web.board.mapper.CommentMapper;
|
import com.sb.web.board.mapper.CommentMapper;
|
||||||
import com.sb.web.board.mapper.PostMapper;
|
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.TagMapper;
|
||||||
import com.sb.web.board.mapper.VoteMapper;
|
import com.sb.web.board.mapper.VoteMapper;
|
||||||
import com.sb.web.common.exception.ApiException;
|
import com.sb.web.common.exception.ApiException;
|
||||||
@@ -41,6 +42,7 @@ public class BoardService {
|
|||||||
private final TagMapper tagMapper;
|
private final TagMapper tagMapper;
|
||||||
private final CommentMapper commentMapper;
|
private final CommentMapper commentMapper;
|
||||||
private final VoteMapper voteMapper;
|
private final VoteMapper voteMapper;
|
||||||
|
private final ReportMapper reportMapper;
|
||||||
private final PointService pointService;
|
private final PointService pointService;
|
||||||
private final RedisTemplate<String, Object> redisTemplate;
|
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 Duration VIEW_DEDUP_TTL = Duration.ofHours(24);
|
||||||
private static final String UP = "UP";
|
private static final String UP = "UP";
|
||||||
private static final String DOWN = "DOWN";
|
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건 노출
|
// 인기 글/댓글: 등록 1일 이내 + 추천 50건 이상이면 상단에 최대 3건 노출
|
||||||
private static final int HOT_WITHIN_HOURS = 24;
|
private static final int HOT_WITHIN_HOURS = 24;
|
||||||
@@ -185,6 +191,7 @@ public class BoardService {
|
|||||||
// 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조)
|
// 투표 정리(댓글 투표는 댓글 삭제 전에 — 서브쿼리가 comment 참조)
|
||||||
voteMapper.deleteCommentVotesByPost(id);
|
voteMapper.deleteCommentVotesByPost(id);
|
||||||
voteMapper.deletePostVotesByPost(id);
|
voteMapper.deletePostVotesByPost(id);
|
||||||
|
reportMapper.deleteByTarget(T_POST, id);
|
||||||
tagMapper.deletePostTagsByPostId(id);
|
tagMapper.deletePostTagsByPostId(id);
|
||||||
commentMapper.deleteByPostId(id);
|
commentMapper.deleteByPostId(id);
|
||||||
postMapper.delete(id);
|
postMapper.delete(id);
|
||||||
@@ -217,9 +224,43 @@ public class BoardService {
|
|||||||
}
|
}
|
||||||
assertCanModify(comment.getAuthorId(), user);
|
assertCanModify(comment.getAuthorId(), user);
|
||||||
voteMapper.deleteCommentVotesByComment(commentId);
|
voteMapper.deleteCommentVotesByComment(commentId);
|
||||||
|
reportMapper.deleteByTarget(T_COMMENT, commentId);
|
||||||
commentMapper.delete(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;
|
return cr;
|
||||||
}).toList());
|
}).toList());
|
||||||
|
// 신고 누적 블라인드 댓글: 관리자가 아니면 본문 숨김
|
||||||
|
boolean viewerAdmin = isAdmin(viewer);
|
||||||
|
if (!viewerAdmin) {
|
||||||
|
comments.forEach(cr -> {
|
||||||
|
if (Boolean.TRUE.equals(cr.getBlocked())) cr.setContent(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
comments = orderWithHot(comments);
|
comments = orderWithHot(comments);
|
||||||
|
|
||||||
PostDetail detail = PostDetail.of(post, tags, comments);
|
PostDetail detail = PostDetail.of(post, tags, comments);
|
||||||
|
|||||||
@@ -95,6 +95,22 @@ CREATE TABLE IF NOT EXISTS comment (
|
|||||||
KEY idx_comment_post (post_id)
|
KEY idx_comment_post (post_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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). 같은 표 재요청 시 취소, 반대면 전환.
|
-- 게시글 추천/비추천 (회원당 1표, UP/DOWN). 같은 표 재요청 시 취소, 반대면 전환.
|
||||||
CREATE TABLE IF NOT EXISTS post_vote (
|
CREATE TABLE IF NOT EXISTS post_vote (
|
||||||
post_id BIGINT NOT NULL,
|
post_id BIGINT NOT NULL,
|
||||||
|
|||||||
@@ -11,11 +11,12 @@
|
|||||||
<result property="authorGooglePicture" column="author_google_picture"/>
|
<result property="authorGooglePicture" column="author_google_picture"/>
|
||||||
<result property="authorProfileImage" column="author_profile_image"/>
|
<result property="authorProfileImage" column="author_profile_image"/>
|
||||||
<result property="content" column="content"/>
|
<result property="content" column="content"/>
|
||||||
|
<result property="blocked" column="blocked"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<select id="findByPostId" parameterType="long" resultMap="commentResultMap">
|
<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
|
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
|
||||||
FROM comment c
|
FROM comment c
|
||||||
LEFT JOIN member m ON m.id = c.author_id
|
LEFT JOIN member m ON m.id = c.author_id
|
||||||
@@ -24,13 +25,17 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<select id="findById" parameterType="long" resultMap="commentResultMap">
|
<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
|
m.google_picture AS author_google_picture, m.profile_image AS author_profile_image
|
||||||
FROM comment c
|
FROM comment c
|
||||||
LEFT JOIN member m ON m.id = c.author_id
|
LEFT JOIN member m ON m.id = c.author_id
|
||||||
WHERE c.id = #{id}
|
WHERE c.id = #{id}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<update id="updateBlocked">
|
||||||
|
UPDATE comment SET blocked = #{blocked} WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
<insert id="insert" parameterType="com.sb.web.board.domain.Comment"
|
<insert id="insert" parameterType="com.sb.web.board.domain.Comment"
|
||||||
useGeneratedKeys="true" keyProperty="id">
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
INSERT INTO comment (post_id, author_id, author_name, content, created_at)
|
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 FROM board_image WHERE member_id = #{memberId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|
||||||
<!-- 포인트 / 결제 / 세션 -->
|
<!-- 신고 / 포인트 / 결제 / 세션 -->
|
||||||
|
<delete id="deleteReportsByMember">
|
||||||
|
DELETE FROM report WHERE member_id = #{memberId}
|
||||||
|
</delete>
|
||||||
<delete id="deletePointHistory">
|
<delete id="deletePointHistory">
|
||||||
DELETE FROM point_history WHERE member_id = #{memberId}
|
DELETE FROM point_history WHERE member_id = #{memberId}
|
||||||
</delete>
|
</delete>
|
||||||
|
|||||||
Reference in New Issue
Block a user