feat(point): 한 달 개근(모든 날 기록) 랜덤 보너스 1~100P
Deploy / deploy (push) Failing after 14m42s

내역 저장 시 현재 월 모든 날에 기록이 있으면 월 1회 랜덤 1~100P 지급.
전월 소급 입력 파밍 방지(현재 월만 인정), 화면 안내 없음(포인트 내역에만).
- AccountEntryMapper.countDistinctEntryDays, PointMapper.countByReason
- PointService.awardMonthComplete(reason=MONTH_COMPLETE_yyyyMM 로 월 1회)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-05 16:56:48 +09:00
parent a0509099f7
commit 45bb025247
6 changed files with 60 additions and 0 deletions
@@ -96,4 +96,9 @@ public interface AccountEntryMapper {
/** 복수 항목의 태그를 한 번에 조회 — {entryId, tagName} 맵 반환 */ /** 복수 항목의 태그를 한 번에 조회 — {entryId, tagName} 맵 반환 */
List<Map<String, Object>> findTagsByEntryIds(@Param("entryIds") List<Long> entryIds); List<Map<String, Object>> findTagsByEntryIds(@Param("entryIds") List<Long> entryIds);
/** 특정 연·월에 기록이 있는 서로 다른 날짜 수 (한 달 개근 판정용) */
int countDistinctEntryDays(@Param("memberId") Long memberId,
@Param("year") int year,
@Param("month") int month);
} }
@@ -256,9 +256,28 @@ public class AccountService {
mapper.insert(entry); mapper.insert(entry);
applyTags(entry.getId(), req.getTagIds(), memberId); applyTags(entry.getId(), req.getTagIds(), memberId);
pointService.awardAccountEntry(memberId); pointService.awardAccountEntry(memberId);
awardIfMonthComplete(entry.getEntryDate(), memberId);
return toResponse(mapper.findByIdAndMember(entry.getId(), 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 @Transactional
public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) { public AccountEntryResponse update(Long id, AccountEntryRequest req, Long memberId) {
AccountEntry entry = mustFind(id, memberId); AccountEntry entry = mustFind(id, memberId);
@@ -52,6 +52,10 @@ public class PointService {
public static final int ACCOUNT_ENTRY_DAILY_LIMIT = 5; // 10P × 5 = 일 50P public static final int ACCOUNT_ENTRY_DAILY_LIMIT = 5; // 10P × 5 = 일 50P
public static final String REASON_ACCOUNT_ENTRY = "ACCOUNT_ENTRY"; 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<String> SPAM_KEYWORDS = List.of( private static final List<String> SPAM_KEYWORDS = List.of(
"http://", "https://", "bit.ly", "goo.gl", "http://", "https://", "bit.ly", "goo.gl",
@@ -143,6 +147,21 @@ public class PointService {
return ACCOUNT_ENTRY_POINT; 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) { public long getPoints(Long memberId) {
Long p = pointMapper.getPoints(memberId); Long p = pointMapper.getPoints(memberId);
return p == null ? 0L : p; return p == null ? 0L : p;
@@ -15,6 +15,9 @@ public interface PointMapper {
/** 오늘 특정 사유로 지급된 횟수 (일일 한도 계산용) */ /** 오늘 특정 사유로 지급된 횟수 (일일 한도 계산용) */
int countTodayGrants(@Param("memberId") Long memberId, @Param("reason") String reason); 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 insertHistory(@Param("memberId") Long memberId, @Param("amount") int amount, @Param("reason") String reason);
int addPoints(@Param("memberId") Long memberId, @Param("amount") int amount); int addPoints(@Param("memberId") Long memberId, @Param("amount") int amount);
@@ -246,4 +246,13 @@
ORDER BY aet.entry_id, t.name ORDER BY aet.entry_id, t.name
</select> </select>
<!-- 특정 연·월에 기록이 있는 서로 다른 날짜 수(한 달 개근 판정) -->
<select id="countDistinctEntryDays" resultType="int">
SELECT COUNT(DISTINCT DAY(entry_date))
FROM account_entry
WHERE member_id = #{memberId}
AND YEAR(entry_date) = #{year}
AND MONTH(entry_date) = #{month}
</select>
</mapper> </mapper>
@@ -8,6 +8,11 @@
WHERE member_id = #{memberId} AND reason = #{reason} AND created_at &gt;= CURDATE() WHERE member_id = #{memberId} AND reason = #{reason} AND created_at &gt;= CURDATE()
</select> </select>
<select id="countByReason" resultType="int">
SELECT COUNT(*) FROM point_history
WHERE member_id = #{memberId} AND reason = #{reason}
</select>
<insert id="insertHistory"> <insert id="insertHistory">
INSERT INTO point_history (member_id, amount, reason, created_at) INSERT INTO point_history (member_id, amount, reason, created_at)
VALUES (#{memberId}, #{amount}, #{reason}, NOW()) VALUES (#{memberId}, #{amount}, #{reason}, NOW())