diff --git a/src/main/java/com/sb/web/board/controller/BoardController.java b/src/main/java/com/sb/web/board/controller/BoardController.java index 57906d4..5e30903 100644 --- a/src/main/java/com/sb/web/board/controller/BoardController.java +++ b/src/main/java/com/sb/web/board/controller/BoardController.java @@ -50,8 +50,9 @@ public class BoardController { @RequestParam(required = false) String category, @RequestParam(required = false) String tag, @RequestParam(required = false) String keyword, - @RequestParam(required = false) String searchType) { - return boardService.list(page, size, category, tag, keyword, searchType); + @RequestParam(required = false) String searchType, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return boardService.list(page, size, category, tag, keyword, searchType, current); } /** 내 글 모아보기 */ @@ -240,4 +241,39 @@ public class BoardController { boardService.dismissReports(body.get("targetType"), Long.valueOf(body.get("targetId")), current); return ResponseEntity.noContent().build(); } + + /* ===================== 사용자 차단 (UGC 1.2) ===================== */ + + /** 사용자 차단 — 차단 즉시 상대 콘텐츠가 내 화면에서 숨겨진다 */ + @PostMapping("/users/{id}/block") + public ResponseEntity blockUser( + @PathVariable Long id, + @RequestBody(required = false) Map body, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + boardService.blockUser(id, body != null ? body.get("reason") : null, current); + return ResponseEntity.noContent().build(); + } + + /** 사용자 차단 해제 */ + @PostMapping("/users/{id}/unblock") + public ResponseEntity unblockUser( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + boardService.unblockUser(id, current); + return ResponseEntity.noContent().build(); + } + + /** 내가 차단한 사용자 목록 (관리 화면) */ + @GetMapping("/blocks") + public List myBlocks( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return boardService.myBlockedUsers(current); + } + + /** 최근 사용자 차단 통지 (관리자) — 운영자 검토용 */ + @GetMapping("/user-blocks") + public List userBlocks( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return boardService.listUserBlocks(current); + } } diff --git a/src/main/java/com/sb/web/board/dto/BlockedUser.java b/src/main/java/com/sb/web/board/dto/BlockedUser.java new file mode 100644 index 0000000..077bf32 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/BlockedUser.java @@ -0,0 +1,18 @@ +package com.sb.web.board.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 내가 차단한 사용자 항목 (차단 목록 관리 화면용). + */ +@Data +public class BlockedUser { + private Long blockedId; + private String name; + private String googlePicture; + private String profileImage; + private String reason; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/board/dto/UserBlockNotice.java b/src/main/java/com/sb/web/board/dto/UserBlockNotice.java new file mode 100644 index 0000000..94b5fe2 --- /dev/null +++ b/src/main/java/com/sb/web/board/dto/UserBlockNotice.java @@ -0,0 +1,19 @@ +package com.sb.web.board.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 사용자 차단 통지 항목 (관리자 화면용). + * 어떤 회원이 어떤 회원을 왜 차단했는지 — 운영자가 24시간 내 검토·조치할 수 있도록 노출. + */ +@Data +public class UserBlockNotice { + private Long blockerId; + private String blockerName; + private Long blockedId; + private String blockedName; + private String reason; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/board/mapper/CommentMapper.java b/src/main/java/com/sb/web/board/mapper/CommentMapper.java index 54fc0d3..c9ae227 100644 --- a/src/main/java/com/sb/web/board/mapper/CommentMapper.java +++ b/src/main/java/com/sb/web/board/mapper/CommentMapper.java @@ -9,7 +9,7 @@ import java.util.List; @Mapper public interface CommentMapper { - List findByPostId(@Param("postId") Long postId); + List findByPostId(@Param("postId") Long postId, @Param("viewerId") Long viewerId); Comment findById(@Param("id") Long id); diff --git a/src/main/java/com/sb/web/board/mapper/PostMapper.java b/src/main/java/com/sb/web/board/mapper/PostMapper.java index 25ecfe8..bba7f3f 100644 --- a/src/main/java/com/sb/web/board/mapper/PostMapper.java +++ b/src/main/java/com/sb/web/board/mapper/PostMapper.java @@ -15,18 +15,21 @@ public interface PostMapper { @Param("category") String category, @Param("tag") String tag, @Param("keyword") String keyword, - @Param("searchType") String searchType); + @Param("searchType") String searchType, + @Param("viewerId") Long viewerId); long countPage(@Param("category") String category, @Param("tag") String tag, @Param("keyword") String keyword, - @Param("searchType") String searchType); + @Param("searchType") String searchType, + @Param("viewerId") Long viewerId); /** 인기 글(추천 minUp 이상 & hours 시간 이내) 상단 노출용 — 추천 많은 순 limit 건 */ List findHotPosts(@Param("category") String category, @Param("hours") int hours, @Param("minUp") int minUp, - @Param("limit") int limit); + @Param("limit") int limit, + @Param("viewerId") Long viewerId); /** 내 글 모아보기 */ List findMyPage(@Param("memberId") Long memberId, diff --git a/src/main/java/com/sb/web/board/mapper/UserBlockMapper.java b/src/main/java/com/sb/web/board/mapper/UserBlockMapper.java new file mode 100644 index 0000000..10a47aa --- /dev/null +++ b/src/main/java/com/sb/web/board/mapper/UserBlockMapper.java @@ -0,0 +1,36 @@ +package com.sb.web.board.mapper; + +import com.sb.web.board.dto.BlockedUser; +import com.sb.web.board.dto.UserBlockNotice; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 사용자 차단(user_block) 매퍼 — 회원 간 차단 관계. + */ +@Mapper +public interface UserBlockMapper { + + /** 차단 추가 (중복은 무시, 재차단 시 사유 갱신) */ + int upsert(@Param("blockerId") Long blockerId, + @Param("blockedId") Long blockedId, + @Param("reason") String reason); + + /** 차단 해제 */ + int delete(@Param("blockerId") Long blockerId, + @Param("blockedId") Long blockedId); + + /** 내가 차단한 회원 id 목록 (피드 필터용) */ + List findBlockedIds(@Param("blockerId") Long blockerId); + + /** 내가 차단한 회원 목록 (관리 화면용 — 이름/아바타 포함) */ + List findBlockedUsers(@Param("blockerId") Long blockerId); + + /** 최근 차단 통지 목록 (관리자 — 운영자 검토용) */ + List listRecent(@Param("limit") int limit); + + /** 회원 탈퇴 시 관련 차단 관계 정리 */ + int deleteByMember(@Param("memberId") Long memberId); +} diff --git a/src/main/java/com/sb/web/board/service/BoardService.java b/src/main/java/com/sb/web/board/service/BoardService.java index 5e36a0f..2742475 100644 --- a/src/main/java/com/sb/web/board/service/BoardService.java +++ b/src/main/java/com/sb/web/board/service/BoardService.java @@ -13,10 +13,13 @@ import com.sb.web.board.dto.PostSummary; import com.sb.web.board.dto.Recommender; import com.sb.web.board.dto.ReportedItem; import com.sb.web.board.dto.VoteResponse; +import com.sb.web.board.dto.BlockedUser; +import com.sb.web.board.dto.UserBlockNotice; 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.UserBlockMapper; import com.sb.web.board.mapper.VoteMapper; import com.sb.web.common.exception.ApiException; import com.sb.web.point.PointService; @@ -44,7 +47,9 @@ public class BoardService { private final CommentMapper commentMapper; private final VoteMapper voteMapper; private final ReportMapper reportMapper; + private final UserBlockMapper userBlockMapper; private final PointService pointService; + private final ContentFilter contentFilter; private final RedisTemplate redisTemplate; /** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */ @@ -63,20 +68,21 @@ public class BoardService { /* ===================== 게시글 ===================== */ - public PageResponse list(int page, int size, String category, String tag, String keyword, String searchType) { + public PageResponse list(int page, int size, String category, String tag, String keyword, String searchType, SessionUser viewer) { int p = Math.max(page, 1); int s = Math.min(Math.max(size, 1), 100); int offset = (p - 1) * s; String st = normalizeSearchType(searchType); String cat = normalizeCategory(category); + Long viewerId = viewer == null ? null : viewer.getId(); // 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다. - List content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st); - long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st); + List content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st, viewerId); + long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st, viewerId); // 1페이지·검색 없을 때만 인기 글을 최상단에 노출 (중복 제거) if (p == 1 && blankToNull(tag) == null && blankToNull(keyword) == null) { - List hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX); + List hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX, viewerId); if (!hot.isEmpty()) { java.util.Set hotIds = new java.util.HashSet<>(); hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); }); @@ -180,6 +186,9 @@ public class BoardService { @Transactional public PostDetail create(PostRequest req, SessionUser author) { + // 무관용 콘텐츠 필터 — 욕설·비방 등 부적절 표현 포함 시 등록 차단 + contentFilter.check(req.getTitle()); + contentFilter.check(req.getContent()); Post post = Post.builder() .title(req.getTitle()) .content(req.getContent()) @@ -207,6 +216,10 @@ public class BoardService { } assertCanModify(post.getAuthorId(), user); + // 무관용 콘텐츠 필터 — 수정 시에도 부적절 표현 차단 + contentFilter.check(req.getTitle()); + contentFilter.check(req.getContent()); + post.setTitle(req.getTitle()); post.setContent(req.getContent()); postMapper.update(post); @@ -242,6 +255,8 @@ public class BoardService { if (Boolean.TRUE.equals(post.getBlocked())) { throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글에는 댓글을 작성할 수 없습니다."); } + // 무관용 콘텐츠 필터 — 부적절 표현 포함 댓글 등록 차단 + contentFilter.check(req.getContent()); Comment comment = Comment.builder() .postId(postId) .authorId(author.getId()) @@ -319,6 +334,39 @@ public class BoardService { reportMapper.deleteByTarget(targetType, targetId); } + /* ===================== 사용자 차단 ===================== */ + + /** + * 사용자 차단 (App Store 1.2 UGC). 차단 즉시 상대 콘텐츠가 내 목록·상세·댓글에서 숨겨진다. + * reason 은 운영자 통지(관리자 차단 목록)에 사용된다. + */ + @Transactional + public void blockUser(Long blockedId, String reason, SessionUser user) { + if (blockedId == null || blockedId.equals(user.getId())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신은 차단할 수 없습니다."); + } + userBlockMapper.upsert(user.getId(), blockedId, blankToNull(reason)); + } + + /** 차단 해제 */ + @Transactional + public void unblockUser(Long blockedId, SessionUser user) { + userBlockMapper.delete(user.getId(), blockedId); + } + + /** 내가 차단한 사용자 목록 (관리 화면용) */ + @Transactional(readOnly = true) + public List myBlockedUsers(SessionUser user) { + return userBlockMapper.findBlockedUsers(user.getId()); + } + + /** 최근 사용자 차단 통지 (관리자 전용) — 운영자 24시간 내 검토용 */ + @Transactional(readOnly = true) + public List listUserBlocks(SessionUser user) { + assertAdmin(user); + return userBlockMapper.listRecent(200); + } + /* ===================== 추천/비추천 ===================== */ /** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */ @@ -392,7 +440,7 @@ public class BoardService { for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) { voteByComment.put(s.getCommentId(), s); } - List comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId).stream().map(c -> { + List comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId, viewerId).stream().map(c -> { CommentResponse cr = CommentResponse.from(c); CommentVoteSummary s = voteByComment.get(c.getId()); if (s != null) { diff --git a/src/main/java/com/sb/web/board/service/ContentFilter.java b/src/main/java/com/sb/web/board/service/ContentFilter.java new file mode 100644 index 0000000..3f76320 --- /dev/null +++ b/src/main/java/com/sb/web/board/service/ContentFilter.java @@ -0,0 +1,56 @@ +package com.sb.web.board.service; + +import com.sb.web.common.exception.ApiException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +import java.text.Normalizer; +import java.util.List; + +/** + * 커뮤니티 콘텐츠 필터 (App Store 가이드라인 1.2 UGC — "부적절한 콘텐츠 필터링" 요건). + * + * 게시글·댓글 작성 시 욕설/혐오/차별 등 불쾌한 표현이 포함되면 등록을 차단한다. + * (스팸/광고 도배는 별도로 PointService.isSpam 에서 블라인드 처리) + * + * 비교 전 정규화: 소문자화 + 공백/특수문자 제거로 "시 발", "시_발" 등 우회를 일부 차단. + */ +@Component +public class ContentFilter { + + // 무관용 정책 대상 금지어(대표 예시 — 필요 시 확장). 소문자·정규화 후 부분일치로 검사. + private static final List BANNED_WORDS = List.of( + // 한글 욕설/비속어 + "시발", "씨발", "씨빨", "시팔", "씨팔", "ㅅㅂ", "ㅆㅂ", + "병신", "ㅄ", "ㅂㅅ", "지랄", "ㅈㄹ", "좆", "좃", "존나", "졸라", + "개새끼", "개색끼", "새끼", "쌍놈", "쌍년", "니미", "니애미", "느금마", "느그", + "닥쳐", "꺼져", "엿먹", "뒤져", "뒈져", "죽어버려", "자살해", + "창녀", "걸레년", "보지", "자지", "섹스", "야동", + // 영문 욕설 + "fuck", "fucking", "motherfucker", "shit", "bitch", "asshole", "bastard", "dick", "cunt", "slut" + ); + + /** 부적절 표현 포함 여부. 정규화(소문자 + 공백·비문자 제거) 후 부분일치 검사. */ + public boolean isObjectionable(String content) { + if (content == null || content.isBlank()) { + return false; + } + String normalized = normalize(content); + return BANNED_WORDS.stream().anyMatch(w -> normalized.contains(normalize(w))); + } + + /** 부적절 표현이 있으면 400 예외를 던져 등록을 차단한다. */ + public void check(String content) { + if (isObjectionable(content)) { + throw new ApiException(HttpStatus.BAD_REQUEST, + "부적절한 표현(욕설·비방 등)이 포함되어 있어 등록할 수 없습니다. 커뮤니티 이용수칙을 확인해 주세요."); + } + } + + /** 소문자화 + 유니코드 정규화 + 문자/숫자 외(공백·기호·자모 분리 우회 등) 제거 */ + private String normalize(String s) { + String lower = Normalizer.normalize(s.toLowerCase(), Normalizer.Form.NFKC); + // 한글/영문/숫자만 남기고 제거 → "시 발", "s.h.i.t" 같은 우회 완화 + return lower.replaceAll("[^0-9a-z\\uac00-\\ud7a3\\u3131-\\u318e]", ""); + } +} diff --git a/src/main/resources/db/board.sql b/src/main/resources/db/board.sql index 246aa42..543d520 100644 --- a/src/main/resources/db/board.sql +++ b/src/main/resources/db/board.sql @@ -130,3 +130,16 @@ CREATE TABLE IF NOT EXISTS comment_vote ( PRIMARY KEY (comment_id, member_id), KEY idx_comment_vote_comment (comment_id, vote_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 사용자 차단 (App Store 가이드라인 1.2 UGC — "악성 사용자 차단" 요건). +-- blocker_id 가 blocked_id 를 차단하면, 목록·상세·댓글에서 blocked_id 의 콘텐츠가 +-- blocker 화면에서 즉시 숨겨진다. reason 은 운영자 통지(신고 기록)에 사용. +CREATE TABLE IF NOT EXISTS user_block ( + blocker_id BIGINT NOT NULL COMMENT '차단한 회원', + blocked_id BIGINT NOT NULL COMMENT '차단당한 회원', + reason VARCHAR(255) NULL COMMENT '차단 사유(운영자 통지용)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (blocker_id, blocked_id), + KEY idx_user_block_blocker (blocker_id), + KEY idx_user_block_blocked (blocked_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/mapper/CommentMapper.xml b/src/main/resources/mapper/CommentMapper.xml index 1084b63..e7b5ee9 100644 --- a/src/main/resources/mapper/CommentMapper.xml +++ b/src/main/resources/mapper/CommentMapper.xml @@ -15,12 +15,19 @@ - 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.post_id = #{postId} + + + AND NOT EXISTS ( + SELECT 1 FROM user_block ub + WHERE ub.blocker_id = #{viewerId} AND ub.blocked_id = c.author_id + ) + ORDER BY c.id ASC diff --git a/src/main/resources/mapper/PostMapper.xml b/src/main/resources/mapper/PostMapper.xml index 7715624..72c8095 100644 --- a/src/main/resources/mapper/PostMapper.xml +++ b/src/main/resources/mapper/PostMapper.xml @@ -3,12 +3,23 @@ "https://mybatis.org/dtd/mybatis-3-mapper.dtd"> + + + + AND NOT EXISTS ( + SELECT 1 FROM user_block ub + WHERE ub.blocker_id = #{viewerId} AND ub.blocked_id = p.author_id + ) + + + JOIN post_tag pt ON pt.post_id = p.id JOIN tag t ON t.id = pt.tag_id + AND p.category = #{category} @@ -61,6 +72,7 @@ AND (#{category} IS NULL OR p.category = #{category}) AND p.created_at >= NOW() - INTERVAL #{hours} HOUR AND (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') >= #{minUp} + ORDER BY (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') DESC, p.id DESC LIMIT #{limit} diff --git a/src/main/resources/mapper/UserBlockMapper.xml b/src/main/resources/mapper/UserBlockMapper.xml new file mode 100644 index 0000000..199d2c9 --- /dev/null +++ b/src/main/resources/mapper/UserBlockMapper.xml @@ -0,0 +1,52 @@ + + + + + + + INSERT INTO user_block (blocker_id, blocked_id, reason, created_at) + VALUES (#{blockerId}, #{blockedId}, #{reason}, NOW()) + ON DUPLICATE KEY UPDATE reason = VALUES(reason) + + + + DELETE FROM user_block WHERE blocker_id = #{blockerId} AND blocked_id = #{blockedId} + + + + + + + + + + DELETE FROM user_block WHERE blocker_id = #{memberId} OR blocked_id = #{memberId} + + +