From ea185942b1e216282c51306e651d9cde23c72be1 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:57:34 +0900 Subject: [PATCH 1/8] =?UTF-8?q?Feat:=20=EC=9C=A0=EC=A0=80=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=EC=97=90=20=EB=A0=88=EB=B2=A8=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../teamcback/domain/user/entity/Level.java | 30 +++- .../teamcback/domain/user/entity/User.java | 15 ++ .../domain/user/service/UserService.java | 17 +-- .../global/aop/UpdateScoreAspect.java | 21 +-- .../global/aop/UpdateScoreAspectTest.java | 142 ------------------ 5 files changed, 49 insertions(+), 176 deletions(-) delete mode 100644 src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java b/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java index 996f80cf..8fa2d4d5 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java @@ -6,31 +6,31 @@ @Getter @AllArgsConstructor public enum Level { - LEVEL1(0, 1, ProfileImage.getUrlByLevel(1)) { // 0~4점 + LEVEL1(0, 1) { // 0~4점 @Override public Level getNextLevel() { return LEVEL2; } }, - LEVEL2(5, 2, ProfileImage.getUrlByLevel(2)) { // 5~19점 + LEVEL2(5, 2) { // 5~19점 @Override public Level getNextLevel() { return LEVEL3; } }, - LEVEL3(20, 3, ProfileImage.getUrlByLevel(3)) { //20~39점 + LEVEL3(20, 3) { //20~39점 @Override public Level getNextLevel() { return LEVEL4; } }, - LEVEL4(40, 4, ProfileImage.getUrlByLevel(4)) { //40~59점 + LEVEL4(40, 4) { //40~59점 @Override public Level getNextLevel() { return LEVEL5; } }, - LEVEL5(60, 5, ProfileImage.getUrlByLevel(5)) { //60점 이상 + LEVEL5(60, 5) { //60점 이상 @Override public Level getNextLevel() { return null; @@ -38,7 +38,23 @@ public Level getNextLevel() { }, ; private final int minScore; private final int levelNumber; - private final String profileImage; public abstract Level getNextLevel(); -} \ No newline at end of file + + // 엔티티 필드로 사용되는 enum이 Hibernate 메타데이터 빌드 시점에 초기화되므로, + // ProfileImage 빈 생성 전에 접근하지 않도록 이미지 URL은 호출 시점에 조회한다. + public String getProfileImage() { + return ProfileImage.getUrlByLevel(levelNumber); + } + + public static Level fromScore(long score) { + // score >= minScore 인 경우 중 가장 높은 레벨 반환 + Level result = LEVEL1; + for (Level level : values()) { + if (score >= level.getMinScore() && level.getMinScore() >= result.getMinScore()) { + result = level; + } + } + return result; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java index 52bb55ed..44950b89 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java @@ -12,6 +12,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; @Entity @Getter @@ -38,6 +41,13 @@ public class User extends BaseEntity { @Column(nullable = false) private Long score = 0L; + // MySQL native enum 타입 대신 VARCHAR로 저장하여 레벨 추가 시 ALTER 없이 확장 가능하게 유지 + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.VARCHAR) + @ColumnDefault("'LEVEL1'") + @Column(nullable = false) + private Level level = Level.LEVEL1; + @Column(nullable = false) private boolean isUpgraded = false; @@ -60,6 +70,7 @@ public void updateUsername(String username) { public void updateScore(Long score, boolean isUpgraded) { this.score = score; + this.level = Level.fromScore(score); // score와 level이 발산하지 않도록 단일 지점에서 갱신 this.isUpgraded = isUpgraded; } @@ -67,4 +78,8 @@ public void updateUpgraded(boolean isUpgraded) { this.isUpgraded = isUpgraded; } + public void syncLevel() { + this.level = Level.fromScore(this.score); + } + } diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index 9c8eb49f..e0a447fb 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -37,9 +37,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Arrays; -import java.util.Comparator; - import static devkor.com.teamcback.global.response.ResultCode.*; @Slf4j @@ -71,7 +68,11 @@ public class UserService { @Transactional public GetUserInfoRes getUserInfo(Long userId) { User user = findUser(userId); - Level level = getLevel(user.getScore()); + Level level = user.getLevel(); + if(level == null) { // 백필 전 레거시 행 방어 (백필 완료 후엔 도달하지 않음) + user.syncLevel(); + level = user.getLevel(); + } Level nextLevel = level.getNextLevel(); Long remainScoreToNextLevel = nextLevel == null ? 0 : nextLevel.getMinScore() - user.getScore(); int percent = 100; @@ -217,14 +218,6 @@ private void checkUsernameAvailability(User user, String username) { } } - private Level getLevel(Long score) { - // score >= minScore 인 경우 중 가장 높은 레벨 반환 - return Arrays.stream(Level.values()) - .filter(lv -> score >= lv.getMinScore()) - .max(Comparator.comparingInt(Level::getMinScore)) - .orElse(Level.LEVEL1); - } - private User findUser(Long userId) { return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); } diff --git a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java index c0ba2a22..a00d77aa 100644 --- a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java +++ b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java @@ -27,8 +27,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Comparator; import static devkor.com.teamcback.global.response.ResultCode.*; @@ -336,8 +334,8 @@ private void injectScoreInfo(Object result, User user, boolean isLevelUp, boolea public void increaseScore(User user, int addScore) { long newScore = user.getScore() + addScore; // 전후 레벨 계산 - Level beforeLv = getLevel(user.getScore()); - Level afterLv = getLevel(newScore); + Level beforeLv = Level.fromScore(user.getScore()); + Level afterLv = Level.fromScore(newScore); // 변했으면 true boolean isChanged = beforeLv != afterLv; @@ -348,22 +346,15 @@ public void increaseScore(User user, int addScore) { * 점수 차이만큼 업데이트 (증가 또는 감소) */ private void updateScoreWithDiff(User user, int scoreDiff) { - long newScore = Math.max(0, user.getScore() + scoreDiff); // 최소 0점 + long oldScore = user.getScore(); + long newScore = Math.max(0, oldScore + scoreDiff); // 최소 0점 // 전후 레벨 계산 - Level beforeLv = getLevel(user.getScore()); - Level afterLv = getLevel(newScore); + Level beforeLv = Level.fromScore(oldScore); + Level afterLv = Level.fromScore(newScore); // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); } - - private Level getLevel(Long score) { - // score >= minScore 인 경우 중 가장 높은 레벨 반환 - return Arrays.stream(Level.values()) - .filter(lv -> score >= lv.getMinScore()) - .max(Comparator.comparingInt(Level::getMinScore)) - .orElse(Level.LEVEL1); - } } diff --git a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java deleted file mode 100644 index fee85b3c..00000000 --- a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package devkor.com.teamcback.global.aop; - -import devkor.com.teamcback.domain.bookmark.dto.request.CreateBookmarkReq; -import devkor.com.teamcback.domain.bookmark.service.BookmarkService; -import devkor.com.teamcback.domain.common.LocationType; -import devkor.com.teamcback.domain.suggestion.dto.request.CreateSuggestionReq; -import devkor.com.teamcback.domain.suggestion.entity.SuggestionType; -import devkor.com.teamcback.domain.suggestion.service.SuggestionService; -import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.domain.user.service.UserService; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import lombok.extern.slf4j.Slf4j; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -import java.util.ArrayList; -import java.util.List; - -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; - -@Slf4j -@Disabled -@ExtendWith(SpringExtension.class) -@SpringBootTest -public class UpdateScoreAspectTest { - - @Autowired - private UserRepository userRepository; - - @Autowired - private UserService userService; - - @Autowired - private SuggestionService suggestionService; - - @Autowired - private BookmarkService bookmarkService; - - @Test - public void suggestionScoreTest() { - Long userId = 36L; // 임시로 제 계정 사용했습니다. - User user = findUser(userId); - CreateSuggestionReq req = new CreateSuggestionReq("Test제목", SuggestionType.INCONVENIENCE, "내용", "이메일"); - - // 건의함 작성 시 포인트 확인 - long beforeScore = user.getScore(); - log.info("건의함 작성 전 Score : " + beforeScore); - suggestionService.createSuggestion(userId, req, null); - - user = findUser(userId); - long afterScore = user.getScore(); - log.info("건의함 작성 후 Score : " + afterScore); - Assertions.assertThat(beforeScore+3).isEqualTo(afterScore); - - // 점수가 기준치를 넘었는지 확인 - if(user.isUpgraded()) { - log.info("레벨이 올랐습니다."); - - // 업그레이드 후 마이페이지 첫 조회 - GetUserInfoRes info = userService.getUserInfo(userId); - log.info("마이페이지 isUgraded : " + info.isUpgraded()); - Assertions.assertThat(info.isUpgraded()).isTrue(); // True - - user = findUser(userId); // 조회 후 User 정보 - log.info("조회 후 isUpgraded : " + user.isUpgraded()); - Assertions.assertThat(user.isUpgraded()).isFalse(); // false - } else { - log.info("레벨이 오르지 않았습니다."); - } - } - - @Test - public void bookmarkScoreTest() { - Long userId = 36L; // 임시로 제 계정 사용했습니다. - List list = new ArrayList<>(); - - list.add(96L); // 카테고리 ID : 새 로그 검사용 - LocationType type = LocationType.PLACE; - long placeId = 66L; - String memo = "memo"; - - User user = findUser(userId); - CreateBookmarkReq req = new CreateBookmarkReq(list, type, placeId, memo); - - // 1. 건의함 작성 시 포인트 확인 - long beforeScore = user.getScore(); - log.info("북마크 생성 전 Score : " + beforeScore); - bookmarkService.createBookmark(userId, req); - - user = findUser(userId); - long afterScore = user.getScore(); - log.info("북마크 생성 후 Score : " + afterScore); - Assertions.assertThat(beforeScore+1).isEqualTo(afterScore); - - // 2. 점수가 기준치를 넘었는지 확인 - if(user.isUpgraded()) { - log.info("레벨이 올랐습니다."); - - // 업그레이드 후 마이페이지 첫 조회 - GetUserInfoRes info = userService.getUserInfo(userId); - log.info("마이페이지 isUgraded : " + info.isUpgraded()); - Assertions.assertThat(info.isUpgraded()).isTrue(); // True - - user = findUser(userId); // 조회 후 User 정보 - log.info("조회 후 isUpgraded : " + user.isUpgraded()); - Assertions.assertThat(user.isUpgraded()).isFalse(); // false - } else { - log.info("레벨이 오르지 않았습니다."); - } - - // 3. 다른 카테고리에 동일 장소 북마크 추가 : 점수 증가X - log.info("북마크 재생성 전 Score : " + afterScore); - list.clear(); - list.add(99L); - req = new CreateBookmarkReq(list, type, placeId, memo); - bookmarkService.createBookmark(userId, req); - - user = findUser(userId); - long againScore = user.getScore(); - log.info("북마크 재생성 후 Score : " + againScore); - Assertions.assertThat(againScore).isEqualTo(afterScore); - - // 4. 동일 북마크 다시 넣었을 때 점수 증가 X - bookmarkService.createBookmark(userId, req); - - user = findUser(userId); - long dupScore = user.getScore(); - log.info("중복 북마크 생성 후 Score (저장X) : " + dupScore); - Assertions.assertThat(dupScore).isEqualTo(againScore); - } - - private User findUser(Long userId) { - return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); - } -} From 9cec8b7ca32e8c4ee4eb921c6a6d0f58dc881202 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:58:03 +0900 Subject: [PATCH 2/8] =?UTF-8?q?Feat:=20=EC=83=81=EC=A0=90=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=9A=A9=ED=95=A0=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/dto/response/GetUserInfoRes.java | 3 + .../teamcback/domain/user/entity/User.java | 12 ++++ .../user/repository/UserRepository.java | 12 ++++ .../global/aop/UpdateScoreAspect.java | 3 + .../domain/user/entity/LevelTest.java | 63 +++++++++++++++++ .../global/aop/UpdateScoreLevelTest.java | 68 +++++++++++++++++++ 6 files changed, 161 insertions(+) create mode 100644 src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java create mode 100644 src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java index 2c7ea2d1..27bddead 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java @@ -21,6 +21,8 @@ public class GetUserInfoRes { private int level; @Schema(description = "score", example = "15") private Long score; + @Schema(description = "보유 포인트 (스토어 재화)", example = "15") + private Long point; @Schema(description = "remainScoreToNextLevel", example = "10") private Long remainScoreToNextLevel; @Schema(description = "percent", example = "75") @@ -38,6 +40,7 @@ public GetUserInfoRes(User user, Long categoryCount, int level, Long remainScore this.role = user.getRole(); this.level = level; this.score = user.getScore(); + this.point = user.getPoint(); this.remainScoreToNextLevel = remainScoreToNextLevel; this.percent = percent; this.isUpgraded = isUpgraded; diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java index 44950b89..41618832 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java @@ -41,6 +41,10 @@ public class User extends BaseEntity { @Column(nullable = false) private Long score = 0L; + // 스토어에서 사용하는 차감형 재화. score(레벨용 누적치)와 같은 양으로 적립되고 구매 시에만 차감된다. + // 컬럼을 nullable로 두는 이유: 기존 행은 NULL로 생성되고, NULL 여부가 "백필 전" 표식이 되어 백필이 영원히 멱등해진다. + private Long point = 0L; + // MySQL native enum 타입 대신 VARCHAR로 저장하여 레벨 추가 시 ALTER 없이 확장 가능하게 유지 @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.VARCHAR) @@ -82,4 +86,12 @@ public void syncLevel() { this.level = Level.fromScore(this.score); } + public Long getPoint() { // 백필 전 NULL 방어 + return point == null ? 0L : point; + } + + public void addPoint(long amount) { + this.point = Math.max(0, getPoint() + amount); + } + } diff --git a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java index 820a9bc0..14139621 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java @@ -3,6 +3,10 @@ import devkor.com.teamcback.domain.user.entity.Provider; import devkor.com.teamcback.domain.user.entity.User; 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; +import org.springframework.transaction.annotation.Transactional; public interface UserRepository extends JpaRepository { boolean existsByUsernameAndUserIdNot(String username, Long id); @@ -12,4 +16,12 @@ public interface UserRepository extends JpaRepository { User findByEmailAndProvider(String email, Provider provider); User findByUserId(long userId); + + /** + * 포인트 차감. 잔액 검증과 차감을 단일 UPDATE로 수행하여 동시 구매 시 이중 차감을 방지한다. + * @return 1이면 차감 성공, 0이면 잔액 부족 + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update User u set u.point = u.point - :price where u.userId = :userId and u.point >= :price") + int deductPoint(@Param("userId") Long userId, @Param("price") int price); } diff --git a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java index a00d77aa..5e8c6071 100644 --- a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java +++ b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java @@ -340,6 +340,7 @@ public void increaseScore(User user, int addScore) { // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); + user.addPoint(addScore); // 스토어 재화는 score와 같은 양으로 적립 } /** @@ -356,5 +357,7 @@ private void updateScoreWithDiff(User user, int scoreDiff) { // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); + // 리뷰 삭제 후 재작성으로 포인트를 무한 적립하는 것을 막기 위해 차감도 동일하게 반영 (최소 0) + user.addPoint(newScore - oldScore); } } diff --git a/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java b/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java new file mode 100644 index 00000000..e164bfa9 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java @@ -0,0 +1,63 @@ +package devkor.com.teamcback.domain.user.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class LevelTest { + + @DisplayName("점수 구간별 레벨 계산 (0/5/20/40/60)") + @ParameterizedTest + @CsvSource({ + "0, LEVEL1", "4, LEVEL1", + "5, LEVEL2", "19, LEVEL2", + "20, LEVEL3", "39, LEVEL3", + "40, LEVEL4", "59, LEVEL4", + "60, LEVEL5", "999, LEVEL5", + }) + void fromScore(long score, Level expected) { + assertEquals(expected, Level.fromScore(score)); + } + + @DisplayName("다음 레벨 체인") + @Test + void getNextLevel() { + assertEquals(Level.LEVEL2, Level.LEVEL1.getNextLevel()); + assertEquals(Level.LEVEL3, Level.LEVEL2.getNextLevel()); + assertEquals(Level.LEVEL4, Level.LEVEL3.getNextLevel()); + assertEquals(Level.LEVEL5, Level.LEVEL4.getNextLevel()); + assertNull(Level.LEVEL5.getNextLevel()); + } + + @DisplayName("updateScore 시 score와 level이 함께 갱신") + @Test + void updateScoreSyncsLevel() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + assertEquals(Level.LEVEL1, user.getLevel()); + + user.updateScore(10L, true); + assertEquals(Level.LEVEL2, user.getLevel()); + assertEquals(10L, user.getScore()); + + // 리뷰 삭제 등으로 점수가 내려가면 레벨도 함께 하락 + user.updateScore(3L, true); + assertEquals(Level.LEVEL1, user.getLevel()); + } + + @DisplayName("포인트 적립/차감은 0 미만으로 내려가지 않음") + @Test + void addPointFloorsAtZero() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + assertEquals(0L, user.getPoint()); + + user.addPoint(10); + assertEquals(10L, user.getPoint()); + + user.addPoint(-15); // 리뷰 삭제 등으로 적립분보다 큰 차감이 와도 0에서 멈춤 + assertEquals(0L, user.getPoint()); + } +} diff --git a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java new file mode 100644 index 00000000..710fe33f --- /dev/null +++ b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java @@ -0,0 +1,68 @@ +package devkor.com.teamcback.global.aop; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import devkor.com.teamcback.domain.common.repository.FileRepository; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.vote.repository.VoteRecordRepository; +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; + +@ExtendWith(MockitoExtension.class) +class UpdateScoreLevelTest { + @InjectMocks + UpdateScoreAspect updateScoreAspect; + + @Mock + UserRepository userRepository; + @Mock + SuggestionRepository suggestionRepository; + @Mock + VoteRecordRepository voteRecordRepository; + @Mock + ReviewRepository reviewRepository; + @Mock + PlaceRepository placeRepository; + @Mock + FileRepository fileRepository; + + @DisplayName("점수 적립 시 레벨업 판정과 level 컬럼 갱신 + 포인트 동일 적립") + @Test + void increaseScoreUpdatesLevel() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + + // 건의 작성 +10점 → LEVEL1(0) → LEVEL2(5~) 레벨업, 포인트도 +10 + updateScoreAspect.increaseScore(user, 10); + assertEquals(10L, user.getScore()); + assertEquals(10L, user.getPoint()); + assertEquals(Level.LEVEL2, user.getLevel()); + assertTrue(user.isUpgraded()); + + // 같은 레벨 내 적립은 레벨업 아님 + updateScoreAspect.increaseScore(user, 5); + assertEquals(15L, user.getScore()); + assertEquals(15L, user.getPoint()); + assertEquals(Level.LEVEL2, user.getLevel()); + assertFalse(user.isUpgraded()); + + // 리뷰 최대 점수(+13)로 임계값을 건너뛰어도 레벨과 컬럼이 일치 + updateScoreAspect.increaseScore(user, 13); + assertEquals(28L, user.getScore()); + assertEquals(28L, user.getPoint()); + assertEquals(Level.LEVEL3, user.getLevel()); + assertTrue(user.isUpgraded()); + } +} From 6f80547070fa0fec39280c89a62e0f418f440b64 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:58:39 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Feat:=20=EA=B8=B0=EC=A1=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EB=A0=88=EB=B2=A8/=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=EA=B0=92=20=EC=84=B8=ED=8C=85=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/repository/UserRepository.java | 25 +++++ .../scheduler/UserLevelBackfillRunner.java | 45 ++++++++ .../UserRepositoryBackfillTest.java | 91 ++++++++++++++++ src/test/resources/application-test.yml | 101 ++++++++++++++++++ src/test/resources/logback-spring.xml | 13 +++ 5 files changed, 275 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java create mode 100644 src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java create mode 100644 src/test/resources/application-test.yml create mode 100644 src/test/resources/logback-spring.xml diff --git a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java index 14139621..3db5c675 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java @@ -17,6 +17,31 @@ public interface UserRepository extends JpaRepository { User findByUserId(long userId); + /** + * score 기준으로 level 컬럼 백필 (레벨 구간: 0/5/20/40/60, Level enum과 동기화 유지) + * level이 null이거나 score와 불일치하는 행만 갱신하므로 멱등 + */ + @Transactional + @Modifying + @Query(value = """ + UPDATE tb_user SET level = + CASE WHEN score >= 60 THEN 'LEVEL5' WHEN score >= 40 THEN 'LEVEL4' + WHEN score >= 20 THEN 'LEVEL3' WHEN score >= 5 THEN 'LEVEL2' ELSE 'LEVEL1' END + WHERE level IS NULL OR level <> + CASE WHEN score >= 60 THEN 'LEVEL5' WHEN score >= 40 THEN 'LEVEL4' + WHEN score >= 20 THEN 'LEVEL3' WHEN score >= 5 THEN 'LEVEL2' ELSE 'LEVEL1' END + """, nativeQuery = true) + int backfillLevels(); + + /** + * point 컬럼 백필: 기존 사용자는 지금까지 적립한 score만큼 포인트를 보유한 것으로 초기화. + * NULL(백필 전 표식)인 행만 갱신하므로 재실행해도 이미 사용한 포인트가 복구되지 않는다 (멱등) + */ + @Transactional + @Modifying + @Query(value = "UPDATE tb_user SET point = score WHERE point IS NULL", nativeQuery = true) + int backfillPoints(); + /** * 포인트 차감. 잔액 검증과 차감을 단일 UPDATE로 수행하여 동시 구매 시 이중 차감을 방지한다. * @return 1이면 차감 성공, 0이면 잔액 부족 diff --git a/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java b/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java new file mode 100644 index 00000000..183e0ded --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java @@ -0,0 +1,45 @@ +package devkor.com.teamcback.domain.user.scheduler; + +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.redis.RedisLockUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * level/point 컬럼 도입에 따른 기존 사용자 백필. + * - level: score와 불일치하는 행만 교정 (정상 상태에서는 0건 갱신) + * - point: NULL인 행만 score 값으로 초기화 (사용 후 0이 된 잔액은 다시 채워지지 않음) + * 매 부팅 시 실행되지만 두 쿼리 모두 멱등. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserLevelBackfillRunner { + private final UserRepository userRepository; + private final RedisLockUtil redisLockUtil; + + @Value("${metrics.environment}") + private String env; + + // 트랜잭션 경계는 UserRepository.backfillLevels()에 둔다. 리스너 메서드를 @Transactional로 감싸면 + // UPDATE 실패를 catch해도 rollback-only 커밋 예외가 리스너 밖으로 나가 부팅이 실패한다. + @EventListener(ApplicationReadyEvent.class) + public void backfillUserLevels() { + if(env.equals("test")) return; // H2 테스트 환경은 create-drop이라 백필 불필요 + Redis 미기동 + + try { + redisLockUtil.executeWithLock("user_level_backfill_lock", 1, 300, () -> { + int levelUpdated = userRepository.backfillLevels(); + int pointUpdated = userRepository.backfillPoints(); + log.info("사용자 백필 완료: level {}건, point {}건 갱신", levelUpdated, pointUpdated); + return null; + }); + } catch (Exception e) { + log.error("backfillUserLevels() 작업 실패: {}", e.getMessage(), e); + } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java b/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java new file mode 100644 index 00000000..f2a5feb7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java @@ -0,0 +1,91 @@ +package devkor.com.teamcback.domain.user.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import devkor.com.teamcback.global.config.QueryDslConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 +class UserRepositoryBackfillTest { + @Autowired + UserRepository userRepository; + + @Autowired + EntityManager em; + + @DisplayName("score와 불일치하는 level만 백필하고, 재실행 시 0건 (멱등)") + @Test + void backfillLevels() { + User staleUser = userRepository.save(new User("stale", "stale@test.com", Role.USER, Provider.KAKAO)); + staleUser.updateScore(60L, false); // level = LEVEL5 + User freshUser = userRepository.save(new User("fresh", "fresh@test.com", Role.USER, Provider.KAKAO)); + freshUser.updateScore(10L, false); // level = LEVEL2 + em.flush(); + + // 컬럼 도입 이전의 레거시 상태(레벨 불일치)를 native SQL로 재현 + em.createNativeQuery("UPDATE tb_user SET level = 'LEVEL1' WHERE username = 'stale'").executeUpdate(); + em.clear(); + + int updated = userRepository.backfillLevels(); + em.clear(); + + assertEquals(1, updated); + assertEquals(Level.LEVEL5, userRepository.findByUserId(staleUser.getUserId()).getLevel()); + assertEquals(Level.LEVEL2, userRepository.findByUserId(freshUser.getUserId()).getLevel()); + + // 두 번째 실행은 아무것도 갱신하지 않음 + assertEquals(0, userRepository.backfillLevels()); + } + + @DisplayName("point가 NULL인 레거시 행만 score 값으로 백필 (사용 후 0이 된 잔액은 재적립 안 됨)") + @Test + void backfillPoints() { + User legacyUser = userRepository.save(new User("legacy", "legacy@test.com", Role.USER, Provider.KAKAO)); + legacyUser.updateScore(30L, false); + User spentUser = userRepository.save(new User("spent", "spent@test.com", Role.USER, Provider.KAKAO)); + spentUser.updateScore(30L, false); // point는 자바 초기값 0 (이미 초기화된 사용자로 간주) + em.flush(); + + // 컬럼 도입 이전의 레거시 상태(point NULL)를 native SQL로 재현 + em.createNativeQuery("UPDATE tb_user SET point = NULL WHERE username = 'legacy'").executeUpdate(); + em.clear(); + + assertEquals(1, userRepository.backfillPoints()); + em.clear(); + + assertEquals(30L, userRepository.findByUserId(legacyUser.getUserId()).getPoint()); + assertEquals(0L, userRepository.findByUserId(spentUser.getUserId()).getPoint()); // NULL 아니면 미변경 + + // 두 번째 실행은 아무것도 갱신하지 않음 (멱등) + assertEquals(0, userRepository.backfillPoints()); + } + + @DisplayName("포인트 차감: 잔액이 충분할 때만 원자적으로 차감") + @Test + void deductPoint() { + User user = userRepository.save(new User("buyer", "buyer@test.com", Role.USER, Provider.KAKAO)); + user.addPoint(10); + em.flush(); + + assertEquals(1, userRepository.deductPoint(user.getUserId(), 7)); + assertEquals(3L, userRepository.findByUserId(user.getUserId()).getPoint()); + + // 잔액(3)보다 큰 금액은 차감 실패 + assertEquals(0, userRepository.deductPoint(user.getUserId(), 4)); + assertEquals(3L, userRepository.findByUserId(user.getUserId()).getPoint()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..ed69394e --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,101 @@ +spring: + cache: + type: none + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1 + username: sa + password: + + jpa: + open-in-view: false + hibernate: + ddl-auto: create-drop + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + show-sql: true + properties: + hibernate: + format_sql: true + defer-datasource-initialization: false + sql: + init: + mode: never + + data: + redis: + host: localhost + port: 6379 + password: + + mail: + host: localhost + port: 25 + username: test + password: test + +jwt: + secret: + key: test-jwt-secret-key-for-testing-purposes-only-must-be-at-least-256-bits + social: + kakao: + iss: test + aud: test + google: + iss: test + aud: test + apple: + iss: test + aud: test + dev-aud: test + admin: + token: test-admin-token + +cloud: + aws: + s3: + bucket: test-bucket + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + +profile: + image: + lv1-url: http://localhost/profile/lv1.jpg + lv2-url: http://localhost/profile/lv2.jpg + lv3-url: http://localhost/profile/lv3.jpg + lv4-url: http://localhost/profile/lv4.jpg + lv5-url: http://localhost/profile/lv5.jpg + +place: + default-image: + cafe: http://localhost/place/CAFE.jpg + cafeteria: http://localhost/place/CAFETERIA.jpg + convenience-store: http://localhost/place/CONVENIENCE_STORE.jpg + gym: http://localhost/place/GYM.jpg + lounge: http://localhost/place/LOUNGE.jpg + reading-room: http://localhost/place/READING_ROOM.jpg + shower-room: http://localhost/place/SHOWER_ROOM.jpg + sleeping-room: http://localhost/place/SLEEPING_ROOM.jpg + study-room: http://localhost/place/STUDY_ROOM.jpg + +date: + api: + holiday: + end-point: http://localhost/api/holiday + encoded-key: test + decoded-key: test + +metrics: + environment: test + +management: + endpoints: + web: + exposure: + include: health + +staff: + emails: test@test.com diff --git a/src/test/resources/logback-spring.xml b/src/test/resources/logback-spring.xml new file mode 100644 index 00000000..8e82bcbe --- /dev/null +++ b/src/test/resources/logback-spring.xml @@ -0,0 +1,13 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + From b3f983872d03ebe787138a5833e6672e131d9d82 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:58:48 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/CreateCharacterReq.java | 35 ++++++++ .../dto/request/ModifyCharacterReq.java | 35 ++++++++ .../domain/character/entity/KoCharacter.java | 79 +++++++++++++++++ .../character/entity/PurchaseStatus.java | 15 ++++ .../character/entity/UserCharacter.java | 40 +++++++++ .../repository/CharacterRepository.java | 16 ++++ .../repository/UserCharacterRepository.java | 19 ++++ .../teamcback/global/response/ResultCode.java | 11 ++- .../com/teamcback/infra/s3/FilePath.java | 3 +- .../repository/CharacterRepositoryTest.java | 65 ++++++++++++++ .../UserCharacterRepositoryTest.java | 86 +++++++++++++++++++ 11 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java b/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java new file mode 100644 index 00000000..2b4f2e27 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.character.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Schema(description = "저장할 캐릭터 정보") +@Getter +@Setter +public class CreateCharacterReq { + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + + @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") + private String description; + + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + + @Schema(description = "해금 레벨 (1~5, 1이면 제한 없음)", example = "2") + private Integer requiredLevel = 1; + + @Schema(description = "정렬 순서", example = "1") + private Integer displayOrder = 0; + + @Schema(description = "노출 여부", example = "true") + private boolean isActive = true; + + @Schema(description = "캐릭터 이미지") + private MultipartFile image; +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java b/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java new file mode 100644 index 00000000..9b74723c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.character.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Schema(description = "수정할 캐릭터 정보") +@Getter +@Setter +public class ModifyCharacterReq { + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + + @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") + private String description; + + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + + @Schema(description = "해금 레벨 (1~5, 1이면 제한 없음)", example = "2") + private Integer requiredLevel = 1; + + @Schema(description = "정렬 순서", example = "1") + private Integer displayOrder = 0; + + @Schema(description = "노출 여부", example = "true") + private boolean isActive = true; + + @Schema(description = "캐릭터 이미지 (미첨부 시 기존 이미지 유지)") + private MultipartFile image; +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java b/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java new file mode 100644 index 00000000..e89a95f7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java @@ -0,0 +1,79 @@ +package devkor.com.teamcback.domain.character.entity; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +// java.lang.Character와의 충돌을 피하기 위해 KoCharacter로 명명 (koyeon의 Ko- 접두어 선례) +@Entity +@Getter +@Table(name = "tb_character") +@NoArgsConstructor +public class KoCharacter extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long characterId; + + @Column(nullable = false, unique = true) + private String name; + + @Column(length = 500) + private String description; + + @Column(length = 200) + private String quote; // 캐릭터 클릭 시 보여줄 대사 + + @Column(nullable = false) + private String imageUrl; + + @Column(nullable = false) + private Integer price; // 구매 가격 (포인트) + + // 해금 레벨: 사용자 레벨이 이 값 이상이어야 구매 가능 (1이면 제한 없음) + @ColumnDefault("1") + @Column(nullable = false) + private Integer requiredLevel = 1; + + @Column(nullable = false) + private Integer displayOrder = 0; + + @Column(nullable = false) + private boolean isActive = true; + + public KoCharacter(String name, String description, String quote, String imageUrl, + Integer price, Integer requiredLevel, Integer displayOrder, boolean isActive) { + this.name = name; + this.description = description; + this.quote = quote; + this.imageUrl = imageUrl; + this.price = price; + this.requiredLevel = requiredLevel; + this.displayOrder = displayOrder; + this.isActive = isActive; + } + + public KoCharacter(CreateCharacterReq req, String imageUrl) { + this(req.getName(), req.getDescription(), req.getQuote(), imageUrl, req.getPrice(), + req.getRequiredLevel(), req.getDisplayOrder(), req.isActive()); + } + + public void update(ModifyCharacterReq req, String imageUrl) { + this.name = req.getName(); + this.description = req.getDescription(); + this.quote = req.getQuote(); + this.imageUrl = imageUrl; + this.price = req.getPrice(); + this.requiredLevel = req.getRequiredLevel(); + this.displayOrder = req.getDisplayOrder(); + this.isActive = req.isActive(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java b/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java new file mode 100644 index 00000000..8e2d7bd1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PurchaseStatus { + OWNED("보유"), + PURCHASABLE("구매 가능"), + LOCKED("미해금"), // 해금 레벨 미달 (포인트와 무관하게 구매 불가) + NOT_ENOUGH_POINT("포인트 부족"); + + private final String name; +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java b/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java new file mode 100644 index 00000000..47f153b2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java @@ -0,0 +1,40 @@ +package devkor.com.teamcback.domain.character.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; + +// createdAt(BaseEntity)이 획득일. UNIQUE(user_id, character_id)가 동시 중복 해금의 최종 방어선 +@Entity +@Getter +@Table(name = "tb_user_character", + uniqueConstraints = @UniqueConstraint(name = "uk_user_character", columnNames = {"user_id", "character_id"})) +@NoArgsConstructor +public class UserCharacter extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userCharacterId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_id", nullable = false) + private KoCharacter character; + + public UserCharacter(User user, KoCharacter character) { + this.user = user; + this.character = character; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java b/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java new file mode 100644 index 00000000..6d206087 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.domain.character.repository; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CharacterRepository extends JpaRepository { + List findAllByIsActiveTrueOrderByDisplayOrderAsc(); + + List findAllByOrderByDisplayOrderAsc(); + + boolean existsByName(String name); + + Optional findByName(String name); +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java b/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java new file mode 100644 index 00000000..13045b54 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.character.repository; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.user.entity.User; +import java.util.List; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserCharacterRepository extends JpaRepository { + @EntityGraph(attributePaths = "character") + List findAllByUser(User user); + + boolean existsByUserAndCharacter(User user, KoCharacter character); + + boolean existsByCharacter(KoCharacter character); + + void deleteAllByUser(User user); +} diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index bad5cd71..87034145 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -102,7 +102,16 @@ public enum ResultCode { COMMENT_TOO_SHORT(HttpStatus.BAD_REQUEST, 15003, "한줄평은 10글자 이상 작성해주세요."), // 신고 16000번대 - NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."); + NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."), + + // 캐릭터 17000번대 + NOT_FOUND_CHARACTER(HttpStatus.NOT_FOUND, 17000, "캐릭터를 찾을 수 없습니다."), + ALREADY_OWNED_CHARACTER(HttpStatus.CONFLICT, 17001, "이미 보유한 캐릭터입니다."), + INSUFFICIENT_POINT(HttpStatus.BAD_REQUEST, 17002, "포인트가 부족합니다."), + NOT_OWNED_CHARACTER(HttpStatus.BAD_REQUEST, 17003, "보유하지 않은 캐릭터입니다."), + CHARACTER_IN_USE(HttpStatus.CONFLICT, 17004, "사용자가 보유 중인 캐릭터는 삭제할 수 없습니다."), + INACTIVE_CHARACTER(HttpStatus.BAD_REQUEST, 17005, "비활성화된 캐릭터입니다."), + INSUFFICIENT_LEVEL(HttpStatus.BAD_REQUEST, 17006, "레벨이 부족합니다."); private final HttpStatus status; private final int code; diff --git a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java index 4c75cbbe..8e3fab61 100644 --- a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java +++ b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java @@ -11,7 +11,8 @@ public enum FilePath { // 파일 경로를 나타내는 상수를 정의 PLACE("place/"), BUILDING_IMAGE("buildingImage/"), SUGGESTION("suggestion/"), - REVIEW("review/"); + REVIEW("review/"), + CHARACTER("character/"); private final String path; // 경로를 저장하는 final 필드 } diff --git a/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java b/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java new file mode 100644 index 00000000..bca6aedd --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java @@ -0,0 +1,65 @@ +package devkor.com.teamcback.domain.character.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import devkor.com.teamcback.global.config.QueryDslConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 +class CharacterRepositoryTest { + @Autowired + CharacterRepository characterRepository; + + @BeforeEach + void setUp() { + characterRepository.save(new KoCharacter("두번째", null, null, "url2", 20, 2, 2, true)); + characterRepository.save(new KoCharacter("첫번째", null, null, "url1", 10, 1, 1, true)); + characterRepository.save(new KoCharacter("비활성", null, null, "url3", 30, 3, 3, false)); + } + + @DisplayName("활성 캐릭터만 정렬 순서대로 조회") + @Test + void findAllByIsActiveTrueOrderByDisplayOrderAsc() { + List characters = characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc(); + + assertEquals(2, characters.size()); + assertEquals("첫번째", characters.get(0).getName()); + assertEquals("두번째", characters.get(1).getName()); + } + + @DisplayName("관리자 조회는 비활성 포함") + @Test + void findAllByOrderByDisplayOrderAsc() { + assertEquals(3, characterRepository.findAllByOrderByDisplayOrderAsc().size()); + } + + @DisplayName("이름 존재 여부 확인 (시더 멱등 처리용)") + @Test + void existsByName() { + assertTrue(characterRepository.existsByName("첫번째")); + assertFalse(characterRepository.existsByName("없는이름")); + } + + @DisplayName("이름 중복 저장 시 제약 위반 (멀티 인스턴스 시드 레이스 방어)") + @Test + void duplicateNameThrows() { + assertThrows(DataIntegrityViolationException.class, () -> + characterRepository.saveAndFlush(new KoCharacter("첫번째", null, null, "url", 10, 1, 9, true))); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java b/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java new file mode 100644 index 00000000..eff13a19 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java @@ -0,0 +1,86 @@ +package devkor.com.teamcback.domain.character.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import devkor.com.teamcback.global.config.QueryDslConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 +class UserCharacterRepositoryTest { + @Autowired + UserCharacterRepository userCharacterRepository; + + @Autowired + CharacterRepository characterRepository; + + @Autowired + UserRepository userRepository; + + User user1; + User user2; + KoCharacter character; + + @BeforeEach + void setUp() { + user1 = userRepository.save(new User("user1", "user1@test.com", Role.USER, Provider.KAKAO)); + user2 = userRepository.save(new User("user2", "user2@test.com", Role.USER, Provider.KAKAO)); + character = characterRepository.save(new KoCharacter("캐릭터", null, null, "url", 10, 1, 1, true)); + + userCharacterRepository.save(new UserCharacter(user1, character)); + } + + @DisplayName("같은 사용자-캐릭터 중복 저장 시 제약 위반 (동시 해금 방어선)") + @Test + void duplicateClaimThrows() { + assertThrows(DataIntegrityViolationException.class, () -> + userCharacterRepository.saveAndFlush(new UserCharacter(user1, character))); + } + + @DisplayName("사용자별 보유 캐릭터만 조회") + @Test + void findAllByUser() { + List owned = userCharacterRepository.findAllByUser(user1); + + assertEquals(1, owned.size()); + assertEquals(character.getCharacterId(), owned.get(0).getCharacter().getCharacterId()); + assertTrue(userCharacterRepository.findAllByUser(user2).isEmpty()); + } + + @DisplayName("보유 여부 확인") + @Test + void existsByUserAndCharacter() { + assertTrue(userCharacterRepository.existsByUserAndCharacter(user1, character)); + assertTrue(userCharacterRepository.existsByCharacter(character)); + } + + @DisplayName("회원 탈퇴 시 본인 보유 이력만 삭제") + @Test + void deleteAllByUser() { + userCharacterRepository.save(new UserCharacter(user2, character)); + + userCharacterRepository.deleteAllByUser(user1); + + assertTrue(userCharacterRepository.findAllByUser(user1).isEmpty()); + assertEquals(1, userCharacterRepository.findAllByUser(user2).size()); + } +} From daf4a27e469fe61c66b2c282c196a0d3719059ed Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:59:02 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Feat:=20=EC=83=81=EC=A0=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20=EC=BA=90=EB=A6=AD=ED=84=B0=20=EA=B5=AC?= =?UTF-8?q?=EB=A7=A4/=EC=9E=A5=EC=B0=A9=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../character/controller/StoreController.java | 129 ++++++++ .../dto/response/EquipCharacterRes.java | 15 + .../dto/response/GetMyCharacterListRes.java | 22 ++ .../dto/response/GetMyCharacterRes.java | 32 ++ .../dto/response/GetStoreCharacterRes.java | 46 +++ .../character/dto/response/GetStoreRes.java | 19 ++ .../dto/response/PurchaseCharacterRes.java | 35 ++ .../dto/response/UnequipCharacterRes.java | 7 + .../character/service/StoreService.java | 203 ++++++++++++ .../teamcback/domain/user/entity/User.java | 7 + .../domain/user/service/UserService.java | 3 + .../global/security/SecurityConfig.java | 1 + .../character/service/StoreServiceTest.java | 298 ++++++++++++++++++ .../service/UserServiceDeleteUserTest.java | 80 +++++ .../service/UserServiceGetUserInfoTest.java | 80 +++++ 15 files changed, 977 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java create mode 100644 src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java b/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java new file mode 100644 index 00000000..d745a92c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java @@ -0,0 +1,129 @@ +package devkor.com.teamcback.domain.character.controller; + +import devkor.com.teamcback.domain.character.dto.response.EquipCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; +import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.UnequipCharacterRes; +import devkor.com.teamcback.domain.character.service.StoreService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/store") +public class StoreController { + private final StoreService storeService; + + /** + * 스토어 조회 (보유 포인트 + 캐릭터 목록) + * @param userDetail 사용자 정보 + */ + @Operation(summary = "스토어 조회", description = "보유 포인트와 전체 캐릭터 목록(보유/구매 가능/포인트 부족) 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("") + public CommonResponse getStore( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail) { + return CommonResponse.success(storeService.getStore(userDetail.getUser().getUserId())); + } + + /** + * 내 보유 캐릭터 목록 조회 + * @param userDetail 사용자 정보 + */ + @Operation(summary = "보유 캐릭터 목록 조회", description = "보유 포인트, 구매한 캐릭터, 대표 장착 캐릭터 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/my") + public CommonResponse getMyCharacters( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail) { + return CommonResponse.success(storeService.getMyCharacters(userDetail.getUser().getUserId())); + } + + /** + * 캐릭터 구매 + * @param userDetail 사용자 정보 + * @param characterId 캐릭터 ID + */ + @Operation(summary = "캐릭터 구매", description = "보유 포인트를 차감하여 캐릭터 구매") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "400", description = "포인트 부족 또는 비활성 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 보유한 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping("/{characterId}/purchase") + public CommonResponse purchaseCharacter( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId) { + return CommonResponse.success(storeService.purchaseCharacter(userDetail.getUser().getUserId(), characterId)); + } + + /** + * 대표 캐릭터 장착 + * @param userDetail 사용자 정보 + * @param characterId 캐릭터 ID + */ + @Operation(summary = "대표 캐릭터 장착", description = "구매한 캐릭터를 대표 캐릭터로 장착") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "400", description = "보유하지 않은 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PutMapping("/{characterId}/equip") + public CommonResponse equipCharacter( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId) { + return CommonResponse.success(storeService.equipCharacter(userDetail.getUser().getUserId(), characterId)); + } + + /** + * 대표 캐릭터 장착 해제 + * @param userDetail 사용자 정보 + */ + @Operation(summary = "대표 캐릭터 장착 해제", description = "대표 캐릭터 장착 해제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @DeleteMapping("/equip") + public CommonResponse unequipCharacter( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail) { + return CommonResponse.success(storeService.unequipCharacter(userDetail.getUser().getUserId())); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java new file mode 100644 index 00000000..604091b7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "대표 캐릭터 장착 결과") +@Getter +public class EquipCharacterRes { + @Schema(description = "장착한 캐릭터 ID", example = "1") + private Long characterId; + + public EquipCharacterRes(Long characterId) { + this.characterId = characterId; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java new file mode 100644 index 00000000..912995a8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "보유 캐릭터 목록") +@Getter +public class GetMyCharacterListRes { + @Schema(description = "보유 포인트", example = "25") + private Long point; + @Schema(description = "대표 장착 캐릭터 ID (미장착 시 null)", example = "1") + private Long equippedCharacterId; + @Schema(description = "보유 캐릭터 목록") + private List characterList; + + public GetMyCharacterListRes(Long point, Long equippedCharacterId, List characterList) { + this.point = point; + this.equippedCharacterId = equippedCharacterId; + this.characterList = characterList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java new file mode 100644 index 00000000..cfe4fbcd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java @@ -0,0 +1,32 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "보유 캐릭터 정보") +@Getter +public class GetMyCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "구매 일시") + private LocalDateTime purchasedAt; + @Schema(description = "대표 장착 여부", example = "true") + private boolean isEquipped; + + public GetMyCharacterRes(UserCharacter userCharacter, boolean isEquipped) { + this.characterId = userCharacter.getCharacter().getCharacterId(); + this.name = userCharacter.getCharacter().getName(); + this.quote = userCharacter.getCharacter().getQuote(); + this.imageUrl = userCharacter.getCharacter().getImageUrl(); + this.purchasedAt = userCharacter.getCreatedAt(); + this.isEquipped = isEquipped; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java new file mode 100644 index 00000000..6396151f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java @@ -0,0 +1,46 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.PurchaseStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "스토어 캐릭터 정보") +@Getter +public class GetStoreCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") + private String description; + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + @Schema(description = "해금 레벨", example = "2") + private Integer requiredLevel; + @Schema(description = "구매 상태 (OWNED/PURCHASABLE/LOCKED/NOT_ENOUGH_POINT)", example = "PURCHASABLE") + private PurchaseStatus status; + @Schema(description = "구매 일시 (미보유 시 null)") + private LocalDateTime purchasedAt; + @Schema(description = "대표 장착 여부", example = "false") + private boolean isEquipped; + + public GetStoreCharacterRes(KoCharacter character, PurchaseStatus status, + LocalDateTime purchasedAt, boolean isEquipped) { + this.characterId = character.getCharacterId(); + this.name = character.getName(); + this.description = character.getDescription(); + this.quote = character.getQuote(); + this.imageUrl = character.getImageUrl(); + this.price = character.getPrice(); + this.requiredLevel = character.getRequiredLevel(); + this.status = status; + this.purchasedAt = purchasedAt; + this.isEquipped = isEquipped; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java new file mode 100644 index 00000000..8501940e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "스토어 정보 (보유 포인트 + 캐릭터 목록)") +@Getter +public class GetStoreRes { + @Schema(description = "보유 포인트", example = "25") + private Long point; + @Schema(description = "캐릭터 목록") + private List characterList; + + public GetStoreRes(Long point, List characterList) { + this.point = point; + this.characterList = characterList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java new file mode 100644 index 00000000..287453b8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "캐릭터 구매 결과") +@Getter +public class PurchaseCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "지불한 포인트", example = "10") + private Integer price; + @Schema(description = "구매 후 잔여 포인트", example = "15") + private Long remainingPoint; + @Schema(description = "구매 일시") + private LocalDateTime purchasedAt; + + public PurchaseCharacterRes(UserCharacter userCharacter, Long remainingPoint) { + this.characterId = userCharacter.getCharacter().getCharacterId(); + this.name = userCharacter.getCharacter().getName(); + this.quote = userCharacter.getCharacter().getQuote(); + this.imageUrl = userCharacter.getCharacter().getImageUrl(); + this.price = userCharacter.getCharacter().getPrice(); + this.remainingPoint = remainingPoint; + this.purchasedAt = userCharacter.getCreatedAt(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java new file mode 100644 index 00000000..429bc45b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "대표 캐릭터 장착 해제 결과") +public class UnequipCharacterRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java new file mode 100644 index 00000000..578393e4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java @@ -0,0 +1,203 @@ +package devkor.com.teamcback.domain.character.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_OWNED_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.INACTIVE_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.INSUFFICIENT_LEVEL; +import static devkor.com.teamcback.global.response.ResultCode.INSUFFICIENT_POINT; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; +import static devkor.com.teamcback.global.response.ResultCode.NOT_OWNED_CHARACTER; + +import devkor.com.teamcback.domain.character.dto.response.EquipCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; +import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.UnequipCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.PurchaseStatus; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class StoreService { + private final CharacterRepository characterRepository; + private final UserCharacterRepository userCharacterRepository; + private final UserRepository userRepository; + + /** + * 스토어 조회 (보유 포인트 + 캐릭터 목록) + * 정렬: 1순위 보유(해금 레벨순→가격순) → 2순위 구매 가능(가격 낮은순) + * → 3순위 미해금·레벨 미달(해금 레벨순) → 4순위 포인트 부족(가격 낮은순) + */ + @Transactional(readOnly = true) + public GetStoreRes getStore(Long userId) { + User user = findUser(userId); + List characters = characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc(); + + Map ownedMap = userCharacterRepository.findAllByUser(user).stream() + .collect(Collectors.toMap(uc -> uc.getCharacter().getCharacterId(), Function.identity())); + + long point = user.getPoint(); + int userLevel = levelNumberOf(user); + Map statusMap = characters.stream() + .collect(Collectors.toMap(KoCharacter::getCharacterId, + c -> getPurchaseStatus(c, ownedMap.containsKey(c.getCharacterId()), userLevel, point))); + + List characterList = characters.stream() + .sorted(storeOrder(statusMap)) + .map(character -> { + UserCharacter owned = ownedMap.get(character.getCharacterId()); + boolean isEquipped = character.getCharacterId().equals(user.getEquippedCharacterId()); + return new GetStoreCharacterRes(character, statusMap.get(character.getCharacterId()), + owned == null ? null : owned.getCreatedAt(), isEquipped); + }) + .toList(); + + return new GetStoreRes(user.getPoint(), characterList); + } + + /** + * 내 보유 캐릭터 목록 조회 + */ + @Transactional(readOnly = true) + public GetMyCharacterListRes getMyCharacters(Long userId) { + User user = findUser(userId); + + List characterList = userCharacterRepository.findAllByUser(user).stream() + .map(uc -> new GetMyCharacterRes(uc, + uc.getCharacter().getCharacterId().equals(user.getEquippedCharacterId()))) + .toList(); + + return new GetMyCharacterListRes(user.getPoint(), user.getEquippedCharacterId(), characterList); + } + + /** + * 캐릭터 구매 (포인트 차감) + */ + @Transactional + public PurchaseCharacterRes purchaseCharacter(Long userId, Long characterId) { + User user = findUser(userId); + KoCharacter character = findCharacter(characterId); + + if(!character.isActive()) throw new GlobalException(INACTIVE_CHARACTER); + + if(userCharacterRepository.existsByUserAndCharacter(user, character)) { + throw new GlobalException(ALREADY_OWNED_CHARACTER); + } + + // 해금 레벨 미달이면 포인트와 무관하게 구매 불가 + if(levelNumberOf(user) < character.getRequiredLevel()) { + throw new GlobalException(INSUFFICIENT_LEVEL); + } + + // 잔액 검증과 차감을 단일 조건부 UPDATE로 수행 (동시 구매 시 이중 차감 방지) + if(userRepository.deductPoint(userId, character.getPrice()) == 0) { + throw new GlobalException(INSUFFICIENT_POINT); + } + + // deductPoint가 영속성 컨텍스트를 비우므로 차감이 반영된 상태로 재조회 + user = findUser(userId); + character = findCharacter(characterId); + + try { + UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); + return new PurchaseCharacterRes(userCharacter, user.getPoint()); + } catch (DataIntegrityViolationException e) { // 동시 중복 구매는 UNIQUE 제약으로 차단 (롤백으로 차감 복구) + throw new GlobalException(ALREADY_OWNED_CHARACTER); + } + } + + /** + * 대표 캐릭터 장착 + */ + @Transactional + public EquipCharacterRes equipCharacter(Long userId, Long characterId) { + User user = findUser(userId); + KoCharacter character = findCharacter(characterId); + + if(!userCharacterRepository.existsByUserAndCharacter(user, character)) { + throw new GlobalException(NOT_OWNED_CHARACTER); + } + + user.updateEquippedCharacter(character.getCharacterId()); + + return new EquipCharacterRes(character.getCharacterId()); + } + + /** + * 대표 캐릭터 장착 해제 + */ + @Transactional + public UnequipCharacterRes unequipCharacter(Long userId) { + User user = findUser(userId); + user.updateEquippedCharacter(null); + + return new UnequipCharacterRes(); + } + + private PurchaseStatus getPurchaseStatus(KoCharacter character, boolean owned, int userLevel, long point) { + if(owned) return PurchaseStatus.OWNED; + if(userLevel < character.getRequiredLevel()) return PurchaseStatus.LOCKED; + return point >= character.getPrice() ? PurchaseStatus.PURCHASABLE : PurchaseStatus.NOT_ENOUGH_POINT; + } + + private Comparator storeOrder(Map statusMap) { + return Comparator + .comparingInt((KoCharacter c) -> statusTier(statusMap.get(c.getCharacterId()))) + .thenComparingInt(c -> primarySortKey(statusMap.get(c.getCharacterId()), c)) + .thenComparingInt(c -> secondarySortKey(statusMap.get(c.getCharacterId()), c)) + .thenComparing(KoCharacter::getDisplayOrder) + .thenComparing(KoCharacter::getCharacterId); + } + + private int statusTier(PurchaseStatus status) { + return switch (status) { + case OWNED -> 0; + case PURCHASABLE -> 1; + case LOCKED -> 2; + case NOT_ENOUGH_POINT -> 3; + }; + } + + private int primarySortKey(PurchaseStatus status, KoCharacter character) { + return switch (status) { + case OWNED, LOCKED -> character.getRequiredLevel(); // 레벨순 + case PURCHASABLE, NOT_ENOUGH_POINT -> character.getPrice(); // 포인트 낮은순 + }; + } + + private int secondarySortKey(PurchaseStatus status, KoCharacter character) { + return status == PurchaseStatus.OWNED ? character.getPrice() : 0; // 보유는 레벨순 다음 포인트순 + } + + private int levelNumberOf(User user) { + // 백필 전 레거시 행 방어: level 컬럼이 비어 있으면 score로 계산 + Level level = user.getLevel() != null ? user.getLevel() : Level.fromScore(user.getScore()); + return level.getLevelNumber(); + } + + private User findUser(Long userId) { + return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + } + + private KoCharacter findCharacter(Long characterId) { + return characterRepository.findById(characterId).orElseThrow(() -> new GlobalException(NOT_FOUND_CHARACTER)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java index 41618832..aa33455c 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java @@ -55,6 +55,9 @@ public class User extends BaseEntity { @Column(nullable = false) private boolean isUpgraded = false; + // 대표 캐릭터 (tb_character 논리 참조, 미장착 시 null) + private Long equippedCharacterId; + @Setter @Column(unique = true) private String code; @@ -94,4 +97,8 @@ public void addPoint(long amount) { this.point = Math.max(0, getPoint() + amount); } + public void updateEquippedCharacter(Long characterId) { + this.equippedCharacterId = characterId; + } + } diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index e0a447fb..68101dde 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -7,6 +7,7 @@ import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; import devkor.com.teamcback.domain.notification.service.PushInstallationService; import devkor.com.teamcback.domain.suggestion.entity.Suggestion; import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; @@ -48,6 +49,7 @@ public class UserService { private final BookmarkRepository bookmarkRepository; private final UserBookmarkLogRepository userBookmarkLogRepository; private final SuggestionRepository suggestionRepository; + private final UserCharacterRepository userCharacterRepository; private final JwtUtil jwtUtil; private final KakaoValidator kakaoValidator; private final GoogleValidator googleValidator; @@ -188,6 +190,7 @@ public DeleteUserRes deleteUser(Long userId) { userBookmarkLogRepository.deleteAll(userBookmarkLogRepository.findByUser(user)); pushInstallationService.deactivateAll(user.getUserId()); // suggestionRepository.deleteAll(suggestionRepository.findByUser(user)); + userCharacterRepository.deleteAllByUser(user); userRepository.delete(user); return new DeleteUserRes(); diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index 50bc866a..c5e79cca 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -93,6 +93,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 .requestMatchers("/api/notifications/installations/**").authenticated() // 토큰 등록 로그인 필요 + .requestMatchers("/api/store/**").authenticated() // 캐릭터 스토어는 로그인 필요 .anyRequest().permitAll() ).exceptionHandling(ex -> ex .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java new file mode 100644 index 00000000..658db82e --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java @@ -0,0 +1,298 @@ +package devkor.com.teamcback.domain.character.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.character.dto.response.GetStoreCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; +import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.PurchaseStatus; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +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.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class StoreServiceTest { + @InjectMocks + StoreService storeService; + + @Mock + CharacterRepository characterRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + UserRepository userRepository; + + static final Long USER_ID = 1L; + static final Long CHARACTER_ID = 10L; + + User user; + KoCharacter character; // 해금 레벨 1, 가격 10 + + @BeforeEach + void setUp() { + user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", USER_ID); + + character = newCharacter(CHARACTER_ID, "아기 호랑이", 10, 1, 1, true); + } + + private KoCharacter newCharacter(Long id, String name, int price, int requiredLevel, int order, boolean active) { + KoCharacter c = new KoCharacter(name, null, name + " 대사", "url", price, requiredLevel, order, active); + ReflectionTestUtils.setField(c, "characterId", id); + return c; + } + + @DisplayName("레벨 충족 + 포인트 충분이면 구매 성공") + @Test + void purchaseCharacter() { + user.addPoint(25); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PurchaseCharacterRes res = storeService.purchaseCharacter(USER_ID, CHARACTER_ID); + + assertEquals(CHARACTER_ID, res.getCharacterId()); + assertEquals(10, res.getPrice()); + verify(userRepository).deductPoint(USER_ID, 10); + } + + @DisplayName("해금 레벨 미달이면 포인트가 충분해도 구매 불가") + @Test + void purchaseInsufficientLevel() { + KoCharacter level3Character = newCharacter(CHARACTER_ID, "청년 호랑이", 10, 3, 3, true); + user.addPoint(100); // 포인트는 충분 + // 유저는 score 0 → LEVEL1 + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(level3Character)); + when(userCharacterRepository.existsByUserAndCharacter(user, level3Character)).thenReturn(false); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.INSUFFICIENT_LEVEL, e.getResultCode()); + verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); + + // 레벨 3 도달 시 구매 가능 + user.updateScore(20L, false); // LEVEL3 + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + assertEquals(CHARACTER_ID, storeService.purchaseCharacter(USER_ID, CHARACTER_ID).getCharacterId()); + } + + @DisplayName("포인트 부족 시 구매 실패") + @Test + void purchaseInsufficientPoint() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(0); // 잔액 부족 + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.INSUFFICIENT_POINT, e.getResultCode()); + verify(userCharacterRepository, never()).saveAndFlush(any()); + } + + @DisplayName("이미 보유한 캐릭터 구매 시 예외 (차감 없음)") + @Test + void purchaseAlreadyOwned() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); + } + + @DisplayName("비활성 캐릭터는 구매 불가") + @Test + void purchaseInactiveRejected() { + KoCharacter inactive = newCharacter(CHARACTER_ID, "숨김 캐릭터", 10, 1, 9, false); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(inactive)); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.INACTIVE_CHARACTER, e.getResultCode()); + } + + @DisplayName("동시 중복 구매 시 제약 위반을 이미 보유로 매핑 (롤백으로 차감 복구)") + @Test + void purchaseRaceMappedToAlreadyOwned() { + user.addPoint(25); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) + .thenThrow(new DataIntegrityViolationException("uk_user_character")); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + } + + @DisplayName("미보유 캐릭터 장착 시 예외, 보유 캐릭터는 장착/해제 성공") + @Test + void equipCharacter() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.equipCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.NOT_OWNED_CHARACTER, e.getResultCode()); + + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); + storeService.equipCharacter(USER_ID, CHARACTER_ID); + assertEquals(CHARACTER_ID, user.getEquippedCharacterId()); + + storeService.unequipCharacter(USER_ID); + assertNull(user.getEquippedCharacterId()); + } + + @DisplayName("스토어 목록: 4단계 상태 판정 (보유/구매가능/미해금/포인트부족)") + @Test + void getStoreStatuses() { + user.updateScore(5L, false); // LEVEL2 + user.addPoint(10); + user.updateEquippedCharacter(CHARACTER_ID); + + KoCharacter ownedCharacter = character; // 보유 + 장착 + KoCharacter purchasableCharacter = newCharacter(11L, "학생 호랑이", 10, 2, 2, true); // 레벨·포인트 충족 + KoCharacter lockedCharacter = newCharacter(12L, "청년 호랑이", 5, 3, 3, true); // 레벨 미달 (포인트는 충분해도 LOCKED) + KoCharacter expensiveCharacter = newCharacter(13L, "비싼 호랑이", 999, 1, 4, true); // 레벨 충족, 포인트 부족 + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) + .thenReturn(List.of(ownedCharacter, purchasableCharacter, lockedCharacter, expensiveCharacter)); + when(userCharacterRepository.findAllByUser(user)) + .thenReturn(List.of(new UserCharacter(user, ownedCharacter))); + + GetStoreRes res = storeService.getStore(USER_ID); + List list = res.getCharacterList(); + + assertEquals(10L, res.getPoint()); + assertEquals(PurchaseStatus.OWNED, statusOf(list, CHARACTER_ID)); + assertEquals(PurchaseStatus.PURCHASABLE, statusOf(list, 11L)); + assertEquals(PurchaseStatus.LOCKED, statusOf(list, 12L)); + assertEquals(PurchaseStatus.NOT_ENOUGH_POINT, statusOf(list, 13L)); + } + + @DisplayName("스토어 정렬: 보유(레벨→포인트) → 구매가능(포인트) → 미해금(레벨) → 포인트부족(포인트)") + @Test + void getStoreSorting() { + user.updateScore(5L, false); // LEVEL2 + user.addPoint(20); + + // displayOrder는 전부 역순(9~1)으로 줘서 정렬이 displayOrder가 아닌 스펙 기준임을 증명 + KoCharacter ownedHighLevel = newCharacter(21L, "보유-레벨2", 5, 2, 9, true); + KoCharacter ownedLowLevel = newCharacter(22L, "보유-레벨1", 10, 1, 8, true); + KoCharacter purchasableExpensive = newCharacter(23L, "구매가능-20p", 20, 1, 7, true); + KoCharacter purchasableCheap = newCharacter(24L, "구매가능-5p", 5, 2, 6, true); + KoCharacter lockedLevel5 = newCharacter(25L, "미해금-레벨5", 0, 5, 5, true); + KoCharacter lockedLevel3 = newCharacter(26L, "미해금-레벨3", 0, 3, 4, true); + KoCharacter poorExpensive = newCharacter(27L, "포인트부족-99p", 99, 1, 3, true); + KoCharacter poorCheap = newCharacter(28L, "포인트부족-30p", 30, 2, 2, true); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) + .thenReturn(List.of(ownedHighLevel, ownedLowLevel, purchasableExpensive, purchasableCheap, + lockedLevel5, lockedLevel3, poorExpensive, poorCheap)); + when(userCharacterRepository.findAllByUser(user)) + .thenReturn(List.of(new UserCharacter(user, ownedHighLevel), new UserCharacter(user, ownedLowLevel))); + + List list = storeService.getStore(USER_ID).getCharacterList(); + List orderedIds = list.stream().map(GetStoreCharacterRes::getCharacterId).toList(); + + assertEquals(List.of( + 22L, 21L, // 1순위 보유: 레벨1(가격10) → 레벨2(가격5) + 24L, 23L, // 2순위 구매가능: 5p → 20p + 26L, 25L, // 3순위 미해금: 레벨3 → 레벨5 + 28L, 27L // 4순위 포인트부족: 30p → 99p + ), orderedIds); + } + + @DisplayName("지급받아 보유 중인 캐릭터는 레벨 미달이어도 '이미 보유' 응답 (검증 순서)") + @Test + void purchaseOwnedTakesPrecedenceOverLevel() { + // 운영자 지급으로 레벨 5 캐릭터를 보유한 레벨 1 사용자 + KoCharacter grantedCharacter = newCharacter(CHARACTER_ID, "어른호랑이", 30, 5, 5, true); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(grantedCharacter)); + when(userCharacterRepository.existsByUserAndCharacter(user, grantedCharacter)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); // INSUFFICIENT_LEVEL이 아니어야 함 + } + + @DisplayName("가격 0 캐릭터는 포인트 0이어도 구매 가능 상태 (경계값)") + @Test + void freeCharacterPurchasableWithZeroPoint() { + // 신규 사용자: score 0, point 0, LEVEL1 + KoCharacter freeCharacter = newCharacter(CHARACTER_ID, "애기호랑이", 0, 1, 1, true); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()).thenReturn(List.of(freeCharacter)); + when(userCharacterRepository.findAllByUser(user)).thenReturn(List.of()); + + GetStoreRes res = storeService.getStore(USER_ID); + + assertEquals(PurchaseStatus.PURCHASABLE, res.getCharacterList().get(0).getStatus()); + } + + @DisplayName("내 보유 캐릭터: 포인트·대표 캐릭터·장착 플래그·대사 반환") + @Test + void getMyCharacters() { + user.addPoint(7); + user.updateEquippedCharacter(CHARACTER_ID); + KoCharacter otherCharacter = newCharacter(11L, "꼬마호랑이", 15, 2, 2, true); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userCharacterRepository.findAllByUser(user)).thenReturn( + List.of(new UserCharacter(user, character), new UserCharacter(user, otherCharacter))); + + var res = storeService.getMyCharacters(USER_ID); + + assertEquals(7L, res.getPoint()); + assertEquals(CHARACTER_ID, res.getEquippedCharacterId()); + assertEquals(2, res.getCharacterList().size()); + assertEquals(true, res.getCharacterList().get(0).isEquipped()); // 장착한 캐릭터 + assertEquals(false, res.getCharacterList().get(1).isEquipped()); // 미장착 보유 캐릭터 + assertEquals("아기 호랑이 대사", res.getCharacterList().get(0).getQuote()); + } + + private PurchaseStatus statusOf(List list, Long characterId) { + return list.stream().filter(c -> c.getCharacterId().equals(characterId)).findFirst().orElseThrow().getStatus(); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java new file mode 100644 index 00000000..2062522b --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java @@ -0,0 +1,80 @@ +package devkor.com.teamcback.domain.user.service; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.jwt.JwtUtil; +import java.util.List; +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.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceDeleteUserTest { + @InjectMocks + UserService userService; + + @Mock + UserRepository userRepository; + @Mock + CategoryRepository categoryRepository; + @Mock + BookmarkRepository bookmarkRepository; + @Mock + UserBookmarkLogRepository userBookmarkLogRepository; + @Mock + SuggestionRepository suggestionRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + PushInstallationService pushInstallationService; + @Mock + JwtUtil jwtUtil; + @Mock + KakaoValidator kakaoValidator; + @Mock + GoogleValidator googleValidator; + @Mock + AppleValidator appleValidator; + @Mock + PasswordEncoder passwordEncoder; + + @DisplayName("회원 탈퇴 시 캐릭터 보유 이력을 사용자 삭제 전에 정리 (FK 제약)") + @Test + void deleteUserCleansUpOwnedCharacters() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 1L); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(categoryRepository.findByUser(user)).thenReturn(List.of()); + when(suggestionRepository.findByUser(user)).thenReturn(List.of()); + when(userBookmarkLogRepository.findByUser(user)).thenReturn(List.of()); + + userService.deleteUser(1L); + + InOrder inOrder = Mockito.inOrder(userCharacterRepository, userRepository); + inOrder.verify(userCharacterRepository).deleteAllByUser(user); // 소유 이력 먼저 + inOrder.verify(userRepository).delete(user); // 그 다음 사용자 삭제 + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java new file mode 100644 index 00000000..053eb5ae --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java @@ -0,0 +1,80 @@ +package devkor.com.teamcback.domain.user.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.jwt.JwtUtil; +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.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceGetUserInfoTest { + @InjectMocks + UserService userService; + + @Mock + UserRepository userRepository; + @Mock + CategoryRepository categoryRepository; + @Mock + BookmarkRepository bookmarkRepository; + @Mock + UserBookmarkLogRepository userBookmarkLogRepository; + @Mock + SuggestionRepository suggestionRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + JwtUtil jwtUtil; + @Mock + KakaoValidator kakaoValidator; + @Mock + GoogleValidator googleValidator; + @Mock + AppleValidator appleValidator; + @Mock + PasswordEncoder passwordEncoder; + + @DisplayName("마이페이지: 영속화된 level 컬럼 기반 응답 + isUpgraded 리셋") + @Test + void getUserInfo() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 1L); + user.updateScore(10L, true); // LEVEL2, 레벨업 직후 상태 + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(categoryRepository.countAllByUser(user)).thenReturn(1L); + + GetUserInfoRes res = userService.getUserInfo(1L); + + assertEquals(2, res.getLevel()); + assertEquals(10L, res.getScore()); + assertEquals(0L, res.getPoint()); // updateScore는 포인트를 건드리지 않음 (적립은 aspect에서) + assertEquals(10L, res.getRemainScoreToNextLevel()); // LEVEL3 시작(20) - 10 + assertEquals(33, res.getPercent()); // 100 * (10-5) / (20-5) + assertTrue(res.isUpgraded()); + assertFalse(user.isUpgraded()); // 조회 후 리셋 + } +} From 13ae479e7556f89258b6764fa29d8f77fca83c20 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:59:19 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EC=BA=90=EB=A6=AD=ED=84=B0=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AdminStoreController.java | 124 +++++++++++ .../dto/response/CreateCharacterRes.java | 15 ++ .../dto/response/DeleteCharacterRes.java | 7 + .../response/GetAdminCharacterListRes.java | 16 ++ .../dto/response/GetAdminCharacterRes.java | 44 ++++ .../dto/response/GrantCharacterRes.java | 15 ++ .../dto/response/ModifyCharacterRes.java | 7 + .../character/service/AdminStoreService.java | 146 +++++++++++++ .../service/AdminStoreServiceTest.java | 196 ++++++++++++++++++ 9 files changed, 570 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java create mode 100644 src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java b/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java new file mode 100644 index 00000000..87a76219 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java @@ -0,0 +1,124 @@ +package devkor.com.teamcback.domain.character.controller; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; +import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.DeleteCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GrantCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; +import devkor.com.teamcback.domain.character.service.AdminStoreService; +import devkor.com.teamcback.global.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/store") +public class AdminStoreController { + private final AdminStoreService adminStoreService; + + /** + * 캐릭터 목록 조회 (비활성 포함) + */ + @Operation(summary = "관리자 캐릭터 목록 조회", description = "비활성 캐릭터를 포함한 전체 캐릭터 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + }) + @GetMapping("") + public CommonResponse getCharacterList() { + return CommonResponse.success(adminStoreService.getCharacterList()); + } + + /** + * 캐릭터 생성 + * @param req 캐릭터 정보 (이미지, 가격 포함) + */ + @Operation(summary = "캐릭터 생성", description = "캐릭터 생성 (이미지 필수, 가격은 0 이상의 포인트)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "400", description = "잘못된 입력", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse createCharacter( + @Parameter(description = "캐릭터 정보") + @ModelAttribute CreateCharacterReq req) { + return CommonResponse.success(adminStoreService.createCharacter(req)); + } + + /** + * 캐릭터 수정 + * @param characterId 캐릭터 ID + * @param req 캐릭터 정보 (이미지 미첨부 시 기존 이미지 유지) + */ + @Operation(summary = "캐릭터 수정", description = "캐릭터 수정 (이미지 미첨부 시 기존 이미지 유지)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PutMapping(value = "/{characterId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse modifyCharacter( + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId, + @Parameter(description = "캐릭터 정보") + @ModelAttribute ModifyCharacterReq req) { + return CommonResponse.success(adminStoreService.modifyCharacter(characterId, req)); + } + + /** + * 캐릭터 삭제 + * @param characterId 캐릭터 ID + */ + @Operation(summary = "캐릭터 삭제", description = "캐릭터 삭제 (구매한 사용자가 있으면 삭제 불가, isActive=false로 숨김 처리 권장)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "409", description = "구매한 사용자가 있는 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @DeleteMapping("/{characterId}") + public CommonResponse deleteCharacter( + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId) { + return CommonResponse.success(adminStoreService.deleteCharacter(characterId)); + } + + /** + * 캐릭터 수동 지급 + * @param characterId 캐릭터 ID + * @param userId 사용자 ID + */ + @Operation(summary = "캐릭터 수동 지급", description = "특정 사용자에게 캐릭터 지급 (이벤트 보상용, 포인트 차감 없음)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 보유한 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping("/{characterId}/grant/{userId}") + public CommonResponse grantCharacter( + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId, + @Parameter(description = "사용자 ID", example = "1") + @PathVariable(name = "userId") Long userId) { + return CommonResponse.success(adminStoreService.grantCharacter(characterId, userId)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java new file mode 100644 index 00000000..07d3aca0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "캐릭터 생성 결과") +@Getter +public class CreateCharacterRes { + @Schema(description = "생성된 캐릭터 ID", example = "1") + private Long characterId; + + public CreateCharacterRes(Long characterId) { + this.characterId = characterId; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java new file mode 100644 index 00000000..fb5df029 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "캐릭터 삭제 결과") +public class DeleteCharacterRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java new file mode 100644 index 00000000..5715e38d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "관리자용 캐릭터 목록") +@Getter +public class GetAdminCharacterListRes { + @Schema(description = "캐릭터 목록") + private List characterList; + + public GetAdminCharacterListRes(List characterList) { + this.characterList = characterList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java new file mode 100644 index 00000000..a4195d80 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java @@ -0,0 +1,44 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "관리자용 캐릭터 정보") +@Getter +public class GetAdminCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 설명") + private String description; + @Schema(description = "캐릭터 대사") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + @Schema(description = "해금 레벨", example = "2") + private Integer requiredLevel; + @Schema(description = "정렬 순서", example = "1") + private Integer displayOrder; + @Schema(description = "노출 여부", example = "true") + private boolean isActive; + @Schema(description = "생성 일시") + private LocalDateTime createdAt; + + public GetAdminCharacterRes(KoCharacter character) { + this.characterId = character.getCharacterId(); + this.name = character.getName(); + this.description = character.getDescription(); + this.quote = character.getQuote(); + this.imageUrl = character.getImageUrl(); + this.price = character.getPrice(); + this.requiredLevel = character.getRequiredLevel(); + this.displayOrder = character.getDisplayOrder(); + this.isActive = character.isActive(); + this.createdAt = character.getCreatedAt(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java new file mode 100644 index 00000000..3f196fce --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "캐릭터 수동 지급 결과") +@Getter +public class GrantCharacterRes { + @Schema(description = "지급된 사용자 캐릭터 ID", example = "1") + private Long userCharacterId; + + public GrantCharacterRes(Long userCharacterId) { + this.userCharacterId = userCharacterId; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java new file mode 100644 index 00000000..560da964 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "캐릭터 수정 결과") +public class ModifyCharacterRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java new file mode 100644 index 00000000..033241d2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java @@ -0,0 +1,146 @@ +package devkor.com.teamcback.domain.character.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_OWNED_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.CHARACTER_IN_USE; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; +import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.DeleteCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GrantCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.infra.s3.FilePath; +import devkor.com.teamcback.infra.s3.S3Util; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class AdminStoreService { + private final CharacterRepository characterRepository; + private final UserCharacterRepository userCharacterRepository; + private final UserRepository userRepository; + private final S3Util s3Util; + + /** + * 캐릭터 목록 조회 (비활성 포함) + */ + @Transactional(readOnly = true) + public GetAdminCharacterListRes getCharacterList() { + List characterList = characterRepository.findAllByOrderByDisplayOrderAsc().stream() + .map(GetAdminCharacterRes::new) + .toList(); + + return new GetAdminCharacterListRes(characterList); + } + + /** + * 캐릭터 생성 + */ + @Transactional + public CreateCharacterRes createCharacter(CreateCharacterReq req) { + validatePrice(req.getPrice()); + validateRequiredLevel(req.getRequiredLevel()); + + String imageUrl = uploadImage(req.getImage()); + if(imageUrl == null) throw new GlobalException(INVALID_INPUT); // 이미지 필수 + + KoCharacter character = characterRepository.save(new KoCharacter(req, imageUrl)); + + return new CreateCharacterRes(character.getCharacterId()); + } + + /** + * 캐릭터 수정 (이미지 미첨부 시 기존 이미지 유지) + */ + @Transactional + public ModifyCharacterRes modifyCharacter(Long characterId, ModifyCharacterReq req) { + validatePrice(req.getPrice()); + validateRequiredLevel(req.getRequiredLevel()); + + KoCharacter character = findCharacter(characterId); + + String imageUrl = uploadImage(req.getImage()); + if(imageUrl == null) { + imageUrl = character.getImageUrl(); + } else if(character.getImageUrl() != null) { + s3Util.deleteFile(character.getImageUrl(), FilePath.CHARACTER); + } + + character.update(req, imageUrl); + + return new ModifyCharacterRes(); + } + + /** + * 캐릭터 삭제 (구매한 사용자가 있으면 불가 - isActive=false로 숨김 처리 안내) + */ + @Transactional + public DeleteCharacterRes deleteCharacter(Long characterId) { + KoCharacter character = findCharacter(characterId); + + if(userCharacterRepository.existsByCharacter(character)) { + throw new GlobalException(CHARACTER_IN_USE); + } + + if(character.getImageUrl() != null) { + s3Util.deleteFile(character.getImageUrl(), FilePath.CHARACTER); + } + characterRepository.delete(character); + + return new DeleteCharacterRes(); + } + + /** + * 캐릭터 수동 지급 (이벤트 보상 등, 포인트 차감 없음) + */ + @Transactional + public GrantCharacterRes grantCharacter(Long characterId, Long userId) { + KoCharacter character = findCharacter(characterId); + User user = userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + + if(userCharacterRepository.existsByUserAndCharacter(user, character)) { + throw new GlobalException(ALREADY_OWNED_CHARACTER); + } + + UserCharacter userCharacter = userCharacterRepository.save(new UserCharacter(user, character)); + + return new GrantCharacterRes(userCharacter.getUserCharacterId()); + } + + private void validatePrice(Integer price) { + if(price == null || price < 0) throw new GlobalException(INVALID_INPUT); + } + + private void validateRequiredLevel(Integer requiredLevel) { + int maxLevel = Level.values()[Level.values().length - 1].getLevelNumber(); + if(requiredLevel == null || requiredLevel < 1 || requiredLevel > maxLevel) { + throw new GlobalException(INVALID_INPUT); + } + } + + private String uploadImage(MultipartFile image) { + if(image == null || image.isEmpty()) return null; + return s3Util.uploadFile(image, FilePath.CHARACTER); + } + + private KoCharacter findCharacter(Long characterId) { + return characterRepository.findById(characterId).orElseThrow(() -> new GlobalException(NOT_FOUND_CHARACTER)); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java new file mode 100644 index 00000000..6d6998f8 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java @@ -0,0 +1,196 @@ +package devkor.com.teamcback.domain.character.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +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; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import devkor.com.teamcback.infra.s3.FilePath; +import devkor.com.teamcback.infra.s3.S3Util; +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.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AdminStoreServiceTest { + @InjectMocks + AdminStoreService adminStoreService; + + @Mock + CharacterRepository characterRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + UserRepository userRepository; + @Mock + S3Util s3Util; + + @DisplayName("캐릭터 생성 시 S3 업로드 후 URL과 가격 저장") + @Test + void createCharacter() { + MockMultipartFile image = new MockMultipartFile("image", "tiger.png", "image/png", new byte[] {1}); + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(10); + req.setDisplayOrder(1); + req.setImage(image); + + when(s3Util.uploadFile(image, FilePath.CHARACTER)).thenReturn("https://s3/character/tiger.png"); + when(characterRepository.save(any(KoCharacter.class))).thenAnswer(invocation -> { + KoCharacter character = invocation.getArgument(0); + ReflectionTestUtils.setField(character, "characterId", 1L); + return character; + }); + + CreateCharacterRes res = adminStoreService.createCharacter(req); + + assertEquals(1L, res.getCharacterId()); + verify(s3Util).uploadFile(image, FilePath.CHARACTER); + } + + @DisplayName("이미지 없이 캐릭터 생성 시 예외") + @Test + void createCharacterWithoutImage() { + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(10); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.createCharacter(req)); + assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); + verify(characterRepository, never()).save(any()); + } + + @DisplayName("가격이 없거나 음수면 생성 불가") + @Test + void createCharacterInvalidPrice() { + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(-1); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.createCharacter(req)); + assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); + + req.setPrice(null); + assertThrows(GlobalException.class, () -> adminStoreService.createCharacter(req)); + } + + @DisplayName("해금 레벨이 1~5 범위를 벗어나면 생성 불가") + @Test + void createCharacterInvalidRequiredLevel() { + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(10); + req.setRequiredLevel(0); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.createCharacter(req)); + assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); + + req.setRequiredLevel(6); // Level enum 최대(5) 초과 + assertThrows(GlobalException.class, () -> adminStoreService.createCharacter(req)); + verify(characterRepository, never()).save(any()); + } + + @DisplayName("캐릭터 수정: 이미지 미첨부 시 기존 이미지 유지, 첨부 시 교체 후 기존 파일 삭제") + @Test + void modifyCharacter() { + KoCharacter character = new KoCharacter("애기호랑이", null, "옛 대사", "old-url", 0, 1, 1, true); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + + devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq req = + new devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq(); + req.setName("애기호랑이"); + req.setQuote("나 호랑이 맞아요?"); + req.setPrice(0); + req.setRequiredLevel(1); + req.setDisplayOrder(1); + + // 이미지 미첨부 → 기존 URL 유지, S3 접근 없음 + adminStoreService.modifyCharacter(1L, req); + assertEquals("old-url", character.getImageUrl()); + assertEquals("나 호랑이 맞아요?", character.getQuote()); + verify(s3Util, never()).deleteFile(any(String.class), any(FilePath.class)); + + // 이미지 첨부 → 새 URL로 교체 + 기존 S3 파일 삭제 + MockMultipartFile image = new MockMultipartFile("image", "new.png", "image/png", new byte[] {1}); + req.setImage(image); + when(s3Util.uploadFile(image, FilePath.CHARACTER)).thenReturn("new-url"); + + adminStoreService.modifyCharacter(1L, req); + assertEquals("new-url", character.getImageUrl()); + verify(s3Util).deleteFile(eq("old-url"), eq(FilePath.CHARACTER)); + } + + @DisplayName("구매한 사용자가 있는 캐릭터 삭제 시 예외") + @Test + void deleteOwnedCharacterRejected() { + KoCharacter character = new KoCharacter("아기 호랑이", null, null, "url", 10, 1, 1, true); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByCharacter(character)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.deleteCharacter(1L)); + assertEquals(ResultCode.CHARACTER_IN_USE, e.getResultCode()); + verify(characterRepository, never()).delete(any()); + } + + @DisplayName("구매자가 없으면 삭제 성공 (S3 이미지도 삭제)") + @Test + void deleteCharacter() { + KoCharacter character = new KoCharacter("아기 호랑이", null, null, "url", 10, 1, 1, true); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByCharacter(character)).thenReturn(false); + + adminStoreService.deleteCharacter(1L); + + verify(s3Util).deleteFile(eq("url"), eq(FilePath.CHARACTER)); + verify(characterRepository).delete(character); + } + + @DisplayName("수동 지급: 이미 보유 시 예외, 미보유 시 무료 지급") + @Test + void grantCharacter() { + KoCharacter character = new KoCharacter("이벤트 캐릭터", null, null, "url", 100, 1, 1, true); + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.grantCharacter(1L, 2L)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userCharacterRepository.save(any(UserCharacter.class))).thenAnswer(invocation -> { + UserCharacter userCharacter = invocation.getArgument(0); + ReflectionTestUtils.setField(userCharacter, "userCharacterId", 5L); + return userCharacter; + }); + + assertEquals(5L, adminStoreService.grantCharacter(1L, 2L).getUserCharacterId()); + assertEquals(0L, user.getPoint()); // 지급은 포인트를 건드리지 않음 + } +} From 1540a26e5c18e8d7fb0b213284d9854e2d824189 Mon Sep 17 00:00:00 2001 From: Lee Ye Seul Date: Sun, 2 Aug 2026 23:59:19 +0900 Subject: [PATCH 7/8] =?UTF-8?q?Feat:=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CharacterDataSeeder.java | 96 +++++++++++++++++++ src/main/resources/application.yml | 4 + .../service/CharacterDataSeederTest.java | 92 ++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java create mode 100644 src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java b/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java new file mode 100644 index 00000000..eb28f367 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java @@ -0,0 +1,96 @@ +package devkor.com.teamcback.domain.character.service; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +/** + * 캐릭터 확정 데이터 시더 (기획 문서 "포인트 상점 기능 > 캐릭터 DB 확정" 기준, 2026-07-29). + * 이름 존재 여부로 행 단위 멱등 처리, 멀티 인스턴스 동시 부팅은 name UNIQUE 제약이 방어. + * 기획 확정 전에 시드했던 구 플레이스홀더 5종은 보유자가 없을 때만 정리한다. + */ +@Slf4j +@Component +@Profile("!test") +@RequiredArgsConstructor +public class CharacterDataSeeder implements ApplicationRunner { + private final CharacterRepository characterRepository; + private final UserCharacterRepository userCharacterRepository; + + // 기획 확정 전 플레이스홀더 시드 이름 (확정 DB와 이름이 달라 공존하게 되므로 제거 대상) + private static final List LEGACY_SEED_NAMES = + List.of("아기 호랑이", "학생 호랑이", "청년 호랑이", "석사 호랑이", "호랑이 대장"); + + @Value("${character.image.base-url}") + private String imageBaseUrl; + + // 메서드 전체를 @Transactional로 묶으면 중복 키 예외를 잡아도 트랜잭션이 rollback-only로 오염되어 + // 커밋 시 UnexpectedRollbackException으로 부팅이 실패한다. save()별 개별 트랜잭션으로 둔다. + @Override + public void run(ApplicationArguments args) { + removeLegacySeeds(); + + List seeds = List.of( + new KoCharacter("애기호랑이", "기본 아바타", + "나 호랑이 맞아요?", imageUrl("01_aegi"), 0, 1, 1, true), + new KoCharacter("꼬마호랑이", "레벨 2 달성 시 해금", + "엄마가 발이 크면 키 크는 거래요. 저 발 엄청 커요!", imageUrl("02_kkoma"), 15, 2, 2, true), + new KoCharacter("포동호랑이", "레벨 3 달성 시 해금", + "어디든 가고 싶어요! 이따 어디 갈까요? 미래관? 과도?", imageUrl("03_podong"), 20, 3, 3, true), + new KoCharacter("학생호랑이", "레벨 4 달성 시 해금", + "안경 쓰면 똑똑해보이잖아요. 머릿속에 들어오는 건 없어요.", imageUrl("04_haksaeng"), 25, 4, 4, true), + new KoCharacter("어른호랑이", "레벨 5 달성 시 해금", + "뭐든 다 할 수 있을 것 같아요. 제가 못할 리 없죠!", imageUrl("05_eoreun"), 30, 5, 5, true), + new KoCharacter("피곤 호랑이", "포인트로 바로 구매 가능", + "대체 며칠째 밤샘인지.. 근데 오늘이 무슨 요일이죠?", imageUrl("06_pigon"), 35, 1, 6, true), + new KoCharacter("과잠 호랑이", "포인트로 바로 구매 가능", + "대학생, 원래 이렇게 힘든 거였어요? 살려주세요…", imageUrl("07_gwajam"), 45, 1, 7, true), + new KoCharacter("로봇호랑이", "포인트로 바로 구매 가능", + "다치기 싫어서 강철 슈트 입었어요. 강해보이죠?", imageUrl("08_robot"), 50, 1, 8, true), + new KoCharacter("천사 호랑이", "포인트로 바로 구매 가능", + "하루에 한번은 좋은 일이 생길 거예요!", imageUrl("09_cheonsa"), 80, 1, 9, true), + new KoCharacter("악마 호랑이", "포인트로 바로 구매 가능", + "화가 난 것 같지만 사실 부끄러움을 숨기고 있는 거에요…", imageUrl("10_akma"), 80, 1, 10, true) + ); + + int saved = 0; + for (KoCharacter seed : seeds) { + if(characterRepository.existsByName(seed.getName())) continue; + try { + characterRepository.save(seed); + saved++; + } catch (DataIntegrityViolationException e) { // 다른 인스턴스가 먼저 적재한 경우 + log.info("캐릭터 시드 중복 감지, 건너뜀: {}", seed.getName()); + } + } + if(saved > 0) { + log.info("캐릭터 시드 데이터 {}건 적재 완료", saved); + } + } + + private void removeLegacySeeds() { + for (String legacyName : LEGACY_SEED_NAMES) { + characterRepository.findByName(legacyName).ifPresent(legacy -> { + if(userCharacterRepository.existsByCharacter(legacy)) { // 보유자가 있으면 수동 정리 필요 + log.warn("구 플레이스홀더 캐릭터에 보유자가 있어 삭제하지 않음: {}", legacyName); + return; + } + characterRepository.delete(legacy); + log.info("구 플레이스홀더 캐릭터 삭제: {}", legacyName); + }); + } + } + + private String imageUrl(String fileName) { + return imageBaseUrl + "/" + fileName + ".png"; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 64b76d02..96aba826 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -119,6 +119,10 @@ profile: lv4-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/profile/lv4.jpg lv5-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/profile/lv5.jpg +character: + image: # 캐릭터 이미지 (피그마 확정 이미지를 이 경로 규칙대로 S3에 업로드: {base-url}/{NN_slug}.png) + base-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/character + place: default-image: cafe: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/place/default_image/CAFE.jpg diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java new file mode 100644 index 00000000..b88946b7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java @@ -0,0 +1,92 @@ +package devkor.com.teamcback.domain.character.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +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; + +@ExtendWith(MockitoExtension.class) +class CharacterDataSeederTest { + @InjectMocks + CharacterDataSeeder seeder; + + @Mock + CharacterRepository characterRepository; + @Mock + UserCharacterRepository userCharacterRepository; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(seeder, "imageBaseUrl", "https://s3/character"); + } + + @DisplayName("첫 실행 시 확정 캐릭터 10건 적재") + @Test + void seedOnFirstRun() { + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.existsByName(anyString())).thenReturn(false); + + seeder.run(null); + + verify(characterRepository, times(10)).save(any(KoCharacter.class)); + } + + @DisplayName("이미 적재된 경우 저장하지 않음 (멱등)") + @Test + void skipOnSecondRun() { + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.existsByName(anyString())).thenReturn(true); + + seeder.run(null); + + verify(characterRepository, never()).save(any(KoCharacter.class)); + } + + @DisplayName("동시 부팅 레이스: 중복 키 예외가 나도 나머지 시드를 계속 적재") + @Test + void continueSeedingAfterDuplicateKeyRace() { + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.existsByName(anyString())).thenReturn(false); + // 첫 번째 save는 다른 인스턴스가 먼저 적재해 UNIQUE 위반, 이후는 성공 + when(characterRepository.save(any(KoCharacter.class))) + .thenThrow(new org.springframework.dao.DataIntegrityViolationException("uk name")) + .thenAnswer(invocation -> invocation.getArgument(0)); + + seeder.run(null); // 예외가 전파되면 부팅 실패 → 테스트 실패 + + verify(characterRepository, times(10)).save(any(KoCharacter.class)); + } + + @DisplayName("구 플레이스홀더는 보유자가 없으면 삭제, 있으면 유지") + @Test + void removeLegacySeeds() { + KoCharacter orphanLegacy = new KoCharacter("아기 호랑이", null, null, "url", 0, 1, 1, true); + KoCharacter ownedLegacy = new KoCharacter("호랑이 대장", null, null, "url", 60, 5, 5, true); + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.findByName("아기 호랑이")).thenReturn(Optional.of(orphanLegacy)); + when(characterRepository.findByName("호랑이 대장")).thenReturn(Optional.of(ownedLegacy)); + when(userCharacterRepository.existsByCharacter(orphanLegacy)).thenReturn(false); + when(userCharacterRepository.existsByCharacter(ownedLegacy)).thenReturn(true); + when(characterRepository.existsByName(anyString())).thenReturn(true); // 시드 자체는 스킵 + + seeder.run(null); + + verify(characterRepository).delete(orphanLegacy); + verify(characterRepository, never()).delete(ownedLegacy); + } +} From a1ec35752e722afb003024c50489cb348be4a104 Mon Sep 17 00:00:00 2001 From: Minwoo Kim <149921142+JokeBear777@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:49:41 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20ResultCode=20=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/devkor/com/teamcback/global/response/ResultCode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index a91567f3..acfc68ec 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -111,7 +111,7 @@ public enum ResultCode { UNSUPPORTED_PUSH_INSTALLATION_VARIANT(HttpStatus.BAD_REQUEST, 17003, "Push installation variant is not supported for test push."), EXPO_PUSH_RETRYABLE_ERROR(HttpStatus.SERVICE_UNAVAILABLE, 17004, "Expo push request failed with retryable error."), EXPO_PUSH_NON_RETRYABLE_ERROR(HttpStatus.BAD_GATEWAY, 17005, "Expo push request failed with non-retryable error."), - EXPO_PUSH_TICKET_ERROR(HttpStatus.BAD_GATEWAY, 17006, "Expo push ticket returned error status."); + EXPO_PUSH_TICKET_ERROR(HttpStatus.BAD_GATEWAY, 17006, "Expo push ticket returned error status."), // 캐릭터 18000번대 NOT_FOUND_CHARACTER(HttpStatus.NOT_FOUND, 18000, "캐릭터를 찾을 수 없습니다."),