feat(point): 게시판 포인트 정책 개편
Deploy / deploy (push) Failing after 12m48s

작성: 10P, 일 50P 한도(5회), 스팸 키워드 포함 시 미지급
추천 받음(작성자): +5P, 일 50P 한도
비추천 받음(작성자): -1P
남의 글 추천(투표자): +1P, 일 10P 한도
추천 취소(투표자): -1P
addPoints에 GREATEST(0,...) 적용 — 잔액 음수 방지
This commit is contained in:
ByungCheol
2026-07-05 12:34:50 +09:00
parent d65dd88bba
commit 32da57e0e0
3 changed files with 109 additions and 17 deletions
@@ -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),
+100 -10
View File
@@ -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 int BOARD_WRITE_DAILY_LIMIT = 5; // 10P × 5 = 일 50P
public static final String REASON_BOARD_WRITE = "BOARD_WRITE";
// 추천 받음 (글 작성자)
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; // 10pt × 5 = 일 50pt 한도
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<String> 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
+1 -1
View File
@@ -14,7 +14,7 @@
</insert>
<update id="addPoints">
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}
</update>
<select id="getPoints" parameterType="long" resultType="long">