feat: 커뮤니티 UGC 안전장치 추가 (App Store 가이드라인 1.2 대응)

- 사용자 차단(user_block) 테이블·매퍼·API 추가
- 게시글/댓글 목록·상세 쿼리에 차단 사용자 필터링(즉시 숨김)
- 부적절 표현(욕설·비방) 콘텐츠 필터 추가(작성·수정·댓글 차단)
- 관리자용 사용자 차단 통지 조회 API

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
isodev
2026-07-15 21:41:34 +09:00
co-authored by Claude Opus 4.8
parent b6976f5612
commit 0b027b3704
12 changed files with 312 additions and 12 deletions
@@ -50,8 +50,9 @@ public class BoardController {
@RequestParam(required = false) String category, @RequestParam(required = false) String category,
@RequestParam(required = false) String tag, @RequestParam(required = false) String tag,
@RequestParam(required = false) String keyword, @RequestParam(required = false) String keyword,
@RequestParam(required = false) String searchType) { @RequestParam(required = false) String searchType,
return boardService.list(page, size, category, tag, keyword, 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); boardService.dismissReports(body.get("targetType"), Long.valueOf(body.get("targetId")), current);
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/* ===================== 사용자 차단 (UGC 1.2) ===================== */
/** 사용자 차단 — 차단 즉시 상대 콘텐츠가 내 화면에서 숨겨진다 */
@PostMapping("/users/{id}/block")
public ResponseEntity<Void> blockUser(
@PathVariable Long id,
@RequestBody(required = false) Map<String, String> 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<Void> unblockUser(
@PathVariable Long id,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
boardService.unblockUser(id, current);
return ResponseEntity.noContent().build();
}
/** 내가 차단한 사용자 목록 (관리 화면) */
@GetMapping("/blocks")
public List<com.sb.web.board.dto.BlockedUser> myBlocks(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return boardService.myBlockedUsers(current);
}
/** 최근 사용자 차단 통지 (관리자) — 운영자 검토용 */
@GetMapping("/user-blocks")
public List<com.sb.web.board.dto.UserBlockNotice> userBlocks(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return boardService.listUserBlocks(current);
}
} }
@@ -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;
}
@@ -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;
}
@@ -9,7 +9,7 @@ import java.util.List;
@Mapper @Mapper
public interface CommentMapper { public interface CommentMapper {
List<Comment> findByPostId(@Param("postId") Long postId); List<Comment> findByPostId(@Param("postId") Long postId, @Param("viewerId") Long viewerId);
Comment findById(@Param("id") Long id); Comment findById(@Param("id") Long id);
@@ -15,18 +15,21 @@ public interface PostMapper {
@Param("category") String category, @Param("category") String category,
@Param("tag") String tag, @Param("tag") String tag,
@Param("keyword") String keyword, @Param("keyword") String keyword,
@Param("searchType") String searchType); @Param("searchType") String searchType,
@Param("viewerId") Long viewerId);
long countPage(@Param("category") String category, long countPage(@Param("category") String category,
@Param("tag") String tag, @Param("tag") String tag,
@Param("keyword") String keyword, @Param("keyword") String keyword,
@Param("searchType") String searchType); @Param("searchType") String searchType,
@Param("viewerId") Long viewerId);
/** 인기 글(추천 minUp 이상 & hours 시간 이내) 상단 노출용 — 추천 많은 순 limit 건 */ /** 인기 글(추천 minUp 이상 & hours 시간 이내) 상단 노출용 — 추천 많은 순 limit 건 */
List<PostSummary> findHotPosts(@Param("category") String category, List<PostSummary> findHotPosts(@Param("category") String category,
@Param("hours") int hours, @Param("hours") int hours,
@Param("minUp") int minUp, @Param("minUp") int minUp,
@Param("limit") int limit); @Param("limit") int limit,
@Param("viewerId") Long viewerId);
/** 내 글 모아보기 */ /** 내 글 모아보기 */
List<PostSummary> findMyPage(@Param("memberId") Long memberId, List<PostSummary> findMyPage(@Param("memberId") Long memberId,
@@ -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<Long> findBlockedIds(@Param("blockerId") Long blockerId);
/** 내가 차단한 회원 목록 (관리 화면용 — 이름/아바타 포함) */
List<BlockedUser> findBlockedUsers(@Param("blockerId") Long blockerId);
/** 최근 차단 통지 목록 (관리자 — 운영자 검토용) */
List<UserBlockNotice> listRecent(@Param("limit") int limit);
/** 회원 탈퇴 시 관련 차단 관계 정리 */
int deleteByMember(@Param("memberId") Long memberId);
}
@@ -13,10 +13,13 @@ import com.sb.web.board.dto.PostSummary;
import com.sb.web.board.dto.Recommender; import com.sb.web.board.dto.Recommender;
import com.sb.web.board.dto.ReportedItem; import com.sb.web.board.dto.ReportedItem;
import com.sb.web.board.dto.VoteResponse; 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.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.ReportMapper;
import com.sb.web.board.mapper.TagMapper; 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.board.mapper.VoteMapper;
import com.sb.web.common.exception.ApiException; import com.sb.web.common.exception.ApiException;
import com.sb.web.point.PointService; import com.sb.web.point.PointService;
@@ -44,7 +47,9 @@ public class BoardService {
private final CommentMapper commentMapper; private final CommentMapper commentMapper;
private final VoteMapper voteMapper; private final VoteMapper voteMapper;
private final ReportMapper reportMapper; private final ReportMapper reportMapper;
private final UserBlockMapper userBlockMapper;
private final PointService pointService; private final PointService pointService;
private final ContentFilter contentFilter;
private final RedisTemplate<String, Object> redisTemplate; private final RedisTemplate<String, Object> redisTemplate;
/** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */ /** 같은 사용자가 이 시간 내 재열람 시 조회수를 다시 올리지 않음 */
@@ -63,20 +68,21 @@ public class BoardService {
/* ===================== 게시글 ===================== */ /* ===================== 게시글 ===================== */
public PageResponse<PostSummary> list(int page, int size, String category, String tag, String keyword, String searchType) { public PageResponse<PostSummary> list(int page, int size, String category, String tag, String keyword, String searchType, SessionUser viewer) {
int p = Math.max(page, 1); int p = Math.max(page, 1);
int s = Math.min(Math.max(size, 1), 100); int s = Math.min(Math.max(size, 1), 100);
int offset = (p - 1) * s; int offset = (p - 1) * s;
String st = normalizeSearchType(searchType); String st = normalizeSearchType(searchType);
String cat = normalizeCategory(category); String cat = normalizeCategory(category);
Long viewerId = viewer == null ? null : viewer.getId();
// 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다. // 목록에는 태그를 표시하지 않으므로 게시글별 태그 조회를 생략한다.
List<PostSummary> content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st); List<PostSummary> content = postMapper.findPage(offset, s, cat, blankToNull(tag), blankToNull(keyword), st, viewerId);
long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st); long total = postMapper.countPage(cat, blankToNull(tag), blankToNull(keyword), st, viewerId);
// 1페이지·검색 없을 때만 인기 글을 최상단에 노출 (중복 제거) // 1페이지·검색 없을 때만 인기 글을 최상단에 노출 (중복 제거)
if (p == 1 && blankToNull(tag) == null && blankToNull(keyword) == null) { if (p == 1 && blankToNull(tag) == null && blankToNull(keyword) == null) {
List<PostSummary> hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX); List<PostSummary> hot = postMapper.findHotPosts(cat, HOT_WITHIN_HOURS, HOT_MIN_UP, HOT_MAX, viewerId);
if (!hot.isEmpty()) { if (!hot.isEmpty()) {
java.util.Set<Long> hotIds = new java.util.HashSet<>(); java.util.Set<Long> hotIds = new java.util.HashSet<>();
hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); }); hot.forEach(h -> { h.setHot(true); hotIds.add(h.getId()); });
@@ -180,6 +186,9 @@ public class BoardService {
@Transactional @Transactional
public PostDetail create(PostRequest req, SessionUser author) { public PostDetail create(PostRequest req, SessionUser author) {
// 무관용 콘텐츠 필터 — 욕설·비방 등 부적절 표현 포함 시 등록 차단
contentFilter.check(req.getTitle());
contentFilter.check(req.getContent());
Post post = Post.builder() Post post = Post.builder()
.title(req.getTitle()) .title(req.getTitle())
.content(req.getContent()) .content(req.getContent())
@@ -207,6 +216,10 @@ public class BoardService {
} }
assertCanModify(post.getAuthorId(), user); assertCanModify(post.getAuthorId(), user);
// 무관용 콘텐츠 필터 — 수정 시에도 부적절 표현 차단
contentFilter.check(req.getTitle());
contentFilter.check(req.getContent());
post.setTitle(req.getTitle()); post.setTitle(req.getTitle());
post.setContent(req.getContent()); post.setContent(req.getContent());
postMapper.update(post); postMapper.update(post);
@@ -242,6 +255,8 @@ public class BoardService {
if (Boolean.TRUE.equals(post.getBlocked())) { if (Boolean.TRUE.equals(post.getBlocked())) {
throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글에는 댓글을 작성할 수 없습니다."); throw new ApiException(HttpStatus.FORBIDDEN, "제한된 게시글에는 댓글을 작성할 수 없습니다.");
} }
// 무관용 콘텐츠 필터 — 부적절 표현 포함 댓글 등록 차단
contentFilter.check(req.getContent());
Comment comment = Comment.builder() Comment comment = Comment.builder()
.postId(postId) .postId(postId)
.authorId(author.getId()) .authorId(author.getId())
@@ -319,6 +334,39 @@ public class BoardService {
reportMapper.deleteByTarget(targetType, targetId); 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<BlockedUser> myBlockedUsers(SessionUser user) {
return userBlockMapper.findBlockedUsers(user.getId());
}
/** 최근 사용자 차단 통지 (관리자 전용) — 운영자 24시간 내 검토용 */
@Transactional(readOnly = true)
public List<UserBlockNotice> listUserBlocks(SessionUser user) {
assertAdmin(user);
return userBlockMapper.listRecent(200);
}
/* ===================== 추천/비추천 ===================== */ /* ===================== 추천/비추천 ===================== */
/** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */ /** 게시글 추천/비추천 토글. 같은 표 재요청 시 취소, 반대면 전환. */
@@ -392,7 +440,7 @@ public class BoardService {
for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) { for (CommentVoteSummary s : voteMapper.findCommentVoteSummary(postId, viewerId)) {
voteByComment.put(s.getCommentId(), s); voteByComment.put(s.getCommentId(), s);
} }
List<CommentResponse> comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId).stream().map(c -> { List<CommentResponse> comments = new java.util.ArrayList<>(commentMapper.findByPostId(postId, viewerId).stream().map(c -> {
CommentResponse cr = CommentResponse.from(c); CommentResponse cr = CommentResponse.from(c);
CommentVoteSummary s = voteByComment.get(c.getId()); CommentVoteSummary s = voteByComment.get(c.getId());
if (s != null) { if (s != null) {
@@ -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<String> 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]", "");
}
}
+13
View File
@@ -130,3 +130,16 @@ CREATE TABLE IF NOT EXISTS comment_vote (
PRIMARY KEY (comment_id, member_id), PRIMARY KEY (comment_id, member_id),
KEY idx_comment_vote_comment (comment_id, vote_type) KEY idx_comment_vote_comment (comment_id, vote_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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;
+8 -1
View File
@@ -15,12 +15,19 @@
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
</resultMap> </resultMap>
<select id="findByPostId" parameterType="long" resultMap="commentResultMap"> <select id="findByPostId" resultMap="commentResultMap">
SELECT c.id, c.post_id, c.author_id, c.author_name, c.content, c.blocked, 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.post_id = #{postId} WHERE c.post_id = #{postId}
<!-- 차단한 사용자의 댓글 숨김 -->
<if test="viewerId != null">
AND NOT EXISTS (
SELECT 1 FROM user_block ub
WHERE ub.blocker_id = #{viewerId} AND ub.blocked_id = c.author_id
)
</if>
ORDER BY c.id ASC ORDER BY c.id ASC
</select> </select>
+12
View File
@@ -3,12 +3,23 @@
"https://mybatis.org/dtd/mybatis-3-mapper.dtd"> "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.board.mapper.PostMapper"> <mapper namespace="com.sb.web.board.mapper.PostMapper">
<!-- 차단한 사용자의 글 숨김 (viewerId 가 차단한 author 제외) -->
<sql id="blockFilter">
<if test="viewerId != null">
AND NOT EXISTS (
SELECT 1 FROM user_block ub
WHERE ub.blocker_id = #{viewerId} AND ub.blocked_id = p.author_id
)
</if>
</sql>
<sql id="filter"> <sql id="filter">
<if test="tag != null and tag != ''"> <if test="tag != null and tag != ''">
JOIN post_tag pt ON pt.post_id = p.id JOIN post_tag pt ON pt.post_id = p.id
JOIN tag t ON t.id = pt.tag_id JOIN tag t ON t.id = pt.tag_id
</if> </if>
<where> <where>
<include refid="blockFilter"/>
<if test="category != null and category != ''"> <if test="category != null and category != ''">
AND p.category = #{category} AND p.category = #{category}
</if> </if>
@@ -61,6 +72,7 @@
AND (#{category} IS NULL OR p.category = #{category}) AND (#{category} IS NULL OR p.category = #{category})
AND p.created_at >= NOW() - INTERVAL #{hours} HOUR 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} AND (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') >= #{minUp}
<include refid="blockFilter"/>
ORDER BY (SELECT COUNT(*) FROM post_vote v WHERE v.post_id = p.id AND v.vote_type = 'UP') DESC, p.id DESC 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} LIMIT #{limit}
</select> </select>
@@ -0,0 +1,52 @@
<?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.UserBlockMapper">
<!-- 차단 추가 (중복 시 사유만 갱신) -->
<insert id="upsert">
INSERT INTO user_block (blocker_id, blocked_id, reason, created_at)
VALUES (#{blockerId}, #{blockedId}, #{reason}, NOW())
ON DUPLICATE KEY UPDATE reason = VALUES(reason)
</insert>
<delete id="delete">
DELETE FROM user_block WHERE blocker_id = #{blockerId} AND blocked_id = #{blockedId}
</delete>
<select id="findBlockedIds" resultType="long">
SELECT blocked_id FROM user_block WHERE blocker_id = #{blockerId}
</select>
<select id="findBlockedUsers" resultType="com.sb.web.board.dto.BlockedUser">
SELECT ub.blocked_id AS blockedId,
m.name AS name,
m.google_picture AS googlePicture,
m.profile_image AS profileImage,
ub.reason AS reason,
ub.created_at AS createdAt
FROM user_block ub
LEFT JOIN member m ON m.id = ub.blocked_id
WHERE ub.blocker_id = #{blockerId}
ORDER BY ub.created_at DESC
</select>
<select id="listRecent" resultType="com.sb.web.board.dto.UserBlockNotice">
SELECT ub.blocker_id AS blockerId,
bm.name AS blockerName,
ub.blocked_id AS blockedId,
tm.name AS blockedName,
ub.reason AS reason,
ub.created_at AS createdAt
FROM user_block ub
LEFT JOIN member bm ON bm.id = ub.blocker_id
LEFT JOIN member tm ON tm.id = ub.blocked_id
ORDER BY ub.created_at DESC
LIMIT #{limit}
</select>
<delete id="deleteByMember">
DELETE FROM user_block WHERE blocker_id = #{memberId} OR blocked_id = #{memberId}
</delete>
</mapper>