From 45bb0252475e4d0acfcd8d334389ac3536a9f6d6 Mon Sep 17 00:00:00 2001 From: sb Date: Sun, 5 Jul 2026 16:56:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(point):=20=ED=95=9C=20=EB=8B=AC=20?= =?UTF-8?q?=EA=B0=9C=EA=B7=BC(=EB=AA=A8=EB=93=A0=20=EB=82=A0=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D)=20=EB=9E=9C=EB=8D=A4=20=EB=B3=B4=EB=84=88=EC=8A=A4?= =?UTF-8?q?=201~100P?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내역 저장 시 현재 월 모든 날에 기록이 있으면 월 1회 랜덤 1~100P 지급. 전월 소급 입력 파밍 방지(현재 월만 인정), 화면 안내 없음(포인트 내역에만). - AccountEntryMapper.countDistinctEntryDays, PointMapper.countByReason - PointService.awardMonthComplete(reason=MONTH_COMPLETE_yyyyMM 로 월 1회) Co-Authored-By: Claude Opus 4.8 --- .../account/mapper/AccountEntryMapper.java | 5 +++++ .../web/account/service/AccountService.java | 19 +++++++++++++++++++ .../java/com/sb/web/point/PointService.java | 19 +++++++++++++++++++ .../com/sb/web/point/mapper/PointMapper.java | 3 +++ .../resources/mapper/AccountEntryMapper.xml | 9 +++++++++ src/main/resources/mapper/PointMapper.xml | 5 +++++ 6 files changed, 60 insertions(+) diff --git a/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java b/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java index 1b1181f..87e5517 100644 --- a/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java +++ b/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java @@ -96,4 +96,9 @@ public interface AccountEntryMapper { /** 복수 항목의 태그를 한 번에 조회 — {entryId, tagName} 맵 반환 */ List> findTagsByEntryIds(@Param("entryIds") List entryIds); + + /** 특정 연·월에 기록이 있는 서로 다른 날짜 수 (한 달 개근 판정용) */ + int countDistinctEntryDays(@Param("memberId") Long memberId, + @Param("year") int year, + @Param("month") int month); } diff --git a/src/main/java/com/sb/web/account/service/AccountService.java b/src/main/java/com/sb/web/account/service/AccountService.java index 7e36bc3..1e9107a 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -256,9 +256,28 @@ public class AccountService { mapper.insert(entry); applyTags(entry.getId(), req.getTagIds(), memberId); pointService.awardAccountEntry(memberId); + awardIfMonthComplete(entry.getEntryDate(), memberId); return toResponse(mapper.findByIdAndMember(entry.getId(), memberId)); } + /** + * 내역의 월이 '현재 진행 중인 달'이고 그 달 모든 날에 기록이 있으면 개근 보너스(랜덤 1~100P) 지급. + * 지난달을 소급 입력해 채우는 파밍을 막기 위해 현재 월만 인정한다(전 월 제외). 판정 실패는 저장에 영향 없음. + */ + private void awardIfMonthComplete(java.time.LocalDate entryDate, Long memberId) { + if (entryDate == null) return; + try { + java.time.LocalDate today = java.time.LocalDate.now(); + if (entryDate.getYear() != today.getYear() || entryDate.getMonthValue() != today.getMonthValue()) return; + int recorded = mapper.countDistinctEntryDays(memberId, entryDate.getYear(), entryDate.getMonthValue()); + if (recorded >= entryDate.lengthOfMonth()) { + pointService.awardMonthComplete(memberId, entryDate.getYear(), entryDate.getMonthValue()); + } + } catch (Exception ignore) { + // 개근 판정 오류는 무시(내역 저장은 이미 완료) + } + } + @Transactional public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) { AccountEntry entry = mustFind(id, memberId); diff --git a/src/main/java/com/sb/web/point/PointService.java b/src/main/java/com/sb/web/point/PointService.java index aae543c..3ae2931 100644 --- a/src/main/java/com/sb/web/point/PointService.java +++ b/src/main/java/com/sb/web/point/PointService.java @@ -52,6 +52,10 @@ public class PointService { public static final int ACCOUNT_ENTRY_DAILY_LIMIT = 5; // 10P × 5 = 일 50P public static final String REASON_ACCOUNT_ENTRY = "ACCOUNT_ENTRY"; + // 한 달 개근(그 달 모든 날에 기록) — 랜덤 1~100P, 월 1회. reason 에 연월 인코딩(MONTH_COMPLETE_yyyyMM) + public static final int MONTH_COMPLETE_MAX_POINT = 100; + public static final String REASON_MONTH_COMPLETE_PREFIX = "MONTH_COMPLETE_"; + // 도배 방지 키워드 — 포함 시 작성 포인트 미지급 private static final List SPAM_KEYWORDS = List.of( "http://", "https://", "bit.ly", "goo.gl", @@ -143,6 +147,21 @@ public class PointService { return ACCOUNT_ENTRY_POINT; } + /** + * 한 달의 모든 날에 기록이 있을 때 주는 랜덤 보너스(1~100P). 월 1회만 지급한다. + * reason 에 연월을 인코딩(MONTH_COMPLETE_yyyyMM)해 같은 달 중복 지급을 막는다. + * @return 지급 포인트(이미 지급됐으면 0) + */ + @Transactional + public int awardMonthComplete(Long memberId, int year, int month) { + String reason = REASON_MONTH_COMPLETE_PREFIX + String.format("%04d%02d", year, month); + if (pointMapper.countByReason(memberId, reason) > 0) return 0; // 이미 지급됨 + int amount = java.util.concurrent.ThreadLocalRandom.current().nextInt(1, MONTH_COMPLETE_MAX_POINT + 1); // 1~100 + pointMapper.insertHistory(memberId, amount, reason); + pointMapper.addPoints(memberId, amount); + return amount; + } + public long getPoints(Long memberId) { Long p = pointMapper.getPoints(memberId); return p == null ? 0L : p; diff --git a/src/main/java/com/sb/web/point/mapper/PointMapper.java b/src/main/java/com/sb/web/point/mapper/PointMapper.java index f6b696a..bc483c0 100644 --- a/src/main/java/com/sb/web/point/mapper/PointMapper.java +++ b/src/main/java/com/sb/web/point/mapper/PointMapper.java @@ -15,6 +15,9 @@ public interface PointMapper { /** 오늘 특정 사유로 지급된 횟수 (일일 한도 계산용) */ int countTodayGrants(@Param("memberId") Long memberId, @Param("reason") String reason); + /** 특정 사유로 지금까지 지급된 총 횟수 (월 1회 등 전체기간 중복 방지용) */ + int countByReason(@Param("memberId") Long memberId, @Param("reason") String reason); + int insertHistory(@Param("memberId") Long memberId, @Param("amount") int amount, @Param("reason") String reason); int addPoints(@Param("memberId") Long memberId, @Param("amount") int amount); diff --git a/src/main/resources/mapper/AccountEntryMapper.xml b/src/main/resources/mapper/AccountEntryMapper.xml index 91fe093..4a51445 100644 --- a/src/main/resources/mapper/AccountEntryMapper.xml +++ b/src/main/resources/mapper/AccountEntryMapper.xml @@ -246,4 +246,13 @@ ORDER BY aet.entry_id, t.name + + + diff --git a/src/main/resources/mapper/PointMapper.xml b/src/main/resources/mapper/PointMapper.xml index fbe8ae9..6eba1c4 100644 --- a/src/main/resources/mapper/PointMapper.xml +++ b/src/main/resources/mapper/PointMapper.xml @@ -8,6 +8,11 @@ WHERE member_id = #{memberId} AND reason = #{reason} AND created_at >= CURDATE() + + INSERT INTO point_history (member_id, amount, reason, created_at) VALUES (#{memberId}, #{amount}, #{reason}, NOW())