Skip to content

Commit 63e9116

Browse files
authored
#144 [Feat] 크레딧 적립/소비 로직 고도화 (#170)
## 작업 내용 - User.credit 캐시 컬럼 기반으로 크레딧 잔액 조회/증감 로직 반영 - 배틀 참여 보상, 다수결 보상, 베댓 보상, 주간 자동 충전 배치 추가 - /api/v1/me/credits/history API 및 관련 문서 추가 - 크레딧/배치/투표 보상 관련 테스트 보강 ## 정책 반영 - 다수결 보상: 14~20일 전 배틀 기준 승수 옵션 투표자 지급 - 베댓 보상: 14~20일 전 배틀 기준 좋아요 상위 3개, 각 10개 이상 좋아요일 때 지급 - 주간 충전: 매주 월요일 00:00 활성 사용자 지급 ## 검증 - sh gradlew test --no-daemon Closes #144
1 parent e1a8846 commit 63e9116

23 files changed

Lines changed: 952 additions & 57 deletions

‎docs/api-specs/reward-api.md‎

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
- **유저 식별**: `custom_data` 필드에 담긴 값을 내부 `user_id`로 매핑하여 처리합니다.
99
- **타입 검증**: `reward_item` 값은 내부 `RewardType` Enum과 매핑하며, 정의되지 않은 값(예: "123")은 에러 처리합니다.
1010
- **데이터 보존**: 보상 요청의 성공 이력을 `ad_reward_history` 테이블에 적재합니다.
11+
- 사용자 크레딧 히스토리 조회는 별도 `/api/v1/me/credits/history` API로 분리되어 있으며 이 문서 범위에 포함하지 않습니다.
1112

1213
---
1314

@@ -55,34 +56,9 @@
5556

5657
---
5758

58-
## 3. 내 보상 이력 API
59+
## 3. 에러 코드
5960

60-
### 3.1 GET /api/v1/me/rewards/history
61-
62-
로그인한 사용자의 보상 획득 이력 조회.쿼리 파라미터
63-
64-
```JSON
65-
{
66-
"statusCode": 200,
67-
"data": {
68-
"items": [
69-
{
70-
"history_id": 105,
71-
"reward_type": "POINT",
72-
"reward_amount": 100,
73-
"transaction_id": "unique_trans_id_20260327_001",
74-
"created_at": "2026-03-27T18:00:00Z"
75-
}
76-
],
77-
"next_cursor": 104
78-
},
79-
"error": null
80-
}
81-
```
82-
83-
## 4. 에러 코드
84-
85-
### 4.1 보상 관련 에러 코드
61+
### 3.1 보상 관련 에러 코드
8662

8763
### 🚨 보상 API 에러 응답 JSON 샘플
8864

@@ -126,11 +102,11 @@
126102
```
127103
---
128104

129-
## 공통 에러 코드
105+
## 4. 공통 에러 코드
130106

131107
| Error Code | HTTP Status | 설명 |
132108
|------------|:-----------:|-------------------------------------|
133109
| `REWARD_INVALID_USER` | `404` | custom_data에 해당하는 유저가 존재하지 않음 |
134110
| `REWARD_INVALID_TYPE` | `400` | 지원하지 않는 reward_item 타입 (Enum 미매칭) |
135111
| `REWARD_INVALID_SIGNATURE` | `401` | AdMob 서명(Signature) 검증 실패 또는 위변조 의심 |
136-
---
112+
---

‎docs/api-specs/user-api.md‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
- `user_tag`는 prefix 없이 생성되는 8자리 이하의 랜덤 문자열입니다.
1111
- 프로필 아바타는 자유 입력 이모지가 아니라 `character_type` 선택 방식으로 관리합니다.
1212
- `GET /api/v1/me/mypage`는 상단 요약 조회, `GET /api/v1/me/recap`은 상세 리캡 조회에 사용합니다.
13+
- `GET /api/v1/me/credits/history`는 로그인한 사용자의 크레딧 적립/소비 내역을 `offset/size` 기반으로 조회합니다.
1314
- 프론트는 `philosopher_type` 값에 따라 사전 정의된 철학자 카드를 통째로 교체 렌더링합니다.
1415
- 그래서 백엔드는 철학자 카드용 `title`, `description`, 해시태그 문구를 내려주지 않습니다.
15-
- 포인트(`point`)는 새 개념으로 도입하되, 이번 버전에서는 현재 DB에서 계산 가능한 항목만 부분 반영합니다.
16-
- 현재 반영 규칙은 `완료된 사후 투표 x 10P`, `입장 변경 x 20P 보너스`입니다.
16+
- 현재 크레딧(`current_point`)은 `users.credit` 캐시 컬럼 기준으로 조회합니다.
17+
- 현재 반영 크레딧 타입은 `BATTLE_VOTE(5)`, `MAJORITY_WIN(10)`, `BEST_COMMENT(50)`, `WEEKLY_CHARGE(40)`, `FREE_CHARGE(가변)` 입니다.
18+
- 다수결/베댓 보상은 매주 월요일 00:00(KST) 배치로 정산하며 대상 배틀 윈도우는 `runDate - 20일`부터 `runDate - 14일`까지입니다.
19+
- 베댓 보상은 배틀당 좋아요 상위 3개 관점만 대상이며 각 관점은 좋아요 10개 이상이어야 합니다.
1720
- 철학자 산출 로직은 추후 확정 예정이며, 현재는 프론트 연동을 위해 임시로 `SOCRATES`를 반환합니다.
1821

1922
### 1.1 공통 프로필 응답 필드
@@ -33,6 +36,7 @@
3336
| `character_type` | `OWL \| FOX \| WOLF \| LION \| PENGUIN \| BEAR \| RABBIT \| CAT` |
3437
| `activity_type` | `COMMENT \| LIKE` |
3538
| `vote_side` | `PRO \| CON` |
39+
| `credit_type` | `BATTLE_VOTE \| MAJORITY_WIN \| BEST_COMMENT \| WEEKLY_CHARGE \| FREE_CHARGE` |
3640

3741
---
3842

@@ -236,7 +240,45 @@
236240
}
237241
```
238242

239-
### 3.5 `GET /api/v1/share/recap`
243+
### 3.5 `GET /api/v1/me/credits/history`
244+
245+
로그인한 사용자의 크레딧 적립/소비 내역 조회.
246+
247+
쿼리 파라미터:
248+
249+
- `offset`: 선택, 0-based 시작 위치
250+
- `size`: 선택
251+
252+
응답:
253+
254+
```json
255+
{
256+
"statusCode": 200,
257+
"data": {
258+
"items": [
259+
{
260+
"id": 301,
261+
"credit_type": "BEST_COMMENT",
262+
"amount": 50,
263+
"reference_id": 200,
264+
"created_at": "2026-04-13T00:00:00"
265+
},
266+
{
267+
"id": 300,
268+
"credit_type": "BATTLE_VOTE",
269+
"amount": 5,
270+
"reference_id": 12345,
271+
"created_at": "2026-04-12T14:30:00"
272+
}
273+
],
274+
"next_offset": 20,
275+
"has_next": true
276+
},
277+
"error": null
278+
}
279+
```
280+
281+
### 3.6 `GET /api/v1/share/recap`
240282

241283
현재 로그인한 사용자의 리캡 공유 키 발급.
242284
이미 발급된 키가 있으면 동일 키를 재사용합니다.
@@ -253,7 +295,7 @@
253295
}
254296
```
255297

256-
### 3.6 `GET /api/v1/share/recap/{shareKey}`
298+
### 3.7 `GET /api/v1/share/recap/{shareKey}`
257299

258300
공유 키로 다른 사용자의 리캡 조회.
259301
인증 없이 호출 가능합니다.
@@ -292,7 +334,7 @@
292334
}
293335
```
294336

295-
### 3.5 `GET /api/v1/me/notification-settings`
337+
### 3.8 `GET /api/v1/me/notification-settings`
296338

297339
마이페이지 알림 설정 조회.
298340

@@ -313,7 +355,7 @@
313355
}
314356
```
315357

316-
### 3.6 `PATCH /api/v1/me/notification-settings`
358+
### 3.9 `PATCH /api/v1/me/notification-settings`
317359

318360
마이페이지 알림 설정 부분 수정.
319361

@@ -343,7 +385,7 @@
343385
}
344386
```
345387

346-
### 3.7 `GET /api/v1/me/notices`
388+
### 3.10 `GET /api/v1/me/notices`
347389

348390
공지/이벤트 목록 조회.
349391

@@ -372,7 +414,7 @@
372414
}
373415
```
374416

375-
### 3.8 `GET /api/v1/me/notices/{noticeId}`
417+
### 3.11 `GET /api/v1/me/notices/{noticeId}`
376418

377419
공지/이벤트 상세 조회.
378420

‎src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ public interface BattleRepository extends JpaRepository<Battle, Long> {
6161
// 기본 조회용
6262
List<Battle> findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status);
6363

64+
// 주간 배치: 특정 기간(targetDate BETWEEN from AND to)의 배틀 조회
65+
List<Battle> findByTargetDateBetweenAndStatusAndDeletedAtIsNull(LocalDate from, LocalDate to, BattleStatus status);
66+
6467
// 탐색 탭: 전체 배틀 검색
6568
@Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL")
6669
List<Battle> searchAll(Pageable pageable);

‎src/main/java/com/swyp/picke/domain/user/controller/MypageController.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.swyp.picke.domain.user.dto.request.UpdateUserProfileRequest;
55
import com.swyp.picke.domain.user.dto.response.BattleRecordListResponse;
66
import com.swyp.picke.domain.user.dto.response.ContentActivityListResponse;
7+
import com.swyp.picke.domain.user.dto.response.CreditHistoryListResponse;
78
import com.swyp.picke.domain.user.dto.response.MypageResponse;
89
import com.swyp.picke.domain.user.dto.response.MyProfileResponse;
910
import com.swyp.picke.domain.user.dto.response.NotificationSettingsResponse;
@@ -66,6 +67,14 @@ public ApiResponse<ContentActivityListResponse> getContentActivities(
6667
return ApiResponse.onSuccess(mypageService.getContentActivities(offset, size, activityType));
6768
}
6869

70+
@GetMapping("/credits/history")
71+
public ApiResponse<CreditHistoryListResponse> getCreditHistory(
72+
@RequestParam(required = false) Integer offset,
73+
@RequestParam(required = false) Integer size
74+
) {
75+
return ApiResponse.onSuccess(mypageService.getCreditHistory(offset, size));
76+
}
77+
6978
@GetMapping("/notification-settings")
7079
public ApiResponse<NotificationSettingsResponse> getNotificationSettings() {
7180
return ApiResponse.onSuccess(mypageService.getNotificationSettings());
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.swyp.picke.domain.user.dto.response;
2+
3+
import com.swyp.picke.domain.user.enums.CreditType;
4+
import java.time.LocalDateTime;
5+
import java.util.List;
6+
7+
public record CreditHistoryListResponse(
8+
List<CreditHistoryItem> items,
9+
Integer nextOffset,
10+
boolean hasNext
11+
) {
12+
public record CreditHistoryItem(
13+
Long id,
14+
CreditType creditType,
15+
int amount,
16+
Long referenceId,
17+
LocalDateTime createdAt
18+
) {}
19+
}

‎src/main/java/com/swyp/picke/domain/user/entity/User.java‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,29 @@ public class User extends BaseEntity {
4141
@Column(name = "deleted_at")
4242
private LocalDateTime deletedAt;
4343

44+
@Column(nullable = false, columnDefinition = "INT DEFAULT 0")
45+
private int credit = 0;
46+
4447
@Builder
4548
private User(String userTag, String nickname, String characterUrl, UserRole role, UserStatus status) {
4649
this.userTag = userTag;
4750
this.nickname = nickname;
4851
this.characterUrl = characterUrl;
4952
this.role = role;
5053
this.status = status;
54+
this.credit = 0;
5155
}
5256

5357
public void delete() {
5458
this.status = UserStatus.DELETED;
5559
this.deletedAt = LocalDateTime.now();
5660
}
61+
62+
/**
63+
* 테스트나 메모리 상 도메인 계산에서만 사용하는 보조 메서드.
64+
* 실제 영속 잔액 반영은 CreditService 가 원자 UPDATE 쿼리로 처리한다.
65+
*/
66+
public void addCredit(int amount) {
67+
this.credit += amount;
68+
}
5769
}

‎src/main/java/com/swyp/picke/domain/user/enums/CreditType.java‎

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,11 @@
44

55
@Getter
66
public enum CreditType {
7-
BATTLE_VOTE(10),
8-
QUIZ_VOTE(5),
9-
MAJORITY_WIN(20),
10-
BEST_COMMENT(50),
11-
TOPIC_SUGGEST(30),
12-
TOPIC_ADOPTED(1000),
13-
AD_REWARD(50),
14-
FREE_CHARGE(0);
7+
BATTLE_VOTE(5), // 배틀 참여 보상: 사후 투표 완료 시 즉시 지급
8+
MAJORITY_WIN(10), // 다수결 보상: 월요일 배치, 2주 전 배틀 TOP≥10 대상
9+
BEST_COMMENT(50), // 베댓 보상: 월요일 배치, 2주 전 배틀 좋아요 1위
10+
WEEKLY_CHARGE(40), // 주간 자동 충전: 매주 월요일 00:00 활성 사용자 전체
11+
FREE_CHARGE(0); // 광고/자유 충전: 가변 금액
1512

1613
private final int defaultAmount;
1714

‎src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,23 @@
22

33
import com.swyp.picke.domain.user.entity.CreditHistory;
44
import com.swyp.picke.domain.user.enums.CreditType;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.Pageable;
57
import org.springframework.data.jpa.repository.JpaRepository;
68
import org.springframework.data.jpa.repository.Query;
79
import org.springframework.data.repository.query.Param;
810

911
public interface CreditHistoryRepository extends JpaRepository<CreditHistory, Long> {
1012

13+
/**
14+
* 유저의 모든 CreditHistory amount 합계.
15+
* User.credit 캐시가 도입된 이후 잔액 조회 경로에서는 사용하지 않는다.
16+
* 백필/검증 용도로만 유지.
17+
*/
1118
@Query("SELECT COALESCE(SUM(c.amount), 0) FROM CreditHistory c WHERE c.user.id = :userId")
1219
int sumAmountByUserId(@Param("userId") Long userId);
1320

1421
boolean existsByUserIdAndCreditTypeAndReferenceId(Long userId, CreditType creditType, Long referenceId);
22+
23+
Page<CreditHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
1524
}
Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,25 @@
11
package com.swyp.picke.domain.user.repository;
22

33
import com.swyp.picke.domain.user.entity.User;
4-
import org.springframework.data.jpa.repository.JpaRepository;
4+
import com.swyp.picke.domain.user.enums.UserStatus;
5+
import java.util.List;
56
import java.util.Optional;
7+
import org.springframework.data.jpa.repository.JpaRepository;
8+
import org.springframework.data.jpa.repository.Modifying;
9+
import org.springframework.data.jpa.repository.Query;
10+
import org.springframework.data.repository.query.Param;
611

712
public interface UserRepository extends JpaRepository<User, Long> {
813
Optional<User> findByUserTag(String userTag);
914
Optional<User> findTopByOrderByIdDesc();
1015
boolean existsByUserTag(String userTag);
16+
17+
@Query("select u.credit from User u where u.id = :id")
18+
Integer findCreditById(@Param("id") Long id);
19+
20+
@Modifying(clearAutomatically = true, flushAutomatically = true)
21+
@Query("update User u set u.credit = u.credit + :amount where u.id = :id")
22+
int incrementCredit(@Param("id") Long id, @Param("amount") int amount);
23+
24+
List<User> findAllByStatus(UserStatus status);
1125
}

‎src/main/java/com/swyp/picke/domain/user/service/CreditService.java‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import com.swyp.picke.global.common.exception.ErrorCode;
1111
import lombok.RequiredArgsConstructor;
1212
import org.springframework.dao.DataIntegrityViolationException;
13+
import org.springframework.data.domain.Page;
14+
import org.springframework.data.domain.Pageable;
1315
import org.springframework.stereotype.Service;
1416
import org.springframework.transaction.annotation.Transactional;
1517

@@ -47,6 +49,9 @@ public void addCredit(Long userId, CreditType creditType, Long referenceId) {
4749
* 특정 유저에게 커스텀 포인트로 크레딧 적립.
4850
* CreditType의 기본 포인트가 아닌 가변 포인트가 필요한 경우(FREE_CHARGE 랜덤 박스 등)에서 사용.
4951
* 예: creditService.addCredit(userId, CreditType.FREE_CHARGE, 15, boxId);
52+
*
53+
* 적립이 성공하면 User.credit 캐시를 동기 증감하여 {@link #getTotalPoints}가 전체 히스토리를 재집계하지 않도록 한다.
54+
* (user, creditType, referenceId) 중복 시 조용히 무시(멱등).
5055
*/
5156
@Transactional
5257
public void addCredit(Long userId, CreditType creditType, int amount, Long referenceId) {
@@ -70,16 +75,34 @@ public void addCredit(Long userId, CreditType creditType, int amount, Long refer
7075
}
7176
throw new CustomException(ErrorCode.CREDIT_SAVE_FAILED);
7277
}
78+
if (userRepository.incrementCredit(userId, amount) == 0) {
79+
throw new CustomException(ErrorCode.USER_NOT_FOUND);
80+
}
7381
}
7482

83+
/**
84+
* 유저의 현재 크레딧 잔액 조회.
85+
* 전체 CreditHistory 집계가 아닌 User.credit 캐시 필드를 읽는다.
86+
*/
7587
public int getTotalPoints(Long userId) {
76-
return creditHistoryRepository.sumAmountByUserId(userId);
88+
Integer credit = userRepository.findCreditById(userId);
89+
if (credit == null) {
90+
throw new CustomException(ErrorCode.USER_NOT_FOUND);
91+
}
92+
return credit;
7793
}
7894

7995
public TierCode getTier(Long userId) {
8096
return TierCode.fromPoints(getTotalPoints(userId));
8197
}
8298

99+
/**
100+
* 크레딧 적립/소비 내역 페이징 조회 (최신순).
101+
*/
102+
public Page<CreditHistory> getHistory(Long userId, Pageable pageable) {
103+
return creditHistoryRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
104+
}
105+
83106
private void validateReferenceId(Long referenceId) {
84107
if (referenceId == null) {
85108
throw new CustomException(ErrorCode.CREDIT_REFERENCE_REQUIRED);

0 commit comments

Comments
 (0)