Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

---

Expand Down Expand Up @@ -55,34 +56,9 @@

---

## 3. 내 보상 이력 API
## 3. 에러 코드

### 3.1 GET /api/v1/me/rewards/history

로그인한 사용자의 보상 획득 이력 조회.쿼리 파라미터

```JSON
{
"statusCode": 200,
"data": {
"items": [
{
"history_id": 105,
"reward_type": "POINT",
"reward_amount": 100,
"transaction_id": "unique_trans_id_20260327_001",
"created_at": "2026-03-27T18:00:00Z"
}
],
"next_cursor": 104
},
"error": null
}
```

## 4. 에러 코드

### 4.1 보상 관련 에러 코드
### 3.1 보상 관련 에러 코드

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

Expand Down Expand Up @@ -126,11 +102,11 @@
```
---

## 공통 에러 코드
## 4. 공통 에러 코드

| Error Code | HTTP Status | 설명 |
|------------|:-----------:|-------------------------------------|
| `REWARD_INVALID_USER` | `404` | custom_data에 해당하는 유저가 존재하지 않음 |
| `REWARD_INVALID_TYPE` | `400` | 지원하지 않는 reward_item 타입 (Enum 미매칭) |
| `REWARD_INVALID_SIGNATURE` | `401` | AdMob 서명(Signature) 검증 실패 또는 위변조 의심 |
---
---
58 changes: 50 additions & 8 deletions docs/api-specs/user-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
- `user_tag`는 prefix 없이 생성되는 8자리 이하의 랜덤 문자열입니다.
- 프로필 아바타는 자유 입력 이모지가 아니라 `character_type` 선택 방식으로 관리합니다.
- `GET /api/v1/me/mypage`는 상단 요약 조회, `GET /api/v1/me/recap`은 상세 리캡 조회에 사용합니다.
- `GET /api/v1/me/credits/history`는 로그인한 사용자의 크레딧 적립/소비 내역을 `offset/size` 기반으로 조회합니다.
- 프론트는 `philosopher_type` 값에 따라 사전 정의된 철학자 카드를 통째로 교체 렌더링합니다.
- 그래서 백엔드는 철학자 카드용 `title`, `description`, 해시태그 문구를 내려주지 않습니다.
- 포인트(`point`)는 새 개념으로 도입하되, 이번 버전에서는 현재 DB에서 계산 가능한 항목만 부분 반영합니다.
- 현재 반영 규칙은 `완료된 사후 투표 x 10P`, `입장 변경 x 20P 보너스`입니다.
- 현재 크레딧(`current_point`)은 `users.credit` 캐시 컬럼 기준으로 조회합니다.
- 현재 반영 크레딧 타입은 `BATTLE_VOTE(5)`, `MAJORITY_WIN(10)`, `BEST_COMMENT(50)`, `WEEKLY_CHARGE(40)`, `FREE_CHARGE(가변)` 입니다.
- 다수결/베댓 보상은 매주 월요일 00:00(KST) 배치로 정산하며 대상 배틀 윈도우는 `runDate - 20일`부터 `runDate - 14일`까지입니다.
- 베댓 보상은 배틀당 좋아요 상위 3개 관점만 대상이며 각 관점은 좋아요 10개 이상이어야 합니다.
- 철학자 산출 로직은 추후 확정 예정이며, 현재는 프론트 연동을 위해 임시로 `SOCRATES`를 반환합니다.

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

---

Expand Down Expand Up @@ -236,7 +240,45 @@
}
```

### 3.5 `GET /api/v1/share/recap`
### 3.5 `GET /api/v1/me/credits/history`

로그인한 사용자의 크레딧 적립/소비 내역 조회.

쿼리 파라미터:

- `offset`: 선택, 0-based 시작 위치
- `size`: 선택

응답:

```json
{
"statusCode": 200,
"data": {
"items": [
{
"id": 301,
"credit_type": "BEST_COMMENT",
"amount": 50,
"reference_id": 200,
"created_at": "2026-04-13T00:00:00"
},
{
"id": 300,
"credit_type": "BATTLE_VOTE",
"amount": 5,
"reference_id": 12345,
"created_at": "2026-04-12T14:30:00"
}
],
"next_offset": 20,
"has_next": true
},
"error": null
}
```

### 3.6 `GET /api/v1/share/recap`

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

### 3.6 `GET /api/v1/share/recap/{shareKey}`
### 3.7 `GET /api/v1/share/recap/{shareKey}`

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

### 3.5 `GET /api/v1/me/notification-settings`
### 3.8 `GET /api/v1/me/notification-settings`

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

Expand All @@ -313,7 +355,7 @@
}
```

### 3.6 `PATCH /api/v1/me/notification-settings`
### 3.9 `PATCH /api/v1/me/notification-settings`

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

Expand Down Expand Up @@ -343,7 +385,7 @@
}
```

### 3.7 `GET /api/v1/me/notices`
### 3.10 `GET /api/v1/me/notices`

공지/이벤트 목록 조회.

Expand Down Expand Up @@ -372,7 +414,7 @@
}
```

### 3.8 `GET /api/v1/me/notices/{noticeId}`
### 3.11 `GET /api/v1/me/notices/{noticeId}`

공지/이벤트 상세 조회.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ public interface BattleRepository extends JpaRepository<Battle, Long> {
// 기본 조회용
List<Battle> findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status);

// 주간 배치: 특정 기간(targetDate BETWEEN from AND to)의 배틀 조회
List<Battle> findByTargetDateBetweenAndStatusAndDeletedAtIsNull(LocalDate from, LocalDate to, BattleStatus status);

// 탐색 탭: 전체 배틀 검색
@Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL")
List<Battle> searchAll(Pageable pageable);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.swyp.picke.domain.user.dto.request.UpdateUserProfileRequest;
import com.swyp.picke.domain.user.dto.response.BattleRecordListResponse;
import com.swyp.picke.domain.user.dto.response.ContentActivityListResponse;
import com.swyp.picke.domain.user.dto.response.CreditHistoryListResponse;
import com.swyp.picke.domain.user.dto.response.MypageResponse;
import com.swyp.picke.domain.user.dto.response.MyProfileResponse;
import com.swyp.picke.domain.user.dto.response.NotificationSettingsResponse;
Expand Down Expand Up @@ -66,6 +67,14 @@ public ApiResponse<ContentActivityListResponse> getContentActivities(
return ApiResponse.onSuccess(mypageService.getContentActivities(offset, size, activityType));
}

@GetMapping("/credits/history")
public ApiResponse<CreditHistoryListResponse> getCreditHistory(
@RequestParam(required = false) Integer offset,
@RequestParam(required = false) Integer size
) {
return ApiResponse.onSuccess(mypageService.getCreditHistory(offset, size));
}

@GetMapping("/notification-settings")
public ApiResponse<NotificationSettingsResponse> getNotificationSettings() {
return ApiResponse.onSuccess(mypageService.getNotificationSettings());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.swyp.picke.domain.user.dto.response;

import com.swyp.picke.domain.user.enums.CreditType;
import java.time.LocalDateTime;
import java.util.List;

public record CreditHistoryListResponse(
List<CreditHistoryItem> items,
Integer nextOffset,
boolean hasNext
) {
public record CreditHistoryItem(
Long id,
CreditType creditType,
int amount,
Long referenceId,
LocalDateTime createdAt
) {}
}
12 changes: 12 additions & 0 deletions src/main/java/com/swyp/picke/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,29 @@ public class User extends BaseEntity {
@Column(name = "deleted_at")
private LocalDateTime deletedAt;

@Column(nullable = false, columnDefinition = "INT DEFAULT 0")
private int credit = 0;

@Builder
private User(String userTag, String nickname, String characterUrl, UserRole role, UserStatus status) {
this.userTag = userTag;
this.nickname = nickname;
this.characterUrl = characterUrl;
this.role = role;
this.status = status;
this.credit = 0;
}

public void delete() {
this.status = UserStatus.DELETED;
this.deletedAt = LocalDateTime.now();
}

/**
* 테스트나 메모리 상 도메인 계산에서만 사용하는 보조 메서드.
* 실제 영속 잔액 반영은 CreditService 가 원자 UPDATE 쿼리로 처리한다.
*/
public void addCredit(int amount) {
this.credit += amount;
}
}
13 changes: 5 additions & 8 deletions src/main/java/com/swyp/picke/domain/user/enums/CreditType.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,11 @@

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

private final int defaultAmount;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,23 @@

import com.swyp.picke.domain.user.entity.CreditHistory;
import com.swyp.picke.domain.user.enums.CreditType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface CreditHistoryRepository extends JpaRepository<CreditHistory, Long> {

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

boolean existsByUserIdAndCreditTypeAndReferenceId(Long userId, CreditType creditType, Long referenceId);

Page<CreditHistory> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
package com.swyp.picke.domain.user.repository;

import com.swyp.picke.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import com.swyp.picke.domain.user.enums.UserStatus;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUserTag(String userTag);
Optional<User> findTopByOrderByIdDesc();
boolean existsByUserTag(String userTag);

@Query("select u.credit from User u where u.id = :id")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Query, @Modifying 어노테이션 궁금해서 찾아봤는데, 신기하네요.
또, 하나 배워갑니다.

Integer findCreditById(@Param("id") Long id);

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("update User u set u.credit = u.credit + :amount where u.id = :id")
int incrementCredit(@Param("id") Long id, @Param("amount") int amount);

List<User> findAllByStatus(UserStatus status);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import com.swyp.picke.global.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

/**
* 유저의 현재 크레딧 잔액 조회.
* 전체 CreditHistory 집계가 아닌 User.credit 캐시 필드를 읽는다.
*/
public int getTotalPoints(Long userId) {
return creditHistoryRepository.sumAmountByUserId(userId);
Integer credit = userRepository.findCreditById(userId);
if (credit == null) {
throw new CustomException(ErrorCode.USER_NOT_FOUND);
}
return credit;
}

public TierCode getTier(Long userId) {
return TierCode.fromPoints(getTotalPoints(userId));
}

/**
* 크레딧 적립/소비 내역 페이징 조회 (최신순).
*/
public Page<CreditHistory> getHistory(Long userId, Pageable pageable) {
return creditHistoryRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
}

private void validateReferenceId(Long referenceId) {
if (referenceId == null) {
throw new CustomException(ErrorCode.CREDIT_REFERENCE_REQUIRED);
Expand Down
Loading