From 32da57e0e0326b0ad39bc6e918b6f4f80a74dfd5 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 5 Jul 2026 12:34:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(point):=20=EA=B2=8C=EC=8B=9C=ED=8C=90=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=A0=95=EC=B1=85=20=EA=B0=9C?= =?UTF-8?q?=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 작성: 10P, 일 50P 한도(5회), 스팸 키워드 포함 시 미지급 추천 받음(작성자): +5P, 일 50P 한도 비추천 받음(작성자): -1P 남의 글 추천(투표자): +1P, 일 10P 한도 추천 취소(투표자): -1P addPoints에 GREATEST(0,...) 적용 — 잔액 음수 방지 --- .../sb/web/board/service/BoardService.java | 6 +- .../java/com/sb/web/point/PointService.java | 118 +++++++++++++++--- src/main/resources/mapper/PointMapper.xml | 2 +- 3 files changed, 109 insertions(+), 17 deletions(-) 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 a2e184c..4814c3b 100644 --- a/src/main/java/com/sb/web/board/service/BoardService.java +++ b/src/main/java/com/sb/web/board/service/BoardService.java @@ -191,7 +191,7 @@ public class BoardService { .build(); postMapper.insert(post); applyTags(post.getId(), req.getTagIds()); - pointService.awardBoardWrite(author.getId()); // 글 작성 보상(일 3회 한도) + pointService.awardBoardWrite(author.getId(), req.getContent()); return assemble(mustFindPost(post.getId()), author); } @@ -245,7 +245,7 @@ public class BoardService { .content(req.getContent()) .build(); commentMapper.insert(comment); - pointService.awardBoardWrite(author.getId()); // 댓글 작성 보상(일 3회 한도, 글과 합산) + pointService.awardBoardWrite(author.getId(), req.getContent()); return CommentResponse.from(commentMapper.findById(comment.getId())); } @@ -328,6 +328,7 @@ public class BoardService { } else { voteMapper.upsertPostVote(postId, memberId, type); } + pointService.applyVotePoints(post.getAuthorId(), memberId, existing, type); return new VoteResponse( voteMapper.countPostVotes(postId, UP), voteMapper.countPostVotes(postId, DOWN), @@ -352,6 +353,7 @@ public class BoardService { } else { voteMapper.upsertCommentVote(commentId, memberId, type); } + pointService.applyVotePoints(comment.getAuthorId(), memberId, existing, type); return new VoteResponse( voteMapper.countCommentVotes(commentId, UP), voteMapper.countCommentVotes(commentId, DOWN), diff --git a/src/main/java/com/sb/web/point/PointService.java b/src/main/java/com/sb/web/point/PointService.java index cefe666..aae543c 100644 --- a/src/main/java/com/sb/web/point/PointService.java +++ b/src/main/java/com/sb/web/point/PointService.java @@ -5,9 +5,18 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + /** * 활동 포인트 적립/조회. - * 게시판 글/댓글 작성 시 10점(하루 3회), 가계부 내역 작성 시 10점(하루 50점 한도). + * + * 정책 요약: + * - 게시글/댓글 작성: 10P, 일 5회(50P) 한도, 스팸 키워드 포함 시 미지급 + * - 추천 받음(글 작성자): +5P, 일 50P 한도 + * - 비추천 받음(글 작성자): -1P + * - 남의 글 추천(투표자): +1P, 일 10P 한도 + * - 추천 취소(투표자): -1P + * - 가계부 내역 작성: 10P, 일 5회(50P) 한도 */ @Service @RequiredArgsConstructor @@ -15,31 +24,112 @@ public class PointService { private final PointMapper pointMapper; - public static final int BOARD_WRITE_POINT = 10; - public static final int BOARD_WRITE_DAILY_LIMIT = 3; - public static final String REASON_BOARD_WRITE = "BOARD_WRITE"; + // 게시글/댓글 작성 + public static final int BOARD_WRITE_POINT = 10; + public static final int BOARD_WRITE_DAILY_LIMIT = 5; // 10P × 5 = 일 50P + public static final String REASON_BOARD_WRITE = "BOARD_WRITE"; - public static final int ACCOUNT_ENTRY_POINT = 10; - public static final int ACCOUNT_ENTRY_DAILY_LIMIT = 5; // 10pt × 5 = 일 50pt 한도 - public static final String REASON_ACCOUNT_ENTRY = "ACCOUNT_ENTRY"; + // 추천 받음 (글 작성자) + public static final int POST_UPVOTED_POINT = 5; + public static final int POST_UPVOTED_DAILY_CAP = 10; // 5P × 10 = 일 50P + public static final String REASON_POST_UPVOTED = "POST_UPVOTED"; + + // 비추천 받음 (글 작성자) + public static final int POST_DOWNVOTED_POINT = -1; + public static final String REASON_POST_DOWNVOTED = "POST_DOWNVOTED"; + + // 남의 글 추천 (투표자) + public static final int VOTE_REWARD_POINT = 1; + public static final int VOTE_REWARD_DAILY_CAP = 10; // 1P × 10 = 일 10P + public static final String REASON_VOTE_REWARD = "VOTE_REWARD"; + + // 추천 취소 (투표자) + public static final int VOTE_CANCEL_POINT = -1; + public static final String REASON_VOTE_CANCEL = "VOTE_CANCEL"; + + // 가계부 내역 작성 + public static final int ACCOUNT_ENTRY_POINT = 10; + public static final int ACCOUNT_ENTRY_DAILY_LIMIT = 5; // 10P × 5 = 일 50P + public static final String REASON_ACCOUNT_ENTRY = "ACCOUNT_ENTRY"; + + // 도배 방지 키워드 — 포함 시 작성 포인트 미지급 + private static final List SPAM_KEYWORDS = List.of( + "http://", "https://", "bit.ly", "goo.gl", + "무료지급", "무료쿠폰", "공짜", "홍보", "광고", "클릭하세요", + "카지노", "토토", "바카라", "도박" + ); + + public boolean isSpam(String content) { + if (content == null || content.isBlank()) return false; + String lower = content.toLowerCase(); + return SPAM_KEYWORDS.stream().anyMatch(lower::contains); + } /** - * 게시판 글/댓글 작성 보상. 오늘 지급 횟수가 한도 미만일 때만 지급한다. - * @return 지급되면 지급 포인트, 한도 초과로 미지급이면 0 + * 게시글/댓글 작성 보상. 스팸 키워드 포함 또는 일 한도 초과 시 미지급. + * @return 지급 포인트 (미지급이면 0) */ @Transactional - public int awardBoardWrite(Long memberId) { + public int awardBoardWrite(Long memberId, String content) { + if (isSpam(content)) return 0; int today = pointMapper.countTodayGrants(memberId, REASON_BOARD_WRITE); - if (today >= BOARD_WRITE_DAILY_LIMIT) { - return 0; - } + if (today >= BOARD_WRITE_DAILY_LIMIT) return 0; pointMapper.insertHistory(memberId, BOARD_WRITE_POINT, REASON_BOARD_WRITE); pointMapper.addPoints(memberId, BOARD_WRITE_POINT); return BOARD_WRITE_POINT; } /** - * 가계부 내역 작성 보상. 건당 10점, 하루 5건(50점)까지만 지급한다. + * 투표 포인트 처리. 이전 투표(existing)와 새 요청(newType)을 비교해 적절한 포인트를 지급/차감한다. + * + * 케이스: + * - existing==newType (취소): UP 취소 → 투표자 -1P / DOWN 취소 → 변동 없음 + * - 새 UP: 작성자 +5P(일 50P 한도), 투표자 +1P(일 10P 한도) + * - 새 DOWN: 작성자 -1P + * - UP→DOWN 전환: 투표자 -1P(취소), 작성자 -1P(비추) + * - DOWN→UP 전환: 작성자 +5P, 투표자 +1P + */ + @Transactional + public void applyVotePoints(Long authorId, Long voterId, String existing, String newType) { + boolean cancel = newType.equals(existing); + + if (cancel) { + // 같은 타입 재요청 = 취소 + if ("UP".equals(existing)) { + // 투표자가 받았던 +1P 회수 + pointMapper.insertHistory(voterId, VOTE_CANCEL_POINT, REASON_VOTE_CANCEL); + pointMapper.addPoints(voterId, VOTE_CANCEL_POINT); + } + // DOWN 취소: 포인트 변동 없음 + return; + } + + // UP→DOWN 전환: 투표자의 추천 보상 먼저 회수 + if ("UP".equals(existing)) { + pointMapper.insertHistory(voterId, VOTE_CANCEL_POINT, REASON_VOTE_CANCEL); + pointMapper.addPoints(voterId, VOTE_CANCEL_POINT); + } + + if ("UP".equals(newType)) { + // 작성자: +5P (일 한도) + if (pointMapper.countTodayGrants(authorId, REASON_POST_UPVOTED) < POST_UPVOTED_DAILY_CAP) { + pointMapper.insertHistory(authorId, POST_UPVOTED_POINT, REASON_POST_UPVOTED); + pointMapper.addPoints(authorId, POST_UPVOTED_POINT); + } + // 투표자: +1P (일 한도) + if (pointMapper.countTodayGrants(voterId, REASON_VOTE_REWARD) < VOTE_REWARD_DAILY_CAP) { + pointMapper.insertHistory(voterId, VOTE_REWARD_POINT, REASON_VOTE_REWARD); + pointMapper.addPoints(voterId, VOTE_REWARD_POINT); + } + } else if ("DOWN".equals(newType)) { + // 작성자: -1P + pointMapper.insertHistory(authorId, POST_DOWNVOTED_POINT, REASON_POST_DOWNVOTED); + pointMapper.addPoints(authorId, POST_DOWNVOTED_POINT); + } + } + + /** + * 가계부 내역 작성 보상. 건당 10P, 하루 5건(50P)까지만 지급한다. * @return 지급되면 지급 포인트, 한도 초과로 미지급이면 0 */ @Transactional diff --git a/src/main/resources/mapper/PointMapper.xml b/src/main/resources/mapper/PointMapper.xml index 0d65d46..fbe8ae9 100644 --- a/src/main/resources/mapper/PointMapper.xml +++ b/src/main/resources/mapper/PointMapper.xml @@ -14,7 +14,7 @@ - UPDATE member SET points = points + #{amount}, updated_at = NOW() WHERE id = #{memberId} + UPDATE member SET points = GREATEST(0, points + #{amount}), updated_at = NOW() WHERE id = #{memberId}