diff --git a/docs/api-specs/reward-api.md b/docs/api-specs/reward-api.md index 24b8a3fe..31d05d7f 100644 --- a/docs/api-specs/reward-api.md +++ b/docs/api-specs/reward-api.md @@ -8,6 +8,7 @@ - **유저 식별**: `custom_data` 필드에 담긴 값을 내부 `user_id`로 매핑하여 처리합니다. - **타입 검증**: `reward_item` 값은 내부 `RewardType` Enum과 매핑하며, 정의되지 않은 값(예: "123")은 에러 처리합니다. - **데이터 보존**: 보상 요청의 성공 이력을 `ad_reward_history` 테이블에 적재합니다. +- 사용자 크레딧 히스토리 조회는 별도 `/api/v1/me/credits/history` API로 분리되어 있으며 이 문서 범위에 포함하지 않습니다. --- @@ -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 샘플 @@ -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) 검증 실패 또는 위변조 의심 | ---- \ No newline at end of file +--- diff --git a/docs/api-specs/user-api.md b/docs/api-specs/user-api.md index 6ce7661c..917fa619 100644 --- a/docs/api-specs/user-api.md +++ b/docs/api-specs/user-api.md @@ -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 공통 프로필 응답 필드 @@ -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` | --- @@ -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` 현재 로그인한 사용자의 리캡 공유 키 발급. 이미 발급된 키가 있으면 동일 키를 재사용합니다. @@ -253,7 +295,7 @@ } ``` -### 3.6 `GET /api/v1/share/recap/{shareKey}` +### 3.7 `GET /api/v1/share/recap/{shareKey}` 공유 키로 다른 사용자의 리캡 조회. 인증 없이 호출 가능합니다. @@ -292,7 +334,7 @@ } ``` -### 3.5 `GET /api/v1/me/notification-settings` +### 3.8 `GET /api/v1/me/notification-settings` 마이페이지 알림 설정 조회. @@ -313,7 +355,7 @@ } ``` -### 3.6 `PATCH /api/v1/me/notification-settings` +### 3.9 `PATCH /api/v1/me/notification-settings` 마이페이지 알림 설정 부분 수정. @@ -343,7 +385,7 @@ } ``` -### 3.7 `GET /api/v1/me/notices` +### 3.10 `GET /api/v1/me/notices` 공지/이벤트 목록 조회. @@ -372,7 +414,7 @@ } ``` -### 3.8 `GET /api/v1/me/notices/{noticeId}` +### 3.11 `GET /api/v1/me/notices/{noticeId}` 공지/이벤트 상세 조회. diff --git a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java index 51eb6031..6bd79776 100644 --- a/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java +++ b/src/main/java/com/swyp/picke/domain/battle/repository/BattleRepository.java @@ -61,6 +61,9 @@ public interface BattleRepository extends JpaRepository { // 기본 조회용 List findByTargetDateAndStatusAndDeletedAtIsNull(LocalDate date, BattleStatus status); + // 주간 배치: 특정 기간(targetDate BETWEEN from AND to)의 배틀 조회 + List findByTargetDateBetweenAndStatusAndDeletedAtIsNull(LocalDate from, LocalDate to, BattleStatus status); + // 탐색 탭: 전체 배틀 검색 @Query("SELECT b FROM Battle b WHERE b.status = 'PUBLISHED' AND b.deletedAt IS NULL") List searchAll(Pageable pageable); diff --git a/src/main/java/com/swyp/picke/domain/user/controller/MypageController.java b/src/main/java/com/swyp/picke/domain/user/controller/MypageController.java index 9cbaccdc..014dbe96 100644 --- a/src/main/java/com/swyp/picke/domain/user/controller/MypageController.java +++ b/src/main/java/com/swyp/picke/domain/user/controller/MypageController.java @@ -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; @@ -66,6 +67,14 @@ public ApiResponse getContentActivities( return ApiResponse.onSuccess(mypageService.getContentActivities(offset, size, activityType)); } + @GetMapping("/credits/history") + public ApiResponse getCreditHistory( + @RequestParam(required = false) Integer offset, + @RequestParam(required = false) Integer size + ) { + return ApiResponse.onSuccess(mypageService.getCreditHistory(offset, size)); + } + @GetMapping("/notification-settings") public ApiResponse getNotificationSettings() { return ApiResponse.onSuccess(mypageService.getNotificationSettings()); diff --git a/src/main/java/com/swyp/picke/domain/user/dto/response/CreditHistoryListResponse.java b/src/main/java/com/swyp/picke/domain/user/dto/response/CreditHistoryListResponse.java new file mode 100644 index 00000000..e0617a4c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/dto/response/CreditHistoryListResponse.java @@ -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 items, + Integer nextOffset, + boolean hasNext +) { + public record CreditHistoryItem( + Long id, + CreditType creditType, + int amount, + Long referenceId, + LocalDateTime createdAt + ) {} +} diff --git a/src/main/java/com/swyp/picke/domain/user/entity/User.java b/src/main/java/com/swyp/picke/domain/user/entity/User.java index c747df3c..a88c8ac6 100644 --- a/src/main/java/com/swyp/picke/domain/user/entity/User.java +++ b/src/main/java/com/swyp/picke/domain/user/entity/User.java @@ -41,6 +41,9 @@ 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; @@ -48,10 +51,19 @@ private User(String userTag, String nickname, String characterUrl, UserRole role 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; + } } diff --git a/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java b/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java index 24da9951..6b3e0edc 100644 --- a/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java +++ b/src/main/java/com/swyp/picke/domain/user/enums/CreditType.java @@ -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; diff --git a/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java index 36a384ea..d420cca0 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/CreditHistoryRepository.java @@ -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 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 findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); } diff --git a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java index 29ebe1c8..59b9b145 100644 --- a/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java +++ b/src/main/java/com/swyp/picke/domain/user/repository/UserRepository.java @@ -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 { Optional findByUserTag(String userTag); Optional findTopByOrderByIdDesc(); boolean existsByUserTag(String userTag); + + @Query("select u.credit from User u where u.id = :id") + 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 findAllByStatus(UserStatus status); } diff --git a/src/main/java/com/swyp/picke/domain/user/service/CreditService.java b/src/main/java/com/swyp/picke/domain/user/service/CreditService.java index d618dd99..d6f738b8 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/CreditService.java +++ b/src/main/java/com/swyp/picke/domain/user/service/CreditService.java @@ -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; @@ -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) { @@ -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 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); diff --git a/src/main/java/com/swyp/picke/domain/user/service/MypageService.java b/src/main/java/com/swyp/picke/domain/user/service/MypageService.java index 22cd4c40..585b1ea6 100644 --- a/src/main/java/com/swyp/picke/domain/user/service/MypageService.java +++ b/src/main/java/com/swyp/picke/domain/user/service/MypageService.java @@ -10,6 +10,10 @@ import com.swyp.picke.domain.perspective.service.PerspectiveQueryService; import com.swyp.picke.domain.user.dto.request.UpdateNotificationSettingsRequest; import com.swyp.picke.domain.user.dto.response.*; +import com.swyp.picke.domain.user.entity.CreditHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import com.swyp.picke.domain.user.enums.ActivityType; import com.swyp.picke.domain.user.enums.CharacterType; import com.swyp.picke.domain.user.enums.PhilosopherType; @@ -277,6 +281,29 @@ private Map loadOptions(List perspectives) { return battleQueryService.findOptionsByIds(optionIds); } + public CreditHistoryListResponse getCreditHistory(Integer offset, Integer size) { + User user = userService.findCurrentUser(); + int pageOffset = offset == null || offset < 0 ? 0 : offset; + int pageSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : size; + + Pageable pageable = PageRequest.of(pageOffset / pageSize, pageSize); + Page page = creditService.getHistory(user.getId(), pageable); + + List items = page.getContent().stream() + .map(h -> new CreditHistoryListResponse.CreditHistoryItem( + h.getId(), + h.getCreditType(), + h.getAmount(), + h.getReferenceId(), + h.getCreatedAt() + )) + .toList(); + + int nextOffset = pageOffset + pageSize; + boolean hasNext = nextOffset < page.getTotalElements(); + return new CreditHistoryListResponse(items, hasNext ? nextOffset : null, hasNext); + } + public NotificationSettingsResponse getNotificationSettings() { User user = userService.findCurrentUser(); UserSettings settings = userService.findUserSettings(user.getId()); diff --git a/src/main/java/com/swyp/picke/domain/user/service/batch/BestCommentRewardJob.java b/src/main/java/com/swyp/picke/domain/user/service/batch/BestCommentRewardJob.java new file mode 100644 index 00000000..da08095e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/service/batch/BestCommentRewardJob.java @@ -0,0 +1,67 @@ +package com.swyp.picke.domain.user.service.batch; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.service.CreditService; +import java.time.LocalDate; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 베댓 보상 배치. + * runDate(월요일) 기준 targetDate ∈ [runDate-20, runDate-14] 범위 배틀의 Perspective 중 + * 좋아요 1위(likeCount desc, createdAt desc) 작성자에게 +50P (CreditType.BEST_COMMENT). + * + * referenceId = perspectiveId. 재실행 멱등. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class BestCommentRewardJob { + + private static final int TOP_COMMENT_LIMIT = 3; + private static final int MIN_LIKE_COUNT = 10; + + private final BattleRepository battleRepository; + private final PerspectiveRepository perspectiveRepository; + private final CreditService creditService; + + @Transactional + public void run(LocalDate runDate) { + LocalDate from = runDate.minusDays(20); + LocalDate to = runDate.minusDays(14); + List battles = battleRepository + .findByTargetDateBetweenAndStatusAndDeletedAtIsNull(from, to, BattleStatus.PUBLISHED); + + log.info("[BestCommentRewardJob] window=[{}, {}] battles={}", from, to, battles.size()); + + for (Battle battle : battles) { + List topComments = perspectiveRepository + .findByBattleIdAndStatusOrderByLikeCountDescCreatedAtDesc( + battle.getId(), PerspectiveStatus.PUBLISHED, PageRequest.of(0, TOP_COMMENT_LIMIT)); + if (topComments.isEmpty()) { + continue; + } + + for (Perspective perspective : topComments) { + if (perspective.getLikeCount() < MIN_LIKE_COUNT) { + continue; + } + creditService.addCredit( + perspective.getUser().getId(), + CreditType.BEST_COMMENT, + perspective.getId() + ); + } + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/user/service/batch/CreditWeeklyBatchScheduler.java b/src/main/java/com/swyp/picke/domain/user/service/batch/CreditWeeklyBatchScheduler.java new file mode 100644 index 00000000..83e18bec --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/service/batch/CreditWeeklyBatchScheduler.java @@ -0,0 +1,50 @@ +package com.swyp.picke.domain.user.service.batch; + +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 매주 월요일 00:00 (KST) 에 크레딧 주간 배치를 실행한다. + * + * 세 잡을 순차로 돌린다: + * 1) 다수결 보상 (+10P) — 2주 전 배틀, 총 투표수 ≥ 10 인 건의 승수 옵션 투표자 전원 + * 2) 베댓 보상 (+50P) — 2주 전 배틀의 Perspective 좋아요 1위 작성자 + * 3) 주간 자동 충전 (+40P) — 활성 사용자 전체 + * + * 다수결/베댓은 동일 스냅샷 윈도우(14~20일 전 targetDate)를 공유한다. + * 각 잡의 referenceId 가 결정적(배틀ID / perspectiveID / 주차코드)이므로 + * CreditHistory 유니크 제약으로 중복 실행 시에도 추가 적립은 발생하지 않는다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CreditWeeklyBatchScheduler { + + private final MajorityWinRewardJob majorityWinRewardJob; + private final BestCommentRewardJob bestCommentRewardJob; + private final WeeklyChargeJob weeklyChargeJob; + + @Scheduled(cron = "0 0 0 ? * MON", zone = "Asia/Seoul") + public void runWeeklyBatch() { + LocalDate runDate = LocalDate.now(); + log.info("[CreditWeeklyBatch] start runDate={}", runDate); + + runSafely("MajorityWinRewardJob", () -> majorityWinRewardJob.run(runDate)); + runSafely("BestCommentRewardJob", () -> bestCommentRewardJob.run(runDate)); + runSafely("WeeklyChargeJob", () -> weeklyChargeJob.run(runDate)); + + log.info("[CreditWeeklyBatch] end runDate={}", runDate); + } + + private void runSafely(String name, Runnable job) { + try { + job.run(); + } catch (Exception e) { + // 한 잡의 실패가 다른 잡을 막지 않도록 격리. 멱등성은 CreditHistory 유니크 제약으로 보장되므로 수동 재실행 가능. + log.error("[CreditWeeklyBatch] {} failed", name, e); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java b/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java new file mode 100644 index 00000000..e687062f --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJob.java @@ -0,0 +1,69 @@ +package com.swyp.picke.domain.user.service.batch; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import java.time.LocalDate; +import java.util.Comparator; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 다수결 보상 배치. + * runDate(월요일) 기준 targetDate ∈ [runDate-20, runDate-14] 범위의 배틀 중 + * 최다 득표 옵션(= 다수결 승자 옵션)을 선정하고, + * 그 옵션을 사전 투표한 사용자 전원에게 +10P (CreditType.MAJORITY_WIN) 를 지급한다. + * + * referenceId = battleId. CreditHistory 유니크 제약으로 같은 배틀 재실행 시 중복 지급 없음. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MajorityWinRewardJob { + private final BattleRepository battleRepository; + private final BattleOptionRepository battleOptionRepository; + private final BattleVoteRepository battleVoteRepository; + private final CreditService creditService; + + @Transactional + public void run(LocalDate runDate) { + LocalDate from = runDate.minusDays(20); + LocalDate to = runDate.minusDays(14); + List battles = battleRepository + .findByTargetDateBetweenAndStatusAndDeletedAtIsNull(from, to, BattleStatus.PUBLISHED); + + log.info("[MajorityWinRewardJob] window=[{}, {}] battles={}", from, to, battles.size()); + + for (Battle battle : battles) { + BattleOption winningOption = resolveWinningOption(battle); + if (winningOption == null) { + continue; + } + + List votes = battleVoteRepository.findAllByBattle(battle); + for (BattleVote vote : votes) { + BattleOption selected = vote.getPreVoteOption(); + if (selected == null || !selected.getId().equals(winningOption.getId())) { + continue; + } + creditService.addCredit(vote.getUser().getId(), CreditType.MAJORITY_WIN, battle.getId()); + } + } + } + + private BattleOption resolveWinningOption(Battle battle) { + List options = battleOptionRepository.findByBattle(battle); + return options.stream() + .max(Comparator.comparingLong(o -> battleVoteRepository.countByBattleAndPreVoteOption(battle, o))) + .orElse(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJob.java b/src/main/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJob.java new file mode 100644 index 00000000..38e7f3ba --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJob.java @@ -0,0 +1,43 @@ +package com.swyp.picke.domain.user.service.batch; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.service.CreditService; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 주간 자동 충전 배치. + * 활성 사용자(UserStatus.ACTIVE) 전원에게 +40P (CreditType.WEEKLY_CHARGE) 지급. + * + * referenceId = 배치 실행 월요일의 yyyyMMdd 정수. 같은 주차 재실행 시 중복 지급 없음. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WeeklyChargeJob { + + private static final DateTimeFormatter KEY_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final UserRepository userRepository; + private final CreditService creditService; + + @Transactional + public void run(LocalDate runDate) { + Long weekKey = Long.parseLong(runDate.format(KEY_FORMAT)); + List activeUsers = userRepository.findAllByStatus(UserStatus.ACTIVE); + + log.info("[WeeklyChargeJob] runDate={} activeUsers={}", runDate, activeUsers.size()); + + for (User user : activeUsers) { + creditService.addCredit(user.getId(), CreditType.WEEKLY_CHARGE, weekKey); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java index 761e2d59..0320de96 100644 --- a/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImpl.java @@ -6,8 +6,10 @@ import com.swyp.picke.domain.battle.service.BattleService; import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; import com.swyp.picke.domain.user.enums.UserBattleStep; import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.service.CreditService; import com.swyp.picke.domain.user.service.UserBattleService; import com.swyp.picke.domain.vote.converter.VoteConverter; import com.swyp.picke.domain.vote.dto.request.VoteRequest; @@ -37,6 +39,7 @@ public class BattleVoteServiceImpl implements BattleVoteService { private final BattleOptionRepository battleOptionRepository; private final UserRepository userRepository; private final UserBattleService userBattleService; + private final CreditService creditService; private final ApplicationEventPublisher eventPublisher; @Override @@ -156,6 +159,9 @@ public VoteResultResponse postVote(Long battleId, Long userId, VoteRequest reque userBattleService.upsertStep(user, battle, UserBattleStep.COMPLETED); eventPublisher.publishEvent(new VoteUpdatedEvent(battleId)); + // 사후 투표 완료 보상 +5P. referenceId=voteId 로 (user, BATTLE_VOTE, voteId) 유니크 제약에 의해 중복 지급 방지. + creditService.addCredit(user.getId(), CreditType.BATTLE_VOTE, vote.getId()); + return new VoteResultResponse(vote.getId(), UserBattleStep.COMPLETED); } diff --git a/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java b/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java new file mode 100644 index 00000000..0a51ead4 --- /dev/null +++ b/src/main/java/com/swyp/picke/global/config/SchedulerConfig.java @@ -0,0 +1,23 @@ +package com.swyp.picke.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +/** + * 스케줄링 활성화. + * 크레딧 주간 배치({@link com.swyp.picke.domain.user.service.batch.CreditWeeklyBatchScheduler})의 {@code @Scheduled} 어노테이션이 동작하려면 필요. + */ +@Configuration +@EnableScheduling +public class SchedulerConfig { + + @Bean + public ThreadPoolTaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(2); + scheduler.setThreadNamePrefix("credit-scheduler-"); + return scheduler; + } +} diff --git a/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java index e3cb0975..dc7610f4 100644 --- a/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/user/service/CreditServiceTest.java @@ -4,6 +4,8 @@ import com.swyp.picke.domain.user.enums.TierCode; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; import com.swyp.picke.domain.user.repository.CreditHistoryRepository; import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.global.common.exception.CustomException; @@ -16,13 +18,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -42,13 +44,27 @@ class CreditServiceTest { @InjectMocks private CreditService creditService; + private User newUser(Long id, int initialCredit) { + User user = User.builder() + .userTag("tag-" + id) + .nickname("nick") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + if (initialCredit != 0) { + user.addCredit(initialCredit); + } + return user; + } + @Test - @DisplayName("현재 로그인 유저에게 기본 크레딧을 적립한다") + @DisplayName("현재 로그인 유저에게 기본 크레딧을 적립하고 User.credit 캐시에도 반영한다") void addCredit_forCurrentUser_savesDefaultAmount() { - User user = org.mockito.Mockito.mock(User.class); - when(user.getId()).thenReturn(1L); + User user = newUser(1L, 0); when(userService.findCurrentUser()).thenReturn(user); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.incrementCredit(1L, CreditType.BATTLE_VOTE.getDefaultAmount())).thenReturn(1); creditService.addCredit(CreditType.BATTLE_VOTE, 10L); @@ -60,6 +76,7 @@ void addCredit_forCurrentUser_savesDefaultAmount() { assertThat(saved.getCreditType()).isEqualTo(CreditType.BATTLE_VOTE); assertThat(saved.getAmount()).isEqualTo(CreditType.BATTLE_VOTE.getDefaultAmount()); assertThat(saved.getReferenceId()).isEqualTo(10L); + verify(userRepository).incrementCredit(1L, CreditType.BATTLE_VOTE.getDefaultAmount()); } @Test @@ -74,24 +91,25 @@ void addCredit_withoutReferenceId_throwsException() { } @Test - @DisplayName("중복 적립 충돌이면 조용히 무시한다") + @DisplayName("중복 적립 충돌이면 조용히 무시하고 캐시도 증가시키지 않는다") void addCredit_duplicateInsert_ignoresConflict() { - User user = org.mockito.Mockito.mock(User.class); + User user = newUser(1L, 7); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) .thenThrow(new DataIntegrityViolationException("duplicate")); when(creditHistoryRepository.existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L)) .thenReturn(true); - creditService.addCredit(1L, CreditType.BATTLE_VOTE, 10, 10L); + creditService.addCredit(1L, CreditType.BATTLE_VOTE, 5, 10L); verify(creditHistoryRepository).existsByUserIdAndCreditTypeAndReferenceId(1L, CreditType.BATTLE_VOTE, 10L); + verify(userRepository, never()).incrementCredit(1L, 5); } @Test - @DisplayName("중복이 아닌 데이터 무결성 오류는 그대로 던진다") + @DisplayName("중복이 아닌 데이터 무결성 오류는 CREDIT_SAVE_FAILED 로 재기동하고 캐시도 증가시키지 않는다") void addCredit_nonDuplicateIntegrityFailure_rethrows() { - User user = org.mockito.Mockito.mock(User.class); + User user = newUser(1L, 3); when(userRepository.findById(1L)).thenReturn(Optional.of(user)); when(creditHistoryRepository.saveAndFlush(any(CreditHistory.class))) .thenThrow(new DataIntegrityViolationException("broken")); @@ -102,12 +120,25 @@ void addCredit_nonDuplicateIntegrityFailure_rethrows() { .isInstanceOf(CustomException.class) .extracting("errorCode") .isEqualTo(ErrorCode.CREDIT_SAVE_FAILED); + + verify(userRepository, never()).incrementCredit(1L, 10); + } + + @Test + @DisplayName("getTotalPoints 는 User.credit 캐시 값을 반환한다 (히스토리 집계 아님)") + void getTotalPoints_readsUserCreditField() { + when(userRepository.findCreditById(1L)).thenReturn(2_500); + + int total = creditService.getTotalPoints(1L); + + assertThat(total).isEqualTo(2_500); + verify(creditHistoryRepository, never()).sumAmountByUserId(any()); } @Test @DisplayName("누적 포인트로 티어를 계산한다") void getTier_returnsTierFromTotalPoints() { - when(creditHistoryRepository.sumAmountByUserId(eq(1L))).thenReturn(2_500); + when(userRepository.findCreditById(1L)).thenReturn(2_500); TierCode tier = creditService.getTier(1L); diff --git a/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java index 054c675c..e73f73a2 100644 --- a/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/user/service/MypageServiceTest.java @@ -12,12 +12,15 @@ import com.swyp.picke.domain.user.dto.request.UpdateNotificationSettingsRequest; 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.NotificationSettingsResponse; import com.swyp.picke.domain.user.dto.response.RecapResponse; import com.swyp.picke.domain.user.dto.response.UserSummary; +import com.swyp.picke.domain.user.entity.CreditHistory; import com.swyp.picke.domain.user.enums.ActivityType; import com.swyp.picke.domain.user.enums.CharacterType; +import com.swyp.picke.domain.user.enums.CreditType; import com.swyp.picke.domain.user.enums.PhilosopherType; import com.swyp.picke.domain.user.enums.TierCode; import com.swyp.picke.domain.user.entity.User; @@ -35,6 +38,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; @@ -293,6 +298,27 @@ void getContentActivities_returns_likes() { assertThat(response.items().get(0).activityType()).isEqualTo(ActivityType.LIKE); } + @Test + @DisplayName("크레딧 내역을 최신순으로 offset 페이징 변환해 반환한다") + void getCreditHistory_returns_paginated_history() { + User user = createUser(1L, "tag"); + CreditHistory latest = creditHistory(301L, user, CreditType.BEST_COMMENT, 50, 91L, LocalDateTime.now()); + CreditHistory older = creditHistory(300L, user, CreditType.BATTLE_VOTE, 5, 90L, LocalDateTime.now().minusDays(1)); + + when(userService.findCurrentUser()).thenReturn(user); + when(creditService.getHistory(1L, PageRequest.of(0, 2))) + .thenReturn(new PageImpl<>(List.of(latest, older), PageRequest.of(0, 2), 3)); + + CreditHistoryListResponse response = mypageService.getCreditHistory(0, 2); + + assertThat(response.items()).hasSize(2); + assertThat(response.items().get(0).id()).isEqualTo(301L); + assertThat(response.items().get(0).creditType()).isEqualTo(CreditType.BEST_COMMENT); + assertThat(response.items().get(1).id()).isEqualTo(300L); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextOffset()).isEqualTo(2); + } + @Test @DisplayName("알림설정을 반환한다") void getNotificationSettings_returns_settings() { @@ -389,4 +415,23 @@ private BattleOption createOption(Battle battle, BattleOptionLabel label) { ReflectionTestUtils.setField(option, "id", generateId()); return option; } + + private CreditHistory creditHistory( + Long id, + User user, + CreditType creditType, + int amount, + Long referenceId, + LocalDateTime createdAt + ) { + CreditHistory history = CreditHistory.builder() + .user(user) + .creditType(creditType) + .amount(amount) + .referenceId(referenceId) + .build(); + ReflectionTestUtils.setField(history, "id", id); + ReflectionTestUtils.setField(history, "createdAt", createdAt); + return history; + } } diff --git a/src/test/java/com/swyp/picke/domain/user/service/batch/BestCommentRewardJobTest.java b/src/test/java/com/swyp/picke/domain/user/service/batch/BestCommentRewardJobTest.java new file mode 100644 index 00000000..a248024d --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/batch/BestCommentRewardJobTest.java @@ -0,0 +1,144 @@ +package com.swyp.picke.domain.user.service.batch; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.perspective.entity.Perspective; +import com.swyp.picke.domain.perspective.enums.PerspectiveStatus; +import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.service.CreditService; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BestCommentRewardJobTest { + + @Mock + private BattleRepository battleRepository; + + @Mock + private PerspectiveRepository perspectiveRepository; + + @Mock + private CreditService creditService; + + @InjectMocks + private BestCommentRewardJob job; + + @Test + @DisplayName("runDate 기준 14~20일 전 targetDate 윈도우로 배틀을 조회한다") + void run_queriesBattlesInTwoWeeksPriorWindow() { + LocalDate runDate = LocalDate.of(2026, 4, 13); + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull( + LocalDate.of(2026, 3, 24), LocalDate.of(2026, 3, 30), BattleStatus.PUBLISHED)) + .thenReturn(List.of()); + + job.run(runDate); + + verify(battleRepository).findByTargetDateBetweenAndStatusAndDeletedAtIsNull( + LocalDate.of(2026, 3, 24), LocalDate.of(2026, 3, 30), BattleStatus.PUBLISHED); + } + + @Test + @DisplayName("좋아요가 10개 미만이면 베댓 보상을 지급하지 않는다") + void run_skipsWhenPerspectiveHasLessThanMinimumLikes() { + Battle battle = battle(100L); + Perspective perspective = perspective(200L, battle, user(10L), 9); + + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull(any(), any(), any())) + .thenReturn(List.of(battle)); + when(perspectiveRepository.findByBattleIdAndStatusOrderByLikeCountDescCreatedAtDesc( + battle.getId(), PerspectiveStatus.PUBLISHED, PageRequest.of(0, 3))) + .thenReturn(List.of(perspective)); + + job.run(LocalDate.of(2026, 4, 13)); + + verify(creditService, never()).addCredit(any(), any(), any()); + } + + @Test + @DisplayName("좋아요 상위 3개 Perspective 작성자에게 BEST_COMMENT 를 지급한다") + void run_rewardsTopThreePerspectiveAuthors() { + Battle battle = battle(100L); + User author1 = user(10L); + User author2 = user(11L); + User author3 = user(12L); + Perspective top1 = perspective(200L, battle, author1, 20); + Perspective top2 = perspective(201L, battle, author2, 15); + Perspective top3 = perspective(202L, battle, author3, 10); + + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull(any(), any(), any())) + .thenReturn(List.of(battle)); + when(perspectiveRepository.findByBattleIdAndStatusOrderByLikeCountDescCreatedAtDesc( + battle.getId(), PerspectiveStatus.PUBLISHED, PageRequest.of(0, 3))) + .thenReturn(List.of(top1, top2, top3)); + + job.run(LocalDate.of(2026, 4, 13)); + + verify(creditService).addCredit(10L, CreditType.BEST_COMMENT, 200L); + verify(creditService).addCredit(11L, CreditType.BEST_COMMENT, 201L); + verify(creditService).addCredit(12L, CreditType.BEST_COMMENT, 202L); + verify(creditService, never()).addCredit(13L, CreditType.BEST_COMMENT, 203L); + } + + private Battle battle(Long id) { + Battle battle = Battle.builder() + .title("battle") + .status(BattleStatus.PUBLISHED) + .build(); + ReflectionTestUtils.setField(battle, "id", id); + return battle; + } + + private User user(Long id) { + User user = User.builder() + .userTag("user-" + id) + .nickname("nick") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private Perspective perspective(Long id, Battle battle, User user, int likeCount) { + BattleOption option = BattleOption.builder() + .battle(battle) + .label(BattleOptionLabel.A) + .title("A") + .stance("stance") + .build(); + + Perspective perspective = Perspective.builder() + .battle(battle) + .user(user) + .option(option) + .content("content") + .build(); + perspective.publish(); + while (perspective.getLikeCount() < likeCount) { + perspective.incrementLikeCount(); + } + ReflectionTestUtils.setField(perspective, "id", id); + return perspective; + } +} diff --git a/src/test/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJobTest.java b/src/test/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJobTest.java new file mode 100644 index 00000000..2315ec3f --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/batch/MajorityWinRewardJobTest.java @@ -0,0 +1,107 @@ +package com.swyp.picke.domain.user.service.batch; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MajorityWinRewardJobTest { + + @Mock private BattleRepository battleRepository; + @Mock private BattleOptionRepository battleOptionRepository; + @Mock private BattleVoteRepository battleVoteRepository; + @Mock private CreditService creditService; + + @InjectMocks + private MajorityWinRewardJob job; + + @Test + @DisplayName("runDate 기준 14~20일 전 targetDate 윈도우로 배틀을 조회한다") + void run_queriesBattlesInTwoWeeksPriorWindow() { + LocalDate runDate = LocalDate.of(2026, 4, 13); + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull( + LocalDate.of(2026, 3, 24), LocalDate.of(2026, 3, 30), BattleStatus.PUBLISHED)) + .thenReturn(List.of()); + + job.run(runDate); + + verify(battleRepository).findByTargetDateBetweenAndStatusAndDeletedAtIsNull( + LocalDate.of(2026, 3, 24), LocalDate.of(2026, 3, 30), BattleStatus.PUBLISHED); + } + + @Test + @DisplayName("최다 득표 옵션을 사전 투표한 사용자에게만 MAJORITY_WIN 을 지급한다") + void run_rewardsOnlyWinningOptionVoters() { + LocalDate runDate = LocalDate.of(2026, 4, 13); + Battle battle = battle(100L); + BattleOption winner = option(1L, battle); + BattleOption loser = option(2L, battle); + + when(battleRepository.findByTargetDateBetweenAndStatusAndDeletedAtIsNull(any(), any(), any())) + .thenReturn(List.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of(winner, loser)); + when(battleVoteRepository.countByBattleAndPreVoteOption(battle, winner)).thenReturn(10L); + when(battleVoteRepository.countByBattleAndPreVoteOption(battle, loser)).thenReturn(5L); + + User userA = user(11L); + User userB = user(12L); + User userC = user(13L); + BattleVote winVoteA = vote(userA, winner); + BattleVote winVoteB = vote(userB, winner); + BattleVote lossVoteC = vote(userC, loser); + when(battleVoteRepository.findAllByBattle(battle)).thenReturn(List.of(winVoteA, winVoteB, lossVoteC)); + + job.run(runDate); + + verify(creditService).addCredit(11L, CreditType.MAJORITY_WIN, 100L); + verify(creditService).addCredit(12L, CreditType.MAJORITY_WIN, 100L); + verify(creditService, never()).addCredit(eq(13L), eq(CreditType.MAJORITY_WIN), any()); + } + + private Battle battle(Long id) { + Battle b = Battle.builder().title("t").build(); + ReflectionTestUtils.setField(b, "id", id); + return b; + } + + private BattleOption option(Long id, Battle battle) { + BattleOption o = BattleOption.builder().battle(battle).title("t").build(); + ReflectionTestUtils.setField(o, "id", id); + return o; + } + + private User user(Long id) { + User u = User.builder().userTag("u" + id).role(UserRole.USER).status(UserStatus.ACTIVE).build(); + ReflectionTestUtils.setField(u, "id", id); + return u; + } + + private BattleVote vote(User user, BattleOption preOption) { + return BattleVote.builder().user(user).battle(preOption.getBattle()).preVoteOption(preOption).build(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJobTest.java b/src/test/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJobTest.java new file mode 100644 index 00000000..0b675986 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/user/service/batch/WeeklyChargeJobTest.java @@ -0,0 +1,59 @@ +package com.swyp.picke.domain.user.service.batch; + +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.service.CreditService; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WeeklyChargeJobTest { + + @Mock + private UserRepository userRepository; + + @Mock + private CreditService creditService; + + @InjectMocks + private WeeklyChargeJob job; + + @Test + @DisplayName("활성 사용자에게만 WEEKLY_CHARGE 를 지급한다") + void run_rewardsOnlyActiveUsers() { + User activeUser1 = user(1L); + User activeUser2 = user(2L); + LocalDate runDate = LocalDate.of(2026, 4, 13); + + when(userRepository.findAllByStatus(UserStatus.ACTIVE)).thenReturn(List.of(activeUser1, activeUser2)); + + job.run(runDate); + + verify(creditService).addCredit(1L, CreditType.WEEKLY_CHARGE, 20260413L); + verify(creditService).addCredit(2L, CreditType.WEEKLY_CHARGE, 20260413L); + } + + private User user(Long id) { + User user = User.builder() + .userTag("user-" + id) + .nickname("nick") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } +} diff --git a/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java new file mode 100644 index 00000000..acdba378 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/vote/service/BattleVoteServiceImplTest.java @@ -0,0 +1,120 @@ +package com.swyp.picke.domain.vote.service; + +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.enums.BattleOptionLabel; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.service.BattleService; +import com.swyp.picke.domain.user.dto.response.UserBattleStatusResponse; +import com.swyp.picke.domain.user.entity.User; +import com.swyp.picke.domain.user.enums.CreditType; +import com.swyp.picke.domain.user.enums.UserBattleStep; +import com.swyp.picke.domain.user.enums.UserRole; +import com.swyp.picke.domain.user.enums.UserStatus; +import com.swyp.picke.domain.user.repository.UserRepository; +import com.swyp.picke.domain.user.service.CreditService; +import com.swyp.picke.domain.user.service.UserBattleService; +import com.swyp.picke.domain.vote.dto.request.VoteRequest; +import com.swyp.picke.domain.vote.dto.response.VoteResultResponse; +import com.swyp.picke.domain.vote.entity.BattleVote; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BattleVoteServiceImplTest { + + @Mock + private BattleVoteRepository battleVoteRepository; + + @Mock + private BattleService battleService; + + @Mock + private BattleOptionRepository battleOptionRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private UserBattleService userBattleService; + + @Mock + private CreditService creditService; + + @InjectMocks + private BattleVoteServiceImpl battleVoteService; + + @Test + @DisplayName("사후 투표 완료 시 참여 보상 크레딧을 지급한다") + void postVote_rewardsBattleParticipationCredit() { + Battle battle = battle(100L); + User user = user(10L); + BattleOption preOption = option(201L, battle, BattleOptionLabel.A); + BattleOption postOption = option(202L, battle, BattleOptionLabel.B); + BattleVote vote = BattleVote.builder() + .user(user) + .battle(battle) + .preVoteOption(preOption) + .build(); + ReflectionTestUtils.setField(vote, "id", 300L); + + when(battleService.findById(100L)).thenReturn(battle); + when(userRepository.findById(10L)).thenReturn(Optional.of(user)); + when(battleOptionRepository.findById(202L)).thenReturn(Optional.of(postOption)); + when(battleVoteRepository.findByBattleAndUser(battle, user)).thenReturn(Optional.of(vote)); + when(userBattleService.getUserBattleStatus(user, battle)) + .thenReturn(new UserBattleStatusResponse(100L, UserBattleStep.POST_VOTE)); + + VoteResultResponse response = battleVoteService.postVote(100L, 10L, new VoteRequest(202L)); + + assertThat(vote.getPostVoteOption()).isEqualTo(postOption); + assertThat(response.voteId()).isEqualTo(300L); + assertThat(response.status()).isEqualTo(UserBattleStep.COMPLETED); + verify(userBattleService).upsertStep(user, battle, UserBattleStep.COMPLETED); + verify(creditService).addCredit(10L, CreditType.BATTLE_VOTE, 300L); + } + + private Battle battle(Long id) { + Battle battle = Battle.builder() + .title("battle") + .summary("summary") + .status(BattleStatus.PUBLISHED) + .build(); + ReflectionTestUtils.setField(battle, "id", id); + return battle; + } + + private User user(Long id) { + User user = User.builder() + .userTag("user-" + id) + .nickname("nick") + .role(UserRole.USER) + .status(UserStatus.ACTIVE) + .build(); + ReflectionTestUtils.setField(user, "id", id); + return user; + } + + private BattleOption option(Long id, Battle battle, BattleOptionLabel label) { + BattleOption option = BattleOption.builder() + .battle(battle) + .label(label) + .title(label.name()) + .stance("stance") + .build(); + ReflectionTestUtils.setField(option, "id", id); + return option; + } +}